Merge feature/module-host-sandbox (Workstream G)
This commit is contained in:
commit
d26861697c
17 changed files with 3585 additions and 70 deletions
29
Cargo.lock
generated
29
Cargo.lock
generated
|
|
@ -317,6 +317,22 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bread-module-host"
|
||||||
|
version = "0.7.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bread-shared",
|
||||||
|
"mlua",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-shared"
|
name = "bread-shared"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
@ -336,6 +352,7 @@ dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bread-shared",
|
"bread-shared",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"landlock",
|
||||||
"libc",
|
"libc",
|
||||||
"mlua",
|
"mlua",
|
||||||
"netlink-packet-core",
|
"netlink-packet-core",
|
||||||
|
|
@ -350,6 +367,7 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"udev",
|
"udev",
|
||||||
|
"uuid",
|
||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -981,6 +999,17 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "landlock"
|
||||||
|
version = "0.4.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1"
|
||||||
|
dependencies = [
|
||||||
|
"enumflags2",
|
||||||
|
"libc",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
|
|
||||||
12
Cargo.toml
12
Cargo.toml
|
|
@ -4,6 +4,7 @@ members = [
|
||||||
"breadd",
|
"breadd",
|
||||||
"bread-cli",
|
"bread-cli",
|
||||||
"bread-emit",
|
"bread-emit",
|
||||||
|
"bread-module-host",
|
||||||
"xtask",
|
"xtask",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
@ -18,3 +19,14 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
dirs = "6.0"
|
dirs = "6.0"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
# Pure-Rust bindings to the Linux Landlock LSM syscalls (landlock_create_ruleset/
|
||||||
|
# landlock_restrict_self) — kernel 5.13+, no external bwrap/firejail binary
|
||||||
|
# dependency. See Workstream G (bread-module-host / breadd's module_host
|
||||||
|
# spawner) — the actual OS-level sandboxing mechanism for out-of-process
|
||||||
|
# module execution. Verified against this repo's dev kernel (6.18) with a
|
||||||
|
# real pre_exec()-restricted child process before adoption: filesystem reads
|
||||||
|
# outside the granted rule set are denied at the kernel level
|
||||||
|
# (RulesetStatus::FullyEnforced / PartiallyEnforced, EACCES on the denied
|
||||||
|
# path), not merely a Lua-level check.
|
||||||
|
landlock = "0.4"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
|
||||||
361
Documentation.md
361
Documentation.md
|
|
@ -9,6 +9,7 @@
|
||||||
- [Run, reload, and watch](#run-reload-and-watch)
|
- [Run, reload, and watch](#run-reload-and-watch)
|
||||||
- [Modules: install and manage](#modules-install-and-manage)
|
- [Modules: install and manage](#modules-install-and-manage)
|
||||||
- [Capability-scoped modules](#capability-scoped-modules-since-v15)
|
- [Capability-scoped modules](#capability-scoped-modules-since-v15)
|
||||||
|
- [Out-of-process module sandboxing](#out-of-process-module-sandboxing-since-v16)
|
||||||
- [Debugging tips](#debugging-tips)
|
- [Debugging tips](#debugging-tips)
|
||||||
- [Dictionary: Lua API](#dictionary-lua-api)
|
- [Dictionary: Lua API](#dictionary-lua-api)
|
||||||
- [Workflows](#workflows-since-v12)
|
- [Workflows](#workflows-since-v12)
|
||||||
|
|
@ -230,6 +231,13 @@ Anything not granted is genuinely **absent** — `bread.fs == nil`, not
|
||||||
defensively (`if bread.fs then ... end`) degrades exactly the way it would
|
defensively (`if bread.fs then ... end`) degrades exactly the way it would
|
||||||
if, say, Bluetooth hardware weren't present.
|
if, say, Bluetooth hardware weren't present.
|
||||||
|
|
||||||
|
*Since: v1.6* — declaring `[[permissions]]` at all (even an empty list)
|
||||||
|
also determines **where** the module runs: see [Out-of-process module
|
||||||
|
sandboxing](#out-of-process-module-sandboxing-since-v16) below. The
|
||||||
|
`bread` table shape described in this section is what such a module sees
|
||||||
|
either way; what changed is what backs it and what happens if the module
|
||||||
|
ignores it entirely and reaches for `os`/`io` directly.
|
||||||
|
|
||||||
### Baseline (always available, no manifest entry needed)
|
### Baseline (always available, no manifest entry needed)
|
||||||
|
|
||||||
Event subscription and timers are how a module does anything at all, so
|
Event subscription and timers are how a module does anything at all, so
|
||||||
|
|
@ -285,25 +293,26 @@ module down for real but is *not* flagged by `bread doctor`, since the
|
||||||
author made a conscious choice rather than just not knowing about this
|
author made a conscious choice rather than just not knowing about this
|
||||||
feature yet.
|
feature yet.
|
||||||
|
|
||||||
### `path`/`bin` are not enforced yet — by design
|
### `path`/`bin` enforcement depends on where the module runs
|
||||||
|
|
||||||
The scoping mechanism above only gates *presence* of a `bread.*` binding.
|
This section describes the **in-process** scoping mechanism
|
||||||
The `path`/`bin` fields on each permission are recorded in the manifest but
|
(`build_scoped_env` in `breadd/src/lua/mod.rs`), which only ever gated
|
||||||
not checked against the actual arguments a module passes at runtime — a
|
*presence* of a `bread.*` binding — the `path`/`bin` fields on each
|
||||||
module with `fs.read` scoped to `~/Wallpapers` can currently call
|
permission were recorded in the manifest but never checked against the
|
||||||
`bread.fs.read("/etc/shadow")` and it will attempt the read (and fail or
|
actual arguments a module passed at runtime, and `os.execute`/`io.open`/
|
||||||
succeed based on normal OS permissions, same as today). Real per-call
|
`debug.*` remained fully reachable from Lua's standard library regardless
|
||||||
argument enforcement needs a hard security boundary this in-process Lua
|
of what a module's `bread` table contained. That's still exactly true for
|
||||||
mechanism can't provide on its own — `os.execute`/`io.open`/`debug.*`
|
a module with **no manifest at all** (the legacy/backward-compat path,
|
||||||
remain reachable from Lua's standard library regardless of what a module's
|
`ungated: true` in `modules.list`) — see [Out-of-process module
|
||||||
`bread` table contains, so a deliberately malicious script can already
|
sandboxing](#out-of-process-module-sandboxing-since-v16) below.
|
||||||
route around `bread.exec`/`bread.fs` entirely. Closing that gap for real is
|
|
||||||
the planned **out-of-process module sandboxing** workstream, which this
|
*Since: v1.6* — a module that declares `[[permissions]]` (any, including
|
||||||
manifest schema exists to feed: recording `path`/`bin` now means modules
|
an explicit empty list) no longer runs in-process at all. It's spawned as
|
||||||
declared today won't need a second migration once that lands. Until then,
|
a separate, OS-sandboxed `bread-module-host` process instead, and for that
|
||||||
treat this mechanism as making accidental over-reach visible and giving
|
process `path`/`bin` *are* enforced for real, at the kernel level, via a
|
||||||
well-behaved modules a way to advertise (and be held to) a minimal surface
|
Landlock ruleset — independent of whether the module even uses the
|
||||||
— not as a hard boundary against hostile code.
|
documented `bread.*` API or goes straight for `os.execute`/`io.open`. See
|
||||||
|
the linked section for exactly what's covered and what's still deferred.
|
||||||
|
|
||||||
### `require("bread.devices")` still works from a scoped module
|
### `require("bread.devices")` still works from a scoped module
|
||||||
|
|
||||||
|
|
@ -339,6 +348,293 @@ negatives on a plain `bread.exec("...")`-style call site should be rare,
|
||||||
but dynamic/computed call sites (`bread[method_name](...)`) won't be
|
but dynamic/computed call sites (`bread[method_name](...)`) won't be
|
||||||
detected.
|
detected.
|
||||||
|
|
||||||
|
## Out-of-process module sandboxing *(Since: v1.6)*
|
||||||
|
|
||||||
|
### The gap this closes
|
||||||
|
|
||||||
|
Capability-scoped modules (above) gate the *documented* `bread.*` API
|
||||||
|
surface — a module without `fs.read` sees `bread.fs == nil`. They never
|
||||||
|
gated Lua's own standard library: `os.execute`, `io.open`, `debug.*`
|
||||||
|
remained fully reachable from a scoped module's chunk regardless of what
|
||||||
|
its `bread` table contained, because that chunk still ran as ordinary Lua
|
||||||
|
code inside `breadd`'s own OS process, sharing its real filesystem/exec
|
||||||
|
access at the kernel level. A well-behaved module degrades correctly when
|
||||||
|
a permission is missing; a deliberately adversarial one just calls
|
||||||
|
`os.execute("cat /etc/shadow")` directly and the in-process mechanism has
|
||||||
|
nothing left to say about it.
|
||||||
|
|
||||||
|
This workstream closes that gap for any module that declares
|
||||||
|
`[[permissions]]` in `bread.module.toml` — including an explicit empty
|
||||||
|
list — by running it in a **separate OS process**, sandboxed at the kernel
|
||||||
|
level via [Landlock](https://docs.kernel.org/userspace-api/landlock.html),
|
||||||
|
instead of inside `breadd`'s own process.
|
||||||
|
|
||||||
|
### What still runs in-process
|
||||||
|
|
||||||
|
A module with **no manifest at all** (no `bread.module.toml`, or one with
|
||||||
|
no `permissions` key) keeps today's pre-v1.6 behavior unchanged: loaded
|
||||||
|
in-process, full ungated `bread` table, `os`/`io`/`debug` reachable —
|
||||||
|
surfaced as `"ungated": true` in `modules.list`/`state.get "modules"`,
|
||||||
|
which is exactly what `bread doctor` reads to warn about it. This is a
|
||||||
|
deliberate scope decision, not an oversight: Landlock needs concrete rules
|
||||||
|
to build a ruleset from, and "no manifest at all" carries no information
|
||||||
|
to build one. A module author who wants real OS-level isolation writes a
|
||||||
|
manifest — that's the whole point of the capability system this reuses.
|
||||||
|
Built-in modules (`bread.devices`/`monitors`/`workspaces`/`binds`) are
|
||||||
|
completely unaffected either way; they never go through manifest-based
|
||||||
|
scoping.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
breadd (trusted) bread-module-host (sandboxed, per module)
|
||||||
|
│ │
|
||||||
|
├─ spawns child, applies a Landlock ──► │ (restriction applied by the
|
||||||
|
│ ruleset via Command::pre_exec │ PARENT before the child's
|
||||||
|
│ BEFORE execve() │ own main() ever runs)
|
||||||
|
│ │
|
||||||
|
├─ hands it a one-time token via │
|
||||||
|
│ $BREAD_MODULE_TOKEN (env, not argv) │
|
||||||
|
│ │
|
||||||
|
│◄── connects to breadd's existing ──────┤
|
||||||
|
│ IPC socket, presents the token │
|
||||||
|
│ via module_host.hello │
|
||||||
|
│ │
|
||||||
|
├─ looks up which module/permissions ──► │ learns its own identity +
|
||||||
|
│ the token was issued for, replies │ granted permissions from
|
||||||
|
│ │ breadd's answer (never
|
||||||
|
│ │ trusted from self-assertion)
|
||||||
|
│ │
|
||||||
|
│◄── module_host.on/off/emit/after/ ─────┤ loads init.lua into a fresh
|
||||||
|
│ every/cancel/fs_read/fs_write/ │ Lua VM; bread.* functions
|
||||||
|
│ exec/exec_capture/state_get/status │ are RPC-backed proxies, not
|
||||||
|
│ (RPC bridge, belt) │ direct bindings
|
||||||
|
│ │
|
||||||
|
│ Landlock ruleset (suspenders, │ os.execute/io.open/debug.*
|
||||||
|
│ enforced by the kernel independent │ still exist in this Lua VM
|
||||||
|
│ of whether the RPC bridge is used) ──►│ but are bounded by the
|
||||||
|
│ kernel regardless
|
||||||
|
```
|
||||||
|
|
||||||
|
One `bread-module-host` process per out-of-process module. Its own
|
||||||
|
dependency footprint is deliberately minimal (`mlua`, `tokio`,
|
||||||
|
`serde_json`, `bread-shared`) — it's reviewable attack surface in its own
|
||||||
|
right, running one module's untrusted Lua.
|
||||||
|
|
||||||
|
### The token/identity handshake
|
||||||
|
|
||||||
|
Workstream A deliberately did not build a generic IPC connection-identity
|
||||||
|
system — it closed a narrower spoofing gap instead — so there was no
|
||||||
|
`module:<name>` identity concept to reuse. `breadd` generates a random
|
||||||
|
one-time token (a v4 UUID) when spawning a module-host child and passes it
|
||||||
|
via the `$BREAD_MODULE_TOKEN` **environment variable**, not argv — argv is
|
||||||
|
visible to any process on the system via `/proc/<pid>/cmdline`, env vars
|
||||||
|
are not without `/proc/<pid>/environ` and matching privileges. The child's
|
||||||
|
first message on the IPC socket, `module_host.hello {token}`, presents
|
||||||
|
that token; `breadd` looks up which module name/permission set the token
|
||||||
|
was issued for (`ModuleHostRegistry::take_pending`, a one-time,
|
||||||
|
consume-on-read lookup) and replies with that identity. The child never
|
||||||
|
asserts its own name and has that trusted — an adversarial process holding
|
||||||
|
a *stolen or guessed* token still can't claim to be a different module
|
||||||
|
than the one `breadd` actually spawned that token for, and a token is
|
||||||
|
consumed on first use so it can't be replayed.
|
||||||
|
|
||||||
|
Other env vars passed to the child: `$BREAD_MODULE_ENTRY` (absolute path
|
||||||
|
to the module's `init.lua`) and `$BREAD_MODULE_SOCKET` (breadd's socket
|
||||||
|
path, for test harnesses that override it — production defaults to the
|
||||||
|
same `bread_shared::resolve_socket_path()` every other client uses).
|
||||||
|
`$BREAD_MODULE_NAME` is also passed, but purely informational (early log
|
||||||
|
lines before the hello handshake completes) — never trusted for identity
|
||||||
|
or permission lookup.
|
||||||
|
|
||||||
|
### The Landlock sandbox
|
||||||
|
|
||||||
|
[Landlock](https://docs.kernel.org/userspace-api/landlock.html) (Linux
|
||||||
|
5.13+) was chosen over wrapping every spawn in `bubblewrap`/`firejail`:
|
||||||
|
it's a pure-Rust crate calling the LSM's syscalls directly
|
||||||
|
(`landlock_create_ruleset`/`landlock_restrict_self`), unprivileged (no
|
||||||
|
setuid helper, no `CAP_SYS_ADMIN`), and fits this workspace's existing
|
||||||
|
preference for native Rust crates over shelling out to external tools
|
||||||
|
(same reasoning as `udev`/`zbus`/`rtnetlink` instead of CLI wrappers).
|
||||||
|
`bubblewrap`-wrapping remains a documented fallback for a target kernel
|
||||||
|
that lacks Landlock (pre-5.13, or compiled out) — not implemented, since
|
||||||
|
Landlock covers this project's actual target.
|
||||||
|
|
||||||
|
The ruleset is built in `breadd` (the parent) and applied via
|
||||||
|
`Command::pre_exec` — the closure runs in the forked child, after
|
||||||
|
`fork()` but before `execve()`, so the restriction covers the module-host
|
||||||
|
binary's own startup, not just the Lua that runs after. Because of that,
|
||||||
|
`bread-module-host` itself needs **zero** Landlock-related code or
|
||||||
|
dependency — by the time its `main()` runs, the restriction is already
|
||||||
|
active and inherited across the `execve()` that started it.
|
||||||
|
|
||||||
|
What the ruleset grants, from `breadd/src/module_host.rs`'s
|
||||||
|
`apply_sandbox`:
|
||||||
|
|
||||||
|
| Grant | Access | Why |
|
||||||
|
|-------|--------|-----|
|
||||||
|
| System library directories (`/usr/lib`, `/lib`, ...) + `/etc/ld.so.cache`/`.preload` | Read + **Execute** | The dynamic linker needs this to start *any* dynamically-linked binary at all — see the note below on why `Execute` is required here, not just `Read`. |
|
||||||
|
| The `bread-module-host` binary's own resolved path | Read + Execute | The one `execve()` this process is expected to have already performed. |
|
||||||
|
| The module's own directory (`init.lua`'s parent) | Read | So the bootstrap process can load the module's Lua at all — distinct from any `fs.read` grant, which governs the module's *own* runtime file I/O, not breadd's ability to hand it its own source. |
|
||||||
|
| `fs.read` with a `path` hint | Read, scoped to that (`~`-expanded) path prefix | Direct mapping from the manifest. |
|
||||||
|
| `fs.write` with a `path` hint | Read + Write + create, scoped to that path prefix | Matches `bread.fs.write`'s own `create_dir_all` + `write` behavior. |
|
||||||
|
| `exec` with a `bin` hint | Read + Execute, scoped to that binary's resolved path | Absolute paths used as-is; bare names resolved via a `$PATH` search, `which`-style. |
|
||||||
|
|
||||||
|
**No `fs.read`/`fs.write`/`exec` granted at all means no corresponding
|
||||||
|
Landlock rule exists, full stop** — the sandboxed process cannot read,
|
||||||
|
write, or execute anything outside the fixed baseline above, regardless
|
||||||
|
of what it tries via `os`/`io` directly.
|
||||||
|
|
||||||
|
**A note on `Execute` and shared libraries**: an earlier version of this
|
||||||
|
mechanism assumed Landlock's `Execute` right only gates `execve()`, and
|
||||||
|
that plain `Read` would be enough for the dynamic linker's `mmap(...,
|
||||||
|
PROT_EXEC, ...)` of `.so` files. That assumption was wrong — verified
|
||||||
|
empirically (not just reasoned about) by spawning a real sandboxed child:
|
||||||
|
with library directories restricted to `Read`-only, even `/bin/sh -c
|
||||||
|
"true"` failed to start at all (`EACCES` on `execve` before a single line
|
||||||
|
of script ran); granting `Execute` on those directories too fixed it. The
|
||||||
|
practical consequence: a module-host child's direct `os.execute`/`io.open`
|
||||||
|
escape hatch, if it names a path under a system library directory
|
||||||
|
specifically, is not denied the way an arbitrary path elsewhere is — the
|
||||||
|
baseline necessarily grants real `Execute` there. This is a materially
|
||||||
|
smaller exposure than no sandbox at all (bounded to files already shipped
|
||||||
|
in the system's own library directories, not the whole filesystem), but
|
||||||
|
it's a real, known trade-off, not swept under the rug. See
|
||||||
|
`breadd/src/module_host.rs`'s `apply_sandbox` doc comment for the full
|
||||||
|
reasoning, including why a fully static (`x86_64-unknown-linux-musl`)
|
||||||
|
build of `bread-module-host` — confirmed available on this project's dev
|
||||||
|
machine — would remove the need for this baseline entirely, and why that
|
||||||
|
wasn't attempted in this pass (a build/packaging change, not a sandbox
|
||||||
|
logic change).
|
||||||
|
|
||||||
|
**fs.read/fs.write with no `path` hint**: the RPC bridge's own
|
||||||
|
belt-and-suspenders permission check still applies, but no Landlock rule
|
||||||
|
is added — Landlock scoping needs a concrete path, and a hint-less grant
|
||||||
|
carries none. A module author who wants the direct `os`/`io` escape hatch
|
||||||
|
mediated at the kernel level too needs to declare a `path`.
|
||||||
|
|
||||||
|
**Network access is explicitly out of scope for this pass** (P2). Landlock
|
||||||
|
gained TCP bind/connect mediation in ABI v4+ (kernel 6.7+), but wiring a
|
||||||
|
`network` permission kind through the manifest schema and the sandbox
|
||||||
|
builder wasn't attempted here.
|
||||||
|
|
||||||
|
### RPC bridge coverage
|
||||||
|
|
||||||
|
`bread-module-host`'s `bread` table is built entirely from RPC-backed
|
||||||
|
proxies to `breadd` (`breadd/src/ipc/module_host_bridge.rs`), not direct
|
||||||
|
in-process bindings. Covered:
|
||||||
|
|
||||||
|
- **Baseline**, always present: `bread.on`/`.once`/`.off`/`.emit`,
|
||||||
|
`bread.after`/`.every`/`.cancel`, `bread.json.decode`, `bread.module`
|
||||||
|
(with a process-local `.store` — see the note below), `bread.log`/
|
||||||
|
`.warn`/`.error`. Also `bread.spawn`/`bread.wait` — the same pure-Lua
|
||||||
|
coroutine sugar `breadd`'s own `install_wait_helper` uses, since it's
|
||||||
|
built entirely on top of `on`/`once`/`after`/`cancel`, all of which are
|
||||||
|
bridged; the source is currently duplicated between `breadd` and
|
||||||
|
`bread-module-host` rather than extracted to `bread-shared` (flagged as
|
||||||
|
follow-up below).
|
||||||
|
- **Gated**, mirroring the permission table above: `bread.fs.read`/
|
||||||
|
`.write` (`fs.read`/`fs.write`), `bread.exec`/`.exec_capture` (`exec`),
|
||||||
|
`bread.state.get` (`state.read`).
|
||||||
|
|
||||||
|
Events/timers are delivered as unsolicited, tagged push messages
|
||||||
|
interleaved with ordinary request/response lines on the same connection
|
||||||
|
(`bread_shared::module_host_ipc::ModuleHostPush`) — a subscription
|
||||||
|
registered via `module_host.on`/`.once` is matched server-side against the
|
||||||
|
same event broadcast every other IPC subscriber reads from.
|
||||||
|
|
||||||
|
**Not yet bridged** (P1/P2 — see below): `bread.state.monitors`/
|
||||||
|
`.active_workspace`/`.active_window`/`.devices`/`.power`/`.network`/
|
||||||
|
`.profile` shorthands, `bread.state.watch`, `bread.fs.exists`/`.readlink`/
|
||||||
|
`.expand`, `bread.profile.activate`, `bread.notify`, `bread.machine.*`,
|
||||||
|
`bread.hyprland.*`, `bread.widget.*`, `bread.bluetooth.*`,
|
||||||
|
`bread.wait_any`/`.wait_all`/`bread.workflow.*`. These namespaces are
|
||||||
|
simply absent (`nil`) from an out-of-process module's `bread` table
|
||||||
|
regardless of what the manifest grants — a real coverage gap versus the
|
||||||
|
in-process mechanism, not a permission-check bug.
|
||||||
|
|
||||||
|
**`bread.module().store` is process-local**, not synced back to `breadd`'s
|
||||||
|
`RuntimeState` — a real, known limitation versus the in-process mechanism
|
||||||
|
(where `M.store.set`/`.get` persists in daemon state and is visible to
|
||||||
|
`bread modules info`/other tooling). Fine for a module's own private
|
||||||
|
scratch state; not fine yet for anything expecting cross-process
|
||||||
|
visibility. Modules that need to report results/state externally should
|
||||||
|
use `bread.emit(...)` instead, which does cross the process boundary.
|
||||||
|
|
||||||
|
### Crash isolation
|
||||||
|
|
||||||
|
Each spawned `bread-module-host` child is reaped by a dedicated thread in
|
||||||
|
`breadd` (`std::process::Child::wait()`, blocking on that thread only —
|
||||||
|
never blocking the IPC server or the Lua engine). On exit for any reason —
|
||||||
|
clean shutdown, a Lua panic, `kill -9` — `breadd` emits
|
||||||
|
`bread.module.crashed` with `{ module, pid, reason, exit_code, signal }`
|
||||||
|
and updates that module's status. Verified end-to-end
|
||||||
|
(`breadd/tests/module_host_sandbox.rs`): killing a module-host child with
|
||||||
|
`SIGKILL` leaves `breadd` itself and every other module (in-process or
|
||||||
|
out-of-process) fully responsive, and the crash event fires with the
|
||||||
|
correct module name and `signal: 9`.
|
||||||
|
|
||||||
|
This is deliberately **detection and reporting**, not a restart/backoff
|
||||||
|
policy — a crashed module-host stays down until the next `bread reload`
|
||||||
|
(or daemon restart) respawns it. Richer supervision (auto-restart,
|
||||||
|
backoff, a circuit breaker) is flagged as follow-up work, not attempted
|
||||||
|
here.
|
||||||
|
|
||||||
|
### New IPC methods
|
||||||
|
|
||||||
|
*Since: v1.6 — `API_VERSION` bumped from `1.5.0` to `1.6.0` in
|
||||||
|
`breadd/src/ipc/mod.rs` for this addition.* All new methods live under the
|
||||||
|
`module_host.*` prefix and are only meaningful on a connection that has
|
||||||
|
completed the `module_host.hello` handshake (see the token/identity
|
||||||
|
section above) — see [Dictionary: IPC protocol](#dictionary-ipc-protocol)
|
||||||
|
for the full list alongside the pre-existing methods.
|
||||||
|
|
||||||
|
### What's implemented vs. deferred
|
||||||
|
|
||||||
|
**Landed (P0)**:
|
||||||
|
- The `bread-module-host` binary, spawn + token-based identity handshake.
|
||||||
|
- Real Landlock sandboxing built from a module's `ModulePermission` list,
|
||||||
|
independently verified at the OS level (`breadd/src/module_host.rs`'s
|
||||||
|
`landlock_denies_reads_outside_granted_path`/
|
||||||
|
`no_exec_permission_means_binary_cannot_be_executed_at_all` unit tests
|
||||||
|
against a real spawned child; `breadd/tests/module_host_sandbox.rs`'s
|
||||||
|
`os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope`
|
||||||
|
end-to-end, going through a real IPC handshake and real Lua calling
|
||||||
|
`os.execute`/`io.open` directly).
|
||||||
|
- RPC bridge for the baseline set plus `fs.read`/`fs.write`/`exec`/
|
||||||
|
`exec_capture`/`state.read` (`state.get` only).
|
||||||
|
- Crash isolation: kill-9 of a module-host child doesn't take `breadd` or
|
||||||
|
any other module down, and is reported via `bread.module.crashed`
|
||||||
|
(`breadd/tests/module_host_sandbox.rs`'s
|
||||||
|
`killing_a_module_host_child_does_not_take_down_breadd_or_other_modules`).
|
||||||
|
|
||||||
|
**Landed beyond the minimum (still P0-adjacent)**:
|
||||||
|
- `bread.spawn`/`bread.wait` (pure-Lua coroutine sugar) work out-of-process
|
||||||
|
too, since they're built entirely on already-bridged primitives.
|
||||||
|
- `bread.state.get` (not originally required for the P0 minimum, added
|
||||||
|
because a pre-existing capability-manifest test exercised it).
|
||||||
|
|
||||||
|
**Deferred (P1 — do next if this workstream continues)**:
|
||||||
|
- `trust = "in-process"` manifest escape hatch for latency-sensitive
|
||||||
|
modules that want to opt back into today's D-mechanism deliberately.
|
||||||
|
- Extracting `bread.spawn`/`bread.wait`'s embedded Lua source (currently
|
||||||
|
duplicated between `breadd` and `bread-module-host`) into a shared
|
||||||
|
`bread-shared` module so the two copies can't drift.
|
||||||
|
- The remaining `bread.*` namespaces over RPC: `bread.state.watch` and the
|
||||||
|
`.monitors`/`.active_workspace`/etc. shorthands, `bread.fs.exists`/
|
||||||
|
`.readlink`/`.expand`, `bread.profile.activate`, `bread.notify`,
|
||||||
|
`bread.machine.*`, `bread.hyprland.*`, `bread.widget.*`,
|
||||||
|
`bread.bluetooth.*`, `bread.wait_any`/`.wait_all`/`bread.workflow.*` —
|
||||||
|
mechanically the same pattern as the ones already bridged.
|
||||||
|
|
||||||
|
**Deferred (P2 — explicitly out of scope for this pass)**:
|
||||||
|
- Network sandboxing / a `network` permission kind.
|
||||||
|
- `bread modules info` showing the resolved sandbox profile.
|
||||||
|
- Full restart/backoff supervision policy for crashed module-hosts.
|
||||||
|
- A fully static (musl) build of `bread-module-host`, which would remove
|
||||||
|
the library-directory `Execute` baseline grant entirely.
|
||||||
|
- 100% RPC coverage of every remaining namespace.
|
||||||
|
|
||||||
## Debugging tips
|
## Debugging tips
|
||||||
|
|
||||||
- Run `bread events` to see live normalized events.
|
- Run `bread events` to see live normalized events.
|
||||||
|
|
@ -1108,6 +1404,7 @@ Events are delivered as a `BreadEvent`:
|
||||||
| Event | Data |
|
| Event | Data |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| `bread.system.startup` | `{}` |
|
| `bread.system.startup` | `{}` |
|
||||||
|
| `bread.module.crashed` *(Since: v1.6)* | `{ module, pid, reason, exit_code, signal }` — an out-of-process `bread-module-host` child exited (crash, panic, `kill -9`, ...). `exit_code`/`signal` are mutually exclusive (whichever applies); see [Out-of-process module sandboxing](#out-of-process-module-sandboxing-since-v16). |
|
||||||
|
|
||||||
#### Devices (udev / Bluetooth)
|
#### Devices (udev / Bluetooth)
|
||||||
|
|
||||||
|
|
@ -1433,6 +1730,34 @@ Available methods:
|
||||||
| `workflows.list` | — | List running/completed workflow instances and their step/status *(Since: v1.2)* |
|
| `workflows.list` | — | List running/completed workflow instances and their step/status *(Since: v1.2)* |
|
||||||
| `widgets.list` | — | List all registered widgets across every module *(Since: v1.3)* |
|
| `widgets.list` | — | List all registered widgets across every module *(Since: v1.3)* |
|
||||||
|
|
||||||
|
*Since: v1.6* — `module_host.*`: the RPC bridge an out-of-process
|
||||||
|
`bread-module-host` child uses in place of direct in-process `bread.*`
|
||||||
|
bindings (see [Out-of-process module
|
||||||
|
sandboxing](#out-of-process-module-sandboxing-since-v16)). Meaningful only
|
||||||
|
on a connection that has completed the handshake below; not intended for
|
||||||
|
direct use by other clients.
|
||||||
|
|
||||||
|
| Method | Params | Description |
|
||||||
|
|--------|--------|-------------|
|
||||||
|
| `module_host.hello` | `token` | One-time handshake. Consumes the token, replies with `{ module, permissions, api_version }` or an error for an unknown/expired token. Takes over the rest of the connection's lifetime as a bidirectional RPC bridge, same as `events.subscribe` does for a plain event stream. |
|
||||||
|
| `module_host.on` / `.once` | `pattern` | Subscribe; replies `{ subscription_id }`. Matches are pushed asynchronously as `{"push":"event", subscription_id, event}` lines interleaved with ordinary responses. |
|
||||||
|
| `module_host.off` | `id` | Cancel a subscription. |
|
||||||
|
| `module_host.after` / `.every` | `delay_ms` / `interval_ms` | Server-managed timer; replies `{ timer_id }`. Fires are pushed as `{"push":"timer", timer_id}`. |
|
||||||
|
| `module_host.cancel` | `id` | Cancel a timer. |
|
||||||
|
| `module_host.emit` | `event`, `data` | Same manual-emit semantics (and reserved-domain guard) as the top-level `emit` method. |
|
||||||
|
| `module_host.log` / `.warn` / `.error` | `message` | Forwarded to `breadd`'s own tracing log, prefixed with the module name. |
|
||||||
|
| `module_host.fs_read` | `path` | Requires `fs.read` granted; path-prefix-checked against the manifest's `path` hint if one was declared. Replies `{ content }` (`null` if unreadable). |
|
||||||
|
| `module_host.fs_write` | `path`, `content` | Requires `fs.write`, same scoping check. |
|
||||||
|
| `module_host.exec` | `cmd` | Requires `exec`; `bin`-hint-checked (by leading command word) if declared. Fire-and-forget, matching `bread.exec`'s own semantics. |
|
||||||
|
| `module_host.exec_capture` | `cmd`, `timeout_ms` | Requires `exec`. Replies `{ ok, stdout }`. |
|
||||||
|
| `module_host.state_get` | `key` | Requires `state.read`. Replies `{ value }`. |
|
||||||
|
| `module_host.status` | `state` (`"loaded"`\|`"load_error"`), `error` | The module-host reports its own load outcome after running `init.lua`; updates `modules.list` status and unblocks `breadd`'s spawn-side wait. |
|
||||||
|
|
||||||
|
Every gated method above checks the module's granted `PermissionKind`s
|
||||||
|
(learned at hello-time) before attempting the call — belt-and-suspenders
|
||||||
|
alongside the Landlock sandbox enforced at the OS level on the
|
||||||
|
module-host process itself, not a replacement for it.
|
||||||
|
|
||||||
The `health` response's `api_version` field lets a client — the CLI, a Lua module via `bread.exec`, or a `bread-client`-linked sibling app — assert compatibility with this document's versioned schema at connect time (see [API Stability & Versioning](#api-stability--versioning)).
|
The `health` response's `api_version` field lets a client — the CLI, a Lua module via `bread.exec`, or a `bread-client`-linked sibling app — assert compatibility with this document's versioned schema at connect time (see [API Stability & Versioning](#api-stability--versioning)).
|
||||||
|
|
||||||
*Since: v1.5 — `emit` without `source` closed a spoofing gap: previously any event name was accepted with zero validation and tagged `System`, the same tag the daemon uses internally for events it originates itself in Rust code (`bread.system.startup`, `bread.profile.activated`, ...). That made a manually-injected event indistinguishable from a trusted, daemon-originated one. Now:*
|
*Since: v1.5 — `emit` without `source` closed a spoofing gap: previously any event name was accepted with zero validation and tagged `System`, the same tag the daemon uses internally for events it originates itself in Rust code (`bread.system.startup`, `bread.profile.activated`, ...). That made a manually-injected event indistinguishable from a trusted, daemon-originated one. Now:*
|
||||||
|
|
|
||||||
29
bread-module-host/Cargo.toml
Normal file
29
bread-module-host/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
[package]
|
||||||
|
name = "bread-module-host"
|
||||||
|
version = "0.7.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "bread-module-host"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
# Deliberately minimal dependency footprint (Workstream G): this binary is
|
||||||
|
# itself reviewable attack surface running third-party Lua under an
|
||||||
|
# OS-level sandbox constructed by breadd's parent process (see
|
||||||
|
# breadd/src/module_host.rs) — it does not depend on landlock itself, since
|
||||||
|
# the Landlock ruleset is applied by breadd via Command::pre_exec() *before*
|
||||||
|
# this binary's own main() ever runs (landlock_restrict_self() applies to
|
||||||
|
# the calling process across the subsequent execve()).
|
||||||
|
[dependencies]
|
||||||
|
bread-shared = { path = "../bread-shared" }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio = { version = "1.40", features = ["net", "io-util", "rt", "rt-multi-thread", "time", "macros", "sync"] }
|
||||||
|
anyhow.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
mlua = { version = "0.9", features = ["lua54", "vendored", "serialize"] }
|
||||||
|
uuid.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
266
bread-module-host/src/io.rs
Normal file
266
bread-module-host/src/io.rs
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
//! The async half of `bread-module-host`: owns the Unix socket connection
|
||||||
|
//! back to `breadd` and speaks the newline-delimited-JSON IPC protocol
|
||||||
|
//! (`breadd/src/ipc/mod.rs`), extended with `module_host.*` methods (see
|
||||||
|
//! `breadd/src/module_host.rs` for the server side of this bridge).
|
||||||
|
//!
|
||||||
|
//! Runs on its own dedicated OS thread with its own single-threaded Tokio
|
||||||
|
//! runtime — mirroring `breadd`'s own `spawn_runtime` split between an async
|
||||||
|
//! IPC/adapters world and a synchronous, single-threaded Lua world (see
|
||||||
|
//! `breadd/src/lua/mod.rs`'s `spawn_runtime`). The Lua-driving thread in
|
||||||
|
//! `main.rs` talks to this thread over two plain `std::sync::mpsc` channels
|
||||||
|
//! (`IoCommand` out, `HostMessage` in) rather than sharing an async runtime,
|
||||||
|
//! since `mlua::Lua` values are not `Send` and Lua callbacks need to make
|
||||||
|
//! synchronous (blocking, from Lua's point of view) RPC calls.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{mpsc, Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bread_shared::{ModuleHostHello, ModuleHostPush};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
/// A request the Lua-driving thread wants sent to `breadd`, with a reply
|
||||||
|
/// channel for the (blocking, from Lua's perspective) response.
|
||||||
|
pub enum IoCommand {
|
||||||
|
Request {
|
||||||
|
method: String,
|
||||||
|
params: Value,
|
||||||
|
reply: mpsc::Sender<Result<Value, String>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something the IO thread has for the Lua-driving thread: either an
|
||||||
|
/// unsolicited push (a subscribed event fired, a timer fired) or "the
|
||||||
|
/// connection is gone" (breadd exited, socket closed, etc).
|
||||||
|
pub enum HostMessage {
|
||||||
|
Push(ModuleHostPush),
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct RpcResponse {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
result: Option<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect, perform the `module_host.hello` handshake, and — on success —
|
||||||
|
/// run the steady-state request/response + push-forwarding loop until the
|
||||||
|
/// connection closes. `hello_tx` is always sent to exactly once, before
|
||||||
|
/// anything else; the caller blocks on it to learn the module's granted
|
||||||
|
/// identity (or why the handshake failed) before doing anything else.
|
||||||
|
pub fn run(
|
||||||
|
socket_path: PathBuf,
|
||||||
|
token: String,
|
||||||
|
cmd_rx: mpsc::Receiver<IoCommand>,
|
||||||
|
host_tx: mpsc::Sender<HostMessage>,
|
||||||
|
hello_tx: mpsc::Sender<Result<ModuleHostHello, String>>,
|
||||||
|
) {
|
||||||
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = hello_tx.send(Err(format!("failed to start io runtime: {e}")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rt.block_on(async move {
|
||||||
|
let stream = match UnixStream::connect(&socket_path).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = hello_tx.send(Err(format!(
|
||||||
|
"failed to connect to {}: {e}",
|
||||||
|
socket_path.display()
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (read_half, mut write_half) = stream.into_split();
|
||||||
|
let mut lines = BufReader::new(read_half).lines();
|
||||||
|
|
||||||
|
let hello_req = json!({
|
||||||
|
"id": "hello",
|
||||||
|
"method": "module_host.hello",
|
||||||
|
"params": { "token": token },
|
||||||
|
});
|
||||||
|
let Ok(hello_line) = serde_json::to_string(&hello_req) else {
|
||||||
|
let _ = hello_tx.send(Err("failed to encode hello request".to_string()));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if write_half
|
||||||
|
.write_all(format!("{hello_line}\n").as_bytes())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
let _ = hello_tx.send(Err("failed to write hello request".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_line = match lines.next_line().await {
|
||||||
|
Ok(Some(line)) => line,
|
||||||
|
Ok(None) => {
|
||||||
|
let _ = hello_tx.send(Err(
|
||||||
|
"connection closed before hello response".to_string(),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = hello_tx.send(Err(format!("read error awaiting hello: {e}")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let resp: RpcResponse = match serde_json::from_str(&response_line) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = hello_tx.send(Err(format!("malformed hello response: {e}")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(err) = resp.error {
|
||||||
|
let _ = hello_tx.send(Err(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let hello: ModuleHostHello = match resp
|
||||||
|
.result
|
||||||
|
.and_then(|v| serde_json::from_value(v).ok())
|
||||||
|
{
|
||||||
|
Some(h) => h,
|
||||||
|
None => {
|
||||||
|
let _ = hello_tx.send(Err("hello response missing result".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if hello_tx.send(Ok(hello)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steady state. `pending` routes response lines back to whichever
|
||||||
|
// Lua-side call is blocked waiting for them; a dedicated thread
|
||||||
|
// bridges the synchronous `cmd_rx` (fed from the Lua thread) onto an
|
||||||
|
// async channel this task can select on.
|
||||||
|
let pending: Arc<Mutex<HashMap<String, mpsc::Sender<Result<Value, String>>>>> =
|
||||||
|
Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
let (async_cmd_tx, mut async_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<IoCommand>();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
while let Ok(cmd) = cmd_rx.recv() {
|
||||||
|
if async_cmd_tx.send(cmd).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let pending_for_writer = pending.clone();
|
||||||
|
let write_task = tokio::spawn(async move {
|
||||||
|
let mut next_id: u64 = 1;
|
||||||
|
while let Some(IoCommand::Request {
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
reply,
|
||||||
|
}) = async_cmd_rx.recv().await
|
||||||
|
{
|
||||||
|
let id = format!("m{next_id}");
|
||||||
|
next_id += 1;
|
||||||
|
let req = json!({ "id": id, "method": method, "params": params });
|
||||||
|
let line = match serde_json::to_string(&req) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = reply.send(Err(e.to_string()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pending_for_writer.lock().unwrap().insert(id.clone(), reply);
|
||||||
|
if write_half
|
||||||
|
.write_all(format!("{line}\n").as_bytes())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
if let Some(tx) = pending_for_writer.lock().unwrap().remove(&id) {
|
||||||
|
let _ = tx.send(Err("write failed; connection lost".to_string()));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let line = match lines.next_line().await {
|
||||||
|
Ok(Some(l)) => l,
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "module-host: connection read error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let value: Value = match serde_json::from_str(&line) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "module-host: malformed line from breadd");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if value.get("push").is_some() {
|
||||||
|
match serde_json::from_value::<ModuleHostPush>(value) {
|
||||||
|
Ok(push) => {
|
||||||
|
if host_tx.send(HostMessage::Push(push)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!(error = %e, "module-host: malformed push message"),
|
||||||
|
}
|
||||||
|
} else if let Ok(resp) = serde_json::from_value::<RpcResponse>(value) {
|
||||||
|
if let Some(tx) = pending.lock().unwrap().remove(&resp.id) {
|
||||||
|
let result = match resp.error {
|
||||||
|
Some(e) => Err(e),
|
||||||
|
None => Ok(resp.result.unwrap_or(Value::Null)),
|
||||||
|
};
|
||||||
|
let _ = tx.send(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write_task.abort();
|
||||||
|
// Any calls still blocked waiting for a reply need to be unblocked
|
||||||
|
// rather than hanging forever now that the connection is gone.
|
||||||
|
for (_, tx) in pending.lock().unwrap().drain() {
|
||||||
|
let _ = tx.send(Err("connection closed".to_string()));
|
||||||
|
}
|
||||||
|
let _ = host_tx.send(HostMessage::Closed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking helper used from Lua callback closures (which run on the
|
||||||
|
/// Lua-driving thread, not the async IO thread): send a request and wait —
|
||||||
|
/// with a timeout, so a wedged connection can't hang a Lua callback forever
|
||||||
|
/// — for its response.
|
||||||
|
pub fn call(
|
||||||
|
cmd_tx: &mpsc::Sender<IoCommand>,
|
||||||
|
method: &str,
|
||||||
|
params: Value,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let (reply_tx, reply_rx) = mpsc::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(IoCommand::Request {
|
||||||
|
method: method.to_string(),
|
||||||
|
params,
|
||||||
|
reply: reply_tx,
|
||||||
|
})
|
||||||
|
.map_err(|_| "io thread gone".to_string())?;
|
||||||
|
reply_rx
|
||||||
|
.recv_timeout(timeout)
|
||||||
|
.map_err(|_| format!("{method} timed out"))?
|
||||||
|
}
|
||||||
531
bread-module-host/src/lua_env.rs
Normal file
531
bread-module-host/src/lua_env.rs
Normal file
|
|
@ -0,0 +1,531 @@
|
||||||
|
//! The Lua half of `bread-module-host`: a `bread` table whose functions are
|
||||||
|
//! RPC-backed proxies to `breadd` instead of directly touching daemon state,
|
||||||
|
//! plus a dispatch loop that turns `ModuleHostPush` messages (from
|
||||||
|
//! `crate::io`) into Lua callback invocations.
|
||||||
|
//!
|
||||||
|
//! Structurally a slimmed-down sibling of `breadd/src/lua/mod.rs`'s
|
||||||
|
//! `LuaEngine`/`spawn_runtime`: one dedicated thread runs Lua synchronously
|
||||||
|
//! and reacts to messages from a channel (`HostMessage` here, `LuaMessage`
|
||||||
|
//! there); a separate thread/task owns the actual async I/O. Only ONE
|
||||||
|
//! module is ever loaded per `bread-module-host` process, so there's no
|
||||||
|
//! module registry, load ordering, or `after` dependency resolution here —
|
||||||
|
//! `breadd` already resolved all of that before deciding this module needed
|
||||||
|
//! its own process.
|
||||||
|
//!
|
||||||
|
//! `bread.module()`'s `store` is process-local (an in-memory table, not
|
||||||
|
//! synced back to `breadd`) — a documented gap vs. the in-process
|
||||||
|
//! implementation's `bread.module().store`, which persists in
|
||||||
|
//! `RuntimeState` and is visible to `bread modules info`/other modules.
|
||||||
|
//! Fine for a single module's own private scratch state; not fine yet for
|
||||||
|
//! anything that expects cross-module visibility. See `Documentation.md`.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use bread_shared::{BreadEvent, ModulePermission, PermissionKind};
|
||||||
|
use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value as LuaValue};
|
||||||
|
use serde_json::{json, Value as JsonValue};
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::io::{call, IoCommand};
|
||||||
|
|
||||||
|
/// Timeout for a single RPC round trip to `breadd`. Generous relative to a
|
||||||
|
/// same-host Unix socket hop — this exists to fail loudly if the connection
|
||||||
|
/// wedges rather than to accommodate genuinely slow calls.
|
||||||
|
const RPC_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Pure-Lua `bread.spawn`/`bread.wait` sugar, copied verbatim from
|
||||||
|
/// `breadd/src/lua/mod.rs`'s `install_wait_helper`. It only depends on
|
||||||
|
/// `coroutine` plus `bread.once`/`bread.on`/`bread.after`/`bread.cancel`,
|
||||||
|
/// all of which this module provides as RPC-backed bindings above, so the
|
||||||
|
/// suspension mechanism works unmodified against a remote event source.
|
||||||
|
///
|
||||||
|
/// Deliberately duplicated rather than shared: extracting this into
|
||||||
|
/// `bread-shared` (so `breadd` and `bread-module-host` load the same
|
||||||
|
/// constant instead of two hand-kept-in-sync copies) is flagged as
|
||||||
|
/// follow-up work in `Documentation.md` — doing it here would also require
|
||||||
|
/// making `breadd`'s currently-private `const BUILTIN_*`/wait-helper
|
||||||
|
/// strings public, which is a larger refactor than this workstream's time
|
||||||
|
/// budget covers.
|
||||||
|
const WAIT_HELPER: &str = r#"
|
||||||
|
bread.spawn = function(fn)
|
||||||
|
local co = coroutine.create(fn)
|
||||||
|
local ok, err = coroutine.resume(co)
|
||||||
|
if not ok then
|
||||||
|
error(err)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
bread.wait = function(pattern, opts)
|
||||||
|
if type(pattern) ~= "string" then
|
||||||
|
error("bread.wait requires a pattern string")
|
||||||
|
end
|
||||||
|
opts = opts or {}
|
||||||
|
local co = coroutine.running()
|
||||||
|
if not co then
|
||||||
|
error("bread.wait must be called inside a coroutine")
|
||||||
|
end
|
||||||
|
local id
|
||||||
|
local timer
|
||||||
|
id = bread.once(pattern, function(event)
|
||||||
|
if timer then
|
||||||
|
bread.cancel(timer)
|
||||||
|
end
|
||||||
|
coroutine.resume(co, event)
|
||||||
|
end)
|
||||||
|
if opts.timeout then
|
||||||
|
timer = bread.after(opts.timeout, function()
|
||||||
|
bread.off(id)
|
||||||
|
coroutine.resume(co, nil)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
return coroutine.yield()
|
||||||
|
end
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn json_to_lua<'lua>(lua: &'lua Lua, value: &JsonValue) -> mlua::Result<LuaValue<'lua>> {
|
||||||
|
Ok(match value {
|
||||||
|
JsonValue::Null => LuaValue::Nil,
|
||||||
|
JsonValue::Bool(b) => LuaValue::Boolean(*b),
|
||||||
|
JsonValue::Number(n) => {
|
||||||
|
if let Some(i) = n.as_i64() {
|
||||||
|
LuaValue::Integer(i as i64)
|
||||||
|
} else {
|
||||||
|
LuaValue::Number(n.as_f64().unwrap_or(0.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JsonValue::String(s) => LuaValue::String(lua.create_string(s)?),
|
||||||
|
JsonValue::Array(arr) => {
|
||||||
|
let tbl = lua.create_table()?;
|
||||||
|
for (i, v) in arr.iter().enumerate() {
|
||||||
|
tbl.set(i + 1, json_to_lua(lua, v)?)?;
|
||||||
|
}
|
||||||
|
LuaValue::Table(tbl)
|
||||||
|
}
|
||||||
|
JsonValue::Object(obj) => {
|
||||||
|
let tbl = lua.create_table()?;
|
||||||
|
for (k, v) in obj.iter() {
|
||||||
|
tbl.set(k.clone(), json_to_lua(lua, v)?)?;
|
||||||
|
}
|
||||||
|
LuaValue::Table(tbl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Lua VM plus the bookkeeping needed to route `ModuleHostPush`
|
||||||
|
/// messages to the right registered callback. Lives entirely on one thread
|
||||||
|
/// (`mlua::Lua` is `!Send`) — see `main.rs`.
|
||||||
|
pub struct ModuleHostLua {
|
||||||
|
lua: Lua,
|
||||||
|
/// subscription_id or timer_id -> the Lua callback registered for it.
|
||||||
|
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
|
||||||
|
registered: Rc<RefCell<bool>>,
|
||||||
|
module_table_key: Rc<RefCell<Option<RegistryKey>>>,
|
||||||
|
module_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModuleHostLua {
|
||||||
|
pub fn new(
|
||||||
|
cmd_tx: mpsc::Sender<IoCommand>,
|
||||||
|
module_name: String,
|
||||||
|
permissions: Vec<ModulePermission>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let lua = Lua::new();
|
||||||
|
let bread = lua.create_table()?;
|
||||||
|
let handlers: Rc<RefCell<HashMap<String, RegistryKey>>> = Rc::new(RefCell::new(HashMap::new()));
|
||||||
|
let registered = Rc::new(RefCell::new(false));
|
||||||
|
let module_table_key: Rc<RefCell<Option<RegistryKey>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
|
Self::install_module_fn(
|
||||||
|
&lua,
|
||||||
|
&bread,
|
||||||
|
module_name.clone(),
|
||||||
|
registered.clone(),
|
||||||
|
module_table_key.clone(),
|
||||||
|
)?;
|
||||||
|
Self::install_logging(&lua, &bread, cmd_tx.clone())?;
|
||||||
|
Self::install_json(&lua, &bread)?;
|
||||||
|
Self::install_events(&lua, &bread, cmd_tx.clone(), handlers.clone())?;
|
||||||
|
Self::install_timers(&lua, &bread, cmd_tx.clone(), handlers.clone())?;
|
||||||
|
Self::install_emit(&lua, &bread, cmd_tx.clone())?;
|
||||||
|
|
||||||
|
let granted: HashSet<PermissionKind> = permissions.iter().map(|p| p.kind).collect();
|
||||||
|
Self::install_fs(&lua, &bread, cmd_tx.clone(), &granted)?;
|
||||||
|
Self::install_exec(&lua, &bread, cmd_tx.clone(), &granted)?;
|
||||||
|
Self::install_state(&lua, &bread, cmd_tx, &granted)?;
|
||||||
|
|
||||||
|
lua.globals().set("bread", bread)?;
|
||||||
|
lua.load(WAIT_HELPER).set_name("<bread-module-host wait helper>").exec()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
lua,
|
||||||
|
handlers,
|
||||||
|
registered,
|
||||||
|
module_table_key,
|
||||||
|
module_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_module_fn(
|
||||||
|
lua: &Lua,
|
||||||
|
bread: &Table,
|
||||||
|
expected_name: String,
|
||||||
|
registered: Rc<RefCell<bool>>,
|
||||||
|
module_table_key: Rc<RefCell<Option<RegistryKey>>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let store: Rc<RefCell<HashMap<String, JsonValue>>> = Rc::new(RefCell::new(HashMap::new()));
|
||||||
|
let module_fn = lua.create_function(move |lua, table: Table| -> mlua::Result<Table> {
|
||||||
|
let name: String = table.get("name")?;
|
||||||
|
if name != expected_name {
|
||||||
|
return Err(LuaError::RuntimeError(format!(
|
||||||
|
"bread.module({{name = \"{name}\"}}) does not match the module breadd spawned this process for (\"{expected_name}\")"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let version: Option<String> = table.get("version").ok();
|
||||||
|
|
||||||
|
let module_tbl = lua.create_table()?;
|
||||||
|
module_tbl.set("name", name.clone())?;
|
||||||
|
if let Some(v) = version {
|
||||||
|
module_tbl.set("version", v)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store_tbl = lua.create_table()?;
|
||||||
|
let store_get = store.clone();
|
||||||
|
let get_fn = lua.create_function(move |lua, key: String| {
|
||||||
|
match store_get.borrow().get(&key) {
|
||||||
|
Some(v) => json_to_lua(lua, v),
|
||||||
|
None => Ok(LuaValue::Nil),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
store_tbl.set("get", get_fn)?;
|
||||||
|
|
||||||
|
let store_set = store.clone();
|
||||||
|
let set_fn = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
|
||||||
|
let json: JsonValue = lua.from_value(value).unwrap_or(JsonValue::Null);
|
||||||
|
store_set.borrow_mut().insert(key, json);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
store_tbl.set("set", set_fn)?;
|
||||||
|
module_tbl.set("store", store_tbl)?;
|
||||||
|
|
||||||
|
*registered.borrow_mut() = true;
|
||||||
|
let key = lua.create_registry_value(module_tbl.clone())?;
|
||||||
|
*module_table_key.borrow_mut() = Some(key);
|
||||||
|
|
||||||
|
Ok(module_tbl)
|
||||||
|
})?;
|
||||||
|
bread.set("module", module_fn)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_logging(lua: &Lua, bread: &Table, cmd_tx: mpsc::Sender<IoCommand>) -> Result<()> {
|
||||||
|
for (name, method) in [
|
||||||
|
("log", "module_host.log"),
|
||||||
|
("warn", "module_host.warn"),
|
||||||
|
("error", "module_host.error"),
|
||||||
|
] {
|
||||||
|
let cmd_tx = cmd_tx.clone();
|
||||||
|
let f = lua.create_function(move |_, message: String| {
|
||||||
|
let _ = call(&cmd_tx, method, json!({ "message": message }), RPC_TIMEOUT);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
bread.set(name, f)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_json(lua: &Lua, bread: &Table) -> Result<()> {
|
||||||
|
let json_tbl = lua.create_table()?;
|
||||||
|
let decode_fn = lua.create_function(|lua, s: String| {
|
||||||
|
match serde_json::from_str::<JsonValue>(&s) {
|
||||||
|
Ok(v) => Ok((json_to_lua(lua, &v)?, LuaValue::Nil)),
|
||||||
|
Err(e) => Ok((LuaValue::Nil, LuaValue::String(lua.create_string(&e.to_string())?))),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
json_tbl.set("decode", decode_fn)?;
|
||||||
|
bread.set("json", json_tbl)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_events(
|
||||||
|
lua: &Lua,
|
||||||
|
bread: &Table,
|
||||||
|
cmd_tx: mpsc::Sender<IoCommand>,
|
||||||
|
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
for (name, once) in [("on", false), ("once", true)] {
|
||||||
|
let cmd_tx = cmd_tx.clone();
|
||||||
|
let handlers = handlers.clone();
|
||||||
|
let method = if once { "module_host.once" } else { "module_host.on" };
|
||||||
|
let f = lua.create_function(move |lua, (pattern, callback): (String, Function)| {
|
||||||
|
let result = call(&cmd_tx, method, json!({ "pattern": pattern }), RPC_TIMEOUT)
|
||||||
|
.map_err(LuaError::external)?;
|
||||||
|
let id = result
|
||||||
|
.get("subscription_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| LuaError::external("module_host.on: missing subscription_id"))?
|
||||||
|
.to_string();
|
||||||
|
let key = lua.create_registry_value(callback)?;
|
||||||
|
handlers.borrow_mut().insert(id.clone(), key);
|
||||||
|
Ok(id)
|
||||||
|
})?;
|
||||||
|
bread.set(name, f)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cmd_tx_off = cmd_tx.clone();
|
||||||
|
let handlers_off = handlers.clone();
|
||||||
|
let off_fn = lua.create_function(move |_, id: String| {
|
||||||
|
let _ = call(&cmd_tx_off, "module_host.off", json!({ "id": id }), RPC_TIMEOUT);
|
||||||
|
handlers_off.borrow_mut().remove(&id);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
bread.set("off", off_fn)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_timers(
|
||||||
|
lua: &Lua,
|
||||||
|
bread: &Table,
|
||||||
|
cmd_tx: mpsc::Sender<IoCommand>,
|
||||||
|
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
for (name, method, param_key) in [
|
||||||
|
("after", "module_host.after", "delay_ms"),
|
||||||
|
("every", "module_host.every", "interval_ms"),
|
||||||
|
] {
|
||||||
|
let cmd_tx = cmd_tx.clone();
|
||||||
|
let handlers = handlers.clone();
|
||||||
|
let f = lua.create_function(move |lua, (delay_ms, callback): (u64, Function)| {
|
||||||
|
let result = call(&cmd_tx, method, json!({ param_key: delay_ms }), RPC_TIMEOUT)
|
||||||
|
.map_err(LuaError::external)?;
|
||||||
|
let id = result
|
||||||
|
.get("timer_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| LuaError::external(format!("{method}: missing timer_id")))?
|
||||||
|
.to_string();
|
||||||
|
let key = lua.create_registry_value(callback)?;
|
||||||
|
handlers.borrow_mut().insert(id.clone(), key);
|
||||||
|
Ok(id)
|
||||||
|
})?;
|
||||||
|
bread.set(name, f)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cmd_tx_cancel = cmd_tx.clone();
|
||||||
|
let handlers_cancel = handlers.clone();
|
||||||
|
let cancel_fn = lua.create_function(move |_, id: String| {
|
||||||
|
let _ = call(&cmd_tx_cancel, "module_host.cancel", json!({ "id": id }), RPC_TIMEOUT);
|
||||||
|
handlers_cancel.borrow_mut().remove(&id);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
bread.set("cancel", cancel_fn)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_emit(lua: &Lua, bread: &Table, cmd_tx: mpsc::Sender<IoCommand>) -> Result<()> {
|
||||||
|
let emit_fn = lua.create_function(move |lua, (event, data): (String, Option<LuaValue>)| {
|
||||||
|
let data_json: JsonValue = match data {
|
||||||
|
Some(v) => lua.from_value(v).unwrap_or(JsonValue::Null),
|
||||||
|
None => json!({}),
|
||||||
|
};
|
||||||
|
call(
|
||||||
|
&cmd_tx,
|
||||||
|
"module_host.emit",
|
||||||
|
json!({ "event": event, "data": data_json }),
|
||||||
|
RPC_TIMEOUT,
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(LuaError::external)
|
||||||
|
})?;
|
||||||
|
bread.set("emit", emit_fn)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `bread.state.get(path)`, gated on `state.read`. Only the `get`
|
||||||
|
/// shorthand is bridged here — `.monitors()`/`.active_workspace()`/etc.
|
||||||
|
/// convenience wrappers and `state.watch` (a standing subscription, a
|
||||||
|
/// materially different capability — see `PermissionKind::StateWatch`'s
|
||||||
|
/// doc comment in `bread-shared`) are deferred; see `Documentation.md`'s
|
||||||
|
/// Workstream G section for the full list of what's bridged vs. not.
|
||||||
|
fn install_state(
|
||||||
|
lua: &Lua,
|
||||||
|
bread: &Table,
|
||||||
|
cmd_tx: mpsc::Sender<IoCommand>,
|
||||||
|
granted: &HashSet<PermissionKind>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !granted.contains(&PermissionKind::StateRead) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let state_tbl = lua.create_table()?;
|
||||||
|
let get_fn = lua.create_function(move |lua, key: String| {
|
||||||
|
let result = call(&cmd_tx, "module_host.state_get", json!({ "key": key }), RPC_TIMEOUT)
|
||||||
|
.map_err(LuaError::external)?;
|
||||||
|
match result.get("value") {
|
||||||
|
Some(v) => json_to_lua(lua, v),
|
||||||
|
None => Ok(LuaValue::Nil),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
state_tbl.set("get", get_fn)?;
|
||||||
|
bread.set("state", state_tbl)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_fs(
|
||||||
|
lua: &Lua,
|
||||||
|
bread: &Table,
|
||||||
|
cmd_tx: mpsc::Sender<IoCommand>,
|
||||||
|
granted: &HashSet<PermissionKind>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !granted.contains(&PermissionKind::FsRead) && !granted.contains(&PermissionKind::FsWrite) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let fs_tbl = lua.create_table()?;
|
||||||
|
if granted.contains(&PermissionKind::FsRead) {
|
||||||
|
let cmd_tx = cmd_tx.clone();
|
||||||
|
let read_fn = lua.create_function(move |_, path: String| {
|
||||||
|
let result = call(&cmd_tx, "module_host.fs_read", json!({ "path": path }), RPC_TIMEOUT)
|
||||||
|
.map_err(LuaError::external)?;
|
||||||
|
Ok(result
|
||||||
|
.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string()))
|
||||||
|
})?;
|
||||||
|
fs_tbl.set("read", read_fn)?;
|
||||||
|
}
|
||||||
|
if granted.contains(&PermissionKind::FsWrite) {
|
||||||
|
let cmd_tx = cmd_tx.clone();
|
||||||
|
let write_fn = lua.create_function(move |_, (path, content): (String, String)| {
|
||||||
|
call(
|
||||||
|
&cmd_tx,
|
||||||
|
"module_host.fs_write",
|
||||||
|
json!({ "path": path, "content": content }),
|
||||||
|
RPC_TIMEOUT,
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(LuaError::external)
|
||||||
|
})?;
|
||||||
|
fs_tbl.set("write", write_fn)?;
|
||||||
|
}
|
||||||
|
bread.set("fs", fs_tbl)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_exec(
|
||||||
|
lua: &Lua,
|
||||||
|
bread: &Table,
|
||||||
|
cmd_tx: mpsc::Sender<IoCommand>,
|
||||||
|
granted: &HashSet<PermissionKind>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !granted.contains(&PermissionKind::Exec) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let cmd_tx_exec = cmd_tx.clone();
|
||||||
|
let exec_fn = lua.create_function(move |_, cmd: String| {
|
||||||
|
call(&cmd_tx_exec, "module_host.exec", json!({ "cmd": cmd }), RPC_TIMEOUT)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(LuaError::external)
|
||||||
|
})?;
|
||||||
|
bread.set("exec", exec_fn)?;
|
||||||
|
|
||||||
|
let exec_capture_fn = lua.create_function(move |_, (cmd, opts): (String, Option<Table>)| {
|
||||||
|
let timeout_ms: u64 = opts
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|o| o.get("timeout_ms").ok())
|
||||||
|
.unwrap_or(2000);
|
||||||
|
let call_timeout = RPC_TIMEOUT + Duration::from_millis(timeout_ms);
|
||||||
|
let result = call(
|
||||||
|
&cmd_tx,
|
||||||
|
"module_host.exec_capture",
|
||||||
|
json!({ "cmd": cmd, "timeout_ms": timeout_ms }),
|
||||||
|
call_timeout,
|
||||||
|
)
|
||||||
|
.map_err(LuaError::external)?;
|
||||||
|
let ok = result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
let stdout = result
|
||||||
|
.get("stdout")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
Ok((ok, stdout))
|
||||||
|
})?;
|
||||||
|
bread.set("exec_capture", exec_capture_fn)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and execute the module's `init.lua`, then verify it actually
|
||||||
|
/// called `bread.module(...)` — mirrors `breadd`'s own
|
||||||
|
/// `load_module`/`load_scoped_lua_file` contract exactly (see
|
||||||
|
/// `breadd/src/lua/mod.rs`).
|
||||||
|
pub fn load_entry(&self, entry_path: &std::path::Path) -> Result<()> {
|
||||||
|
let src = std::fs::read_to_string(entry_path)
|
||||||
|
.map_err(|e| anyhow!("failed to read {}: {e}", entry_path.display()))?;
|
||||||
|
self.lua
|
||||||
|
.load(&src)
|
||||||
|
.set_name(entry_path.to_string_lossy().as_ref())
|
||||||
|
.exec()
|
||||||
|
.map_err(|e| anyhow!(e.to_string()))?;
|
||||||
|
|
||||||
|
if !*self.registered.borrow() {
|
||||||
|
return Err(anyhow!("module did not call bread.module(...)"));
|
||||||
|
}
|
||||||
|
self.run_on_load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_on_load(&self) -> Result<()> {
|
||||||
|
let key_ref = self.module_table_key.borrow();
|
||||||
|
let Some(key) = key_ref.as_ref() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let module_tbl: Table = self
|
||||||
|
.lua
|
||||||
|
.registry_value(key)
|
||||||
|
.map_err(|e| anyhow!(e.to_string()))?;
|
||||||
|
let hook: Option<Function> = module_tbl.get("on_load").ok();
|
||||||
|
drop(key_ref);
|
||||||
|
if let Some(hook) = hook {
|
||||||
|
hook.call::<_, ()>(())
|
||||||
|
.map_err(|e| anyhow!("{} on_load failed: {e}", self.module_name))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch_event(&self, subscription_id: &str, event: &BreadEvent) {
|
||||||
|
let func = self.lookup(subscription_id);
|
||||||
|
if let Some(func) = func {
|
||||||
|
if let Err(e) = self.call_event_handler(&func, event) {
|
||||||
|
error!(subscription_id, error = %e, "module-host: event handler error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch_timer(&self, timer_id: &str) {
|
||||||
|
let func = self.lookup(timer_id);
|
||||||
|
if let Some(func) = func {
|
||||||
|
if let Err(e) = func.call::<_, ()>(()) {
|
||||||
|
error!(timer_id, error = %e, "module-host: timer handler error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup(&self, id: &str) -> Option<Function<'_>> {
|
||||||
|
let handlers = self.handlers.borrow();
|
||||||
|
let key = handlers.get(id)?;
|
||||||
|
self.lua.registry_value::<Function>(key).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_event_handler(&self, func: &Function, event: &BreadEvent) -> mlua::Result<()> {
|
||||||
|
let data = json_to_lua(&self.lua, &event.data)?;
|
||||||
|
let evt_tbl = self.lua.create_table()?;
|
||||||
|
evt_tbl.set("event", event.event.clone())?;
|
||||||
|
evt_tbl.set("data", data)?;
|
||||||
|
evt_tbl.set("timestamp", event.timestamp)?;
|
||||||
|
evt_tbl.set("id", event.id.clone())?;
|
||||||
|
if let Some(caused_by) = &event.caused_by {
|
||||||
|
evt_tbl.set("caused_by", caused_by.clone())?;
|
||||||
|
}
|
||||||
|
func.call::<_, ()>(evt_tbl)
|
||||||
|
}
|
||||||
|
}
|
||||||
164
bread-module-host/src/main.rs
Normal file
164
bread-module-host/src/main.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
//! `bread-module-host` — the out-of-process runtime for a single third-party
|
||||||
|
//! Bread module (Workstream G).
|
||||||
|
//!
|
||||||
|
//! `breadd` spawns one of these per out-of-process module (see
|
||||||
|
//! `breadd/src/module_host.rs`), sandboxed at the OS level via a Landlock
|
||||||
|
//! ruleset applied by the parent *before* this binary's own `main()` ever
|
||||||
|
//! runs (through `Command::pre_exec` — see that module's doc comment for
|
||||||
|
//! why this binary itself has no Landlock dependency at all). This process
|
||||||
|
//! then:
|
||||||
|
//!
|
||||||
|
//! 1. Connects to `breadd`'s existing IPC socket
|
||||||
|
//! (`$XDG_RUNTIME_DIR/bread/breadd.sock` by default).
|
||||||
|
//! 2. Presents the one-time token `breadd` gave it (via `$BREAD_MODULE_TOKEN`,
|
||||||
|
//! an env var rather than argv, which is visible to any process via
|
||||||
|
//! `/proc/*/cmdline`) via `module_host.hello` and learns its own identity
|
||||||
|
//! (module name + granted permissions) from `breadd`'s answer — it never
|
||||||
|
//! asserts its own name and have that trusted.
|
||||||
|
//! 3. Loads exactly one module's `init.lua` (`$BREAD_MODULE_ENTRY`) into a
|
||||||
|
//! fresh Lua VM whose `bread` table is built entirely from RPC-backed
|
||||||
|
//! proxies (see `lua_env`) instead of direct in-process bindings.
|
||||||
|
//! 4. Reports load success/failure back to `breadd` (`module_host.status`),
|
||||||
|
//! then dispatches subscribed events/timers pushed down the same
|
||||||
|
//! connection until it closes.
|
||||||
|
//!
|
||||||
|
//! Env vars, all required except `BREAD_MODULE_SOCKET` and
|
||||||
|
//! `BREAD_MODULE_NAME`:
|
||||||
|
//! - `BREAD_MODULE_TOKEN` — one-time handshake token.
|
||||||
|
//! - `BREAD_MODULE_ENTRY` — absolute path to the module's entry `.lua` file.
|
||||||
|
//! - `BREAD_MODULE_SOCKET` — override for breadd's socket path (defaults to
|
||||||
|
//! `bread_shared::resolve_socket_path()`, the same resolution breadd's own
|
||||||
|
//! `Config::socket_path` uses).
|
||||||
|
//! - `BREAD_MODULE_NAME` — informational only (early log lines before the
|
||||||
|
//! hello response arrives); never trusted for permission lookup.
|
||||||
|
|
||||||
|
mod io;
|
||||||
|
mod lua_env;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bread_shared::{ModuleHostHello, ModuleHostPush};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use io::{HostMessage, IoCommand};
|
||||||
|
use lua_env::ModuleHostLua;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let module_name_hint = std::env::var("BREAD_MODULE_NAME").unwrap_or_else(|_| "?".to_string());
|
||||||
|
|
||||||
|
let token = match std::env::var("BREAD_MODULE_TOKEN") {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!("bread-module-host: missing BREAD_MODULE_TOKEN env var");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let entry = match std::env::var("BREAD_MODULE_ENTRY") {
|
||||||
|
Ok(e) => PathBuf::from(e),
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!("bread-module-host: missing BREAD_MODULE_ENTRY env var");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let socket_path = match std::env::var("BREAD_MODULE_SOCKET") {
|
||||||
|
Ok(s) => PathBuf::from(s),
|
||||||
|
Err(_) => bread_shared::resolve_socket_path(),
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
module_hint = %module_name_hint,
|
||||||
|
entry = %entry.display(),
|
||||||
|
socket = %socket_path.display(),
|
||||||
|
"bread-module-host starting"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel::<IoCommand>();
|
||||||
|
let (host_tx, host_rx) = mpsc::channel::<HostMessage>();
|
||||||
|
let (hello_tx, hello_rx) = mpsc::channel::<Result<ModuleHostHello, String>>();
|
||||||
|
|
||||||
|
if std::thread::Builder::new()
|
||||||
|
.name("module-host-io".to_string())
|
||||||
|
.spawn(move || io::run(socket_path, token, cmd_rx, host_tx, hello_tx))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
eprintln!("bread-module-host: failed to spawn io thread");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hello = match hello_rx.recv_timeout(Duration::from_secs(15)) {
|
||||||
|
Ok(Ok(h)) => h,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
error!(error = %e, "bread-module-host: hello handshake failed");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
error!("bread-module-host: timed out waiting for hello handshake");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
module = %hello.module,
|
||||||
|
permissions = ?hello.permissions,
|
||||||
|
api_version = %hello.api_version,
|
||||||
|
"bread-module-host: identity established by breadd"
|
||||||
|
);
|
||||||
|
|
||||||
|
let engine = match ModuleHostLua::new(cmd_tx.clone(), hello.module.clone(), hello.permissions.clone()) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "bread-module-host: failed to build lua environment");
|
||||||
|
report_status(&cmd_tx, false, Some(e.to_string()));
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match engine.load_entry(&entry) {
|
||||||
|
Ok(()) => {
|
||||||
|
info!(module = %hello.module, "bread-module-host: module loaded successfully");
|
||||||
|
report_status(&cmd_tx, true, None);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(module = %hello.module, error = %e, "bread-module-host: module load failed");
|
||||||
|
report_status(&cmd_tx, false, Some(e.to_string()));
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steady state: dispatch pushed events/timers until the connection to
|
||||||
|
// breadd drops (breadd exited, socket closed, or we were killed and
|
||||||
|
// this line never runs at all — see breadd/src/module_host.rs's
|
||||||
|
// child-reap thread for the other half of that crash-isolation story).
|
||||||
|
loop {
|
||||||
|
match host_rx.recv() {
|
||||||
|
Ok(HostMessage::Push(ModuleHostPush::Event {
|
||||||
|
subscription_id,
|
||||||
|
event,
|
||||||
|
})) => {
|
||||||
|
engine.dispatch_event(&subscription_id, &event);
|
||||||
|
}
|
||||||
|
Ok(HostMessage::Push(ModuleHostPush::Timer { timer_id })) => {
|
||||||
|
engine.dispatch_timer(&timer_id);
|
||||||
|
}
|
||||||
|
Ok(HostMessage::Closed) | Err(_) => {
|
||||||
|
warn!("bread-module-host: connection to breadd closed, exiting");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report_status(cmd_tx: &mpsc::Sender<IoCommand>, ok: bool, error: Option<String>) {
|
||||||
|
let params = if ok {
|
||||||
|
serde_json::json!({ "state": "loaded" })
|
||||||
|
} else {
|
||||||
|
serde_json::json!({ "state": "load_error", "error": error })
|
||||||
|
};
|
||||||
|
let _ = io::call(cmd_tx, "module_host.status", params, Duration::from_secs(5));
|
||||||
|
}
|
||||||
|
|
@ -10,9 +10,11 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub mod apps;
|
pub mod apps;
|
||||||
pub mod glob;
|
pub mod glob;
|
||||||
|
pub mod module_host_ipc;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
pub mod widget;
|
pub mod widget;
|
||||||
|
|
||||||
|
pub use module_host_ipc::{ModuleHostHello, ModuleHostPush};
|
||||||
pub use permissions::{ModulePermission, PermissionKind};
|
pub use permissions::{ModulePermission, PermissionKind};
|
||||||
|
|
||||||
/// Identifies which adapter produced an event.
|
/// Identifies which adapter produced an event.
|
||||||
|
|
|
||||||
108
bread-shared/src/module_host_ipc.rs
Normal file
108
bread-shared/src/module_host_ipc.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
//! Wire types shared between `breadd`'s IPC server and the `bread-module-host`
|
||||||
|
//! client for the out-of-process module bridge (Workstream G).
|
||||||
|
//!
|
||||||
|
//! Living here (rather than duplicated as private structs in each crate)
|
||||||
|
//! means the two processes can't drift on what a `module_host.hello`
|
||||||
|
//! response or an async event/timer push looks like on the wire — the same
|
||||||
|
//! failure mode `ModulePermission`/`PermissionKind` already guard against
|
||||||
|
//! for the manifest schema (see `permissions.rs`).
|
||||||
|
//!
|
||||||
|
//! The request side (`{"id", "method", "params"}`) and the plain response
|
||||||
|
//! side (`{"id", "result"/"error"}`) are *not* duplicated here: they're
|
||||||
|
//! generic enough (a bare method+params envelope) that both ends already
|
||||||
|
//! define their own minimal local copy, and sharing a type for something
|
||||||
|
//! that's just "an id, a string, and a `Value`" buys little. What's shared
|
||||||
|
//! is the part that's easy to get subtly wrong across two independently
|
||||||
|
//! maintained crates: the exact shape of the one-time `hello` handshake
|
||||||
|
//! result and the tagged push-message envelope used for unsolicited
|
||||||
|
//! event/timer delivery on an otherwise request/response connection.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::permissions::ModulePermission;
|
||||||
|
use crate::BreadEvent;
|
||||||
|
|
||||||
|
/// The successful result of a `module_host.hello` call — what `breadd`
|
||||||
|
/// looked up for the presented one-time token, told back to the
|
||||||
|
/// `bread-module-host` process that presented it. Deliberately does not
|
||||||
|
/// trust anything the child process asserts about its own identity (see
|
||||||
|
/// `breadd/src/module_host.rs`'s token/registry doc comments) — this is
|
||||||
|
/// `breadd` telling the child who *it* has decided the child is, based on
|
||||||
|
/// which token was issued for which pending spawn.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModuleHostHello {
|
||||||
|
pub module: String,
|
||||||
|
pub permissions: Vec<ModulePermission>,
|
||||||
|
pub api_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unsolicited message `breadd` pushes down an already-established
|
||||||
|
/// module-host connection, interleaved with ordinary request/response
|
||||||
|
/// lines. Distinguished on the wire by the `"push"` tag (internally-tagged
|
||||||
|
/// enum), which never collides with a plain `{"id", "result"/"error"}`
|
||||||
|
/// response envelope or an `{"id", "method", "params"}` request envelope —
|
||||||
|
/// neither of those ever carries a `"push"` key.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "push")]
|
||||||
|
pub enum ModuleHostPush {
|
||||||
|
/// A `bread.on`/`bread.once` subscription (registered via
|
||||||
|
/// `module_host.on`/`module_host.once`) matched an event.
|
||||||
|
#[serde(rename = "event")]
|
||||||
|
Event {
|
||||||
|
subscription_id: String,
|
||||||
|
event: BreadEvent,
|
||||||
|
},
|
||||||
|
/// A `bread.after`/`bread.every` timer (registered via
|
||||||
|
/// `module_host.after`/`module_host.every`) fired.
|
||||||
|
#[serde(rename = "timer")]
|
||||||
|
Timer { timer_id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{AdapterSource, PermissionKind};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_round_trips() {
|
||||||
|
let hello = ModuleHostHello {
|
||||||
|
module: "wallpaper".to_string(),
|
||||||
|
permissions: vec![ModulePermission {
|
||||||
|
kind: PermissionKind::FsRead,
|
||||||
|
path: Some("~/Wallpapers".to_string()),
|
||||||
|
bin: None,
|
||||||
|
}],
|
||||||
|
api_version: "1.6.0".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&hello).unwrap();
|
||||||
|
let back: ModuleHostHello = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back.module, "wallpaper");
|
||||||
|
assert_eq!(back.permissions.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_event_tag_is_distinguishable_from_a_response_envelope() {
|
||||||
|
let push = ModuleHostPush::Event {
|
||||||
|
subscription_id: "sub-1".to_string(),
|
||||||
|
event: BreadEvent::new("bread.test.tick", AdapterSource::Manual, serde_json::json!({})),
|
||||||
|
};
|
||||||
|
let value = serde_json::to_value(&push).unwrap();
|
||||||
|
assert_eq!(value.get("push").and_then(|v| v.as_str()), Some("event"));
|
||||||
|
// A plain response envelope never has a "push" key — this is the
|
||||||
|
// disambiguator bread-module-host's read loop relies on.
|
||||||
|
assert!(value.get("id").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_timer_round_trips() {
|
||||||
|
let push = ModuleHostPush::Timer {
|
||||||
|
timer_id: "timer-1".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&push).unwrap();
|
||||||
|
let back: ModuleHostPush = serde_json::from_str(&json).unwrap();
|
||||||
|
match back {
|
||||||
|
ModuleHostPush::Timer { timer_id } => assert_eq!(timer_id, "timer-1"),
|
||||||
|
_ => panic!("wrong variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,8 @@ netlink-packet-route = "0.11"
|
||||||
netlink-packet-core = "0.4"
|
netlink-packet-core = "0.4"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
notify = "6.1"
|
notify = "6.1"
|
||||||
|
landlock.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ use tracing::{error, info, warn};
|
||||||
use crate::adapters::AdapterStatus;
|
use crate::adapters::AdapterStatus;
|
||||||
use crate::core::state_engine::StateHandle;
|
use crate::core::state_engine::StateHandle;
|
||||||
use crate::lua::RuntimeHandle;
|
use crate::lua::RuntimeHandle;
|
||||||
|
use crate::module_host::ModuleHostRegistry;
|
||||||
|
|
||||||
|
mod module_host_bridge;
|
||||||
|
|
||||||
/// The Bread Automation API version (Lua API surface + IPC methods + event
|
/// The Bread Automation API version (Lua API surface + IPC methods + event
|
||||||
/// vocabulary + runtime-state schema), per `Documentation.md`'s "API
|
/// vocabulary + runtime-state schema), per `Documentation.md`'s "API
|
||||||
|
|
@ -27,7 +30,11 @@ use crate::lua::RuntimeHandle;
|
||||||
/// something new-but-additive (a binding, an event, an IPC param); bump the
|
/// something new-but-additive (a binding, an event, an IPC param); bump the
|
||||||
/// major version only for a breaking change, which should not happen inside
|
/// major version only for a breaking change, which should not happen inside
|
||||||
/// this daemon's v1 lifetime per that section's stated policy.
|
/// this daemon's v1 lifetime per that section's stated policy.
|
||||||
const API_VERSION: &str = "1.5.0";
|
///
|
||||||
|
/// *Since 1.6.0* — Workstream G's `module_host.*` methods (hello handshake
|
||||||
|
/// plus the RPC bridge a `bread-module-host` child uses in place of direct
|
||||||
|
/// in-process `bread.*` bindings).
|
||||||
|
const API_VERSION: &str = "1.6.0";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
|
|
@ -42,6 +49,11 @@ pub struct Server {
|
||||||
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
|
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
|
||||||
started_at: Instant,
|
started_at: Instant,
|
||||||
pid: u32,
|
pid: u32,
|
||||||
|
/// Workstream G: token/identity bookkeeping for out-of-process module
|
||||||
|
/// hosts, shared with the Lua engine (which spawns them). See
|
||||||
|
/// `crate::module_host` and `module_host_bridge` (this module's
|
||||||
|
/// `module_host.*` method handling).
|
||||||
|
module_host_registry: ModuleHostRegistry,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -62,7 +74,7 @@ struct IpcResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
// Server::new legitimately requires all 8 fields; a builder pattern here would be
|
// Server::new legitimately requires all 10 fields; a builder pattern here would be
|
||||||
// over-engineering for a single-call-site constructor.
|
// over-engineering for a single-call-site constructor.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
|
|
@ -75,6 +87,7 @@ impl Server {
|
||||||
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
|
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
|
||||||
subscription_count: Arc<AtomicU64>,
|
subscription_count: Arc<AtomicU64>,
|
||||||
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
|
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
|
||||||
|
module_host_registry: ModuleHostRegistry,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
socket_path,
|
socket_path,
|
||||||
|
|
@ -84,6 +97,7 @@ impl Server {
|
||||||
emit_tx,
|
emit_tx,
|
||||||
raw_tx,
|
raw_tx,
|
||||||
adapter_status,
|
adapter_status,
|
||||||
|
module_host_registry,
|
||||||
subscription_count,
|
subscription_count,
|
||||||
event_buffer,
|
event_buffer,
|
||||||
started_at: Instant::now(),
|
started_at: Instant::now(),
|
||||||
|
|
@ -176,6 +190,20 @@ impl Server {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Workstream G: a `bread-module-host` child's very first message
|
||||||
|
// presents its one-time spawn token. From here on this
|
||||||
|
// connection is a dedicated, bidirectional module-host bridge
|
||||||
|
// (RPC requests interleaved with async event/timer pushes) —
|
||||||
|
// see `module_host_bridge::handle_module_host_connection` —
|
||||||
|
// rather than a one-shot request/response exchange, so it takes
|
||||||
|
// over the rest of this connection's lifetime exactly like
|
||||||
|
// `events.subscribe` above does for a plain event stream.
|
||||||
|
if req.method == "module_host.hello" {
|
||||||
|
return self
|
||||||
|
.handle_module_host_connection(req, lines, write_half)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
let response = match self.handle_request(req).await {
|
let response = match self.handle_request(req).await {
|
||||||
Ok(res) => IpcResponse {
|
Ok(res) => IpcResponse {
|
||||||
id: res.0,
|
id: res.0,
|
||||||
|
|
@ -339,24 +367,7 @@ impl Server {
|
||||||
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
|
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
|
||||||
return Err((id, "missing event name".to_string()));
|
return Err((id, "missing event name".to_string()));
|
||||||
};
|
};
|
||||||
if let Some(domain) = event_domain(event) {
|
self.manual_emit(event, data)
|
||||||
if is_reserved_domain(domain) {
|
|
||||||
return Err((
|
|
||||||
id,
|
|
||||||
format!(
|
|
||||||
"event '{event}' claims the reserved '{domain}' domain — manual emit cannot impersonate an adapter-owned event; use a custom event name, or a sourced emit if this should go through the normalizer"
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self
|
|
||||||
.emit_tx
|
|
||||||
.send(BreadEvent::new(event, AdapterSource::Manual, data))
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
return Err((id, "emit channel closed".to_string()));
|
|
||||||
}
|
|
||||||
Ok(json!({ "emitted": true }))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"health" => {
|
"health" => {
|
||||||
|
|
@ -409,6 +420,31 @@ impl Server {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unsourced-emit logic, factored out of `handle_request`'s `"emit"`
|
||||||
|
/// case so `module_host_bridge`'s `module_host.emit` (Workstream G) can
|
||||||
|
/// share the exact same reserved-domain guard rather than re-deriving
|
||||||
|
/// it — see the original inline comment (still above the one call site
|
||||||
|
/// in `handle_request`) for why the guard exists: a same-UID socket
|
||||||
|
/// client (or, now, a module-host child) must not be able to
|
||||||
|
/// impersonate a real adapter-owned event namespace.
|
||||||
|
fn manual_emit(&self, event: &str, data: Value) -> std::result::Result<Value, String> {
|
||||||
|
if let Some(domain) = event_domain(event) {
|
||||||
|
if is_reserved_domain(domain) {
|
||||||
|
return Err(format!(
|
||||||
|
"event '{event}' claims the reserved '{domain}' domain — manual emit cannot impersonate an adapter-owned event; use a custom event name, or a sourced emit if this should go through the normalizer"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.emit_tx
|
||||||
|
.send(BreadEvent::new(event, AdapterSource::Manual, data))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err("emit channel closed".to_string());
|
||||||
|
}
|
||||||
|
Ok(json!({ "emitted": true }))
|
||||||
|
}
|
||||||
|
|
||||||
async fn stream_events(
|
async fn stream_events(
|
||||||
&self,
|
&self,
|
||||||
writer: &mut tokio::net::unix::OwnedWriteHalf,
|
writer: &mut tokio::net::unix::OwnedWriteHalf,
|
||||||
|
|
|
||||||
552
breadd/src/ipc/module_host_bridge.rs
Normal file
552
breadd/src/ipc/module_host_bridge.rs
Normal file
|
|
@ -0,0 +1,552 @@
|
||||||
|
//! The `module_host.*` side of the IPC protocol (Workstream G): once a
|
||||||
|
//! connection presents a valid one-time token via `module_host.hello`, this
|
||||||
|
//! module takes over its remaining lifetime as a bidirectional RPC bridge —
|
||||||
|
//! ordinary request/response lines interleaved with unsolicited
|
||||||
|
//! event/timer pushes — for exactly one `bread-module-host` child.
|
||||||
|
//!
|
||||||
|
//! # Wire shape
|
||||||
|
//!
|
||||||
|
//! Requests/responses reuse the existing `IpcRequest`/`IpcResponse`
|
||||||
|
//! envelope unchanged. Pushes are a separate, `"push"`-tagged envelope
|
||||||
|
//! (`bread_shared::ModuleHostPush`) that never collides with a response —
|
||||||
|
//! see that type's doc comment. A single `mpsc` channel (`out_tx`/`out_rx`)
|
||||||
|
//! feeds one writer task so both kinds of outgoing line interleave safely
|
||||||
|
//! on the one underlying socket without any extra locking.
|
||||||
|
//!
|
||||||
|
//! # Where the "belt" is, relative to the "suspenders"
|
||||||
|
//!
|
||||||
|
//! Every method here re-checks the module's granted `PermissionKind`s
|
||||||
|
//! before doing anything — `fs_read`/`fs_write`/`exec`/`exec_capture`
|
||||||
|
//! additionally check the manifest's `path`/`bin` scoping hint. This is
|
||||||
|
//! the belt; `module_host::apply_sandbox`'s Landlock ruleset (enforced by
|
||||||
|
//! the kernel on the child process directly, independent of whether the
|
||||||
|
//! child even uses this RPC bridge at all) is the suspenders. A module
|
||||||
|
//! that skips this bridge entirely and calls `os.execute`/`io.open`
|
||||||
|
//! directly from Lua bypasses every check in this file — that's the
|
||||||
|
//! scenario the sandbox exists for, not this file.
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bread_shared::{glob, ModuleHostHello, ModuleHostPush, ModulePermission, PermissionKind};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tokio::io::{AsyncWriteExt, BufReader, Lines};
|
||||||
|
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
|
||||||
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::core::types::ModuleLoadState;
|
||||||
|
use crate::module_host::ModuleHostOutcome;
|
||||||
|
|
||||||
|
use super::{IpcRequest, IpcResponse, Server, API_VERSION};
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// Authenticate a `module_host.hello` request against the pending-token
|
||||||
|
/// registry and, on success, run this connection's dedicated
|
||||||
|
/// request/response + push loop until it closes. Mirrors
|
||||||
|
/// `handle_connection`'s `events.subscribe` special-case in spirit
|
||||||
|
/// (taking over the rest of the connection's lifetime) but is
|
||||||
|
/// bidirectional rather than one-directional.
|
||||||
|
pub(super) async fn handle_module_host_connection(
|
||||||
|
&self,
|
||||||
|
hello_req: IpcRequest,
|
||||||
|
mut lines: Lines<BufReader<OwnedReadHalf>>,
|
||||||
|
write_half: OwnedWriteHalf,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let hello_id = hello_req.id.clone();
|
||||||
|
let token = hello_req
|
||||||
|
.params
|
||||||
|
.get("token")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
|
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();
|
||||||
|
let writer_task = tokio::spawn(async move {
|
||||||
|
let mut write_half = write_half;
|
||||||
|
while let Some(line) = out_rx.recv().await {
|
||||||
|
if write_half.write_all(line.as_bytes()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let send = |resp: IpcResponse| -> anyhow::Result<()> {
|
||||||
|
let line = format!("{}\n", serde_json::to_string(&resp)?);
|
||||||
|
let _ = out_tx.send(line);
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(token) = token else {
|
||||||
|
send(IpcResponse {
|
||||||
|
id: hello_id,
|
||||||
|
result: None,
|
||||||
|
error: Some("module_host.hello: missing token".to_string()),
|
||||||
|
})?;
|
||||||
|
drop(out_tx);
|
||||||
|
let _ = writer_task.await;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(pending) = self.module_host_registry.take_pending(&token) else {
|
||||||
|
send(IpcResponse {
|
||||||
|
id: hello_id,
|
||||||
|
result: None,
|
||||||
|
error: Some("invalid or expired module-host token".to_string()),
|
||||||
|
})?;
|
||||||
|
drop(out_tx);
|
||||||
|
let _ = writer_task.await;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let module_name = pending.module_name.clone();
|
||||||
|
let permissions = pending.permissions.clone();
|
||||||
|
let mut outcome_tx = Some(pending.outcome_tx);
|
||||||
|
|
||||||
|
let hello_result = ModuleHostHello {
|
||||||
|
module: module_name.clone(),
|
||||||
|
permissions: permissions.clone(),
|
||||||
|
api_version: API_VERSION.to_string(),
|
||||||
|
};
|
||||||
|
send(IpcResponse {
|
||||||
|
id: hello_id,
|
||||||
|
result: Some(serde_json::to_value(&hello_result)?),
|
||||||
|
error: None,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!(module = %module_name, permissions = ?permissions, "module-host authenticated");
|
||||||
|
|
||||||
|
let mut subs: HashMap<String, JoinHandle<()>> = HashMap::new();
|
||||||
|
let mut timers: HashMap<String, JoinHandle<()>> = HashMap::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let line = match lines.next_line().await {
|
||||||
|
Ok(Some(l)) => l,
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(module = %module_name, error = %e, "module-host connection read error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let req: IpcRequest = match serde_json::from_str(&line) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
send(IpcResponse {
|
||||||
|
id: "?".to_string(),
|
||||||
|
result: None,
|
||||||
|
error: Some(format!("parse error: {e}")),
|
||||||
|
})?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let req_id = req.id.clone();
|
||||||
|
let result = self
|
||||||
|
.dispatch_module_host_method(
|
||||||
|
&req,
|
||||||
|
&module_name,
|
||||||
|
&permissions,
|
||||||
|
&out_tx,
|
||||||
|
&mut subs,
|
||||||
|
&mut timers,
|
||||||
|
&mut outcome_tx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let resp = match result {
|
||||||
|
Ok(v) => IpcResponse {
|
||||||
|
id: req_id,
|
||||||
|
result: Some(v),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(e) => IpcResponse {
|
||||||
|
id: req_id,
|
||||||
|
result: None,
|
||||||
|
error: Some(e),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
send(resp)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (_, h) in subs.drain() {
|
||||||
|
h.abort();
|
||||||
|
}
|
||||||
|
for (_, h) in timers.drain() {
|
||||||
|
h.abort();
|
||||||
|
}
|
||||||
|
drop(out_tx);
|
||||||
|
let _ = writer_task.await;
|
||||||
|
|
||||||
|
// The connection dropped before the module ever reported
|
||||||
|
// load-success/load-failure (e.g. it crashed mid-`init.lua`, or
|
||||||
|
// never got that far) — unblock whatever's still waiting in
|
||||||
|
// `spawn_module_host` rather than leaving it to time out.
|
||||||
|
if let Some(tx) = outcome_tx.take() {
|
||||||
|
let _ = tx.send(ModuleHostOutcome::LoadError(format!(
|
||||||
|
"module-host connection for '{module_name}' closed before reporting ready"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn dispatch_module_host_method(
|
||||||
|
&self,
|
||||||
|
req: &IpcRequest,
|
||||||
|
module_name: &str,
|
||||||
|
permissions: &[ModulePermission],
|
||||||
|
out_tx: &mpsc::UnboundedSender<String>,
|
||||||
|
subs: &mut HashMap<String, JoinHandle<()>>,
|
||||||
|
timers: &mut HashMap<String, JoinHandle<()>>,
|
||||||
|
outcome_tx: &mut Option<std::sync::mpsc::Sender<ModuleHostOutcome>>,
|
||||||
|
) -> std::result::Result<Value, String> {
|
||||||
|
match req.method.as_str() {
|
||||||
|
"module_host.on" | "module_host.once" => {
|
||||||
|
let once = req.method == "module_host.once";
|
||||||
|
let pattern = req
|
||||||
|
.params
|
||||||
|
.get("pattern")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("missing pattern")?
|
||||||
|
.to_string();
|
||||||
|
let sub_id = Uuid::new_v4().to_string();
|
||||||
|
let mut rx = self.event_tx.subscribe();
|
||||||
|
let out_tx2 = out_tx.clone();
|
||||||
|
let sid = sub_id.clone();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(evt) => {
|
||||||
|
if glob::matches_pattern(&pattern, &evt.event) {
|
||||||
|
let push = ModuleHostPush::Event {
|
||||||
|
subscription_id: sid.clone(),
|
||||||
|
event: evt,
|
||||||
|
};
|
||||||
|
let Ok(line) = serde_json::to_string(&push) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if out_tx2.send(format!("{line}\n")).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if once {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
subs.insert(sub_id.clone(), handle);
|
||||||
|
Ok(json!({ "subscription_id": sub_id }))
|
||||||
|
}
|
||||||
|
"module_host.off" => {
|
||||||
|
let id = req
|
||||||
|
.params
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("missing id")?
|
||||||
|
.to_string();
|
||||||
|
if let Some(h) = subs.remove(&id) {
|
||||||
|
h.abort();
|
||||||
|
}
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
"module_host.after" | "module_host.every" => {
|
||||||
|
let every = req.method == "module_host.every";
|
||||||
|
let key = if every { "interval_ms" } else { "delay_ms" };
|
||||||
|
let ms = req
|
||||||
|
.params
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
.max(1);
|
||||||
|
let timer_id = Uuid::new_v4().to_string();
|
||||||
|
let out_tx2 = out_tx.clone();
|
||||||
|
let tid = timer_id.clone();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
if every {
|
||||||
|
let mut iv = tokio::time::interval(Duration::from_millis(ms));
|
||||||
|
iv.tick().await; // first tick fires immediately; consume it so the module's first callback fires after one full interval
|
||||||
|
loop {
|
||||||
|
iv.tick().await;
|
||||||
|
let push = ModuleHostPush::Timer {
|
||||||
|
timer_id: tid.clone(),
|
||||||
|
};
|
||||||
|
let Ok(line) = serde_json::to_string(&push) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if out_tx2.send(format!("{line}\n")).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tokio::time::sleep(Duration::from_millis(ms)).await;
|
||||||
|
let push = ModuleHostPush::Timer { timer_id: tid };
|
||||||
|
if let Ok(line) = serde_json::to_string(&push) {
|
||||||
|
let _ = out_tx2.send(format!("{line}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
timers.insert(timer_id.clone(), handle);
|
||||||
|
Ok(json!({ "timer_id": timer_id }))
|
||||||
|
}
|
||||||
|
"module_host.cancel" => {
|
||||||
|
let id = req
|
||||||
|
.params
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("missing id")?
|
||||||
|
.to_string();
|
||||||
|
if let Some(h) = timers.remove(&id) {
|
||||||
|
h.abort();
|
||||||
|
}
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
"module_host.state_get" => {
|
||||||
|
if !permissions
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.kind == PermissionKind::StateRead)
|
||||||
|
{
|
||||||
|
return Err("state.read not granted to this module".to_string());
|
||||||
|
}
|
||||||
|
let key = req
|
||||||
|
.params
|
||||||
|
.get("key")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
match self.state_handle.state_get(&key).await {
|
||||||
|
Some(v) => Ok(json!({ "value": v })),
|
||||||
|
None => Err("state path not found".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"module_host.emit" => {
|
||||||
|
let event = req
|
||||||
|
.params
|
||||||
|
.get("event")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("missing event")?
|
||||||
|
.to_string();
|
||||||
|
let data = req.params.get("data").cloned().unwrap_or_else(|| json!({}));
|
||||||
|
self.manual_emit(&event, data)
|
||||||
|
}
|
||||||
|
"module_host.log" | "module_host.warn" | "module_host.error" => {
|
||||||
|
let message = req
|
||||||
|
.params
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
match req.method.as_str() {
|
||||||
|
"module_host.log" => info!(module = %module_name, "{message}"),
|
||||||
|
"module_host.warn" => warn!(module = %module_name, "{message}"),
|
||||||
|
_ => error!(module = %module_name, "{message}"),
|
||||||
|
}
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
"module_host.fs_read" => {
|
||||||
|
if !permissions
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.kind == PermissionKind::FsRead)
|
||||||
|
{
|
||||||
|
return Err("fs.read not granted to this module".to_string());
|
||||||
|
}
|
||||||
|
let path = req
|
||||||
|
.params
|
||||||
|
.get("path")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("missing path")?
|
||||||
|
.to_string();
|
||||||
|
if !path_allowed(permissions, PermissionKind::FsRead, &path) {
|
||||||
|
return Err(format!(
|
||||||
|
"path '{path}' is outside this module's granted fs.read scope"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let expanded = bread_shared::expand_path(&path);
|
||||||
|
let content = std::fs::read_to_string(&expanded).ok();
|
||||||
|
Ok(json!({ "content": content }))
|
||||||
|
}
|
||||||
|
"module_host.fs_write" => {
|
||||||
|
if !permissions
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.kind == PermissionKind::FsWrite)
|
||||||
|
{
|
||||||
|
return Err("fs.write not granted to this module".to_string());
|
||||||
|
}
|
||||||
|
let path = req
|
||||||
|
.params
|
||||||
|
.get("path")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("missing path")?
|
||||||
|
.to_string();
|
||||||
|
if !path_allowed(permissions, PermissionKind::FsWrite, &path) {
|
||||||
|
return Err(format!(
|
||||||
|
"path '{path}' is outside this module's granted fs.write scope"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let content = req
|
||||||
|
.params
|
||||||
|
.get("content")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let expanded = bread_shared::expand_path(&path);
|
||||||
|
if let Some(parent) = expanded.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
std::fs::write(&expanded, content).map_err(|e| e.to_string())?;
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
"module_host.exec" => {
|
||||||
|
if !permissions.iter().any(|p| p.kind == PermissionKind::Exec) {
|
||||||
|
return Err("exec not granted to this module".to_string());
|
||||||
|
}
|
||||||
|
let cmd = req
|
||||||
|
.params
|
||||||
|
.get("cmd")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("missing cmd")?
|
||||||
|
.to_string();
|
||||||
|
if !bin_allowed(permissions, &cmd) {
|
||||||
|
return Err("command is outside this module's granted exec bin scope".to_string());
|
||||||
|
}
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
match std::process::Command::new("sh").arg("-c").arg(&cmd).status() {
|
||||||
|
Ok(status) if !status.success() => {
|
||||||
|
warn!(cmd = %cmd, code = ?status.code(), "module_host.exec exited non-zero");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(cmd = %cmd, error = %e, "module_host.exec failed to spawn");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
"module_host.exec_capture" => {
|
||||||
|
if !permissions.iter().any(|p| p.kind == PermissionKind::Exec) {
|
||||||
|
return Err("exec not granted to this module".to_string());
|
||||||
|
}
|
||||||
|
let cmd = req
|
||||||
|
.params
|
||||||
|
.get("cmd")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("missing cmd")?
|
||||||
|
.to_string();
|
||||||
|
if !bin_allowed(permissions, &cmd) {
|
||||||
|
return Err("command is outside this module's granted exec bin scope".to_string());
|
||||||
|
}
|
||||||
|
let timeout_ms = req
|
||||||
|
.params
|
||||||
|
.get("timeout_ms")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.unwrap_or(2000);
|
||||||
|
let handle =
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
std::process::Command::new("sh").arg("-c").arg(&cmd).output()
|
||||||
|
});
|
||||||
|
match tokio::time::timeout(Duration::from_millis(timeout_ms + 500), handle).await {
|
||||||
|
Ok(Ok(Ok(out))) => Ok(json!({
|
||||||
|
"ok": out.status.success(),
|
||||||
|
"stdout": String::from_utf8_lossy(&out.stdout),
|
||||||
|
})),
|
||||||
|
_ => Ok(json!({ "ok": false, "stdout": "" })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"module_host.status" => {
|
||||||
|
let state = req
|
||||||
|
.params
|
||||||
|
.get("state")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("load_error");
|
||||||
|
let error = req
|
||||||
|
.params
|
||||||
|
.get("error")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string);
|
||||||
|
let (load_state, outcome) = if state == "loaded" {
|
||||||
|
(ModuleLoadState::Loaded, ModuleHostOutcome::Ready)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
ModuleLoadState::LoadError,
|
||||||
|
ModuleHostOutcome::LoadError(
|
||||||
|
error.clone().unwrap_or_else(|| "module load failed".to_string()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// Out-of-process modules are never "ungated": they only
|
||||||
|
// exist in this branch because they declared a manifest
|
||||||
|
// (see lua/mod.rs's load_module), so `ungated=false`
|
||||||
|
// unconditionally here is correct, not a placeholder.
|
||||||
|
self.state_handle.set_module_status_ex(
|
||||||
|
module_name.to_string(),
|
||||||
|
load_state,
|
||||||
|
error,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if let Some(tx) = outcome_tx.take() {
|
||||||
|
let _ = tx.send(outcome);
|
||||||
|
}
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
other => Err(format!("unknown module_host method: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Belt-and-suspenders path scoping for `fs_read`/`fs_write`: if the
|
||||||
|
/// manifest declared a `path` hint for this permission kind, the requested
|
||||||
|
/// path (after `~`-expansion) must fall under at least one granted prefix.
|
||||||
|
/// No hint at all means this RPC-level check stays permissive (matching
|
||||||
|
/// Workstream D's existing "un-hinted grant = ungated within that
|
||||||
|
/// namespace" behavior) — Landlock's own ruleset (built independently in
|
||||||
|
/// `module_host::apply_sandbox`) does NOT grant a filesystem rule for an
|
||||||
|
/// un-hinted permission, so the direct `os`/`io` escape hatch remains
|
||||||
|
/// kernel-denied for that case regardless of what this function returns.
|
||||||
|
fn path_allowed(permissions: &[ModulePermission], kind: PermissionKind, path: &str) -> bool {
|
||||||
|
let hints: Vec<&String> = permissions
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.kind == kind)
|
||||||
|
.filter_map(|p| p.path.as_ref())
|
||||||
|
.collect();
|
||||||
|
if hints.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let expanded = bread_shared::expand_path(path);
|
||||||
|
hints.iter().any(|hint| {
|
||||||
|
let hint_expanded = bread_shared::expand_path(hint);
|
||||||
|
expanded.starts_with(&hint_expanded)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same idea as [`path_allowed`] for `exec`'s `bin` hint: compares by file
|
||||||
|
/// name (so `bin = "hyprpaper"` matches a command invoking
|
||||||
|
/// `/usr/bin/hyprpaper` as well as a bare `hyprpaper`) or an exact leading
|
||||||
|
/// token match.
|
||||||
|
fn bin_allowed(permissions: &[ModulePermission], cmd: &str) -> bool {
|
||||||
|
let hints: Vec<&String> = permissions
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.kind == PermissionKind::Exec)
|
||||||
|
.filter_map(|p| p.bin.as_ref())
|
||||||
|
.collect();
|
||||||
|
if hints.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let first_word = cmd.split_whitespace().next().unwrap_or("");
|
||||||
|
let cmd_leaf = std::path::Path::new(first_word)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|f| f.to_str())
|
||||||
|
.unwrap_or(first_word);
|
||||||
|
hints.iter().any(|hint| {
|
||||||
|
let hint_leaf = std::path::Path::new(hint.as_str())
|
||||||
|
.file_name()
|
||||||
|
.and_then(|f| f.to_str())
|
||||||
|
.unwrap_or(hint.as_str());
|
||||||
|
cmd_leaf == hint_leaf || first_word == hint.as_str()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ use crate::core::subscriptions::SubscriptionId;
|
||||||
use crate::core::types::{
|
use crate::core::types::{
|
||||||
DeviceRule, MatchCondition, ModuleLoadState, RuntimeState, WorkflowState, WorkflowStatus,
|
DeviceRule, MatchCondition, ModuleLoadState, RuntimeState, WorkflowState, WorkflowStatus,
|
||||||
};
|
};
|
||||||
|
use crate::module_host::{self, ModuleHostOutcome, ModuleHostRegistry};
|
||||||
use bread_shared::now_unix_ms;
|
use bread_shared::now_unix_ms;
|
||||||
|
|
||||||
pub enum LuaMessage {
|
pub enum LuaMessage {
|
||||||
|
|
@ -90,6 +91,7 @@ pub fn spawn_runtime(
|
||||||
config: Config,
|
config: Config,
|
||||||
state_handle: StateHandle,
|
state_handle: StateHandle,
|
||||||
emit_tx: mpsc::UnboundedSender<BreadEvent>,
|
emit_tx: mpsc::UnboundedSender<BreadEvent>,
|
||||||
|
module_host_registry: ModuleHostRegistry,
|
||||||
) -> Result<RuntimeHandle> {
|
) -> Result<RuntimeHandle> {
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
let recent_errors = Arc::new(Mutex::new(VecDeque::with_capacity(50)));
|
let recent_errors = Arc::new(Mutex::new(VecDeque::with_capacity(50)));
|
||||||
|
|
@ -114,6 +116,7 @@ pub fn spawn_runtime(
|
||||||
emit_tx,
|
emit_tx,
|
||||||
thread_tx.clone(),
|
thread_tx.clone(),
|
||||||
recent_errors,
|
recent_errors,
|
||||||
|
module_host_registry,
|
||||||
) {
|
) {
|
||||||
Ok(engine) => engine,
|
Ok(engine) => engine,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|
@ -246,6 +249,11 @@ struct LuaEngine {
|
||||||
modules_config: ModulesConfig,
|
modules_config: ModulesConfig,
|
||||||
notifications_config: NotificationsConfig,
|
notifications_config: NotificationsConfig,
|
||||||
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
|
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
|
||||||
|
/// Workstream G: spawn/token bookkeeping for out-of-process module
|
||||||
|
/// hosts, shared with `ipc::Server` (which authenticates the spawned
|
||||||
|
/// children and serves their RPC calls). See `crate::module_host`.
|
||||||
|
module_host_registry: ModuleHostRegistry,
|
||||||
|
socket_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LuaEngine {
|
impl LuaEngine {
|
||||||
|
|
@ -255,7 +263,9 @@ impl LuaEngine {
|
||||||
emit_tx: mpsc::UnboundedSender<BreadEvent>,
|
emit_tx: mpsc::UnboundedSender<BreadEvent>,
|
||||||
lua_tx: mpsc::UnboundedSender<LuaMessage>,
|
lua_tx: mpsc::UnboundedSender<LuaMessage>,
|
||||||
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
|
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
|
||||||
|
module_host_registry: ModuleHostRegistry,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
|
let socket_path = config.socket_path();
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
lua: Lua::new(),
|
lua: Lua::new(),
|
||||||
handlers: Arc::new(Mutex::new(HashMap::new())),
|
handlers: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
|
@ -271,6 +281,8 @@ impl LuaEngine {
|
||||||
state_handle,
|
state_handle,
|
||||||
emit_tx,
|
emit_tx,
|
||||||
lua_tx,
|
lua_tx,
|
||||||
|
module_host_registry,
|
||||||
|
socket_path,
|
||||||
entry_point: config.lua_entry_point(),
|
entry_point: config.lua_entry_point(),
|
||||||
module_path: config.lua_module_path(),
|
module_path: config.lua_module_path(),
|
||||||
modules_config: config.modules.clone(),
|
modules_config: config.modules.clone(),
|
||||||
|
|
@ -1525,15 +1537,37 @@ impl LuaEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_module(&self, decl: &ModuleDecl) -> Result<()> {
|
fn load_module(&self, decl: &ModuleDecl) -> Result<()> {
|
||||||
|
// Workstream G branch point: a third-party module that declared
|
||||||
|
// `[[permissions]]` (opted into the D capability-manifest system —
|
||||||
|
// `decl.permissions.is_some()`, including `Some(&[])`) gets spawned
|
||||||
|
// as a separate, OS-sandboxed `bread-module-host` process instead of
|
||||||
|
// being loaded into this Lua VM at all. Its Lua state, `bread.on`
|
||||||
|
// handlers, timers, etc. all live in that other process from here
|
||||||
|
// on — none of this engine's module-table/on_load bookkeeping below
|
||||||
|
// applies to it, hence the early return.
|
||||||
|
//
|
||||||
|
// `decl.permissions.is_none()` (no manifest, or a manifest with no
|
||||||
|
// `permissions` key) falls through to the unchanged in-process,
|
||||||
|
// unscoped path for backward compatibility — see
|
||||||
|
// `ModuleDecl::permissions`'s doc comment and `Documentation.md`'s
|
||||||
|
// "Workstream G" section for why this is a deliberate scope
|
||||||
|
// decision rather than an oversight: Landlock needs concrete rules
|
||||||
|
// to build from, and "no manifest at all" carries none.
|
||||||
|
if decl.source.is_none() {
|
||||||
|
if let Some(permissions) = decl.permissions.as_ref() {
|
||||||
|
return self.load_out_of_process_module(decl, permissions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.set_current_module(Some(decl.name.clone()));
|
self.set_current_module(Some(decl.name.clone()));
|
||||||
let result = if let Some(source) = decl.source {
|
let result = if let Some(source) = decl.source {
|
||||||
// Builtins (bread.monitors/devices/workspaces/binds) — embedded
|
// Builtins (bread.monitors/devices/workspaces/binds) — embedded
|
||||||
// source, always the full ambient bread table, never scoped.
|
// source, always the full ambient bread table, never scoped.
|
||||||
self.load_lua_source(source, &decl.name)
|
self.load_lua_source(source, &decl.name)
|
||||||
} else {
|
} else {
|
||||||
// Third-party, on-disk modules only. Capability-scoped per
|
// Third-party, on-disk, no-manifest module: today's original
|
||||||
// decl.permissions — see load_scoped_lua_file.
|
// behavior, unchanged (full ungated in-process access).
|
||||||
self.load_scoped_lua_file(&decl.path, &decl.name, decl.permissions.as_deref())
|
self.load_scoped_lua_file(&decl.path, &decl.name, None)
|
||||||
};
|
};
|
||||||
self.set_current_module(None);
|
self.set_current_module(None);
|
||||||
result?;
|
result?;
|
||||||
|
|
@ -1545,6 +1579,33 @@ impl LuaEngine {
|
||||||
self.run_on_load(&decl.name)
|
self.run_on_load(&decl.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn (or respawn, on `bread reload`) a sandboxed `bread-module-host`
|
||||||
|
/// child for `decl` and block until it reports ready or fails — see
|
||||||
|
/// `crate::module_host::spawn_module_host`. Blocking here (rather than
|
||||||
|
/// making `load_module` async) keeps `load_module`'s existing
|
||||||
|
/// synchronous "a module either loaded or it didn't" contract intact
|
||||||
|
/// for callers like `load_init_and_modules` and the `modules.reload`
|
||||||
|
/// IPC method, which both expect to know Loaded-vs-LoadError before
|
||||||
|
/// they return.
|
||||||
|
fn load_out_of_process_module(
|
||||||
|
&self,
|
||||||
|
decl: &ModuleDecl,
|
||||||
|
permissions: &[ModulePermission],
|
||||||
|
) -> Result<()> {
|
||||||
|
let outcome = module_host::spawn_module_host(
|
||||||
|
&self.module_host_registry,
|
||||||
|
&decl.name,
|
||||||
|
&decl.path,
|
||||||
|
permissions,
|
||||||
|
&self.socket_path,
|
||||||
|
&self.emit_tx,
|
||||||
|
)?;
|
||||||
|
match outcome {
|
||||||
|
ModuleHostOutcome::Ready => Ok(()),
|
||||||
|
ModuleHostOutcome::LoadError(err) => Err(anyhow!(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load `init.lua` (the trusted entry point) or any other file that
|
/// Load `init.lua` (the trusted entry point) or any other file that
|
||||||
/// should see the real, unscoped `bread` global exactly like today.
|
/// should see the real, unscoped `bread` global exactly like today.
|
||||||
/// Not used for third-party modules — see [`load_scoped_lua_file`].
|
/// Not used for third-party modules — see [`load_scoped_lua_file`].
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ mod adapters;
|
||||||
mod core;
|
mod core;
|
||||||
mod ipc;
|
mod ipc;
|
||||||
mod lua;
|
mod lua;
|
||||||
|
mod module_host;
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::atomic::AtomicU64;
|
use std::sync::atomic::AtomicU64;
|
||||||
|
|
@ -37,9 +38,14 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
let subscription_count = Arc::new(AtomicU64::new(0));
|
let subscription_count = Arc::new(AtomicU64::new(0));
|
||||||
let state_handle = StateHandle::new(state.clone(), state_cmd_tx);
|
let state_handle = StateHandle::new(state.clone(), state_cmd_tx);
|
||||||
|
let module_host_registry = module_host::ModuleHostRegistry::new();
|
||||||
|
|
||||||
let lua_runtime =
|
let lua_runtime = lua::spawn_runtime(
|
||||||
lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?;
|
config.clone(),
|
||||||
|
state_handle.clone(),
|
||||||
|
normalized_tx.clone(),
|
||||||
|
module_host_registry.clone(),
|
||||||
|
)?;
|
||||||
let lua_tx = lua_runtime.sender();
|
let lua_tx = lua_runtime.sender();
|
||||||
|
|
||||||
tokio::spawn(run_state_engine(
|
tokio::spawn(run_state_engine(
|
||||||
|
|
@ -119,6 +125,7 @@ async fn main() -> Result<()> {
|
||||||
adapter_status,
|
adapter_status,
|
||||||
subscription_count,
|
subscription_count,
|
||||||
event_buffer,
|
event_buffer,
|
||||||
|
module_host_registry.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
info!("breadd fully started");
|
info!("breadd fully started");
|
||||||
|
|
@ -136,6 +143,7 @@ async fn main() -> Result<()> {
|
||||||
let _ = shutdown_tx.send(true);
|
let _ = shutdown_tx.send(true);
|
||||||
|
|
||||||
lua_runtime.shutdown();
|
lua_runtime.shutdown();
|
||||||
|
module_host_registry.shutdown_all();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
732
breadd/src/module_host.rs
Normal file
732
breadd/src/module_host.rs
Normal file
|
|
@ -0,0 +1,732 @@
|
||||||
|
//! Spawning, token-based identity, and OS-level sandboxing for out-of-process
|
||||||
|
//! module hosts (Workstream G).
|
||||||
|
//!
|
||||||
|
//! # Why a process, not just the existing Lua-level scoping
|
||||||
|
//!
|
||||||
|
//! Workstream D's `build_scoped_env` (see `lua/mod.rs`) gates the
|
||||||
|
//! *documented* `bread.*` surface by controlling which keys exist on the
|
||||||
|
//! `bread` table a module's chunk sees. Its own doc comment says plainly
|
||||||
|
//! that `os.execute`/`io.open`/`debug.*` remain fully reachable from a
|
||||||
|
//! scoped module — Lua's stdlib isn't sandboxed at all, only the `bread`
|
||||||
|
//! table is. A module that never calls `bread.fs`/`bread.exec` and instead
|
||||||
|
//! calls `io.open`/`os.execute` directly bypasses the whole mechanism,
|
||||||
|
//! because everything still runs as Lua code inside `breadd`'s own OS
|
||||||
|
//! process, sharing its real filesystem/exec access at the kernel level.
|
||||||
|
//!
|
||||||
|
//! This module closes that gap for any module that opted into the
|
||||||
|
//! capability-manifest system (`decl.permissions.is_some()` — see
|
||||||
|
//! `lua/mod.rs`'s `load_module`): instead of loading its chunk in-process,
|
||||||
|
//! `breadd` spawns a separate `bread-module-host` OS process for it,
|
||||||
|
//! restricted by a Landlock ruleset built from that module's granted
|
||||||
|
//! `ModulePermission`s *before* the child ever executes a byte of the
|
||||||
|
//! module's Lua.
|
||||||
|
//!
|
||||||
|
//! # Why Landlock over bubblewrap/firejail
|
||||||
|
//!
|
||||||
|
//! - Pure Rust, no external sandboxing binary dependency — this workspace's
|
||||||
|
//! existing style already favors native Rust crates over shelling out
|
||||||
|
//! (see e.g. `udev`, `zbus`, `rtnetlink` instead of wrapping CLI tools).
|
||||||
|
//! - Unprivileged: no setuid helper, no CAP_SYS_ADMIN, works from an
|
||||||
|
//! ordinary user session exactly like the rest of `breadd`.
|
||||||
|
//! - Available since Linux 5.13; this repo's dev kernel is 6.18 and the
|
||||||
|
//! mechanism was verified against it directly before adoption (see the
|
||||||
|
//! `landlock` entry in the workspace `Cargo.toml` and
|
||||||
|
//! `module_host::tests::landlock_denies_reads_outside_granted_path`
|
||||||
|
//! below) — a `pre_exec`-restricted child process attempting to read a
|
||||||
|
//! file outside its granted rule set gets `EACCES` from the kernel, not a
|
||||||
|
//! Lua-level error.
|
||||||
|
//! - `bubblewrap`-wrapping remains a documented fallback if a target
|
||||||
|
//! platform's kernel lacks Landlock support (pre-5.13, or a hardened
|
||||||
|
//! kernel config with it compiled out) — not implemented here since
|
||||||
|
//! Landlock covers this repo's actual target (a modern desktop Linux
|
||||||
|
//! kernel) and keeps the dependency footprint native-Rust-only.
|
||||||
|
//!
|
||||||
|
//! # What Landlock does *not* cover here (P2, explicitly deferred)
|
||||||
|
//!
|
||||||
|
//! Network access. Landlock gained TCP bind/connect mediation in ABI v4+
|
||||||
|
//! (kernel 6.7+), but wiring a `network` permission kind through the
|
||||||
|
//! manifest schema, `PermissionKind`, and this sandbox builder is scoped
|
||||||
|
//! out of this workstream's P0 — see `Documentation.md`.
|
||||||
|
//!
|
||||||
|
//! # The token handshake
|
||||||
|
//!
|
||||||
|
//! Workstream A deliberately did not build a generic IPC connection-identity
|
||||||
|
//! system (it closed a narrower spoofing gap instead), so there's no
|
||||||
|
//! existing `module:<name>` identity concept to hook into. This module adds
|
||||||
|
//! the minimal mechanism Workstream G actually needs: `breadd` generates a
|
||||||
|
//! random one-time token when spawning a module-host child, hands it to the
|
||||||
|
//! child via `$BREAD_MODULE_TOKEN` (an env var, not argv — argv is visible
|
||||||
|
//! to any process on the system via `/proc/<pid>/cmdline`, env vars are not
|
||||||
|
//! without `/proc/<pid>/environ` + matching privileges), and the child's
|
||||||
|
//! first message on the IPC socket (`module_host.hello`) presents that
|
||||||
|
//! token. `breadd` looks up which module name/manifest/permission set the
|
||||||
|
//! token was issued for — see [`ModuleHostRegistry::take_pending`] — rather
|
||||||
|
//! than trusting any name the child process might assert about itself.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::os::unix::process::{CommandExt, ExitStatusExt};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use bread_shared::{AdapterSource, BreadEvent, ModulePermission, PermissionKind};
|
||||||
|
use landlock::{
|
||||||
|
make_bitflags, Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr,
|
||||||
|
RulesetCreatedAttr, RulesetStatus, ABI,
|
||||||
|
};
|
||||||
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
/// What `load_module` ultimately learns about a spawn attempt, reported back
|
||||||
|
/// over IPC (`module_host.hello` consumes the pending entry;
|
||||||
|
/// `module_host.status` supplies the final verdict) via
|
||||||
|
/// [`PendingModuleHost::outcome_tx`].
|
||||||
|
pub enum ModuleHostOutcome {
|
||||||
|
Ready,
|
||||||
|
LoadError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `breadd` knows about a spawned-but-not-yet-authenticated module-host
|
||||||
|
/// child, keyed by the one-time token it was handed. Consumed exactly once,
|
||||||
|
/// by whichever connection presents the matching token first (see
|
||||||
|
/// `ipc::Server`'s `module_host.hello` handling).
|
||||||
|
pub struct PendingModuleHost {
|
||||||
|
pub module_name: String,
|
||||||
|
pub permissions: Vec<ModulePermission>,
|
||||||
|
pub outcome_tx: std::sync::mpsc::Sender<ModuleHostOutcome>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActiveModuleHost {
|
||||||
|
pid: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared handle to the pending-token / active-child bookkeeping, cloned
|
||||||
|
/// into both the Lua engine thread (which spawns children) and the IPC
|
||||||
|
/// server (which authenticates them and serves their RPC calls).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ModuleHostRegistry {
|
||||||
|
inner: Arc<Mutex<Inner>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Inner {
|
||||||
|
pending: HashMap<String, PendingModuleHost>,
|
||||||
|
active: HashMap<String, ActiveModuleHost>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModuleHostRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModuleHostRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Mutex::new(Inner::default())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_pending(&self, token: String, pending: PendingModuleHost) {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.pending
|
||||||
|
.insert(token, pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-time consumption of a pending token, called from the IPC side
|
||||||
|
/// when a connection presents it via `module_host.hello`. Returns
|
||||||
|
/// `None` for an unknown/already-consumed/expired token — the caller
|
||||||
|
/// must not extend any trust to that connection in that case.
|
||||||
|
pub fn take_pending(&self, token: &str) -> Option<PendingModuleHost> {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.pending
|
||||||
|
.remove(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_active(&self, name: String, pid: u32) {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.active
|
||||||
|
.insert(name, ActiveModuleHost { pid });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_active(&self, name: &str) {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.active
|
||||||
|
.remove(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort SIGTERM of a previously spawned module-host for `name`,
|
||||||
|
/// if still tracked as active. Called at the top of
|
||||||
|
/// [`spawn_module_host`] so `bread reload`/`modules.reload` respawning
|
||||||
|
/// the same module doesn't leak an orphaned duplicate process still
|
||||||
|
/// holding an open IPC connection and reacting to events alongside its
|
||||||
|
/// replacement. The old process's own reap thread (started when it was
|
||||||
|
/// first spawned) will still notice it exit and emit
|
||||||
|
/// `bread.module.crashed` for it — a known rough edge documented in
|
||||||
|
/// `Documentation.md`: an intentional reload-triggered replacement
|
||||||
|
/// currently looks identical, on the wire, to an unexpected crash.
|
||||||
|
fn terminate_existing(&self, name: &str) {
|
||||||
|
let pid = {
|
||||||
|
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
inner.active.get(name).map(|a| a.pid)
|
||||||
|
};
|
||||||
|
if let Some(pid) = pid {
|
||||||
|
unsafe {
|
||||||
|
libc::kill(pid as libc::pid_t, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort SIGTERM of every still-tracked module-host child. Called
|
||||||
|
/// from `breadd`'s shutdown path so stopping the daemon doesn't leave
|
||||||
|
/// orphaned sandboxed processes holding a now-dead socket connection.
|
||||||
|
pub fn shutdown_all(&self) {
|
||||||
|
let pids: Vec<u32> = {
|
||||||
|
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
inner.active.values().map(|a| a.pid).collect()
|
||||||
|
};
|
||||||
|
for pid in pids {
|
||||||
|
// SAFETY: kill(2) with a pid we just read from our own
|
||||||
|
// bookkeeping and a plain termination signal; no memory safety
|
||||||
|
// concerns, just an FFI call.
|
||||||
|
unsafe {
|
||||||
|
libc::kill(pid as libc::pid_t, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long `load_module` blocks waiting for a freshly spawned module-host
|
||||||
|
/// to either report ready (`module_host.status{state:"loaded"}`) or fail —
|
||||||
|
/// mirrors the synchronous "a module either loaded or it didn't" contract
|
||||||
|
/// `load_scoped_lua_file` already has for in-process modules. 25s rather
|
||||||
|
/// than something tighter: a real spawn (process fork/exec + Landlock
|
||||||
|
/// ruleset setup + Lua init) takes well under a second in isolation, but
|
||||||
|
/// this repo's integration test suite spawns many real `breadd` +
|
||||||
|
/// `bread-module-host` process pairs concurrently (`cargo test`'s default
|
||||||
|
/// parallelism), and under that load a spawn occasionally takes several
|
||||||
|
/// seconds of wall-clock time waiting for CPU/scheduler time rather than
|
||||||
|
/// being slow on its own merits.
|
||||||
|
const READY_TIMEOUT: Duration = Duration::from_secs(45);
|
||||||
|
|
||||||
|
/// Spawn a sandboxed `bread-module-host` child for one third-party module
|
||||||
|
/// and block (on a `std::sync::mpsc` channel, not an async await — this is
|
||||||
|
/// called from the Lua engine's own dedicated OS thread, which is not
|
||||||
|
/// async) until it reports ready or fails to within [`READY_TIMEOUT`].
|
||||||
|
///
|
||||||
|
/// `emit_tx` is used once, later, not by this function directly: the
|
||||||
|
/// crash-detection thread this function spawns uses it to emit
|
||||||
|
/// `bread.module.crashed` if the child dies after having successfully
|
||||||
|
/// loaded.
|
||||||
|
pub fn spawn_module_host(
|
||||||
|
registry: &ModuleHostRegistry,
|
||||||
|
module_name: &str,
|
||||||
|
entry_path: &Path,
|
||||||
|
permissions: &[ModulePermission],
|
||||||
|
socket_path: &Path,
|
||||||
|
emit_tx: &UnboundedSender<BreadEvent>,
|
||||||
|
) -> Result<ModuleHostOutcome> {
|
||||||
|
registry.terminate_existing(module_name);
|
||||||
|
|
||||||
|
let token = uuid::Uuid::new_v4().to_string();
|
||||||
|
let (outcome_tx, outcome_rx) = std::sync::mpsc::channel();
|
||||||
|
registry.insert_pending(
|
||||||
|
token.clone(),
|
||||||
|
PendingModuleHost {
|
||||||
|
module_name: module_name.to_string(),
|
||||||
|
permissions: permissions.to_vec(),
|
||||||
|
outcome_tx,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let bin_path = resolve_module_host_binary();
|
||||||
|
|
||||||
|
let mut cmd = Command::new(&bin_path);
|
||||||
|
cmd.env("BREAD_MODULE_TOKEN", &token)
|
||||||
|
.env("BREAD_MODULE_ENTRY", entry_path)
|
||||||
|
.env("BREAD_MODULE_SOCKET", socket_path)
|
||||||
|
.env("BREAD_MODULE_NAME", module_name)
|
||||||
|
.stdin(std::process::Stdio::null());
|
||||||
|
|
||||||
|
let sandbox_permissions = permissions.to_vec();
|
||||||
|
let sandbox_bin_path = bin_path.clone();
|
||||||
|
let sandbox_module_name = module_name.to_string();
|
||||||
|
let sandbox_entry_path = entry_path.to_path_buf();
|
||||||
|
// SAFETY: the closure runs in the forked child between fork() and
|
||||||
|
// execve() (that's what pre_exec is for). It only touches its own
|
||||||
|
// captured, already-allocated data plus filesystem/landlock syscalls —
|
||||||
|
// no allocation-unsafe signal-handler tricks, matching the same
|
||||||
|
// pattern the `landlock` crate's own sandboxing examples use for
|
||||||
|
// restricting a spawned child.
|
||||||
|
unsafe {
|
||||||
|
cmd.pre_exec(move || {
|
||||||
|
apply_sandbox(&sandbox_bin_path, &sandbox_entry_path, &sandbox_permissions).map_err(|e| {
|
||||||
|
std::io::Error::other(format!(
|
||||||
|
"landlock sandbox setup failed for module '{sandbox_module_name}': {e}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = match cmd.spawn() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
registry.take_pending(&token);
|
||||||
|
return Err(anyhow!(
|
||||||
|
"failed to spawn bread-module-host at {}: {e}",
|
||||||
|
bin_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pid = child.id();
|
||||||
|
registry.insert_active(module_name.to_string(), pid);
|
||||||
|
info!(module = %module_name, pid, bin = %bin_path.display(), "spawned bread-module-host");
|
||||||
|
|
||||||
|
// Reap thread: detects the child exiting for ANY reason (clean exit,
|
||||||
|
// panic, `kill -9`) without blocking breadd's IPC server or the Lua
|
||||||
|
// engine thread — this is the mechanism behind the "crash isolation"
|
||||||
|
// acceptance test (P0 item 5): killing this child must not take breadd
|
||||||
|
// or any other module down with it, and breadd must notice and report
|
||||||
|
// it via `bread.module.crashed`.
|
||||||
|
{
|
||||||
|
let registry = registry.clone();
|
||||||
|
let emit_tx = emit_tx.clone();
|
||||||
|
let module_name = module_name.to_string();
|
||||||
|
let thread_name = format!("mh-reap-{}", short(&module_name));
|
||||||
|
if let Err(e) = std::thread::Builder::new()
|
||||||
|
.name(thread_name)
|
||||||
|
.spawn(move || {
|
||||||
|
let status = child.wait();
|
||||||
|
registry.remove_active(&module_name);
|
||||||
|
let (reason, exit_code, signal) = describe_exit(&status);
|
||||||
|
warn!(module = %module_name, pid, reason = %reason, "bread-module-host exited");
|
||||||
|
let _ = emit_tx.send(BreadEvent::new(
|
||||||
|
"bread.module.crashed",
|
||||||
|
AdapterSource::System,
|
||||||
|
serde_json::json!({
|
||||||
|
"module": module_name,
|
||||||
|
"pid": pid,
|
||||||
|
"reason": reason,
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"signal": signal,
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
})
|
||||||
|
{
|
||||||
|
error!(error = %e, "failed to spawn module-host reap thread");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match outcome_rx.recv_timeout(READY_TIMEOUT) {
|
||||||
|
Ok(outcome) => Ok(outcome),
|
||||||
|
Err(_) => {
|
||||||
|
registry.take_pending(&token);
|
||||||
|
Ok(ModuleHostOutcome::LoadError(format!(
|
||||||
|
"module-host for '{module_name}' did not report ready within {:?}",
|
||||||
|
READY_TIMEOUT
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn short(name: &str) -> String {
|
||||||
|
name.chars().take(12).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe_exit(
|
||||||
|
status: &std::io::Result<std::process::ExitStatus>,
|
||||||
|
) -> (String, Option<i32>, Option<i32>) {
|
||||||
|
match status {
|
||||||
|
Ok(s) => {
|
||||||
|
if let Some(code) = s.code() {
|
||||||
|
(format!("exited with code {code}"), Some(code), None)
|
||||||
|
} else if let Some(sig) = s.signal() {
|
||||||
|
(format!("killed by signal {sig}"), None, Some(sig))
|
||||||
|
} else {
|
||||||
|
("exited (unknown reason)".to_string(), None, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => (format!("wait() failed: {e}"), None, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the `bread-module-host` binary's path: prefer the sibling of
|
||||||
|
/// `breadd`'s own executable (the layout `cargo build --workspace` and this
|
||||||
|
/// repo's packaging both produce — all workspace binaries land in the same
|
||||||
|
/// `target/{debug,release}` or install bindir), falling back to a bare
|
||||||
|
/// `PATH` lookup for layouts where `current_exe()` resolution is
|
||||||
|
/// unreliable.
|
||||||
|
pub fn resolve_module_host_binary() -> PathBuf {
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(dir) = exe.parent() {
|
||||||
|
let candidate = dir.join("bread-module-host");
|
||||||
|
if candidate.exists() {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from("bread-module-host")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build and apply the Landlock ruleset for a module-host child, from
|
||||||
|
/// inside `Command::pre_exec` (i.e. after `fork()`, before `execve()` of
|
||||||
|
/// `bread-module-host` itself — so the restriction covers that very
|
||||||
|
/// `execve()` too, which is why the baseline rules below exist at all).
|
||||||
|
///
|
||||||
|
/// # The baseline (always granted, not manifest-driven)
|
||||||
|
///
|
||||||
|
/// A dynamically linked binary needs to read its own file (to `execve` it)
|
||||||
|
/// and load the shared libraries `ld.so` maps into it. The initial version
|
||||||
|
/// of this function assumed Landlock's `Execute` access right gates
|
||||||
|
/// `execve()`/`execveat()` only, and that granting plain `ReadFile` on the
|
||||||
|
/// library directories would be enough for the dynamic linker's
|
||||||
|
/// `mmap(..., PROT_EXEC, ...)` calls on `.so` files. That assumption was
|
||||||
|
/// **wrong** — verified empirically (not just reasoned about from the
|
||||||
|
/// kernel docs) by spawning a real sandboxed child: with library
|
||||||
|
/// directories restricted to `ReadFile`-only, even `/bin/sh -c "true"`
|
||||||
|
/// fails `execve()` with `EACCES` before running a single line of script;
|
||||||
|
/// granting `Execute` on those directories too makes it work. So the
|
||||||
|
/// running kernel's Landlock implementation *does* mediate the executable
|
||||||
|
/// `mmap` the dynamic linker performs via the same `Execute` right,
|
||||||
|
/// contrary to what a first reading of "Execute a file" (the kernel doc's
|
||||||
|
/// one-line description) suggests. The baseline therefore grants:
|
||||||
|
/// - `ReadFile | ReadDir | Execute` on the system library directories and
|
||||||
|
/// `ReadFile` on `/etc/ld.so.cache`/`/etc/ld.so.preload` — what the
|
||||||
|
/// dynamic linker actually needs to start this binary at all.
|
||||||
|
/// - `ReadFile | Execute` on the `bread-module-host` binary's own resolved
|
||||||
|
/// path specifically (not a whole directory).
|
||||||
|
///
|
||||||
|
/// **Known trade-off, not swept under the rug**: this means a module-host
|
||||||
|
/// child's direct `os.execute`/`io.open` escape hatch, if it names a path
|
||||||
|
/// under `/usr/lib`/`/lib` (etc.) directly, is not denied by Landlock the
|
||||||
|
/// way an arbitrary path elsewhere on the filesystem is — the baseline
|
||||||
|
/// necessarily grants real `Execute` there, not just enough for the linker.
|
||||||
|
/// This is a materially smaller exposure than "no sandbox at all" (it's
|
||||||
|
/// bounded to files already shipped in the system's own library
|
||||||
|
/// directories, not the whole filesystem, and not anything a manifest
|
||||||
|
/// didn't otherwise ask for), but it is a real gap worth being honest
|
||||||
|
/// about — see `Documentation.md`'s "Workstream G" section. A fully static
|
||||||
|
/// build of `bread-module-host` (e.g. targeting `x86_64-unknown-linux-musl`
|
||||||
|
/// — confirmed available via `rustup target list --installed` in this
|
||||||
|
/// repo's dev environment) would remove the need for this baseline
|
||||||
|
/// entirely, since there'd be no dynamic linker involved at all; that's
|
||||||
|
/// flagged as follow-up work rather than attempted here, since it's a
|
||||||
|
/// build/packaging change (cross-compiling mlua's vendored Lua and every
|
||||||
|
/// transitive dependency against musl, plus a CI/xtask change) bigger than
|
||||||
|
/// this workstream's remaining time budget affords.
|
||||||
|
///
|
||||||
|
/// # The manifest-driven grants
|
||||||
|
///
|
||||||
|
/// - `fs.read` with a `path` hint -> `ReadFile | ReadDir` scoped to that
|
||||||
|
/// (`~`-expanded) path prefix.
|
||||||
|
/// - `fs.write` with a `path` hint -> the read bits above plus
|
||||||
|
/// `WriteFile | MakeReg | MakeDir` (matches `bread.fs.write`'s own
|
||||||
|
/// `create_dir_all` + `write` behavior).
|
||||||
|
/// - `exec` with a `bin` hint -> `ReadFile | Execute` scoped to that
|
||||||
|
/// binary's resolved path (absolute paths used as-is; bare names are
|
||||||
|
/// resolved via a `$PATH` search, `which`-style).
|
||||||
|
/// - `fs.read`/`fs.write` with **no** `path` hint: the RPC bridge's
|
||||||
|
/// belt-and-suspenders permission check still applies (see
|
||||||
|
/// `ipc/mod.rs`), but no Landlock rule is added, since Landlock scoping
|
||||||
|
/// needs a concrete path. A module author who wants the direct
|
||||||
|
/// `os`/`io` escape hatch mediated at the kernel level too needs to
|
||||||
|
/// declare a `path` — documented as a known sharp edge in
|
||||||
|
/// `Documentation.md` rather than silently "fixed" by granting
|
||||||
|
/// filesystem-wide access.
|
||||||
|
/// - Every other `PermissionKind` (`state.*`, `notify`, `machine`,
|
||||||
|
/// `hyprland`, `widget`, `bluetooth`, `profile.activate`) is RPC-gated
|
||||||
|
/// only (see `ipc/mod.rs`) — they have no filesystem shape to hand
|
||||||
|
/// Landlock in the first place.
|
||||||
|
fn apply_sandbox(
|
||||||
|
module_host_bin: &Path,
|
||||||
|
entry_path: &Path,
|
||||||
|
permissions: &[ModulePermission],
|
||||||
|
) -> Result<()> {
|
||||||
|
let abi = ABI::V1;
|
||||||
|
let lib_dir_access = make_bitflags!(AccessFs::{ReadFile | ReadDir | Execute});
|
||||||
|
let read_file_only = make_bitflags!(AccessFs::{ReadFile});
|
||||||
|
let read_only = make_bitflags!(AccessFs::{ReadFile | ReadDir});
|
||||||
|
let read_and_exec = make_bitflags!(AccessFs::{ReadFile | Execute});
|
||||||
|
let read_and_write =
|
||||||
|
make_bitflags!(AccessFs::{ReadFile | ReadDir | WriteFile | MakeReg | MakeDir});
|
||||||
|
|
||||||
|
let mut ruleset = Ruleset::default()
|
||||||
|
.handle_access(AccessFs::from_all(abi))
|
||||||
|
.map_err(|e| anyhow!("landlock handle_access: {e}"))?
|
||||||
|
.create()
|
||||||
|
.map_err(|e| anyhow!("landlock ruleset create: {e}"))?;
|
||||||
|
|
||||||
|
for dir in ["/usr/lib", "/usr/lib64", "/lib", "/lib64"] {
|
||||||
|
let p = Path::new(dir);
|
||||||
|
if p.exists() {
|
||||||
|
if let Ok(fd) = PathFd::new(p) {
|
||||||
|
ruleset = ruleset
|
||||||
|
.add_rule(PathBeneath::new(fd, lib_dir_access))
|
||||||
|
.map_err(|e| anyhow!("landlock rule for {dir}: {e}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for f in ["/etc/ld.so.cache", "/etc/ld.so.preload"] {
|
||||||
|
let p = Path::new(f);
|
||||||
|
if p.exists() {
|
||||||
|
if let Ok(fd) = PathFd::new(p) {
|
||||||
|
ruleset = ruleset
|
||||||
|
.add_rule(PathBeneath::new(fd, read_file_only))
|
||||||
|
.map_err(|e| anyhow!("landlock rule for {f}: {e}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(fd) = PathFd::new(module_host_bin) {
|
||||||
|
ruleset = ruleset
|
||||||
|
.add_rule(PathBeneath::new(fd, read_and_exec))
|
||||||
|
.map_err(|e| anyhow!("landlock rule for module-host binary: {e}"))?;
|
||||||
|
}
|
||||||
|
// The module-host bootstrap process needs to read its OWN module's
|
||||||
|
// directory (init.lua, bread.module.toml, an optional lib/ subtree —
|
||||||
|
// the same directory shape load_scoped_lua_file's in-process
|
||||||
|
// counterpart reads from) to load any Lua at all, entirely separate
|
||||||
|
// from whatever `fs.read` the manifest grants for the module's own
|
||||||
|
// runtime file I/O. Without this rule, EVERY out-of-process module
|
||||||
|
// fails to load — including ones with no `fs.read` permission at
|
||||||
|
// all — since it can't even read its own entry file.
|
||||||
|
if let Some(module_dir) = entry_path.parent() {
|
||||||
|
if let Ok(fd) = PathFd::new(module_dir) {
|
||||||
|
ruleset = ruleset
|
||||||
|
.add_rule(PathBeneath::new(fd, read_only))
|
||||||
|
.map_err(|e| {
|
||||||
|
anyhow!("landlock rule for module directory {}: {e}", module_dir.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for perm in permissions {
|
||||||
|
match perm.kind {
|
||||||
|
PermissionKind::FsRead => {
|
||||||
|
if let Some(path) = &perm.path {
|
||||||
|
let expanded = bread_shared::expand_path(path);
|
||||||
|
if let Ok(fd) = PathFd::new(&expanded) {
|
||||||
|
ruleset = ruleset
|
||||||
|
.add_rule(PathBeneath::new(fd, read_only))
|
||||||
|
.map_err(|e| {
|
||||||
|
anyhow!("landlock fs.read rule for {}: {e}", expanded.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PermissionKind::FsWrite => {
|
||||||
|
if let Some(path) = &perm.path {
|
||||||
|
let expanded = bread_shared::expand_path(path);
|
||||||
|
if let Ok(fd) = PathFd::new(&expanded) {
|
||||||
|
ruleset = ruleset
|
||||||
|
.add_rule(PathBeneath::new(fd, read_and_write))
|
||||||
|
.map_err(|e| {
|
||||||
|
anyhow!("landlock fs.write rule for {}: {e}", expanded.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PermissionKind::Exec => {
|
||||||
|
if let Some(bin) = &perm.bin {
|
||||||
|
if let Some(resolved) = resolve_bin_path(bin) {
|
||||||
|
if let Ok(fd) = PathFd::new(&resolved) {
|
||||||
|
ruleset = ruleset
|
||||||
|
.add_rule(PathBeneath::new(fd, read_and_exec))
|
||||||
|
.map_err(|e| {
|
||||||
|
anyhow!("landlock exec rule for {}: {e}", resolved.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = ruleset
|
||||||
|
.restrict_self()
|
||||||
|
.map_err(|e| anyhow!("landlock restrict_self: {e}"))?;
|
||||||
|
if !matches!(status.ruleset, RulesetStatus::FullyEnforced) {
|
||||||
|
// Not fatal: PartiallyEnforced still means real kernel enforcement
|
||||||
|
// for whatever subset the running kernel/LSM stack supports (see
|
||||||
|
// this module's doc comment — verified directly against this
|
||||||
|
// repo's dev kernel, which reports PartiallyEnforced yet still
|
||||||
|
// denies out-of-scope reads). NotEnforced (pre-5.13 kernel, or
|
||||||
|
// Landlock compiled out) would mean this module is running fully
|
||||||
|
// unsandboxed — loud enough to want in the log, not loud enough to
|
||||||
|
// refuse to start the module entirely and regress availability.
|
||||||
|
eprintln!(
|
||||||
|
"bread-module-host: landlock ruleset status = {:?} (not fully enforced on this kernel)",
|
||||||
|
status.ruleset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `which`-style resolution for an `exec` permission's `bin` hint: absolute
|
||||||
|
/// paths are used as-is, bare names are searched on `$PATH`.
|
||||||
|
fn resolve_bin_path(bin: &str) -> Option<PathBuf> {
|
||||||
|
let p = Path::new(bin);
|
||||||
|
if p.is_absolute() {
|
||||||
|
return Some(p.to_path_buf());
|
||||||
|
}
|
||||||
|
let path_var = std::env::var_os("PATH")?;
|
||||||
|
for dir in std::env::split_paths(&path_var) {
|
||||||
|
let candidate = dir.join(bin);
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
/// The single most important test in this whole workstream (see the
|
||||||
|
/// task's P0 item 4 and `Documentation.md`'s "Workstream G" section):
|
||||||
|
/// a real spawned child, restricted only by `apply_sandbox` for a
|
||||||
|
/// module granted `fs.read` on exactly one directory, must be denied
|
||||||
|
/// by the *kernel* — not a Lua-level check — when it tries to read a
|
||||||
|
/// file outside that directory. This talks to `apply_sandbox` and
|
||||||
|
/// `Command::pre_exec` exactly the way `spawn_module_host` does; the
|
||||||
|
/// full end-to-end version (going through the real IPC handshake and
|
||||||
|
/// an actual `os.execute`/`io.open` call from inside Lua) lives in
|
||||||
|
/// `breadd/tests/module_host_sandbox.rs`.
|
||||||
|
#[test]
|
||||||
|
fn landlock_denies_reads_outside_granted_path() {
|
||||||
|
let allowed_dir = tempfile::tempdir().unwrap();
|
||||||
|
let allowed_file = allowed_dir.path().join("allowed.txt");
|
||||||
|
std::fs::write(&allowed_file, b"ok").unwrap();
|
||||||
|
|
||||||
|
let denied_dir = tempfile::tempdir().unwrap();
|
||||||
|
let denied_file = denied_dir.path().join("secret.txt");
|
||||||
|
std::fs::write(&denied_file, b"nope").unwrap();
|
||||||
|
|
||||||
|
// Mirrors the task's own acceptance scenario verbatim:
|
||||||
|
// `os.execute("cat /etc/shadow")` from inside a module granted
|
||||||
|
// `fs.read` for exactly one other directory. `cat` does a plain
|
||||||
|
// `open()`+`read()` — no shell builtin involved — which is both
|
||||||
|
// the most faithful stand-in for the direct `os`/`io` escape hatch
|
||||||
|
// and (empirically, see the note on `no_exec_permission_...` below)
|
||||||
|
// avoids a bash `read`-builtin quirk that turned out to need more
|
||||||
|
// than a `ReadFile` grant for reasons unrelated to what this test
|
||||||
|
// is actually checking.
|
||||||
|
let cat_bin = resolve_bin_path("cat").expect("cat not found on $PATH");
|
||||||
|
let permissions = vec![
|
||||||
|
ModulePermission {
|
||||||
|
kind: PermissionKind::FsRead,
|
||||||
|
path: Some(allowed_dir.path().to_string_lossy().to_string()),
|
||||||
|
bin: None,
|
||||||
|
},
|
||||||
|
ModulePermission {
|
||||||
|
kind: PermissionKind::Exec,
|
||||||
|
path: None,
|
||||||
|
bin: Some(cat_bin.to_string_lossy().to_string()),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let sh_bin = which_sh();
|
||||||
|
let mut cmd = Command::new(&sh_bin);
|
||||||
|
cmd.arg("-c").arg(format!(
|
||||||
|
"{cat} {allowed} && echo ALLOWED_OK; {cat} {denied} && echo DENIED_UNEXPECTEDLY_OK",
|
||||||
|
cat = cat_bin.display(),
|
||||||
|
allowed = allowed_file.display(),
|
||||||
|
denied = denied_file.display(),
|
||||||
|
));
|
||||||
|
cmd.stdout(std::process::Stdio::piped());
|
||||||
|
cmd.stderr(std::process::Stdio::piped());
|
||||||
|
|
||||||
|
let sandbox_bin = sh_bin.clone();
|
||||||
|
unsafe {
|
||||||
|
cmd.pre_exec(move || {
|
||||||
|
apply_sandbox(&sandbox_bin, Path::new("/nonexistent/dummy/entry.lua"), &permissions)
|
||||||
|
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = cmd.output().expect("failed to run sandboxed sh");
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
stdout.contains("ALLOWED_OK"),
|
||||||
|
"expected the granted directory to remain readable; stdout={stdout} stderr={stderr}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("DENIED_UNEXPECTEDLY_OK"),
|
||||||
|
"sandboxed process read a file OUTSIDE its granted fs.read path — Landlock did not enforce; stdout={stdout} stderr={stderr}"
|
||||||
|
);
|
||||||
|
// The kernel denial surfaces as `cat`'s own "Permission denied"
|
||||||
|
// (EACCES from open()), on stderr — confirming this was an OS-level
|
||||||
|
// denial, not e.g. the file simply not existing.
|
||||||
|
assert!(
|
||||||
|
stderr.to_lowercase().contains("permission denied"),
|
||||||
|
"expected a kernel permission-denied error for the out-of-scope read; stderr={stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_exec_permission_means_binary_cannot_be_executed_at_all() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let script_path = dir.path().join("run.sh");
|
||||||
|
{
|
||||||
|
let mut f = std::fs::File::create(&script_path).unwrap();
|
||||||
|
writeln!(f, "#!/bin/sh\necho SHOULD_NOT_RUN").unwrap();
|
||||||
|
}
|
||||||
|
std::fs::set_permissions(
|
||||||
|
&script_path,
|
||||||
|
std::os::unix::fs::PermissionsExt::from_mode(0o755),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// No permissions granted at all: the sandboxed process should not
|
||||||
|
// be able to execute ANYTHING, including a script sitting right
|
||||||
|
// next to files it might otherwise be able to read.
|
||||||
|
let permissions: Vec<ModulePermission> = vec![];
|
||||||
|
|
||||||
|
let sh_bin = which_sh();
|
||||||
|
let mut cmd = Command::new(&sh_bin);
|
||||||
|
cmd.arg("-c")
|
||||||
|
.arg(format!("{} && echo RAN", script_path.display()));
|
||||||
|
cmd.stdout(std::process::Stdio::piped());
|
||||||
|
cmd.stderr(std::process::Stdio::piped());
|
||||||
|
|
||||||
|
let sandbox_bin = sh_bin.clone();
|
||||||
|
unsafe {
|
||||||
|
cmd.pre_exec(move || {
|
||||||
|
apply_sandbox(&sandbox_bin, Path::new("/nonexistent/dummy/entry.lua"), &permissions)
|
||||||
|
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = cmd.output().expect("failed to run sandboxed sh");
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("RAN"),
|
||||||
|
"sandboxed process executed a script with no `exec` permission granted; stdout={stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn which_sh() -> PathBuf {
|
||||||
|
for candidate in ["/bin/sh", "/usr/bin/sh"] {
|
||||||
|
let p = PathBuf::from(candidate);
|
||||||
|
if p.exists() {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("no /bin/sh or /usr/bin/sh found — cannot run sandbox tests");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -438,6 +438,21 @@ async fn modules_list_returns_array() -> Result<()> {
|
||||||
/// `bread.state.get(...)`, but `bread.fs` and `bread.exec` must be
|
/// `bread.state.get(...)`, but `bread.fs` and `bread.exec` must be
|
||||||
/// genuinely *absent* from the `bread` table it sees — `nil`, not merely
|
/// genuinely *absent* from the `bread` table it sees — `nil`, not merely
|
||||||
/// permission-denied when called.
|
/// 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]
|
#[tokio::test]
|
||||||
async fn scoped_module_sees_only_granted_state_read_permission() -> Result<()> {
|
async fn scoped_module_sees_only_granted_state_read_permission() -> Result<()> {
|
||||||
let manifest = r#"
|
let manifest = r#"
|
||||||
|
|
@ -455,23 +470,26 @@ path = "monitors"
|
||||||
let module_lua = r#"
|
let module_lua = r#"
|
||||||
local M = bread.module({ name = "scoped-test", version = "1.0.0" })
|
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")
|
local ok = pcall(bread.state.get, "monitors")
|
||||||
M.store.set("state_get_ok", ok)
|
bread.emit("test.scoped_result", {
|
||||||
M.store.set("fs_present", bread.fs ~= nil)
|
state_get_ok = ok,
|
||||||
M.store.set("exec_present", bread.exec ~= nil)
|
fs_present = bread.fs ~= nil,
|
||||||
M.store.set("exec_capture_present", bread.exec_capture ~= nil)
|
exec_present = bread.exec ~= nil,
|
||||||
M.store.set("bluetooth_present", bread.bluetooth ~= nil)
|
exec_capture_present = bread.exec_capture ~= nil,
|
||||||
|
bluetooth_present = bread.bluetooth ~= nil,
|
||||||
-- Baseline must still work from inside a scoped module.
|
-- Baseline must still work from inside a scoped module.
|
||||||
M.store.set("json_present", bread.json ~= nil)
|
json_present = bread.json ~= nil,
|
||||||
M.store.set("log_present", bread.log ~= nil)
|
log_present = bread.log ~= nil,
|
||||||
end
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
return M
|
return M
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
let harness = TestHarness::spawn_with_module("scoped-test", Some(manifest), module_lua)?;
|
let harness = TestHarness::spawn_with_module("scoped-test", Some(manifest), module_lua)?;
|
||||||
harness.wait_until_ready().await?;
|
harness.wait_until_ready().await?;
|
||||||
|
harness.wait_for_module_loaded("scoped-test").await?;
|
||||||
|
|
||||||
let modules = harness
|
let modules = harness
|
||||||
.send_request("state.get", json!({"key": "modules"}))
|
.send_request("state.get", json!({"key": "modules"}))
|
||||||
|
|
@ -487,10 +505,13 @@ return M
|
||||||
Some("loaded"),
|
Some("loaded"),
|
||||||
"module failed to load: {entry}"
|
"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
|
let store = harness.trigger_and_await_result("test.scoped_result").await?;
|
||||||
.get("store")
|
|
||||||
.ok_or_else(|| anyhow!("no store on module status: {entry}"))?;
|
|
||||||
assert_eq!(store.get("state_get_ok"), Some(&json!(true)));
|
assert_eq!(store.get("state_get_ok"), Some(&json!(true)));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.get("fs_present"),
|
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("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!(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();
|
harness.shutdown();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -538,6 +553,15 @@ return M
|
||||||
|
|
||||||
let harness = TestHarness::spawn_with_module("legacy-test", None, module_lua)?;
|
let harness = TestHarness::spawn_with_module("legacy-test", None, module_lua)?;
|
||||||
harness.wait_until_ready().await?;
|
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
|
let modules = harness
|
||||||
.send_request("state.get", json!({"key": "modules"}))
|
.send_request("state.get", json!({"key": "modules"}))
|
||||||
|
|
@ -579,6 +603,12 @@ return M
|
||||||
/// "baseline only" declaration, distinct from no manifest at all: it must
|
/// "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
|
/// 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.
|
/// `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]
|
#[tokio::test]
|
||||||
async fn explicit_empty_permissions_is_scoped_but_not_flagged_ungated() -> Result<()> {
|
async fn explicit_empty_permissions_is_scoped_but_not_flagged_ungated() -> Result<()> {
|
||||||
let manifest = r#"
|
let manifest = r#"
|
||||||
|
|
@ -593,16 +623,19 @@ permissions = []
|
||||||
let module_lua = r#"
|
let module_lua = r#"
|
||||||
local M = bread.module({ name = "empty-perms-test", version = "1.0.0" })
|
local M = bread.module({ name = "empty-perms-test", version = "1.0.0" })
|
||||||
|
|
||||||
function M.on_load()
|
bread.on("test.trigger", function()
|
||||||
M.store.set("fs_present", bread.fs ~= nil)
|
bread.emit("test.empty_perms_result", {
|
||||||
M.store.set("state_present", bread.state ~= nil)
|
fs_present = bread.fs ~= nil,
|
||||||
end
|
state_present = bread.state ~= nil,
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
return M
|
return M
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
let harness = TestHarness::spawn_with_module("empty-perms-test", Some(manifest), module_lua)?;
|
let harness = TestHarness::spawn_with_module("empty-perms-test", Some(manifest), module_lua)?;
|
||||||
harness.wait_until_ready().await?;
|
harness.wait_until_ready().await?;
|
||||||
|
harness.wait_for_module_loaded("empty-perms-test").await?;
|
||||||
|
|
||||||
let modules = harness
|
let modules = harness
|
||||||
.send_request("state.get", json!({"key": "modules"}))
|
.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}"))?;
|
.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"));
|
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!(
|
assert_eq!(
|
||||||
entry.get("ungated"),
|
entry.get("ungated"),
|
||||||
Some(&json!(false)),
|
Some(&json!(false)),
|
||||||
"an explicit empty permissions list is a deliberate declaration, not 'undeclared'"
|
"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();
|
harness.shutdown();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -1556,7 +1590,94 @@ enabled = false
|
||||||
Ok(parsed.get("result").cloned().unwrap_or_else(|| json!({})))
|
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.kill();
|
||||||
let _ = self.child.wait();
|
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