xtask/src/staging_guard.rs
//! **The staging tree carries what it declares and nothing else** — the guard
//! I6 asked for, over the one directory git cannot watch.
//!
//! # The leak, measured
//!
//! `assets/appliance-root/` is the ISO variant's staging tree
//! (`crates/gunnar-build-thing/src/recipe.rs`, `staging =
//! "assets/appliance-root"`), and edda's `image_build` grafts it at the
//! **initramfs root**. Whatever sits in it when a medium is mastered ships
//! inside that medium.
//!
//! It is also a real, writable path on the HOST, and dwarves' own
//! `gunnar_appliance_iso` forge test — living in ANOTHER repository —
//! bootstraps fixture state into it: `control.redb` (4 403 200 B),
//! `control.git/` and `repos/`, a demo repository and a test-minted key. On
//! 2026-08-13 that state was there, and it was grafted onto the initramfs root
//! of **every ISO this box built** until it was found on 2026-08-27.
//!
//! # Why no review step could have caught it, and what that dictates
//!
//! `assets/appliance-root/data/.gitignore` is `*` with two exceptions. That
//! ignore file is CORRECT — `/data` ships empty and a stray write must never be
//! one `git add` away from a commit — and it is exactly why the leak was
//! invisible: `git status` was clean throughout, every diff was clean, and the
//! junk was on disk the whole time.
//!
//! So the guard cannot ask git. It reads the **filesystem**, which is the thing
//! the mastering step reads, and it compares against [`ALLOWED`] — the two
//! files that are committed and are the whole declared content of the tree.
//!
//! # A scan that saw nothing is not a pass
//!
//! [`scan`] reports how many entries it walked, and [`task_guard`] refuses on
//! zero. An empty answer from a mistyped root has the same shape as a clean
//! tree, which is the failure a red-first proof is about; here it is the *likely* failure,
//! because the whole tree is two dotfiles deep in one subdirectory.
//!
//! [`task_guard`]: ../../xtask/src/main.rs
//!
//! # When a variant legitimately stages a file
//!
//! Then [`ALLOWED`] grows **in the same commit as the declaration**, and the
//! two are read side by side. Today no variant stages anything here: the live
//! ISO declares `params: Vec::new()` (lane `740908e0` — the live medium takes
//! the TOFU door), so `IsoBuild::staged_param_files()` is empty for it and
//! nothing is written into this tree at build time. The installer's
//! `admin.pub` and `hub.conf` ride the ISO volume flat and are materialized
//! onto P3 by the installer's PID 1; they never touch this directory.
use std::path::{Path, PathBuf};
/// The staging tree, relative to the workspace root. One writer of this string
/// in the guard; the declaration's copy is
/// `crates/gunnar-build-thing/src/recipe.rs`.
pub const STAGING_TREE: &str = "assets/appliance-root";
/// Every path the staging tree is allowed to hold, relative to
/// [`STAGING_TREE`]. Both are committed; `data/` ships empty and `.keep` says
/// so in prose.
pub const ALLOWED: &[&str] = &["data/.keep", "data/.gitignore"];
/// One path that is in the tree and not in [`ALLOWED`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stray {
/// Path relative to [`STAGING_TREE`], `/`-separated.
pub rel: String,
/// Bytes on disk, so a report says how much would have shipped.
pub bytes: u64,
/// Whether it is a directory — `control.git/` and `repos/` were.
pub is_dir: bool,
}
impl std::fmt::Display for Stray {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let what = if self.is_dir { "directory" } else { "file" };
write!(
f,
"{STAGING_TREE}/{} [staging-tree] undeclared {what} ({} byte(s)) — this tree is \
grafted at the initramfs ROOT, so it would ship inside every ISO built from here. \
It is `.gitignore`d, so no diff and no `git status` can show it to you. Move it \
out, or add it to `xtask::staging_guard::ALLOWED` in the same commit as the \
declaration that stages it.",
self.rel, self.bytes
)
}
}
/// Walk [`STAGING_TREE`] under `root` and report everything not in
/// [`ALLOWED`], plus **how many entries were walked**.
///
/// The count is the guard's own proof of life: a caller must refuse on zero
/// rather than read it as a clean tree. A missing tree yields `(vec![], 0)`
/// for the same reason — "the directory is not there" and "the directory is
/// clean" are different answers and only one of them is a pass.
#[must_use]
pub fn scan(root: &Path) -> (Vec<Stray>, usize) {
let base = root.join(STAGING_TREE);
let mut strays = Vec::new();
let mut seen = 0usize;
walk(&base, &base, &mut strays, &mut seen);
strays.sort_by(|a, b| a.rel.cmp(&b.rel));
(strays, seen)
}
fn walk(base: &Path, dir: &Path, strays: &mut Vec<Stray>, seen: &mut usize) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(rel) = relative(base, &path) else {
continue;
};
*seen += 1;
let is_dir = path.is_dir();
if ALLOWED.contains(&rel.as_str()) {
continue;
}
// A directory that only CONTAINS allowed paths is itself fine — `data/`
// is the case — so descend before judging it. A directory holding
// nothing allowed is reported by its own strays, not by its name, so a
// `repos/` full of junk names the junk.
if is_dir {
let before = strays.len();
let seen_before = *seen;
walk(base, &path, strays, seen);
if strays.len() == before && *seen == seen_before {
// Empty and undeclared: still a stray, because an empty
// directory on the initramfs root is a mount point somebody
// did not declare. A directory that gained strays is named by
// them; one that gained only allowed paths is `data/`.
strays.push(Stray {
rel,
bytes: 0,
is_dir: true,
});
}
continue;
}
let bytes = entry.metadata().map(|m| m.len()).unwrap_or(0);
strays.push(Stray {
rel,
bytes,
is_dir: false,
});
}
}
fn relative(base: &Path, path: &Path) -> Option<String> {
let rel: PathBuf = path.strip_prefix(base).ok()?.to_path_buf();
Some(rel.to_string_lossy().replace('\\', "/"))
}