Add capability-scoped module API (Workstream D)

ModuleManifest gains a structured [[permissions]] field (bread-shared's
new ModulePermission/PermissionKind, shared between bread-cli and breadd
so the two never drift on what a permission "type" string means).

breadd now gives every third-party module's Lua chunk a scoped _ENV
instead of the shared global table: load_scoped_lua_file builds a fresh
`bread` table containing only baseline bindings (event subscription,
timers, json, module/store, logging, and the pure-Lua sugar built on top
of those) plus whatever the manifest's permissions grant, with a
metatable __index falling back to the real globals for everything else
(stdlib, require/package - so require("bread.devices") keeps working,
since builtins load unscoped and their closures capture that environment
lexically regardless of the caller's). _G is explicitly rebound to the
scoped table itself to close the obvious escape hatch. A module with no
manifest, or a manifest with no permissions key, keeps full ambient
access unchanged (today's behavior) but is now tracked as `ungated` in
module status and surfaced by `bread doctor`. An explicit `permissions =
[]` is scoped for real but not flagged, since that's a deliberate
declaration.

Adds `bread modules audit <name>`: a best-effort text scan of a module's
.lua files suggesting a [[permissions]] block to paste into its manifest.

Converts examples/modules/cpu-temp-widget.lua into a directory module
with a worked bread.module.toml (fs.read + widget) as the reference
example. Documentation.md gets a new "Capability-scoped modules" section
covering the taxonomy, the require()/closure mechanism, and an explicit
note that path/bin scoping is recorded but not yet enforced per-call -
that's the out-of-process module sandboxing workstream this manifest
schema is laid down for. API_VERSION bumped 1.4.0 -> 1.5.0.
This commit is contained in:
Breadway 2026-08-04 22:24:16 +08:00
parent 96639516b1
commit 6841163620
13 changed files with 1329 additions and 14 deletions

View file

@ -44,6 +44,7 @@ pub enum StateCommand {
status: ModuleLoadState,
last_error: Option<String>,
builtin: bool,
ungated: bool,
},
SetProfile {
name: String,
@ -128,12 +129,31 @@ impl StateHandle {
status: ModuleLoadState,
last_error: Option<String>,
builtin: bool,
) {
self.set_module_status_ex(name, status, last_error, builtin, false);
}
/// Same as [`set_module_status`](Self::set_module_status) but also
/// records whether the module is running with full, ungated `bread.*`
/// access (no `permissions` declared in its manifest). Kept as a
/// separate method rather than changing `set_module_status`'s signature
/// everywhere so call sites that don't yet know the answer (load
/// errors, disabled modules, etc.) don't have to thread a meaningless
/// value through.
pub fn set_module_status_ex(
&self,
name: String,
status: ModuleLoadState,
last_error: Option<String>,
builtin: bool,
ungated: bool,
) {
let _ = self.command_tx.send(StateCommand::SetModuleStatus {
name,
status,
last_error,
builtin,
ungated,
});
}
@ -303,18 +323,21 @@ async fn handle_command(
status,
last_error,
builtin,
ungated,
} => {
let mut guard = state.write().await;
if let Some(existing) = guard.modules.iter_mut().find(|m| m.name == name) {
existing.status = status;
existing.last_error = last_error;
existing.builtin = builtin;
existing.ungated = ungated;
} else {
guard.modules.push(crate::core::types::ModuleStatus {
name,
status,
last_error,
builtin,
ungated,
store: HashMap::new(),
});
}

View file

@ -123,6 +123,15 @@ pub struct ModuleStatus {
pub builtin: bool,
#[serde(default)]
pub store: HashMap<String, Value>,
/// `true` when this is a third-party module running with full, ungated
/// `bread.*` access because its `bread.module.toml` declares no
/// `permissions` at all (or the module has no manifest on disk). Always
/// `false` for builtin modules, which are never subject to capability
/// scoping in the first place — see the "Capability-scoped modules"
/// section of `Documentation.md`. `bread doctor` surfaces this as a
/// warning so an ungated module doesn't stay invisible forever.
#[serde(default)]
pub ungated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -27,7 +27,7 @@ use crate::lua::RuntimeHandle;
/// 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
/// this daemon's v1 lifetime per that section's stated policy.
const API_VERSION: &str = "1.4.0";
const API_VERSION: &str = "1.5.0";
#[derive(Clone)]
pub struct Server {

View file

@ -9,7 +9,7 @@ use std::time::Duration;
use anyhow::{anyhow, Result};
use bread_shared::widget::{WidgetNode, WidgetPlacement, WidgetSpec};
use bread_shared::{AdapterSource, BreadEvent};
use bread_shared::{AdapterSource, BreadEvent, ModulePermission, PermissionKind};
use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
@ -197,6 +197,16 @@ struct ModuleDecl {
path: PathBuf,
source: Option<&'static str>,
builtin: bool,
/// Declared `[[permissions]]` from this module's `bread.module.toml`,
/// read from disk alongside `path` for non-builtin modules (`None` for
/// builtins, which never go through manifest-based scoping at all).
///
/// `None` here also covers the third-party, no-manifest-at-all and
/// manifest-with-no-permissions-key cases — both mean "not declared",
/// which `load_scoped_lua_file` treats as full, ungated access for
/// backward compatibility (see `Documentation.md`). `Some(vec![])` is a
/// deliberate "baseline only" declaration and is scoped down for real.
permissions: Option<Vec<ModulePermission>>,
}
struct ModuleInfo {
@ -1349,7 +1359,19 @@ impl LuaEngine {
}
match self.scan_module_decl(&path) {
Ok(decl) => decls.push(decl),
Ok(mut decl) => {
// Manifest lives beside the module's entry file
// (bread-cli's install_from_local always writes
// <modules_dir>/<name>/{bread.module.toml,init.lua} —
// see bread-cli/src/modules_mgmt.rs). A hand-authored
// flat file with no sibling manifest (e.g. the
// "Your first module" walkthrough's modules/hello.lua)
// has no manifest to find at all, which read_module_permissions
// reports the same way as an existing-but-permissions-less
// one: None, i.e. full ungated backward-compat access.
decl.permissions = read_module_permissions(&path);
decls.push(decl);
}
Err(err) => {
self.state_handle.set_module_status(
name,
@ -1378,21 +1400,28 @@ impl LuaEngine {
let mut load_order = Vec::new();
for decl in ordered {
load_order.push(decl.name.clone());
// Static per-decl (not per-status-transition) property: whether
// this module is running with full, ungated bread.* access. Only
// ever true for a non-builtin module with no declared
// permissions — see ModuleDecl::permissions' doc comment.
let ungated = !decl.builtin && decl.permissions.is_none();
match self.load_module(&decl) {
Ok(()) => {
self.state_handle.set_module_status(
self.state_handle.set_module_status_ex(
decl.name.clone(),
ModuleLoadState::Loaded,
None,
decl.builtin,
ungated,
);
}
Err(err) => {
self.state_handle.set_module_status(
self.state_handle.set_module_status_ex(
decl.name.clone(),
ModuleLoadState::LoadError,
Some(err.to_string()),
decl.builtin,
ungated,
);
}
}
@ -1406,9 +1435,13 @@ impl LuaEngine {
fn load_module(&self, decl: &ModuleDecl) -> Result<()> {
self.set_current_module(Some(decl.name.clone()));
let result = if let Some(source) = decl.source {
// Builtins (bread.monitors/devices/workspaces/binds) — embedded
// source, always the full ambient bread table, never scoped.
self.load_lua_source(source, &decl.name)
} else {
self.load_lua_file(&decl.path, &decl.name, decl.builtin)
// Third-party, on-disk modules only. Capability-scoped per
// decl.permissions — see load_scoped_lua_file.
self.load_scoped_lua_file(&decl.path, &decl.name, decl.permissions.as_deref())
};
self.set_current_module(None);
result?;
@ -1420,6 +1453,9 @@ impl LuaEngine {
self.run_on_load(&decl.name)
}
/// Load `init.lua` (the trusted entry point) or any other file that
/// should see the real, unscoped `bread` global exactly like today.
/// Not used for third-party modules — see [`load_scoped_lua_file`].
fn load_lua_file(&self, path: &Path, module_name: &str, builtin: bool) -> Result<()> {
if !path.exists() {
warn!(path = %path.display(), "lua file does not exist; skipping");
@ -1440,6 +1476,207 @@ impl LuaEngine {
Ok(())
}
/// Load a third-party module's `.lua` file, giving its chunk a
/// capability-scoped `_ENV` instead of the real shared globals.
///
/// `permissions: None` means the module's manifest declared no
/// `permissions` at all (no manifest on disk, or a manifest predating
/// this field) — backward compat: full, ungated access, identical to
/// `load_lua_file`. `Some(perms)` (including `Some(&[])`) builds a fresh
/// `bread` table containing only the baseline bindings plus whatever
/// `perms` grants, and sets it as the chunk's environment. See
/// `build_scoped_env` for how `require`/stdlib stay reachable.
fn load_scoped_lua_file(
&self,
path: &Path,
module_name: &str,
permissions: Option<&[ModulePermission]>,
) -> Result<()> {
if !path.exists() {
warn!(path = %path.display(), "lua file does not exist; skipping");
self.state_handle.set_module_status(
module_name.to_string(),
ModuleLoadState::NotFound,
None,
false,
);
return Ok(());
}
let src = fs::read_to_string(path)?;
let chunk = self.lua.load(&src).set_name(path.to_string_lossy().as_ref());
match permissions {
None => {
// No manifest / no permissions declared: today's behavior,
// unchanged. Do NOT call set_environment here at all (rather
// than passing globals() explicitly) so this stays
// byte-for-byte the same code path load_lua_file already
// uses and has always used.
chunk.exec()?;
}
Some(perms) => {
let env = self.build_scoped_env(perms)?;
chunk.set_environment(env).exec()?;
}
}
Ok(())
}
/// Build a fresh `_ENV` table for a capability-scoped module chunk.
///
/// Shape: a table whose own `bread` key is a *new* table containing only
/// the baseline bindings (event subscription, timers, json, module/
/// store, logging, and the pure-Lua sugar built on top of those —
/// debounce/spawn/wait*/workflow) plus whichever gated namespaces
/// `perms` grants (fs, exec, notify, machine, hyprland, widget,
/// bluetooth, state, profile — split at the granularity the manifest
/// schema exposes, e.g. `fs.read` without `fs.write` yields a `bread.fs`
/// table with `.read`/`.exists`/`.readlink`/`.expand` but no `.write`).
/// A permission that isn't granted means the corresponding key is
/// genuinely absent (`bread.fs == nil`), not present-but-erroring.
///
/// Everything else — `pairs`, `string`, `table`, `math`, `pcall`,
/// `coroutine`, `require`, `package`, ... — is reached through a
/// metatable `__index` that falls back to the real global table, so
/// `require("bread.devices")` still works: `require` is the real global
/// function operating on the real global `package.loaded`, which
/// already contains `bread.devices`'s module table by the time any
/// third-party module loads (builtins load first). That module table's
/// own methods (`devices.on()` etc.) were themselves defined while
/// `bread.devices` was loaded unscoped, so they close over the *real*
/// `bread` table as an upvalue — Lua closures capture their defining
/// environment lexically, not the caller's — which is exactly why
/// `require("bread.devices")` keeps working from inside a scoped module
/// with no special-casing needed here.
///
/// One deliberate hardening step beyond that: `_G` is explicitly
/// rebound to point at this same scoped table (self-referentially, the
/// same way stock Lua's base library self-references the real global
/// table under `_G`). Without that, `local G = _G; G.bread.fs...` would
/// walk straight past the whole mechanism, since `_G` is just an
/// ordinary global (not magic in Lua 5.2+) and would otherwise resolve
/// through the `__index` fallback to the *real* globals table.
///
/// What this does **not** do: strip `os`/`io`/`debug` from the
/// fallback. Those remain reachable from a scoped module exactly as
/// they are from an unscoped one — `os.execute`/`io.open` bypass
/// `bread.exec`/`bread.fs` gating entirely if a module chooses to use
/// them directly. This mechanism gates the documented `bread.*` API
/// surface (so a well-behaved module naturally degrades, and an
/// accidental over-reach is caught), it is not a hard security boundary
/// against a deliberately malicious script — that's what the
/// out-of-process module sandboxing workstream this manifest schema
/// exists for is for.
fn build_scoped_env(&self, perms: &[ModulePermission]) -> Result<Table<'_>> {
let globals = self.lua.globals();
let real_bread: Table = globals.get("bread")?;
let granted: HashSet<PermissionKind> = perms.iter().map(|p| p.kind).collect();
let scoped_bread = self.lua.create_table()?;
// Baseline — always available, no manifest entry required.
const BASELINE_KEYS: &[&str] = &[
"on", "once", "filter", "off", "emit", "after", "every", "cancel", "json", "module",
"log", "warn", "error", "debounce", "spawn", "wait", "wait_any", "wait_all",
"workflow",
];
for key in BASELINE_KEYS {
let v: Value = real_bread.get(*key)?;
if !matches!(v, Value::Nil) {
scoped_bread.set(*key, v)?;
}
}
if granted.contains(&PermissionKind::StateRead) || granted.contains(&PermissionKind::StateWatch)
{
let real_state: Table = real_bread.get("state")?;
let scoped_state = self.lua.create_table()?;
if granted.contains(&PermissionKind::StateRead) {
for key in [
"get",
"monitors",
"active_workspace",
"active_window",
"devices",
"power",
"network",
"profile",
] {
let v: Value = real_state.get(key)?;
scoped_state.set(key, v)?;
}
}
if granted.contains(&PermissionKind::StateWatch) {
let v: Value = real_state.get("watch")?;
scoped_state.set("watch", v)?;
}
scoped_bread.set("state", scoped_state)?;
}
if granted.contains(&PermissionKind::ProfileActivate) {
let v: Value = real_bread.get("profile")?;
scoped_bread.set("profile", v)?;
}
if granted.contains(&PermissionKind::Exec) {
let exec: Value = real_bread.get("exec")?;
scoped_bread.set("exec", exec)?;
let exec_capture: Value = real_bread.get("exec_capture")?;
scoped_bread.set("exec_capture", exec_capture)?;
}
if granted.contains(&PermissionKind::Notify) {
let v: Value = real_bread.get("notify")?;
scoped_bread.set("notify", v)?;
}
if granted.contains(&PermissionKind::Machine) {
let v: Value = real_bread.get("machine")?;
scoped_bread.set("machine", v)?;
}
if granted.contains(&PermissionKind::Hyprland) {
let v: Value = real_bread.get("hyprland")?;
scoped_bread.set("hyprland", v)?;
}
if granted.contains(&PermissionKind::Widget) {
let v: Value = real_bread.get("widget")?;
scoped_bread.set("widget", v)?;
}
if granted.contains(&PermissionKind::Bluetooth) {
let v: Value = real_bread.get("bluetooth")?;
scoped_bread.set("bluetooth", v)?;
}
if granted.contains(&PermissionKind::FsRead) || granted.contains(&PermissionKind::FsWrite) {
let real_fs: Table = real_bread.get("fs")?;
let scoped_fs = self.lua.create_table()?;
if granted.contains(&PermissionKind::FsRead) {
for key in ["read", "exists", "readlink", "expand"] {
let v: Value = real_fs.get(key)?;
scoped_fs.set(key, v)?;
}
}
if granted.contains(&PermissionKind::FsWrite) {
let v: Value = real_fs.get("write")?;
scoped_fs.set("write", v)?;
}
scoped_bread.set("fs", scoped_fs)?;
}
let env = self.lua.create_table()?;
let mt = self.lua.create_table()?;
mt.set("__index", self.lua.globals())?;
env.set_metatable(Some(mt));
env.set("bread", scoped_bread)?;
env.set("_G", env.clone())?;
Ok(env)
}
fn load_lua_source(&self, source: &str, module_name: &str) -> Result<()> {
self.lua
.load(source)
@ -1575,11 +1812,12 @@ impl LuaEngine {
if let Err(err) = result {
error!(module = %name, error = %err, "module on_reload failed");
let builtin = self.module_is_builtin(&name);
self.state_handle.set_module_status(
self.state_handle.set_module_status_ex(
name.to_string(),
ModuleLoadState::Degraded,
Some(err.to_string()),
builtin,
self.module_ungated(&name),
);
}
}
@ -1600,11 +1838,12 @@ impl LuaEngine {
if let Err(err) = result {
error!(module = %name, error = %err, "module on_unload failed");
let builtin = self.module_is_builtin(&name);
self.state_handle.set_module_status(
self.state_handle.set_module_status_ex(
name.to_string(),
ModuleLoadState::Degraded,
Some(err.to_string()),
builtin,
self.module_ungated(&name),
);
}
}
@ -1624,11 +1863,12 @@ impl LuaEngine {
message: err.to_string(),
});
}
self.state_handle.set_module_status(
self.state_handle.set_module_status_ex(
module.to_string(),
ModuleLoadState::Degraded,
Some(err.to_string()),
builtin,
self.module_ungated(module),
);
if let Some(hook) = self.get_module_hook(module, "on_error") {
match hook.call::<_, bool>(err.to_string()) {
@ -1672,6 +1912,21 @@ impl LuaEngine {
.unwrap_or(false)
}
/// Whether `name` is a third-party module running with full, ungated
/// `bread.*` access (no `permissions` declared). See
/// `ModuleDecl::permissions`'s doc comment for exactly what "declared"
/// means. Always `false` for builtins and for unknown module names.
fn module_ungated(&self, name: &str) -> bool {
self.module_decls
.lock()
.ok()
.and_then(|map| {
map.get(name)
.map(|d| !d.builtin && d.permissions.is_none())
})
.unwrap_or(false)
}
fn set_current_module(&self, name: Option<String>) {
if let Ok(mut guard) = self.current_module.lock() {
*guard = name;
@ -1796,6 +2051,10 @@ impl LuaEngine {
path: module_path.clone(),
source: None,
builtin: false,
// Populated afterwards by the caller (load_init_and_modules),
// which reads bread.module.toml from disk — scan_module_decl
// only cares about the bread.module({...}) declaration itself.
permissions: None,
});
Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string()))
})?;
@ -2494,6 +2753,47 @@ fn is_lib_path(module_root: &Path, path: &Path) -> bool {
.unwrap_or(false)
}
/// Read the `permissions` declared in the `bread.module.toml` manifest
/// sibling to a third-party module's entry file, if any.
///
/// `bread-cli`'s `install_from_local` always lays a module out as
/// `<modules_dir>/<name>/{bread.module.toml,init.lua,...}` (see
/// `bread-cli/src/modules_mgmt.rs`), so the manifest is always the entry
/// file's parent directory + `bread.module.toml`. Returns `None` — meaning
/// "not declared", handled as full ungated backward-compat access by
/// `load_scoped_lua_file` — whenever: the module has no directory-level
/// manifest at all (a hand-authored flat file, e.g. the "Your first
/// module" walkthrough's `modules/hello.lua`); the manifest exists but has
/// no `permissions` key; or the manifest fails to parse (logged, not
/// treated as a load error — a broken manifest shouldn't also break the
/// module load path it's unrelated to).
fn read_module_permissions(module_file: &Path) -> Option<Vec<ModulePermission>> {
#[derive(serde::Deserialize)]
struct PermissionsOnly {
#[serde(default)]
permissions: Option<Vec<ModulePermission>>,
}
let manifest_path = module_file.parent()?.join("bread.module.toml");
if !manifest_path.exists() {
return None;
}
let raw = match fs::read_to_string(&manifest_path) {
Ok(raw) => raw,
Err(err) => {
warn!(path = %manifest_path.display(), error = %err, "failed to read bread.module.toml");
return None;
}
};
match toml::from_str::<PermissionsOnly>(&raw) {
Ok(parsed) => parsed.permissions,
Err(err) => {
warn!(path = %manifest_path.display(), error = %err, "failed to parse bread.module.toml; treating as no permissions declared");
None
}
}
}
/// `lua.to_value()`'s default `Options` map JSON null / Rust `Option::None`
/// to a distinct `lua.null()` sentinel rather than real Lua `nil`, to
/// preserve JSON round-trip fidelity — but bread never round-trips a Lua
@ -2585,6 +2885,10 @@ fn module_store_set(
status: ModuleLoadState::Loaded,
last_error: None,
builtin: false,
// Placeholder until the real load-time status (with the correct
// ungated value) lands via set_module_status_ex; this fallback only
// fires if a module's own store is written before that happens.
ungated: false,
store,
});
}
@ -2916,6 +3220,10 @@ fn builtin_module_decls(disabled: &HashSet<String>) -> Vec<ModuleDecl> {
path: PathBuf::from(format!("<builtin:{name}>")),
source: Some(source),
builtin: true,
// Builtins never go through manifest-based scoping (or the
// "ungated" doctor warning) — they always get the full ambient
// bread table, by design.
permissions: None,
});
}

View file

@ -279,6 +279,207 @@ async fn modules_list_returns_array() -> Result<()> {
Ok(())
}
// ---------------------------------------------------------------------------
// Capability-scoped module API (Workstream D)
// ---------------------------------------------------------------------------
/// Core regression test from the capability-manifest report: a third-party
/// module whose manifest grants only `state.read` can call
/// `bread.state.get(...)`, but `bread.fs` and `bread.exec` must be
/// genuinely *absent* from the `bread` table it sees — `nil`, not merely
/// permission-denied when called.
#[tokio::test]
async fn scoped_module_sees_only_granted_state_read_permission() -> Result<()> {
let manifest = r#"
name = "scoped-test"
version = "1.0.0"
description = "test"
author = "test"
source = "test"
installed_at = ""
[[permissions]]
type = "state.read"
path = "monitors"
"#;
let module_lua = r#"
local M = bread.module({ name = "scoped-test", version = "1.0.0" })
function M.on_load()
local ok = pcall(bread.state.get, "monitors")
M.store.set("state_get_ok", ok)
M.store.set("fs_present", bread.fs ~= nil)
M.store.set("exec_present", bread.exec ~= nil)
M.store.set("exec_capture_present", bread.exec_capture ~= nil)
M.store.set("bluetooth_present", bread.bluetooth ~= nil)
-- Baseline must still work from inside a scoped module.
M.store.set("json_present", bread.json ~= nil)
M.store.set("log_present", bread.log ~= nil)
end
return M
"#;
let harness = TestHarness::spawn_with_module("scoped-test", Some(manifest), module_lua)?;
harness.wait_until_ready().await?;
let modules = harness
.send_request("state.get", json!({"key": "modules"}))
.await?;
let entry = modules
.as_array()
.and_then(|arr| arr.iter().find(|m| m.get("name").and_then(Value::as_str) == Some("scoped-test")))
.cloned()
.ok_or_else(|| anyhow!("scoped-test module not found in modules state; dump: {modules}"))?;
assert_eq!(
entry.get("status").and_then(Value::as_str),
Some("loaded"),
"module failed to load: {entry}"
);
let store = entry
.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("fs_present"),
Some(&json!(false)),
"bread.fs must be absent (nil) without an fs.read/fs.write permission"
);
assert_eq!(
store.get("exec_present"),
Some(&json!(false)),
"bread.exec must be absent (nil) without an exec permission"
);
assert_eq!(store.get("exec_capture_present"), Some(&json!(false)));
assert_eq!(store.get("bluetooth_present"), Some(&json!(false)));
assert_eq!(store.get("json_present"), Some(&json!(true)), "baseline bread.json must still be present");
assert_eq!(store.get("log_present"), Some(&json!(true)), "baseline bread.log must still be present");
assert_eq!(
entry.get("ungated"),
Some(&json!(false)),
"a module with a manifest that declares permissions must not be flagged ungated"
);
harness.shutdown();
Ok(())
}
/// A third-party module installed with no `bread.module.toml` manifest at
/// all (the pre-existing/legacy case) keeps full, unscoped `bread.*`
/// access — but is surfaced as `ungated` in module status, which is exactly
/// what `bread doctor` reads to print its "no permissions declared"
/// warning.
#[tokio::test]
async fn module_with_no_manifest_keeps_full_access_but_is_flagged_ungated() -> Result<()> {
let module_lua = r#"
local M = bread.module({ name = "legacy-test", version = "1.0.0" })
function M.on_load()
M.store.set("fs_present", bread.fs ~= nil)
M.store.set("exec_present", bread.exec ~= nil)
M.store.set("bluetooth_present", bread.bluetooth ~= nil)
end
return M
"#;
let harness = TestHarness::spawn_with_module("legacy-test", None, module_lua)?;
harness.wait_until_ready().await?;
let modules = harness
.send_request("state.get", json!({"key": "modules"}))
.await?;
let entry = modules
.as_array()
.and_then(|arr| arr.iter().find(|m| m.get("name").and_then(Value::as_str) == Some("legacy-test")))
.cloned()
.ok_or_else(|| anyhow!("legacy-test module not found in modules state; dump: {modules}"))?;
assert_eq!(
entry.get("status").and_then(Value::as_str),
Some("loaded"),
"module failed to load: {entry}"
);
let store = entry
.get("store")
.ok_or_else(|| anyhow!("no store on module status: {entry}"))?;
assert_eq!(
store.get("fs_present"),
Some(&json!(true)),
"no manifest declared -> backward compat full access, bread.fs must be present"
);
assert_eq!(store.get("exec_present"), Some(&json!(true)));
assert_eq!(store.get("bluetooth_present"), Some(&json!(true)));
assert_eq!(
entry.get("ungated"),
Some(&json!(true)),
"a module with no permissions manifest must be flagged ungated for `bread doctor`"
);
harness.shutdown();
Ok(())
}
/// An explicit `permissions = []` (present but empty) is a deliberate
/// "baseline only" declaration, distinct from no manifest at all: it must
/// scope the module down for real (no fs/exec/etc.) but must *not* trip the
/// `ungated` doctor warning, since the author made a conscious choice.
#[tokio::test]
async fn explicit_empty_permissions_is_scoped_but_not_flagged_ungated() -> Result<()> {
let manifest = r#"
name = "empty-perms-test"
version = "1.0.0"
description = "test"
author = "test"
source = "test"
installed_at = ""
permissions = []
"#;
let module_lua = r#"
local M = bread.module({ name = "empty-perms-test", version = "1.0.0" })
function M.on_load()
M.store.set("fs_present", bread.fs ~= nil)
M.store.set("state_present", bread.state ~= nil)
end
return M
"#;
let harness = TestHarness::spawn_with_module("empty-perms-test", Some(manifest), module_lua)?;
harness.wait_until_ready().await?;
let modules = harness
.send_request("state.get", json!({"key": "modules"}))
.await?;
let entry = modules
.as_array()
.and_then(|arr| {
arr.iter()
.find(|m| m.get("name").and_then(Value::as_str) == Some("empty-perms-test"))
})
.cloned()
.ok_or_else(|| anyhow!("empty-perms-test module not found in modules state; dump: {modules}"))?;
assert_eq!(entry.get("status").and_then(Value::as_str), Some("loaded"));
let store = entry.get("store").unwrap();
assert_eq!(store.get("fs_present"), Some(&json!(false)));
assert_eq!(store.get("state_present"), Some(&json!(false)));
assert_eq!(
entry.get("ungated"),
Some(&json!(false)),
"an explicit empty permissions list is a deliberate declaration, not 'undeclared'"
);
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn modules_reload_succeeds() -> Result<()> {
let harness = TestHarness::spawn()?;
@ -688,6 +889,82 @@ 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,
})
}
/// Like `spawn_with_init`, but also installs one third-party,
/// directory-based module (`<modules_dir>/<name>/{bread.module.toml,
/// init.lua}`) — the same on-disk shape `bread modules install`
/// produces — before starting the daemon. `manifest_toml` is written
/// verbatim as `bread.module.toml`; pass `None` to install the module
/// with no manifest file at all (the legacy/backward-compat case).
fn spawn_with_module(
module_name: &str,
manifest_toml: Option<&str>,
module_init_lua: &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");
let module_dir = bread_cfg.join("modules").join(module_name);
fs::create_dir_all(&module_dir)?;
fs::write(
bread_cfg.join("init.lua"),
"bread.on('bread.system.startup', function() end)\n",
)?;
if let Some(manifest) = manifest_toml {
fs::write(module_dir.join("bread.module.toml"), manifest)?;
}
fs::write(module_dir.join("init.lua"), module_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
"#,