Skip to main content

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}