refactor: remove remote module install, extract bread-sync, make CI real

Security:
- Remove `bread modules install github:…`. Remote fetch pulled unreviewed
  third-party Lua and ran it with full bread.exec() privileges in an
  unsandboxed runtime. Module install is now local-only; parse_source
  rejects github:/git: with an explicit message.

bread-sync extracted from the workspace (parked for its own project):
- Removed from workspace members (now excluded); see bread-sync/EXTRACTION.md
- Removed the entire `bread sync` CLI surface and now-unused deps
  (bread-sync, reqwest, tar, flate2; tempfile demoted to dev-dependency)
- Removed the sync.status IPC method from breadd plus its integration tests
- Moved the generic `expand_path` helper into bread-shared (with unit tests)

CI now actually runs and gates quality:
- Trigger on master/dev (was `main` — CI had never run, not once)
- Added `cargo fmt --check` and `clippy -D warnings`; fixed 4 clippy warnings
- Dropped the macOS matrix entry (breadd is Linux-only: udev/rtnetlink);
  added the libudev-dev system dependency the Linux build needs

Hardening / honesty:
- New ipc test: daemon survives repeated reloads and the event pipeline
  resumes (the prior suite only had a single happy-path reload check)
- Docs scrubbed of sync across README/Documentation/Overview/DAEMON
- "production-ready" and "compositor-agnostic" claims reworded to match
  reality rather than aspiration

Note: bread-sync/src/export.rs held pre-existing local WIP authored outside
this change set and is intentionally excluded from this commit.
This commit is contained in:
Breadway 2026-05-17 00:22:21 +08:00
parent 3be8eec065
commit cc456b78fe
14 changed files with 202 additions and 1946 deletions

View file

@ -5,7 +5,6 @@ edition = "2021"
[dependencies]
bread-shared = { path = "../bread-shared" }
bread-sync = { path = "../bread-sync" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true

View file

@ -267,32 +267,6 @@ impl Server {
"recent_errors": recent_errors,
}))
}
"sync.status" => {
let sync_path = bread_sync::config::bread_config_dir().join("sync.toml");
match std::fs::read_to_string(&sync_path)
.ok()
.and_then(|s| s.parse::<toml::Value>().ok())
{
Some(toml) => {
let machine = toml
.get("machine")
.and_then(|m| m.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let remote = toml
.get("remote")
.and_then(|r| r.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
Ok(json!({
"initialized": true,
"machine": machine,
"remote": remote,
}))
}
None => Ok(json!({ "initialized": false })),
}
}
"events.replay" => {
let since_ms = req
.params

View file

@ -873,7 +873,8 @@ impl LuaEngine {
})?;
bread.set("module", module_fn)?;
// bread.machine — machine name and tags from sync.toml
// bread.machine — hostname/tags; reads an optional, externally-managed
// ~/.config/bread/sync.toml if present (bread does not create it)
let machine_tbl = self.lua.create_table()?;
let name_fn = self
@ -947,9 +948,9 @@ impl LuaEngine {
})?;
bluetooth_tbl.set("power", power_fn)?;
let powered_fn = self.lua.create_function(move |_lua, ()| {
Ok(bluetooth_query(|| bluetooth_get_powered()).ok())
})?;
let powered_fn = self
.lua
.create_function(move |_lua, ()| Ok(bluetooth_query(bluetooth_get_powered).ok()))?;
bluetooth_tbl.set("powered", powered_fn)?;
let connect_fn = self.lua.create_function(move |_lua, address: String| {
@ -983,7 +984,7 @@ impl LuaEngine {
bluetooth_tbl.set("scan", scan_fn)?;
let devices_fn = self.lua.create_function(move |lua, ()| {
let devs = match bluetooth_query(|| bluetooth_list_devices()) {
let devs = match bluetooth_query(bluetooth_list_devices) {
Ok(d) => d,
Err(_) => return Ok(Value::Nil),
};
@ -2298,7 +2299,8 @@ where
.block_on(factory());
let _ = tx.send(result);
});
rx.recv().map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))?
rx.recv()
.map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))?
}
async fn bluetooth_find_adapter(conn: &zbus::Connection) -> anyhow::Result<String> {
@ -2392,7 +2394,11 @@ async fn bluetooth_disconnect(address: String) -> anyhow::Result<()> {
async fn bluetooth_set_scanning(enabled: bool) -> anyhow::Result<()> {
let conn = zbus::Connection::system().await?;
let adapter = bluetooth_find_adapter(&conn).await?;
let method = if enabled { "StartDiscovery" } else { "StopDiscovery" };
let method = if enabled {
"StartDiscovery"
} else {
"StopDiscovery"
};
conn.call_method(
Some("org.bluez"),
adapter.as_str(),
@ -2429,7 +2435,7 @@ async fn bluetooth_list_devices() -> anyhow::Result<Vec<BluetoothDevice>> {
> = msg.body()?;
let mut devices = Vec::new();
for (_, interfaces) in &objects {
for interfaces in objects.values() {
if let Some(props) = interfaces.get("org.bluez.Device1") {
let json = serde_json::to_value(props).unwrap_or_else(|_| serde_json::json!({}));
devices.push(BluetoothDevice {

View file

@ -161,37 +161,49 @@ async fn modules_reload_succeeds() -> Result<()> {
}
#[tokio::test]
async fn sync_status_uninitialized_when_no_config() -> Result<()> {
async fn daemon_survives_repeated_reloads_and_pipeline_resumes() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let result = harness.send_request("sync.status", json!({})).await?;
assert_eq!(
result.get("initialized").and_then(Value::as_bool),
Some(false)
);
// Event emitted before any reload.
harness
.send_request("emit", json!({"event": "bread.reload.before", "data": {}}))
.await?;
harness.shutdown();
Ok(())
}
// Hammer reload: each cycle drops and rebuilds the Lua VM, cancels timers,
// and re-registers subscriptions. A wedge here (lost Lua thread, deadlocked
// dispatch, paused-and-never-resumed pipeline) is the regression this guards
// — the previous suite only checked a single happy-path reload.
for _ in 0..3 {
let r = harness.send_request("modules.reload", json!({})).await?;
assert_eq!(r.get("ok").and_then(Value::as_bool), Some(true));
}
#[tokio::test]
async fn sync_status_reports_initialized_with_config() -> Result<()> {
let harness = TestHarness::spawn_with_sync_config("myhost", "git@example.com:user/repo.git")?;
harness.wait_until_ready().await?;
// Daemon must still answer control requests after the reload storm.
let ping = harness.send_request("ping", json!({})).await?;
assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true));
let health = harness.send_request("health", json!({})).await?;
assert_eq!(health.get("ok").and_then(Value::as_bool), Some(true));
let result = harness.send_request("sync.status", json!({})).await?;
assert_eq!(
result.get("initialized").and_then(Value::as_bool),
Some(true)
);
assert_eq!(
result.get("machine").and_then(Value::as_str),
Some("myhost")
);
assert_eq!(
result.get("remote").and_then(Value::as_str),
Some("git@example.com:user/repo.git")
// The pipeline must have resumed: an event emitted *after* the reloads
// still flows through normalization into the replay buffer.
harness
.send_request("emit", json!({"event": "bread.reload.after", "data": {}}))
.await?;
sleep(Duration::from_millis(100)).await;
let replay = harness
.send_request("events.replay", json!({"since_ms": 30_000}))
.await?;
let names: Vec<&str> = replay
.as_array()
.expect("replay result should be array")
.iter()
.filter_map(|e| e.get("event").and_then(Value::as_str))
.collect();
assert!(
names.contains(&"bread.reload.after"),
"event pipeline did not resume after reload; got {names:?}"
);
harness.shutdown();
@ -385,14 +397,6 @@ struct TestHarness {
impl TestHarness {
fn spawn() -> Result<Self> {
Self::spawn_inner(None)
}
fn spawn_with_sync_config(machine: &str, remote_url: &str) -> Result<Self> {
Self::spawn_inner(Some((machine.to_string(), remote_url.to_string())))
}
fn spawn_inner(sync_config: Option<(String, String)>) -> Result<Self> {
let temp = tempfile::tempdir()?;
let runtime_dir = temp.path().join("runtime");
let config_home = temp.path().join("config");
@ -433,21 +437,6 @@ enabled = false
"#,
)?;
if let Some((machine, remote_url)) = sync_config {
let sync_toml = format!(
r#"
[remote]
url = "{remote_url}"
branch = "main"
[machine]
name = "{machine}"
tags = []
"#
);
fs::write(bread_cfg.join("sync.toml"), sync_toml)?;
}
let socket_path = runtime_dir.join("bread").join("breadd.sock");
let child = Command::new(env!("CARGO_BIN_EXE_breadd"))
.env("XDG_RUNTIME_DIR", &runtime_dir)