Add Workstream G: out-of-process, Landlock-sandboxed module runtime
Closes the gap Workstream D's in-process capability scoping left open: build_scoped_env only gated presence of bread.* bindings, but os.execute/ io.open/debug.* remained fully reachable since a module's Lua still ran inside breadd's own process. A module that declares [[permissions]] in bread.module.toml (including an explicit empty list) is now spawned as a separate bread-module-host process instead, restricted by a Landlock ruleset breadd builds from that module's granted permissions and applies via Command::pre_exec before the child executes any Lua at all. A module with no manifest at all keeps today's in-process, ungated behavior for backward compatibility. - bread-module-host: new minimal binary (mlua + tokio + serde_json) that connects to breadd's existing IPC socket, presents a one-time spawn token, and proxies bread.* calls as RPC instead of direct bindings. - breadd/src/module_host.rs: spawn + token registry + apply_sandbox (Landlock ruleset construction), with unit tests that spawn a real child and verify denial at the OS level, not a Lua-level check. - breadd/src/ipc/module_host_bridge.rs: the module_host.* RPC bridge (on/once/off/emit/after/every/cancel, fs.read/write, exec/exec_capture, state.get, log/warn/error, status) plus the hello handshake. Bumped API_VERSION to 1.6.0. - breadd/tests/module_host_sandbox.rs: end-to-end acceptance tests going through a real spawned breadd + bread-module-host + IPC handshake — os.execute/io.open denied outside a module's granted fs.read scope, and kill -9 on a module-host child leaving breadd and other modules intact while breadd reports bread.module.crashed. - bread-shared/src/module_host_ipc.rs: shared wire types (hello result, tagged event/timer push envelope) so breadd and bread-module-host can't drift on the handshake/push shape. Deferred (documented in Documentation.md's Workstream G section): the trust="in-process" opt-out, remaining bread.* namespaces over RPC (hyprland/widget/machine/bluetooth/notify/state.watch), network sandboxing, and a fully static build that would remove the Execute grant Landlock's dynamic-linker requirement forces on system library dirs.
This commit is contained in:
parent
450454d164
commit
1e2817537b
17 changed files with 3585 additions and 70 deletions
|
|
@ -438,6 +438,21 @@ async fn modules_list_returns_array() -> Result<()> {
|
|||
/// `bread.state.get(...)`, but `bread.fs` and `bread.exec` must be
|
||||
/// genuinely *absent* from the `bread` table it sees — `nil`, not merely
|
||||
/// permission-denied when called.
|
||||
///
|
||||
/// *Since Workstream G*: a module that declares `[[permissions]]` (any,
|
||||
/// including an explicit empty list — see
|
||||
/// `explicit_empty_permissions_is_scoped_but_not_flagged_ungated` below)
|
||||
/// now runs out-of-process in a real `bread-module-host` child instead of
|
||||
/// in-process with a scoped Lua `_ENV` (see `breadd/src/lua/mod.rs`'s
|
||||
/// `load_module`) — the presence/absence check this test exists for still
|
||||
/// holds, just enforced by what `bread-module-host`'s own `ModuleHostLua`
|
||||
/// constructs the `bread` table from (see `bread-module-host/src/
|
||||
/// lua_env.rs`) instead of `build_scoped_env`. `M.store.set(...)` no
|
||||
/// longer works as the result-reporting channel here, since an
|
||||
/// out-of-process module's `bread.module().store` is process-local (not
|
||||
/// synced back to `breadd`'s `RuntimeState` — a documented gap, see
|
||||
/// `Documentation.md`'s Workstream G section) — `bread.emit(...)` is used
|
||||
/// instead, which *does* cross the process boundary via the RPC bridge.
|
||||
#[tokio::test]
|
||||
async fn scoped_module_sees_only_granted_state_read_permission() -> Result<()> {
|
||||
let manifest = r#"
|
||||
|
|
@ -455,23 +470,26 @@ path = "monitors"
|
|||
let module_lua = r#"
|
||||
local M = bread.module({ name = "scoped-test", version = "1.0.0" })
|
||||
|
||||
function M.on_load()
|
||||
bread.on("test.trigger", function()
|
||||
local ok = pcall(bread.state.get, "monitors")
|
||||
M.store.set("state_get_ok", ok)
|
||||
M.store.set("fs_present", bread.fs ~= nil)
|
||||
M.store.set("exec_present", bread.exec ~= nil)
|
||||
M.store.set("exec_capture_present", bread.exec_capture ~= nil)
|
||||
M.store.set("bluetooth_present", bread.bluetooth ~= nil)
|
||||
-- Baseline must still work from inside a scoped module.
|
||||
M.store.set("json_present", bread.json ~= nil)
|
||||
M.store.set("log_present", bread.log ~= nil)
|
||||
end
|
||||
bread.emit("test.scoped_result", {
|
||||
state_get_ok = ok,
|
||||
fs_present = bread.fs ~= nil,
|
||||
exec_present = bread.exec ~= nil,
|
||||
exec_capture_present = bread.exec_capture ~= nil,
|
||||
bluetooth_present = bread.bluetooth ~= nil,
|
||||
-- Baseline must still work from inside a scoped module.
|
||||
json_present = bread.json ~= nil,
|
||||
log_present = bread.log ~= nil,
|
||||
})
|
||||
end)
|
||||
|
||||
return M
|
||||
"#;
|
||||
|
||||
let harness = TestHarness::spawn_with_module("scoped-test", Some(manifest), module_lua)?;
|
||||
harness.wait_until_ready().await?;
|
||||
harness.wait_for_module_loaded("scoped-test").await?;
|
||||
|
||||
let modules = harness
|
||||
.send_request("state.get", json!({"key": "modules"}))
|
||||
|
|
@ -487,10 +505,13 @@ return M
|
|||
Some("loaded"),
|
||||
"module failed to load: {entry}"
|
||||
);
|
||||
assert_eq!(
|
||||
entry.get("ungated"),
|
||||
Some(&json!(false)),
|
||||
"a module with a manifest that declares permissions must not be flagged ungated"
|
||||
);
|
||||
|
||||
let store = entry
|
||||
.get("store")
|
||||
.ok_or_else(|| anyhow!("no store on module status: {entry}"))?;
|
||||
let store = harness.trigger_and_await_result("test.scoped_result").await?;
|
||||
assert_eq!(store.get("state_get_ok"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
store.get("fs_present"),
|
||||
|
|
@ -507,12 +528,6 @@ return M
|
|||
assert_eq!(store.get("json_present"), Some(&json!(true)), "baseline bread.json must still be present");
|
||||
assert_eq!(store.get("log_present"), Some(&json!(true)), "baseline bread.log must still be present");
|
||||
|
||||
assert_eq!(
|
||||
entry.get("ungated"),
|
||||
Some(&json!(false)),
|
||||
"a module with a manifest that declares permissions must not be flagged ungated"
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -538,6 +553,15 @@ return M
|
|||
|
||||
let harness = TestHarness::spawn_with_module("legacy-test", None, module_lua)?;
|
||||
harness.wait_until_ready().await?;
|
||||
// `wait_until_ready` only proves the IPC socket is accepting
|
||||
// connections — module loading runs concurrently on the Lua engine's
|
||||
// own thread (see `lua::spawn_runtime`), so without this the check
|
||||
// below races module load completion even for an in-process module.
|
||||
// Usually fast enough not to matter, but flaky under this suite's
|
||||
// heavier concurrent process load (see Workstream G's
|
||||
// `module_host_sandbox.rs` tests, which run real sandboxed child
|
||||
// processes alongside this one).
|
||||
harness.wait_for_module_loaded("legacy-test").await?;
|
||||
|
||||
let modules = harness
|
||||
.send_request("state.get", json!({"key": "modules"}))
|
||||
|
|
@ -579,6 +603,12 @@ return M
|
|||
/// "baseline only" declaration, distinct from no manifest at all: it must
|
||||
/// scope the module down for real (no fs/exec/etc.) but must *not* trip the
|
||||
/// `ungated` doctor warning, since the author made a conscious choice.
|
||||
///
|
||||
/// *Since Workstream G*: `Some(vec![])` also opts this module into the
|
||||
/// out-of-process sandboxed path (same as any other declared
|
||||
/// `[[permissions]]`), and — as in the test above — results come back via
|
||||
/// `bread.emit` on a `test.trigger` handler rather than `M.store`. See that
|
||||
/// test's doc comment for the full explanation.
|
||||
#[tokio::test]
|
||||
async fn explicit_empty_permissions_is_scoped_but_not_flagged_ungated() -> Result<()> {
|
||||
let manifest = r#"
|
||||
|
|
@ -593,16 +623,19 @@ permissions = []
|
|||
let module_lua = r#"
|
||||
local M = bread.module({ name = "empty-perms-test", version = "1.0.0" })
|
||||
|
||||
function M.on_load()
|
||||
M.store.set("fs_present", bread.fs ~= nil)
|
||||
M.store.set("state_present", bread.state ~= nil)
|
||||
end
|
||||
bread.on("test.trigger", function()
|
||||
bread.emit("test.empty_perms_result", {
|
||||
fs_present = bread.fs ~= nil,
|
||||
state_present = bread.state ~= nil,
|
||||
})
|
||||
end)
|
||||
|
||||
return M
|
||||
"#;
|
||||
|
||||
let harness = TestHarness::spawn_with_module("empty-perms-test", Some(manifest), module_lua)?;
|
||||
harness.wait_until_ready().await?;
|
||||
harness.wait_for_module_loaded("empty-perms-test").await?;
|
||||
|
||||
let modules = harness
|
||||
.send_request("state.get", json!({"key": "modules"}))
|
||||
|
|
@ -617,15 +650,16 @@ return M
|
|||
.ok_or_else(|| anyhow!("empty-perms-test module not found in modules state; dump: {modules}"))?;
|
||||
|
||||
assert_eq!(entry.get("status").and_then(Value::as_str), Some("loaded"));
|
||||
let store = entry.get("store").unwrap();
|
||||
assert_eq!(store.get("fs_present"), Some(&json!(false)));
|
||||
assert_eq!(store.get("state_present"), Some(&json!(false)));
|
||||
assert_eq!(
|
||||
entry.get("ungated"),
|
||||
Some(&json!(false)),
|
||||
"an explicit empty permissions list is a deliberate declaration, not 'undeclared'"
|
||||
);
|
||||
|
||||
let store = harness.trigger_and_await_result("test.empty_perms_result").await?;
|
||||
assert_eq!(store.get("fs_present"), Some(&json!(false)));
|
||||
assert_eq!(store.get("state_present"), Some(&json!(false)));
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1556,7 +1590,94 @@ enabled = false
|
|||
Ok(parsed.get("result").cloned().unwrap_or_else(|| json!({})))
|
||||
}
|
||||
|
||||
fn shutdown(mut self) {
|
||||
/// Poll `modules.list`/`state.get "modules"`-equivalent status until
|
||||
/// `name` reaches `Loaded` (or `LoadError`, which is treated as a test
|
||||
/// failure). Out-of-process modules (Workstream G:
|
||||
/// `decl.permissions.is_some()`, see `breadd/src/lua/mod.rs`) report
|
||||
/// their load outcome asynchronously — a passing `wait_until_ready`
|
||||
/// only proves the daemon's IPC socket itself is up, not that any
|
||||
/// particular module has finished spawning/connecting/authenticating/
|
||||
/// running its `init.lua` yet.
|
||||
async fn wait_for_module_loaded(&self, name: &str) -> Result<()> {
|
||||
// Comfortably exceeds module_host::READY_TIMEOUT (breadd's own
|
||||
// spawn-side wait) so this test-side poll doesn't give up before
|
||||
// breadd itself would.
|
||||
let deadline = Instant::now() + Duration::from_secs(55);
|
||||
while Instant::now() < deadline {
|
||||
let modules = self.send_request("state.get", json!({"key": "modules"})).await?;
|
||||
if let Some(arr) = modules.as_array() {
|
||||
for m in arr {
|
||||
if m.get("name").and_then(Value::as_str) == Some(name) {
|
||||
match m.get("status").and_then(Value::as_str) {
|
||||
Some("loaded") => return Ok(()),
|
||||
Some("load_error") => {
|
||||
return Err(anyhow!("module '{name}' failed to load: {m}"))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(anyhow!("module '{name}' did not reach Loaded within timeout"))
|
||||
}
|
||||
|
||||
/// Subscribe to `result_event`, send a `test.trigger` manual emit to
|
||||
/// kick off whatever Lua handler is waiting on it, and return the
|
||||
/// triggered event's `data`. See `breadd/tests/module_host_sandbox.rs`'s
|
||||
/// module doc comment for why this trigger-based pattern exists at all:
|
||||
/// a module reporting its result from `on_load` directly would race the
|
||||
/// daemon's own startup sequence, since `tokio::sync::broadcast` (what
|
||||
/// `events.subscribe` reads from) never replays history to a subscriber
|
||||
/// that joins after a send already happened.
|
||||
async fn trigger_and_await_result(&self, result_event: &str) -> Result<Value> {
|
||||
let stream = UnixStream::connect(self.socket_path()).await?;
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let subscribe = json!({
|
||||
"id": "sub-1",
|
||||
"method": "events.subscribe",
|
||||
"params": { "filter": result_event },
|
||||
});
|
||||
write_half
|
||||
.write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes())
|
||||
.await?;
|
||||
let mut reader = BufReader::new(read_half).lines();
|
||||
let _ack = reader.next_line().await?;
|
||||
|
||||
self.send_request("emit", json!({ "event": "test.trigger", "data": {} }))
|
||||
.await?;
|
||||
|
||||
let line = timeout(Duration::from_secs(10), reader.next_line())
|
||||
.await
|
||||
.map_err(|_| anyhow!("timed out waiting for {result_event}"))??
|
||||
.ok_or_else(|| anyhow!("connection closed before {result_event} arrived"))?;
|
||||
let event: Value = serde_json::from_str(&line)?;
|
||||
event
|
||||
.get("data")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("{result_event} missing data"))
|
||||
}
|
||||
|
||||
fn shutdown(self) {
|
||||
// Drop (below) does the actual killing.
|
||||
drop(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestHarness {
|
||||
/// A test that fails partway through (an `?`-propagated error, a
|
||||
/// failed `assert!` unwinding) must not leak a live `breadd` process —
|
||||
/// worse, since Workstream G, a leaked `breadd` can itself have spawned
|
||||
/// `bread-module-host` children under a real Landlock sandbox, which
|
||||
/// don't exit on their own once the parent socket's other end goes
|
||||
/// away instantly (they notice on their next read and exit, but that's
|
||||
/// not instant). Without this, a single failing test in this file
|
||||
/// leaves orphaned processes for every *other* concurrently-running
|
||||
/// test to contend with for CPU/scheduler time — turning one flaky
|
||||
/// failure into cascading slowdowns/timeouts across the whole suite
|
||||
/// (observed directly while developing Workstream G's tests).
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
|
|
|
|||
537
breadd/tests/module_host_sandbox.rs
Normal file
537
breadd/tests/module_host_sandbox.rs
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
//! Workstream G acceptance tests: the real, end-to-end version of the two
|
||||
//! things this workstream exists to prove, going through a real spawned
|
||||
//! `breadd` + a real spawned `bread-module-host` child + a real IPC
|
||||
//! handshake — not the in-isolation Landlock-mechanism unit tests in
|
||||
//! `breadd/src/module_host.rs` (`landlock_denies_reads_outside_granted_path`,
|
||||
//! `no_exec_permission_means_binary_cannot_be_executed_at_all`), which only
|
||||
//! exercise `apply_sandbox` directly against a plain `sh`/`cat`.
|
||||
//!
|
||||
//! 1. [`os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope`] —
|
||||
//! a module granted `fs.read` for exactly one directory (and nothing
|
||||
//! else) runs real Lua that calls `io.open`/`os.execute` directly,
|
||||
//! bypassing the RPC bridge entirely and going straight for the
|
||||
//! `os`/`io` escape hatch Workstream D's in-process scoping admittedly
|
||||
//! leaves open (see `breadd/src/lua/mod.rs`'s `build_scoped_env` doc
|
||||
//! comment). This is the single most important test in the whole
|
||||
//! workstream: proving the denial is a *kernel* permission error, not a
|
||||
//! Lua-level check that a well-behaved module merely chooses to respect.
|
||||
//! 2. [`killing_a_module_host_child_does_not_take_down_breadd_or_other_modules`] —
|
||||
//! `kill -9` on a running module-host child's PID, confirming `breadd`
|
||||
//! itself and a second, unrelated module both keep responding, and that
|
||||
//! `breadd` detects the death and reports it via `bread.module.crashed`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::{json, Value};
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::time::{sleep, timeout};
|
||||
|
||||
// NOTE: these tests need `target/{debug,release}/bread-module-host` to
|
||||
// already exist — `breadd::module_host::resolve_module_host_binary` looks
|
||||
// for it as a sibling of `breadd`'s own executable. `bread-module-host` is
|
||||
// a bin-only crate (no `[lib]` target — deliberately, see its Cargo.toml),
|
||||
// so it can't be pulled in as a `[dev-dependencies]` entry to force cargo
|
||||
// to build it via `env!("CARGO_BIN_EXE_...")`, the usual trick for this.
|
||||
// Running via `cargo test --workspace` (this repo's documented/required
|
||||
// verification command — see Documentation.md) builds every workspace
|
||||
// member, including `bread-module-host`, before any test runs, so this
|
||||
// isn't a problem in practice; running `cargo test -p breadd` in isolation
|
||||
// without a prior `cargo build --workspace` would need one first.
|
||||
|
||||
struct TestHarness {
|
||||
_temp: TempDir,
|
||||
child: Child,
|
||||
socket_path: PathBuf,
|
||||
#[allow(dead_code)]
|
||||
home: PathBuf,
|
||||
}
|
||||
|
||||
impl TestHarness {
|
||||
/// Spawns a real `breadd` with `[modules] builtin = false` and one
|
||||
/// directory-based module per `(name, manifest_toml, init_lua)` entry —
|
||||
/// the same on-disk shape `bread modules install` produces
|
||||
/// (`<modules_dir>/<name>/{bread.module.toml,init.lua}`).
|
||||
fn spawn_with_modules(modules: &[(&str, &str, &str)]) -> Result<Self> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let runtime_dir = temp.path().join("runtime");
|
||||
let config_home = temp.path().join("config");
|
||||
let home = temp.path().join("home");
|
||||
fs::create_dir_all(&runtime_dir)?;
|
||||
fs::create_dir_all(&config_home)?;
|
||||
fs::create_dir_all(&home)?;
|
||||
|
||||
let bread_cfg = config_home.join("bread");
|
||||
fs::create_dir_all(bread_cfg.join("modules"))?;
|
||||
fs::write(
|
||||
bread_cfg.join("init.lua"),
|
||||
"bread.on('bread.system.startup', function() end)\n",
|
||||
)?;
|
||||
|
||||
for (name, manifest_toml, init_lua) in modules {
|
||||
let module_dir = bread_cfg.join("modules").join(name);
|
||||
fs::create_dir_all(&module_dir)?;
|
||||
if !manifest_toml.is_empty() {
|
||||
fs::write(module_dir.join("bread.module.toml"), manifest_toml)?;
|
||||
}
|
||||
fs::write(module_dir.join("init.lua"), init_lua)?;
|
||||
}
|
||||
|
||||
fs::write(
|
||||
bread_cfg.join("breadd.toml"),
|
||||
r#"
|
||||
[daemon]
|
||||
log_level = "error"
|
||||
|
||||
[lua]
|
||||
entry_point = "~/.config/bread/init.lua"
|
||||
module_path = "~/.config/bread/modules"
|
||||
|
||||
[modules]
|
||||
builtin = false
|
||||
|
||||
[adapters.hyprland]
|
||||
enabled = false
|
||||
|
||||
[adapters.udev]
|
||||
enabled = false
|
||||
|
||||
[adapters.power]
|
||||
enabled = false
|
||||
|
||||
[adapters.network]
|
||||
enabled = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
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)
|
||||
.env("XDG_CONFIG_HOME", &config_home)
|
||||
.env("HOME", &home)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
|
||||
Ok(Self {
|
||||
_temp: temp,
|
||||
child,
|
||||
socket_path,
|
||||
home,
|
||||
})
|
||||
}
|
||||
|
||||
fn socket_path(&self) -> &Path {
|
||||
&self.socket_path
|
||||
}
|
||||
|
||||
async fn wait_until_ready(&self) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
while Instant::now() < deadline {
|
||||
if self.socket_path.exists() {
|
||||
if self.send_request("ping", json!({})).await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(anyhow!("daemon did not become ready in time"))
|
||||
}
|
||||
|
||||
/// Poll `modules.list` until `name` shows up `Loaded` — out-of-process
|
||||
/// modules report their load outcome asynchronously (see
|
||||
/// `breadd/src/lua/mod.rs`'s `load_out_of_process_module`), so a plain
|
||||
/// `wait_until_ready` (which only proves the daemon's IPC socket is up)
|
||||
/// isn't enough to know a specific module has finished spawning,
|
||||
/// connecting, authenticating, and running its `init.lua`.
|
||||
async fn wait_for_module_loaded(&self, name: &str) -> Result<()> {
|
||||
// Comfortably exceeds module_host::READY_TIMEOUT (breadd's own
|
||||
// spawn-side wait) so this test-side poll doesn't give up before
|
||||
// breadd itself would.
|
||||
let deadline = Instant::now() + Duration::from_secs(55);
|
||||
while Instant::now() < deadline {
|
||||
let modules = self.send_request("modules.list", json!({})).await?;
|
||||
if let Some(arr) = modules.as_array() {
|
||||
for m in arr {
|
||||
if m.get("name").and_then(Value::as_str) == Some(name) {
|
||||
if m.get("status").and_then(Value::as_str) == Some("loaded") {
|
||||
return Ok(());
|
||||
}
|
||||
if m.get("status").and_then(Value::as_str) == Some("load_error") {
|
||||
return Err(anyhow!(
|
||||
"module '{name}' failed to load: {:?}",
|
||||
m.get("last_error")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(anyhow!("module '{name}' did not reach Loaded within timeout"))
|
||||
}
|
||||
|
||||
async fn send_request(&self, method: &str, params: Value) -> Result<Value> {
|
||||
let stream = UnixStream::connect(self.socket_path()).await?;
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
|
||||
let req = json!({ "id": "1", "method": method, "params": params });
|
||||
write_half
|
||||
.write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes())
|
||||
.await?;
|
||||
|
||||
let mut lines = BufReader::new(read_half).lines();
|
||||
let line = lines
|
||||
.next_line()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("missing ipc response"))?;
|
||||
let parsed: Value = serde_json::from_str(&line)?;
|
||||
|
||||
if let Some(err) = parsed.get("error").and_then(Value::as_str) {
|
||||
return Err(anyhow!(err.to_string()));
|
||||
}
|
||||
Ok(parsed.get("result").cloned().unwrap_or_else(|| json!({})))
|
||||
}
|
||||
|
||||
/// Find the PID of a `bread-module-host` child spawned for this
|
||||
/// harness's `breadd` by scanning `/proc/*/environ` for
|
||||
/// `BREAD_MODULE_NAME=<module_name>` — the module-host binary never
|
||||
/// puts its identity in argv (see its own doc comment on why:
|
||||
/// `/proc/*/cmdline` is visible to any process), so this is the same
|
||||
/// kind of environment-based lookup, just from the test side instead
|
||||
/// of breadd's.
|
||||
fn find_module_host_pid(&self, module_name: &str) -> Result<u32> {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
for entry in fs::read_dir("/proc")?.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let Some(pid_str) = file_name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(pid) = pid_str.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
let environ_path = entry.path().join("environ");
|
||||
let Ok(environ) = fs::read(&environ_path) else {
|
||||
continue;
|
||||
};
|
||||
let wanted = format!("BREAD_MODULE_NAME={module_name}");
|
||||
if environ
|
||||
.split(|b| *b == 0)
|
||||
.any(|var| var == wanted.as_bytes())
|
||||
{
|
||||
return Ok(pid);
|
||||
}
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err(anyhow!(
|
||||
"no bread-module-host process found for module '{module_name}'"
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(self) {
|
||||
// Drop does the actual killing (see below) — this method exists so
|
||||
// call sites can be explicit about "done with this harness" without
|
||||
// caring exactly how cleanup happens.
|
||||
drop(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestHarness {
|
||||
/// Any `?`-propagated failure partway through a test (a timed-out
|
||||
/// event, a failed assertion via `anyhow!` — though assertion panics
|
||||
/// unwind rather than `?`-return, they still run `Drop`) must not leak
|
||||
/// a live `breadd` (and, transitively, any `bread-module-host`
|
||||
/// children it spawned) — `kill` here, not just on the happy path via
|
||||
/// `shutdown()`, is what keeps a failed test run from leaving orphaned
|
||||
/// sandboxed processes behind for the next run to trip over.
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// The P0 acceptance test: verified at the OS level, not asserted. See this
|
||||
/// file's module doc comment.
|
||||
#[tokio::test]
|
||||
async fn os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope() -> Result<()>
|
||||
{
|
||||
let allowed_dir = tempfile::tempdir()?;
|
||||
let allowed_file = allowed_dir.path().join("allowed.txt");
|
||||
fs::write(&allowed_file, "allowed-content")?;
|
||||
|
||||
// Deliberately NOT under $HOME/anything the manifest grants, and
|
||||
// deliberately world-readable-by-this-user (normal DAC permissions
|
||||
// alone would NOT deny this) so a pass here can only be explained by
|
||||
// Landlock, not by an unrelated ordinary permission error — the same
|
||||
// reasoning as the `deny_dir`/`secret.txt` split in
|
||||
// `breadd/src/module_host.rs`'s unit tests, just end-to-end this time.
|
||||
let deny_dir = tempfile::tempdir()?;
|
||||
let deny_file = deny_dir.path().join("secret.txt");
|
||||
fs::write(&deny_file, "top-secret-content")?;
|
||||
|
||||
let manifest = format!(
|
||||
r#"
|
||||
name = "escape-hatch-test"
|
||||
|
||||
[[permissions]]
|
||||
type = "fs.read"
|
||||
path = "{}"
|
||||
"#,
|
||||
allowed_dir.path().display()
|
||||
);
|
||||
|
||||
// No `exec` permission granted at all, so `os.execute` should fail
|
||||
// outright (can't even launch `/bin/sh` under Landlock) — and
|
||||
// `io.open`, which doesn't need a subprocess at all, directly tests
|
||||
// the FsRead scoping. Results are reported back over `bread.emit`
|
||||
// (baseline, always available) since this module runs in a separate
|
||||
// process we can't otherwise introspect from the test.
|
||||
//
|
||||
// The checks run on a `bread.on("test.trigger", ...)` handler, NOT in
|
||||
// `on_load` — a module loads (and, if it ran in `on_load`, would emit
|
||||
// its result) as part of daemon startup, which races the test's own
|
||||
// `events.subscribe` connection. `tokio::sync::broadcast` (what
|
||||
// `events.subscribe` reads from) does not replay history to a
|
||||
// subscriber that joins after a send already happened — a late
|
||||
// subscription just misses it, no error, no buffering — so without
|
||||
// this explicit trigger the test would be racing the daemon's own
|
||||
// startup sequence rather than reliably observing anything.
|
||||
let init_lua = format!(
|
||||
r#"
|
||||
local M = bread.module({{ name = "escape-hatch-test", version = "1.0.0" }})
|
||||
|
||||
bread.on("test.trigger", function(trigger_event)
|
||||
local allowed_result = "ALLOWED_READ_FAILED"
|
||||
local fh = io.open("{allowed}", "r")
|
||||
if fh then
|
||||
local content = fh:read("*a")
|
||||
fh:close()
|
||||
allowed_result = "ALLOWED_READ_OK:" .. content
|
||||
end
|
||||
|
||||
local denied_result = "DENIED_READ_UNEXPECTEDLY_SUCCEEDED"
|
||||
local deny_fh = io.open("{denied}", "r")
|
||||
if deny_fh then
|
||||
local content = deny_fh:read("*a")
|
||||
deny_fh:close()
|
||||
denied_result = "DENIED_READ_UNEXPECTEDLY_SUCCEEDED:" .. content
|
||||
else
|
||||
denied_result = "io.open denied"
|
||||
end
|
||||
|
||||
local exec_ok = os.execute("cat {denied} > /dev/null 2>&1")
|
||||
local exec_result
|
||||
if exec_ok == true then
|
||||
exec_result = "EXEC_UNEXPECTEDLY_SUCCEEDED"
|
||||
else
|
||||
exec_result = "exec denied or failed"
|
||||
end
|
||||
|
||||
bread.emit("test.escape_hatch_result", {{
|
||||
allowed_result = allowed_result,
|
||||
denied_result = denied_result,
|
||||
exec_result = exec_result,
|
||||
}})
|
||||
end)
|
||||
|
||||
return M
|
||||
"#,
|
||||
allowed = allowed_file.display(),
|
||||
denied = deny_file.display(),
|
||||
);
|
||||
|
||||
let harness = TestHarness::spawn_with_modules(&[("escape-hatch-test", &manifest, &init_lua)])?;
|
||||
harness.wait_until_ready().await?;
|
||||
// Guarantees the module's `bread.on("test.trigger", ...)` subscription
|
||||
// is already registered server-side before the trigger below is sent —
|
||||
// "loaded" status is only reported (via `module_host.status`) after the
|
||||
// module's whole init.lua chunk, including that top-level `bread.on`
|
||||
// call, has finished executing. See this test's other race-avoidance
|
||||
// comment above for why this matters.
|
||||
harness.wait_for_module_loaded("escape-hatch-test").await?;
|
||||
|
||||
let stream = UnixStream::connect(harness.socket_path()).await?;
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let subscribe = json!({
|
||||
"id": "sub-1",
|
||||
"method": "events.subscribe",
|
||||
"params": { "filter": "test.escape_hatch_result" },
|
||||
});
|
||||
write_half
|
||||
.write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes())
|
||||
.await?;
|
||||
let mut reader = BufReader::new(read_half).lines();
|
||||
let _ack = reader.next_line().await?;
|
||||
|
||||
harness
|
||||
.send_request("emit", json!({ "event": "test.trigger", "data": {} }))
|
||||
.await?;
|
||||
|
||||
let line = timeout(Duration::from_secs(15), reader.next_line())
|
||||
.await
|
||||
.map_err(|_| anyhow!("timed out waiting for test.escape_hatch_result event"))??
|
||||
.ok_or_else(|| anyhow!("connection closed before event arrived"))?;
|
||||
let event: Value = serde_json::from_str(&line)?;
|
||||
let data = event
|
||||
.get("data")
|
||||
.ok_or_else(|| anyhow!("event missing data"))?;
|
||||
|
||||
let allowed_result = data.get("allowed_result").and_then(Value::as_str).unwrap_or("");
|
||||
let denied_result = data.get("denied_result").and_then(Value::as_str).unwrap_or("");
|
||||
let exec_result = data.get("exec_result").and_then(Value::as_str).unwrap_or("");
|
||||
|
||||
assert!(
|
||||
allowed_result.starts_with("ALLOWED_READ_OK"),
|
||||
"the granted fs.read directory should remain readable via direct io.open; got {allowed_result:?}"
|
||||
);
|
||||
assert!(
|
||||
!denied_result.contains("UNEXPECTEDLY_SUCCEEDED"),
|
||||
"io.open on a path OUTSIDE the granted fs.read scope must be denied at the kernel level (Landlock), not merely un-offered by an RPC binding — got {denied_result:?}"
|
||||
);
|
||||
assert!(
|
||||
!exec_result.contains("UNEXPECTEDLY_SUCCEEDED"),
|
||||
"os.execute with no `exec` permission granted must not be able to run anything at all — got {exec_result:?}"
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// P0 item 5: killing a module-host child must not take `breadd` (or any
|
||||
/// other module) down with it, and `breadd` must notice and report it.
|
||||
#[tokio::test]
|
||||
async fn killing_a_module_host_child_does_not_take_down_breadd_or_other_modules() -> Result<()> {
|
||||
// An explicit, empty `permissions = []` — not "no manifest at all" — is
|
||||
// what opts a module into the out-of-process sandboxed path with zero
|
||||
// grants (see `ModuleDecl::permissions`'s doc comment in
|
||||
// `breadd/src/lua/mod.rs`: `None` means "no manifest", which keeps
|
||||
// today's in-process, ungated legacy behavior; `Some(vec![])` means
|
||||
// "deliberately baseline-only" and IS routed out-of-process).
|
||||
let victim_manifest = "name = \"victim\"\npermissions = []\n";
|
||||
|
||||
let victim_init = r#"
|
||||
local M = bread.module({ name = "victim", version = "1.0.0" })
|
||||
function M.on_load() end
|
||||
return M
|
||||
"#;
|
||||
|
||||
// The "control" module stays in-process (no manifest at all — the
|
||||
// legacy/backward-compat path) specifically so this test also proves
|
||||
// an out-of-process module's crash doesn't disturb an *in-process*
|
||||
// module either, not just breadd's own IPC responsiveness.
|
||||
let control_init = r#"
|
||||
local M = bread.module({ name = "control", version = "1.0.0" })
|
||||
bread.on("bread.custom.ping_control", function(event)
|
||||
bread.emit("bread.custom.pong_control", {})
|
||||
end)
|
||||
return M
|
||||
"#;
|
||||
|
||||
let harness = TestHarness::spawn_with_modules(&[
|
||||
("victim", victim_manifest, victim_init),
|
||||
("control", "", control_init),
|
||||
])?;
|
||||
harness.wait_until_ready().await?;
|
||||
harness.wait_for_module_loaded("victim").await?;
|
||||
|
||||
// Subscribe to bread.module.crashed BEFORE killing, so we can't miss it.
|
||||
let crash_stream = UnixStream::connect(harness.socket_path()).await?;
|
||||
let (crash_read, mut crash_write) = crash_stream.into_split();
|
||||
crash_write
|
||||
.write_all(
|
||||
format!(
|
||||
"{}\n",
|
||||
serde_json::to_string(&json!({
|
||||
"id": "crash-sub",
|
||||
"method": "events.subscribe",
|
||||
"params": { "filter": "bread.module.crashed" },
|
||||
}))?
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
let mut crash_reader = BufReader::new(crash_read).lines();
|
||||
let _ack = crash_reader.next_line().await?;
|
||||
|
||||
let victim_pid = harness.find_module_host_pid("victim")?;
|
||||
let kill_status = Command::new("kill").args(["-9", &victim_pid.to_string()]).status()?;
|
||||
assert!(kill_status.success(), "failed to send SIGKILL to victim module-host");
|
||||
|
||||
// breadd itself must keep responding.
|
||||
let ping = harness.send_request("ping", json!({})).await?;
|
||||
assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true));
|
||||
|
||||
// The unrelated in-process "control" module must keep dispatching
|
||||
// events normally.
|
||||
let control_stream = UnixStream::connect(harness.socket_path()).await?;
|
||||
let (control_read, mut control_write) = control_stream.into_split();
|
||||
control_write
|
||||
.write_all(
|
||||
format!(
|
||||
"{}\n",
|
||||
serde_json::to_string(&json!({
|
||||
"id": "pong-sub",
|
||||
"method": "events.subscribe",
|
||||
"params": { "filter": "bread.custom.pong_control" },
|
||||
}))?
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
let mut control_reader = BufReader::new(control_read).lines();
|
||||
let _ack = control_reader.next_line().await?;
|
||||
|
||||
harness
|
||||
.send_request(
|
||||
"emit",
|
||||
json!({ "event": "bread.custom.ping_control", "data": {} }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let pong_line = timeout(Duration::from_secs(10), control_reader.next_line())
|
||||
.await
|
||||
.map_err(|_| anyhow!("control module did not respond after victim was killed"))??
|
||||
.ok_or_else(|| anyhow!("control connection closed unexpectedly"))?;
|
||||
let pong: Value = serde_json::from_str(&pong_line)?;
|
||||
assert_eq!(
|
||||
pong.get("event").and_then(Value::as_str),
|
||||
Some("bread.custom.pong_control"),
|
||||
"control module should still be alive and responsive after the victim module-host was killed"
|
||||
);
|
||||
|
||||
// breadd must have detected the death and reported it.
|
||||
let crash_line = timeout(Duration::from_secs(10), crash_reader.next_line())
|
||||
.await
|
||||
.map_err(|_| anyhow!("bread.module.crashed was not emitted after kill -9"))??
|
||||
.ok_or_else(|| anyhow!("crash subscription connection closed unexpectedly"))?;
|
||||
let crash_event: Value = serde_json::from_str(&crash_line)?;
|
||||
assert_eq!(
|
||||
crash_event
|
||||
.get("data")
|
||||
.and_then(|d| d.get("module"))
|
||||
.and_then(Value::as_str),
|
||||
Some("victim"),
|
||||
"bread.module.crashed should identify the module whose host process died"
|
||||
);
|
||||
assert_eq!(
|
||||
crash_event
|
||||
.get("data")
|
||||
.and_then(|d| d.get("signal"))
|
||||
.and_then(Value::as_i64),
|
||||
Some(9),
|
||||
"the crash report should reflect that the process was killed by SIGKILL"
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue