agent_bridle_core/rootfs.rs
1//! Minimal-rootfs **builder** (ADR 0013 D2 / agent-bridle#107) — the foundation
2//! of the Tier-2 program-identity close.
3//!
4//! ADR 0013's keystone (D1): confine program *identity* by controlling **what
5//! exists** in the process's filesystem view, not by allow-listing reads (a
6//! readable ELF is a runnable ELF, so `ld.so <readable>` trampolines past
7//! Landlock's `Execute`). This module computes the *plan* for a read-only root
8//! tree that **physically contains only** the granted program files, their shared
9//! library closure, the dynamic loader, the curated runtime data, and the granted
10//! `fs_read`/`fs_write` roots — and nothing else executable. With no un-granted
11//! ELF present, `find -exec curl`, a `system("curl")`, a shebang to an un-granted
12//! interpreter, and an `ld.so` trampoline all fail because the target is absent.
13//!
14//! This is the **builder only** (the plan + a copy-materializer for tests). Wiring
15//! it into a runnable jail (`unshare` + `pivot_root`, read-only bind-mounts, the
16//! privileged broker) is #109/#108; booting it as a micro-VM guest is #111. The
17//! production materialization is read-only **bind-mounts** (so files are shared,
18//! not copied); [`materialize_copy`] is the test/diagnostic path.
19//!
20//! Linux-only (it shells out to `ldd` to resolve the loader's view of the closure
21//! and reads `/proc`); inert until a Tier-1.5/Tier-2 backend consumes it.
22
23use std::collections::BTreeSet;
24use std::path::{Path, PathBuf};
25use std::process::Command;
26
27use crate::{Caveats, NormalizationPolicy, RootfsPolicy, Scope};
28
29// The curated runtime data paths a permitted program reads (locale, timezone, CA
30// bundles, resolver/loader config, `/dev` + `/proc/self` essentials) are supplied
31// by `RootfsPolicy::data_paths` (config.rs) — never executables (so they do not
32// reopen the loader trampoline). The shared libraries are added *specifically*
33// from the per-binary `ldd` closure, NOT by binding `/usr/lib` wholesale, so only
34// the `.so`s the granted binaries actually need are present.
35
36/// The directories a bare program name is resolved against (mirrors the loader's
37/// search, `$PATH` then the configured `fallback`). Only used to find the granted
38/// binaries' real paths for the plan.
39fn search_dirs(fallback: &[String]) -> Vec<PathBuf> {
40 if let Ok(path) = std::env::var("PATH") {
41 let dirs: Vec<PathBuf> = path
42 .split(':')
43 .filter(|s| !s.is_empty())
44 .map(PathBuf::from)
45 .collect();
46 if !dirs.is_empty() {
47 return dirs;
48 }
49 }
50 fallback.iter().map(PathBuf::from).collect()
51}
52
53/// Resolve a granted `exec` entry (a bare name or a path) to an absolute, existing
54/// program file — canonicalized so the plan anchors the real inode.
55fn resolve_program(entry: &str, search_fallback: &[String]) -> Option<PathBuf> {
56 let candidate = if entry.contains('/') {
57 let p = PathBuf::from(entry);
58 p.is_file().then_some(p)
59 } else {
60 search_dirs(search_fallback)
61 .into_iter()
62 .map(|d| d.join(entry))
63 .find(|c| c.is_file())
64 }?;
65 candidate.canonicalize().ok()
66}
67
68/// The shared-library closure of `bin` as the loader sees it: parse `ldd`'s output
69/// for the resolved `=> /abs/path` libraries and the trailing loader line. `ldd`
70/// uses `LD_TRACE_LOADED_OBJECTS` (it does not execute the target for a normal
71/// dynamic binary), and the granted binaries are trusted system tools. A static
72/// binary (no dynamic deps) yields an empty closure — not an error.
73fn ldd_closure(bin: &Path) -> Vec<PathBuf> {
74 let out = match Command::new("ldd").arg(bin).output() {
75 Ok(o) if o.status.success() => o.stdout,
76 // "not a dynamic executable" / static / ldd error ⇒ no closure to add.
77 _ => return Vec::new(),
78 };
79 let text = String::from_utf8_lossy(&out);
80 let mut libs = BTreeSet::new();
81 for line in text.lines() {
82 let line = line.trim();
83 // "libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x...)"
84 if let Some(rhs) = line.split(" => ").nth(1) {
85 let path = rhs.split(" (").next().unwrap_or("").trim();
86 if path.starts_with('/') {
87 // Keep the SONAME path ldd reports (e.g. `libz.so.1`), NOT its
88 // canonical target (`libz.so.1.3`): the dynamic loader opens the
89 // soname, so the jail must expose the `.so` at that exact path or
90 // the granted program fails to load (agent-bridle#113 — a
91 // deny-of-function the runtime canary surfaced). A bind-mount of the
92 // soname (a symlink on most distros) exposes the real file's content
93 // at that name; `libc.so.6` worked before only because it is a real
94 // file, not a symlink.
95 let pb = PathBuf::from(path);
96 if pb.exists() {
97 libs.insert(pb);
98 }
99 }
100 } else if line.starts_with('/') {
101 // The loader itself: "/lib64/ld-linux-x86-64.so.2 (0x...)".
102 let path = line.split(" (").next().unwrap_or("").trim();
103 if let Ok(c) = PathBuf::from(path).canonicalize() {
104 libs.insert(c);
105 }
106 }
107 // "linux-vdso.so.1 (0x...)" has no path ⇒ skipped (kernel-provided).
108 }
109 libs.into_iter().collect()
110}
111
112/// One path the minimal rootfs exposes.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct RootfsEntry {
115 /// The host path to expose at the same location inside the rootfs.
116 pub src: PathBuf,
117 /// `true` ⇒ exposed read-write (an `fs_write` root); else read-only.
118 pub writable: bool,
119 /// `true` ⇒ a directory mount-point (bind-mounted in production; an empty
120 /// dir in [`materialize_copy`]); `false` ⇒ a single file (copied/bound).
121 pub is_dir: bool,
122}
123
124/// The plan for a minimal rootfs: exactly the paths to expose, nothing else.
125#[derive(Debug, Clone, Default, PartialEq, Eq)]
126pub struct RootfsPlan {
127 /// The exposed paths (deduplicated, sorted).
128 pub entries: Vec<RootfsEntry>,
129}
130
131/// Widen the static closure for known runtime-dynamic loaders (ADR 0013 D7 /
132/// agent-bridle#113). `dlopen` / `ctypes` loads and glibc NSS modules are not in
133/// the `ldd` closure, so a granted toolchain can fail to load a `.so` it needs at
134/// runtime (a **deny-of-function**, not a safety hole). This adds their typical
135/// **library/data** paths — `.so` files and stdlib data dirs, **never** `/usr/bin`
136/// executables — so the D1 identity invariant (no un-granted *program* is
137/// reachable) still holds: the fallback widens libraries, not the executable set.
138fn add_runtime_closure_fallback(
139 programs: &BTreeSet<String>,
140 files: &mut BTreeSet<PathBuf>,
141 ro_dirs: &mut BTreeSet<PathBuf>,
142 nss_fallback: bool,
143 python_fallback: bool,
144) {
145 // glibc `dlopen`s `libnss_*.so.N` at runtime (getpwnam, gethostbyname, …).
146 // They live in libc's directory but are never in the static closure. Add them
147 // from the same dir(s) as the resolved libc (canonicalized ⇒ the real `.so.N`,
148 // the soname glibc actually opens; the `.so` dev symlinks are not needed).
149 // Toggle: disabling only makes the rootfs *more* minimal (I7, #146).
150 if nss_fallback {
151 let libc_dirs: BTreeSet<PathBuf> = files
152 .iter()
153 .filter(|p| {
154 p.file_name()
155 .and_then(|n| n.to_str())
156 .is_some_and(|n| n.starts_with("libc.so"))
157 })
158 .filter_map(|p| p.parent().map(Path::to_path_buf))
159 .collect();
160 for dir in &libc_dirs {
161 if let Ok(rd) = std::fs::read_dir(dir) {
162 for entry in rd.flatten() {
163 let name = entry.file_name();
164 let name = name.to_string_lossy();
165 if name.starts_with("libnss_") && name.contains(".so") {
166 if let Ok(c) = entry.path().canonicalize() {
167 files.insert(c);
168 }
169 }
170 }
171 }
172 }
173 }
174
175 // Python `dlopen`s C-extensions from its stdlib (`lib-dynload/*.so`) and reads
176 // the pure-python stdlib — none of it in the static closure, and the
177 // interpreter will not even start without it. When a `python*` is granted, add
178 // the versioned stdlib dirs (data + `.so`; a bind-mount includes `lib-dynload`
179 // and preserves internal symlinks). No `/usr/bin` is added.
180 let wants_python = python_fallback
181 && programs.iter().any(|p| {
182 Path::new(p)
183 .file_name()
184 .and_then(|n| n.to_str())
185 .unwrap_or(p)
186 .starts_with("python")
187 });
188 if wants_python {
189 for base in ["/usr/lib", "/usr/local/lib", "/usr/lib64"] {
190 if let Ok(rd) = std::fs::read_dir(base) {
191 for entry in rd.flatten() {
192 if entry.file_name().to_string_lossy().starts_with("python3")
193 && entry.path().is_dir()
194 {
195 ro_dirs.insert(entry.path());
196 }
197 }
198 }
199 }
200 }
201}
202
203/// Build the minimal-rootfs plan for `effective` (ADR 0013 D2). Requires `exec` to
204/// be **confined** (`Only`) — a minimal rootfs is meaningless when any program may
205/// run, so an ambient `exec` is rejected (the caller falls back to the Tier-1
206/// boundary). The plan contains: the resolved granted program files + each one's
207/// `ldd` shared-library closure (incl. the loader) + the configured data paths +
208/// the granted `fs_read` (ro) / `fs_write` (rw) roots — and nothing else.
209pub fn build_rootfs_plan(
210 effective: &Caveats,
211 rootfs: &RootfsPolicy,
212 norm: &NormalizationPolicy,
213) -> Result<RootfsPlan, String> {
214 let programs = match &effective.exec {
215 Scope::All => {
216 return Err("minimal rootfs requires a confined exec scope (exec: Only)".to_string())
217 }
218 Scope::Only(set) => set,
219 };
220 let search_fallback = &rootfs.search_dirs;
221
222 // (src, writable, is_dir) accumulated then deduped.
223 let mut files: BTreeSet<PathBuf> = BTreeSet::new(); // ro files (binaries + .so + loader + data files)
224 let mut ro_dirs: BTreeSet<PathBuf> = BTreeSet::new();
225 let mut rw_dirs: BTreeSet<PathBuf> = BTreeSet::new();
226
227 for prog in programs {
228 let bin = resolve_program(prog, search_fallback)
229 .ok_or_else(|| format!("granted program not found: {prog}"))?;
230 // Toggle: the static `ldd` closure. Disabling only removes `.so`s from the
231 // plan (more minimal) — a granted dynamic program then fails to load, so
232 // this is a capability/degradation knob, never a confinement relaxation.
233 if norm.ldd_closure {
234 for so in ldd_closure(&bin) {
235 files.insert(so);
236 }
237 }
238 files.insert(bin);
239 }
240
241 // D7 runtime-closure fallback (agent-bridle#113): `dlopen`/`ctypes`/NSS loads
242 // are undecidable to enumerate statically, so widen the closure with known
243 // dynamic **library/data** paths — never un-granted executables, so the D1
244 // identity invariant still holds.
245 add_runtime_closure_fallback(
246 programs,
247 &mut files,
248 &mut ro_dirs,
249 norm.nss_closure_fallback,
250 norm.python_closure_fallback,
251 );
252
253 for d in rootfs.data_paths.resolve() {
254 let p = PathBuf::from(&d);
255 match p.metadata() {
256 Ok(m) if m.is_dir() => {
257 ro_dirs.insert(p);
258 }
259 Ok(_) => {
260 files.insert(p);
261 }
262 Err(_) => {} // absent ⇒ skipped (harmless)
263 }
264 }
265
266 // Granted fs roots: read roots ro, write roots rw (write wins on overlap).
267 if let Scope::Only(rd) = &effective.fs_read {
268 for p in rd {
269 let pb = PathBuf::from(p);
270 if pb.is_dir() {
271 ro_dirs.insert(pb);
272 } else if pb.exists() {
273 files.insert(pb);
274 }
275 }
276 }
277 if let Scope::Only(wr) = &effective.fs_write {
278 for p in wr {
279 let pb = PathBuf::from(p);
280 if pb.is_dir() {
281 ro_dirs.remove(&pb);
282 rw_dirs.insert(pb);
283 } else if pb.exists() {
284 files.insert(pb);
285 }
286 }
287 }
288
289 let mut entries: Vec<RootfsEntry> = Vec::new();
290 entries.extend(files.into_iter().map(|src| RootfsEntry {
291 src,
292 writable: false,
293 is_dir: false,
294 }));
295 entries.extend(ro_dirs.into_iter().map(|src| RootfsEntry {
296 src,
297 writable: false,
298 is_dir: true,
299 }));
300 entries.extend(rw_dirs.into_iter().map(|src| RootfsEntry {
301 src,
302 writable: true,
303 is_dir: true,
304 }));
305 entries.sort_by(|a, b| a.src.cmp(&b.src));
306 Ok(RootfsPlan { entries })
307}
308
309/// Materialize a plan by **copying** file entries (and creating empty mount-point
310/// dirs for directory entries) under `dest`, preserving each `src`'s absolute path
311/// (so `/usr/bin/cat` lands at `dest/usr/bin/cat`). This is the **test /
312/// diagnostic** path — production exposes the same plan via read-only bind-mounts
313/// (no copy) through the broker (#108). Directory entries are left as empty
314/// mount-points (their contents are bound at run time), so this does NOT recurse
315/// into large data trees like `/usr/share`.
316pub fn materialize_copy(plan: &RootfsPlan, dest: &Path) -> std::io::Result<()> {
317 for e in &plan.entries {
318 let rel = e.src.strip_prefix("/").unwrap_or(&e.src);
319 let target = dest.join(rel);
320 if e.is_dir {
321 std::fs::create_dir_all(&target)?; // empty mount-point
322 } else {
323 if let Some(parent) = target.parent() {
324 std::fs::create_dir_all(parent)?;
325 }
326 // A file entry may be a special file (/dev/null) — skip copy if it is
327 // not a regular file, but record the mount-point's parent above.
328 if e.src.is_file() {
329 std::fs::copy(&e.src, &target)?;
330 }
331 }
332 }
333 Ok(())
334}
335
336/// A content-addressed cache of materialized minimal rootfs trees (#112 / ADR
337/// 0013 D7). Building a rootfs (resolving the `ldd` closure + assembling the
338/// tree) is the expensive step; keying it by the *(granted-binaries + resolved
339/// closure)* identity lets repeated runs of the same toolchain reuse the build.
340///
341/// The cache stores [`materialize_copy`] trees keyed by [`RootfsCache::key`];
342/// production keys the read-only bind-mount jail by the *same* key.
343pub struct RootfsCache {
344 root: PathBuf,
345}
346
347impl RootfsCache {
348 /// A cache rooted at `root` (created on first store).
349 pub fn new(root: impl Into<PathBuf>) -> Self {
350 Self { root: root.into() }
351 }
352
353 /// The content key for `plan`: a BLAKE3 [`crate::ContentId`] (hex) over each
354 /// file entry's `(path, ro/rw, len, mtime)` and each directory mount-point's
355 /// `(path, ro/rw)`. A changed granted binary or `.so` (different len/mtime) ⇒
356 /// a different key ⇒ a rebuild; a changed exec scope ⇒ different paths ⇒ a
357 /// different key. Directory mount-points key by path only — their contents are
358 /// bind-mounted at run time, not part of the built tree.
359 #[must_use]
360 pub fn key(plan: &RootfsPlan) -> String {
361 let mut buf = String::new();
362 for e in &plan.entries {
363 buf.push_str(&e.src.to_string_lossy());
364 buf.push('\u{0}');
365 buf.push_str(if e.writable { "rw" } else { "ro" });
366 buf.push('\u{0}');
367 buf.push_str(if e.is_dir { "d" } else { "f" });
368 if !e.is_dir {
369 if let Ok(m) = e.src.metadata() {
370 buf.push_str(&format!("\u{0}{}", m.len()));
371 if let Ok(mtime) = m.modified() {
372 if let Ok(d) = mtime.duration_since(std::time::UNIX_EPOCH) {
373 buf.push_str(&format!("\u{0}{}", d.as_nanos()));
374 }
375 }
376 }
377 }
378 buf.push('\n');
379 }
380 crate::ContentId::of_bytes(buf.as_bytes())
381 .as_bytes()
382 .iter()
383 .map(|b| format!("{b:02x}"))
384 .collect()
385 }
386
387 /// The cached rootfs directory for `plan`, materializing it (copy) exactly
388 /// once. Returns `(dir, hit)` — `hit == true` ⇒ a complete prior build was
389 /// reused. A partial (interrupted) build is detected by the absence of the
390 /// completion marker and rebuilt from scratch.
391 pub fn get_or_materialize(&self, plan: &RootfsPlan) -> std::io::Result<(PathBuf, bool)> {
392 let dir = self.root.join(Self::key(plan));
393 let marker = dir.join(".bridle-rootfs-complete");
394 if marker.is_file() {
395 return Ok((dir, true));
396 }
397 if dir.exists() {
398 std::fs::remove_dir_all(&dir)?; // clear a partial/stale build
399 }
400 materialize_copy(plan, &dir)?;
401 std::fs::create_dir_all(&dir)?; // ensure the root exists even for an empty plan
402 std::fs::write(&marker, b"")?;
403 Ok((dir, false))
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 fn unique_dir(tag: &str) -> PathBuf {
412 use std::sync::atomic::{AtomicU64, Ordering};
413 static N: AtomicU64 = AtomicU64::new(0);
414 let mut d = std::env::temp_dir();
415 d.push(format!(
416 "agent-bridle-rootfs-{}-{}-{}",
417 tag,
418 std::process::id(),
419 N.fetch_add(1, Ordering::Relaxed)
420 ));
421 std::fs::create_dir_all(&d).unwrap();
422 d
423 }
424
425 #[test]
426 fn ambient_exec_is_rejected() {
427 // exec: All ⇒ no minimal rootfs (any program could run).
428 let err = build_rootfs_plan(
429 &Caveats::top(),
430 &crate::RootfsPolicy::default(),
431 &crate::NormalizationPolicy::default(),
432 )
433 .unwrap_err();
434 assert!(err.contains("confined exec scope"), "{err}");
435 }
436
437 /// ADR 0013 D1 invariant: the built tree contains the granted program (and its
438 /// library closure), and **no un-granted program** — identity by what *exists*.
439 #[test]
440 fn rootfs_contains_granted_program_and_not_ungranted_tools() {
441 let work = unique_dir("work");
442 let cav = Caveats {
443 exec: Scope::only(["cat".to_string()]),
444 fs_read: Scope::only([work.to_string_lossy().into_owned()]),
445 fs_write: Scope::only([work.to_string_lossy().into_owned()]),
446 ..Caveats::top()
447 };
448 let plan = build_rootfs_plan(
449 &cav,
450 &crate::RootfsPolicy::default(),
451 &crate::NormalizationPolicy::default(),
452 )
453 .expect("plan");
454
455 // The granted binary and at least one library (its closure) are planned.
456 let has_cat = plan
457 .entries
458 .iter()
459 .any(|e| e.src.file_name().map(|n| n == "cat").unwrap_or(false) && !e.is_dir);
460 let has_lib = plan
461 .entries
462 .iter()
463 .any(|e| e.src.to_string_lossy().contains("/libc.so"));
464 assert!(
465 has_cat,
466 "granted `cat` must be in the plan: {:?}",
467 plan.entries
468 );
469 assert!(
470 has_lib,
471 "cat's libc closure must be in the plan: {:?}",
472 plan.entries
473 );
474
475 // The writable work dir is rw; the loader is present.
476 assert!(plan.entries.iter().any(|e| e.writable
477 && e.is_dir
478 && e.src == work.canonicalize().unwrap_or(work.clone())));
479 assert!(plan
480 .entries
481 .iter()
482 .any(|e| e.src.to_string_lossy().contains("ld-")));
483
484 // No un-granted program/interpreter anywhere in the plan (the D1 invariant).
485 for tool in [
486 "/curl", "/sh", "/bash", "/python3", "/perl", "/head", "/wget", "/nc",
487 ] {
488 assert!(
489 !plan.entries.iter().any(|e| {
490 let s = e.src.to_string_lossy();
491 s.ends_with(tool) || s.contains(&format!("/bin{tool}"))
492 }),
493 "un-granted tool `{tool}` must NOT be in the minimal rootfs: {:?}",
494 plan.entries
495 );
496 }
497
498 // Materialize (copy) and re-check on the real tree: cat present, sh absent.
499 let dest = unique_dir("root");
500 materialize_copy(&plan, &dest).expect("materialize");
501 let cat_present = dest.join("usr/bin/cat").exists()
502 || dest.join("bin/cat").exists()
503 || dest.join("usr/local/bin/cat").exists();
504 assert!(cat_present, "materialized tree must contain cat");
505 for tool in [
506 "usr/bin/sh",
507 "bin/sh",
508 "usr/bin/curl",
509 "bin/bash",
510 "usr/bin/head",
511 ] {
512 assert!(
513 !dest.join(tool).exists(),
514 "materialized minimal rootfs must NOT contain un-granted `{tool}`"
515 );
516 }
517
518 let _ = std::fs::remove_dir_all(&work);
519 let _ = std::fs::remove_dir_all(&dest);
520 }
521
522 /// #144 (I5): the curated data-path list is config-driven — a `replace`d empty
523 /// `data_paths` drops the built-in DATA_PATHS (e.g. `/usr/share`) from the
524 /// plan, proving the builder reads the policy and not the const. Would fail on
525 /// the old const path (which always injected `/usr/share`).
526 #[test]
527 fn rootfs_data_paths_are_config_driven() {
528 let cav = Caveats {
529 exec: Scope::only(["cat".to_string()]),
530 ..Caveats::top()
531 };
532 let has_usr_share =
533 |p: &RootfsPlan| p.entries.iter().any(|e| e.src == Path::new("/usr/share"));
534
535 // Default policy: /usr/share (a DATA_PATHS dir) is planned.
536 let default_plan = build_rootfs_plan(
537 &cav,
538 &crate::RootfsPolicy::default(),
539 &crate::NormalizationPolicy::default(),
540 )
541 .expect("plan");
542 assert!(
543 has_usr_share(&default_plan),
544 "default plan must include the built-in /usr/share data dir"
545 );
546
547 // Empty, `replace`d data_paths ⇒ /usr/share is gone (the policy drives it).
548 let stripped = crate::RootfsPolicy {
549 data_paths: crate::PathList {
550 base: vec![],
551 extra: vec![],
552 replace: true,
553 },
554 ..crate::RootfsPolicy::default()
555 };
556 let stripped_plan =
557 build_rootfs_plan(&cav, &stripped, &crate::NormalizationPolicy::default())
558 .expect("plan");
559 assert!(
560 !has_usr_share(&stripped_plan),
561 "a replace'd empty data_paths must drop /usr/share from the plan"
562 );
563 }
564
565 /// #146 (I7): the `ldd` static-closure normalization is a toggle — disabling
566 /// it drops the `.so` closure from the plan (a capability/degradation knob,
567 /// never a confinement relaxation: fewer files present is strictly tighter).
568 /// Would fail on the old always-on path.
569 #[test]
570 fn rootfs_ldd_closure_is_toggleable() {
571 let cav = Caveats {
572 exec: Scope::only(["cat".to_string()]),
573 ..Caveats::top()
574 };
575 let has_lib = |p: &RootfsPlan| {
576 p.entries
577 .iter()
578 .any(|e| e.src.to_string_lossy().contains("/libc.so"))
579 };
580
581 // Default (on): cat's libc closure is planned.
582 let on = build_rootfs_plan(
583 &cav,
584 &crate::RootfsPolicy::default(),
585 &crate::NormalizationPolicy::default(),
586 )
587 .expect("plan");
588 assert!(has_lib(&on), "default plan must include cat's libc closure");
589
590 // Off: the `.so` closure is gone (the toggle drives it).
591 let off = crate::NormalizationPolicy {
592 ldd_closure: false,
593 ..crate::NormalizationPolicy::default()
594 };
595 let plan = build_rootfs_plan(&cav, &crate::RootfsPolicy::default(), &off).expect("plan");
596 assert!(
597 !has_lib(&plan),
598 "disabling ldd_closure must drop the .so closure from the plan"
599 );
600 }
601
602 /// #112: the cache key is stable for the same grant and varies with the
603 /// granted-program set (the D7 content-key).
604 #[test]
605 fn cache_key_is_stable_and_varies_with_grant() {
606 let work = unique_dir("ck");
607 let mk = |prog: &str| Caveats {
608 exec: Scope::only([prog.to_string()]),
609 fs_read: Scope::only([work.to_string_lossy().into_owned()]),
610 ..Caveats::top()
611 };
612 let k_cat = RootfsCache::key(
613 &build_rootfs_plan(
614 &mk("cat"),
615 &crate::RootfsPolicy::default(),
616 &crate::NormalizationPolicy::default(),
617 )
618 .unwrap(),
619 );
620 let k_cat2 = RootfsCache::key(
621 &build_rootfs_plan(
622 &mk("cat"),
623 &crate::RootfsPolicy::default(),
624 &crate::NormalizationPolicy::default(),
625 )
626 .unwrap(),
627 );
628 let k_grep = RootfsCache::key(
629 &build_rootfs_plan(
630 &mk("grep"),
631 &crate::RootfsPolicy::default(),
632 &crate::NormalizationPolicy::default(),
633 )
634 .unwrap(),
635 );
636 assert_eq!(k_cat, k_cat2, "same grant ⇒ stable key");
637 assert_ne!(k_cat, k_grep, "different exec scope ⇒ different key");
638 assert_eq!(k_cat.len(), 64, "hex of a 32-byte BLAKE3 content id");
639 let _ = std::fs::remove_dir_all(&work);
640 }
641
642 /// #112: the cache materializes a plan once, then reports a hit (reuse).
643 #[test]
644 fn cache_materializes_once_then_hits() {
645 let work = unique_dir("cm");
646 std::fs::write(work.join("data"), b"x").unwrap();
647 let cav = Caveats {
648 exec: Scope::only(["cat".to_string()]),
649 fs_read: Scope::only([work.to_string_lossy().into_owned()]),
650 fs_write: Scope::only([work.to_string_lossy().into_owned()]),
651 ..Caveats::top()
652 };
653 let plan = build_rootfs_plan(
654 &cav,
655 &crate::RootfsPolicy::default(),
656 &crate::NormalizationPolicy::default(),
657 )
658 .expect("plan");
659 let cache_root = unique_dir("cache");
660 let cache = RootfsCache::new(&cache_root);
661
662 let (dir1, hit1) = cache.get_or_materialize(&plan).expect("build");
663 assert!(!hit1, "first build is a miss");
664 assert!(
665 dir1.join(".bridle-rootfs-complete").is_file(),
666 "completion marker written"
667 );
668 assert!(
669 dir1.join("usr/bin/cat").exists() || dir1.join("bin/cat").exists(),
670 "cached tree contains the granted program"
671 );
672
673 let (dir2, hit2) = cache.get_or_materialize(&plan).expect("reuse");
674 assert!(hit2, "second build is a cache hit");
675 assert_eq!(dir1, dir2, "same keyed directory");
676
677 let _ = std::fs::remove_dir_all(&work);
678 let _ = std::fs::remove_dir_all(&cache_root);
679 }
680
681 /// #113 / ADR 0013 D7: granting `python3` widens the closure with the python
682 /// stdlib dir(s) (so startup imports and `dlopen`ed C-extensions resolve) — but
683 /// adds **no un-granted executable** (`/usr/bin/*`), preserving the D1 identity
684 /// invariant. Skips if python3 is not installed on the host.
685 #[test]
686 fn python_fallback_adds_stdlib_without_executables() {
687 let work = unique_dir("py");
688 let cav = Caveats {
689 exec: Scope::only(["python3".to_string()]),
690 fs_read: Scope::only([work.to_string_lossy().into_owned()]),
691 ..Caveats::top()
692 };
693 let plan = match build_rootfs_plan(
694 &cav,
695 &crate::RootfsPolicy::default(),
696 &crate::NormalizationPolicy::default(),
697 ) {
698 Ok(p) => p,
699 Err(_) => return, // python3 not installed ⇒ nothing to prove
700 };
701 let has_py_stdlib = plan
702 .entries
703 .iter()
704 .any(|e| e.is_dir && !e.writable && e.src.to_string_lossy().contains("/python3"));
705 assert!(
706 has_py_stdlib,
707 "python stdlib dir must be in the plan: {:?}",
708 plan.entries
709 );
710 // D1: the only executable file the fallback may leave in a bin dir is the
711 // granted python itself — never another `/usr/bin` program.
712 for e in &plan.entries {
713 if e.is_dir {
714 continue;
715 }
716 let s = e.src.to_string_lossy();
717 if s.starts_with("/usr/bin") || s.starts_with("/bin") || s.contains("/sbin/") {
718 let base = e.src.file_name().and_then(|n| n.to_str()).unwrap_or("");
719 assert!(
720 base.starts_with("python"),
721 "fallback must not add an un-granted executable: {s}"
722 );
723 }
724 }
725 let _ = std::fs::remove_dir_all(&work);
726 }
727
728 /// #113 / ADR 0013 D7: the NSS modules glibc `dlopen`s at runtime (never in the
729 /// static `ldd` closure) are added for a dynamically-linked grant. Asserts only
730 /// when the host actually ships them (all mainstream glibc distros do).
731 #[test]
732 fn nss_modules_added_to_closure() {
733 let work = unique_dir("nss");
734 let cav = Caveats {
735 exec: Scope::only(["cat".to_string()]),
736 fs_read: Scope::only([work.to_string_lossy().into_owned()]),
737 ..Caveats::top()
738 };
739 let plan = build_rootfs_plan(
740 &cav,
741 &crate::RootfsPolicy::default(),
742 &crate::NormalizationPolicy::default(),
743 )
744 .expect("plan");
745 let nss_in_plan = |p: &RootfsPlan| {
746 p.entries.iter().any(|e| {
747 e.src
748 .file_name()
749 .and_then(|n| n.to_str())
750 .is_some_and(|n| n.starts_with("libnss_"))
751 })
752 };
753 // Derive libc's directory from the plan; if the host ships NSS modules
754 // there, the fallback must have added them.
755 if let Some(libc) = plan.entries.iter().find(|e| {
756 e.src
757 .file_name()
758 .and_then(|n| n.to_str())
759 .is_some_and(|n| n.starts_with("libc.so"))
760 }) {
761 if let Some(dir) = libc.src.parent() {
762 let host_has_nss = dir
763 .read_dir()
764 .into_iter()
765 .flatten()
766 .flatten()
767 .any(|e| e.file_name().to_string_lossy().starts_with("libnss_"));
768 if host_has_nss {
769 assert!(
770 nss_in_plan(&plan),
771 "NSS modules must be added to the closure: {:?}",
772 plan.entries
773 );
774 }
775 }
776 }
777 let _ = std::fs::remove_dir_all(&work);
778 }
779}