Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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('\\', "/"))
}