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

nordisk/gunnar

xtask/src/one_clone.rs

//! **ONE CLONE — plus exactly the siblings the recipe DECLARES.** No manifest in
//! this repository may name a path that leaves it, except into a sibling
//! checkout that `[build.toolchain] siblings` lists
//! (`crates/gunnar-recipe/src/recipe.rs`, `toolchain()`).
//!
//! ## Why the rule grew a declared exception (2026-09-29)
//!
//! The strict form below — nothing outside the tree, ever — stopped being the
//! build's truth on 2026-09-25/28, deliberately and with measurements:
//! gunnar-front links `gunnar-ui` and `monetize` by path (the owner's ruling that
//! the console and monetize ride vm1's own medium), nordisk-pairing moved to
//! `Skidbladnir`, and `edda-admit` / `disk-snapshot` went from a git pin to a
//! PATH because the Build Thing container compiles `--network none --offline`
//! and mounts only the registry, never `CARGO_HOME/git` (release-2 judgment,
//! crates/gunnar-ssh/Cargo.toml, the root Cargo.toml note at `edda-admit`). Each
//! of those commits grew `siblings` in the SAME commit, as the recipe's own
//! comment demands, and `CargoToolchain::check_escaping_path_deps` refuses at
//! build time any escape into a sibling the list does not name. The gate here
//! kept asserting the older, stricter sentence and was red on master and on
//! every release branch for it — 21 lines, every one into a declared sibling.
//!
//! So the gate now asserts what the build actually depends on, and stays exactly
//! as sharp everywhere else: a path into an UNDECLARED sibling, a path reaching
//! above the checkout's parent, and an absolute path are still reported, and the
//! lockfile half still reports every locally-resolved package that neither this
//! repository nor a declared sibling defines. The declaration is read from the
//! recipe — the code is the source — not from a list kept here.
//!
//! ## What this guards, and why a comment could not
//!
//! `bench/Cargo.toml` has carried the rule in prose since 2026-08:
//!
//! ```text
//!  * NEVER `path = "../../edda/…"`. **gunnar must build from ONE clone**
//! ```
//!
//! It was written there because the property had already been lost, and the
//! prose did not stop it being lost again: on 2026-09-12 five lines still
//! reached out of the tree — four edda leaves in `deploy/upcloud/Cargo.toml`
//! and `znippy-zoomies` in `bench-stock/Cargo.toml`. Every one of them carried
//! a comment explaining why it was harmless, and every one of those comments
//! gave the same reason (*"this tree already requires the sibling checkout"*),
//! which stopped being true when the trees around them were converted one at a
//! time and nobody re-read the stragglers. A rule enforced by prose is a rule
//! that decays at exactly the speed the prose goes stale.
//!
//! ## Why a sibling path is not a preference
//!
//! `cargo` reads a path dependency's manifest at **RESOLVE** time, before it
//! builds anything and regardless of `optional`, features, or the workspace
//! `exclude` list. So one such line does not merely make a build need a
//! neighbour — it makes `cargo metadata`, `cargo tree`, `cargo package` and
//! every tool built on them a hard error in a clone with nothing beside it:
//!
//! ```text
//! error: failed to load manifest for dependency `nornir-hash`
//! Caused by: failed to read `/edda/crates/nornir-hash/Cargo.toml`
//! ```
//!
//! That is why the scan below is **lexical** and never touches the filesystem.
//! The machine this runs on is precisely the machine where the sibling DOES
//! exist; a check that asked the disk would be green here and say nothing about
//! the clone that matters. Normalising `..` by hand is the point, not a
//! shortcut.
//!
//! ## The two halves
//!
//! [`escapes`] is the resolve-time half: every `path = "…"` in every
//! `Cargo.toml`, normalised against its own directory, must land inside the
//! repository root. It covers dependency tables, `[patch.crates-io]` and
//! `[[bin]]`/`[[example]]` target paths alike — a target file outside the tree
//! breaks a clone just as thoroughly as a dependency does, and one rule that
//! cannot be argued about beats three that can.
//!
//! [`foreign_lock_packages`] is the lockfile half, and it is a different
//! question rather than a restatement. A `[[package]]` with no `source = "…"`
//! line is a LOCAL package, and a lockfile is the only place the *result* of a
//! resolve is written down: if every source-less package in every lockfile is a
//! crate that lives in this repository, then nothing outside it was resolved,
//! whatever the manifests say. The two halves fail independently — a stale
//! lockfile committed beside a fixed manifest is caught by the second and not
//! the first.
//!
//! Both are pure `std` string work so the guard does not become the thing it
//! guards; the one input from outside is the declared sibling list, read from
//! `gunnar_recipe` (xtask's one dependency).

use std::collections::BTreeSet;
use std::path::Path;

/// One `path = "…"` that leaves the repository.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Escape {
    /// The manifest, relative to the repository root.
    pub manifest: String,
    /// 1-based line number within that manifest.
    pub line: usize,
    /// The literal as written.
    pub written: String,
    /// Where it lands once `.` and `..` are folded, relative to the root — with
    /// the leading `../` kept, because that is the whole complaint.
    pub resolved: String,
}

impl Escape {
    /// One line, in the shape a failing test should print.
    pub fn describe(&self) -> String {
        format!(
            "{}:{}: path = \"{}\" leaves the repository (resolves to {})",
            self.manifest, self.line, self.written, self.resolved
        )
    }
}

/// A source-less `[[package]]` in a lockfile whose name is not a crate of this
/// repository — i.e. the recorded result of resolving something off-tree.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ForeignLocal {
    /// The lockfile, relative to the repository root.
    pub lockfile: String,
    /// The package name as the lockfile spells it.
    pub package: String,
}

impl ForeignLocal {
    pub fn describe(&self) -> String {
        format!(
            "{}: `{}` is locked as a LOCAL package but no Cargo.toml in this \
             repository or in a declared sibling defines it, so the resolve reached a \
             checkout the build container will not have",
            self.lockfile, self.package
        )
    }
}

/// **Is this line a `path = "…"` assignment, and to what?**
///
/// Returns `None` for a comment line. gunnar's manifests are mostly prose —
/// `bench/Cargo.toml` spells the forbidden shape out in full in order to forbid
/// it, and `bench-stock/Cargo.toml` carries a commented-out `[patch]` recipe —
/// so a scanner that did not strip comments would be red on the documentation
/// of the rule it enforces, which is the most confusing way a guard can fail.
pub fn path_literal(line: &str) -> Option<&str> {
    let trimmed = line.trim_start();
    if trimmed.starts_with('#') {
        return None;
    }
    let mut rest = trimmed;
    loop {
        let at = rest.find("path")?;
        let after = &rest[at + "path".len()..];
        let before_ok = at == 0 || {
            let b = rest.as_bytes()[at - 1];
            !(b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
        };
        let value = after.trim_start();
        if before_ok && value.starts_with('=') {
            let v = value[1..].trim_start();
            if let Some(open) = v.strip_prefix('"') {
                if let Some(end) = open.find('"') {
                    return Some(&open[..end]);
                }
            }
        }
        rest = &rest[at + "path".len()..];
    }
}

/// **Fold `.` and `..` without asking the filesystem.**
///
/// `Path::canonicalize` is the wrong tool twice over: it fails when the target
/// is absent (which is the case this guard exists to describe) and it succeeds
/// when the sibling happens to be on this disk (which is every developer box).
/// So the components are folded by hand. A `..` that would pop above the root
/// is kept as a leading `../`, which is what makes the result say *how far*
/// outside the tree the line reached.
pub fn fold(base_rel: &str, literal: &str) -> String {
    let mut out: Vec<&str> = Vec::new();
    let mut above = 0usize;
    for part in base_rel.split('/').chain(literal.split('/')) {
        match part {
            "" | "." => {}
            ".." => {
                if out.pop().is_none() {
                    above += 1;
                }
            }
            p => out.push(p),
        }
    }
    let mut s = "../".repeat(above);
    s.push_str(&out.join("/"));
    if s.is_empty() {
        ".".to_string()
    } else {
        s
    }
}

/// The escapes in ONE manifest's text, without reading anything.
///
/// `manifest_rel` is the manifest's own path from the repository root
/// (`deploy/upcloud/Cargo.toml`); the literals are folded against its parent.
/// Taking the text as an argument is what lets the red-first proof inject a
/// sibling path without writing a file — and it is the same code the disk walk
/// runs, not a second copy of the rule.
pub fn escapes_in(manifest_rel: &str, text: &str) -> Vec<Escape> {
    let dir = match manifest_rel.rfind('/') {
        Some(i) => &manifest_rel[..i],
        None => "",
    };
    let mut found = Vec::new();
    for (i, line) in text.lines().enumerate() {
        let Some(literal) = path_literal(line) else {
            continue;
        };
        if literal.starts_with('/') {
            found.push(Escape {
                manifest: manifest_rel.to_string(),
                line: i + 1,
                written: literal.to_string(),
                resolved: literal.to_string(),
            });
            continue;
        }
        let resolved = fold(dir, literal);
        if resolved.starts_with("../") {
            found.push(Escape {
                manifest: manifest_rel.to_string(),
                line: i + 1,
                written: literal.to_string(),
                resolved,
            });
        }
    }
    found
}

/// Every `Cargo.toml` in the tree, relative to `root`, sorted.
///
/// `target/`, `.git/` and `vendor/*/target` are skipped; everything else is
/// scanned, INCLUDING the detached workspaces (`bench/`, `bench-stock/`,
/// `installtest/`, `deploy/upcloud/`) and `xtask/tests/fixtures/`. Detachment
/// is exactly what let the stragglers hide: a detached workspace is invisible
/// to `cargo metadata` at the root, so nothing that walks the crate graph can
/// see it. A directory walk can.
pub fn manifests(root: &Path) -> Vec<String> {
    let mut out = Vec::new();
    walk(root, root, &mut out);
    out.sort();
    out
}

fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        let name = entry.file_name();
        let name = name.to_string_lossy();
        if path.is_dir() {
            if name == "target" || name == ".git" || name.starts_with(".iso-out") {
                continue;
            }
            walk(root, &path, out);
        } else if name == "Cargo.toml" {
            if let Ok(rel) = path.strip_prefix(root) {
                out.push(rel.to_string_lossy().replace('\\', "/"));
            }
        }
    }
}

/// **THE GATE, resolve-time half.** Every escaping `path = "…"` in the tree.
pub fn escapes(root: &Path) -> Vec<Escape> {
    let mut found = Vec::new();
    for rel in manifests(root) {
        let Ok(text) = std::fs::read_to_string(root.join(&rel)) else {
            continue;
        };
        found.extend(escapes_in(&rel, &text));
    }
    found
}

/// **The siblings the recipe declares** — `[build.toolchain] siblings`, read
/// from `gunnar_recipe` (the code is the source; `.nornir/build-variants.toml`
/// is emitted from it). Empty when the recipe declares no toolchain.
pub fn declared_siblings() -> Vec<String> {
    gunnar_recipe::emit_recipe::declared()
        .toolchain
        .map(|tc| tc.siblings)
        .unwrap_or_default()
}

/// The sibling checkout a folded path lands in: `../edda/crates/x` → `edda`.
/// `None` for a path that stays inside, reaches ABOVE the parent
/// (`../../x`), or is absolute — none of which a declaration can make whole.
pub fn sibling_of(resolved: &str) -> Option<&str> {
    let rest = resolved.strip_prefix("../")?;
    if rest.starts_with("../") || rest.is_empty() {
        return None;
    }
    rest.split('/')
        .next()
        .filter(|s| !s.is_empty() && *s != "..")
}

/// The escapes a declaration does NOT cover: into an undeclared sibling, above
/// the parent, or absolute.
pub fn undeclared(escapes: Vec<Escape>, siblings: &[String]) -> Vec<Escape> {
    escapes
        .into_iter()
        .filter(|e| match sibling_of(&e.resolved) {
            Some(sib) => !siblings.iter().any(|d| d == sib),
            None => true,
        })
        .collect()
}

/// **THE GATE, resolve-time half, against the declaration.**
pub fn undeclared_escapes(root: &Path, siblings: &[String]) -> Vec<Escape> {
    undeclared(escapes(root), siblings)
}

/// Declared siblings that are not checked out beside `root`. Every one of
/// them is needed for `cargo metadata` to resolve at all, and the lockfile
/// half cannot vouch for a package defined in a checkout it cannot read.
pub fn missing_siblings(root: &Path, siblings: &[String]) -> Vec<String> {
    let parent = root.parent().unwrap_or(root);
    siblings
        .iter()
        .filter(|s| !parent.join(s).is_dir())
        .cloned()
        .collect()
}

/// Every crate name a `Cargo.toml` in this repository defines.
///
/// Read off `name = "…"` inside a `[package]` table, so a `[dependencies]`
/// entry spelled `name = ` (there is none, but the shape is legal) cannot widen
/// the allowlist the lockfile half checks against.
pub fn crate_names(root: &Path) -> BTreeSet<String> {
    let mut names = BTreeSet::new();
    for rel in manifests(root) {
        let Ok(text) = std::fs::read_to_string(root.join(&rel)) else {
            continue;
        };
        let mut in_package = false;
        for line in text.lines() {
            let t = line.trim();
            if t.starts_with('#') {
                continue;
            }
            if t.starts_with('[') {
                in_package = t == "[package]";
                continue;
            }
            if in_package {
                if let Some(v) = t.strip_prefix("name") {
                    let v = v.trim_start();
                    if let Some(v) = v.strip_prefix('=') {
                        let v = v.trim().trim_matches('"');
                        if !v.is_empty() {
                            names.insert(v.to_string());
                        }
                    }
                }
            }
        }
    }
    names
}

/// Every `Cargo.lock` in the tree, relative to `root`, sorted.
pub fn lockfiles(root: &Path) -> Vec<String> {
    let mut out = Vec::new();
    walk_locks(root, root, &mut out);
    out.sort();
    out
}

fn walk_locks(root: &Path, dir: &Path, out: &mut Vec<String>) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        let name = entry.file_name();
        let name = name.to_string_lossy();
        if path.is_dir() {
            if name == "target" || name == ".git" || name.starts_with(".iso-out") {
                continue;
            }
            walk_locks(root, &path, out);
        } else if name == "Cargo.lock" {
            if let Ok(rel) = path.strip_prefix(root) {
                out.push(rel.to_string_lossy().replace('\\', "/"));
            }
        }
    }
}

/// The source-less packages of ONE lockfile's text.
///
/// A `[[package]]` block with no `source = "…"` line is a package cargo
/// resolved from a PATH. Taking text rather than a file is again what makes the
/// red-first proof possible without writing one.
pub fn local_packages_in(text: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut name: Option<String> = None;
    let mut sourced = false;
    let mut in_block = false;
    let flush = |name: &mut Option<String>, sourced: &mut bool, out: &mut Vec<String>| {
        if let Some(n) = name.take() {
            if !*sourced {
                out.push(n);
            }
        }
        *sourced = false;
    };
    for line in text.lines() {
        let t = line.trim();
        if t == "[[package]]" {
            flush(&mut name, &mut sourced, &mut out);
            in_block = true;
            continue;
        }
        if !in_block {
            continue;
        }
        if let Some(v) = t.strip_prefix("name = \"") {
            name = v.strip_suffix('"').map(str::to_string);
        } else if t.starts_with("source = \"") {
            sourced = true;
        } else if t.starts_with('[') {
            flush(&mut name, &mut sourced, &mut out);
            in_block = false;
        }
    }
    flush(&mut name, &mut sourced, &mut out);
    out
}

/// **THE GATE, lockfile half.** Every locked LOCAL package that neither this
/// repository nor one of the declared `siblings` (checked out beside it)
/// defines. With `siblings` empty it is the strict one-clone question.
pub fn foreign_lock_packages(root: &Path, siblings: &[String]) -> Vec<ForeignLocal> {
    let mut ours = crate_names(root);
    if let Some(parent) = root.parent() {
        for sib in siblings {
            ours.extend(crate_names(&parent.join(sib)));
        }
    }
    let mut found = Vec::new();
    for rel in lockfiles(root) {
        let Ok(text) = std::fs::read_to_string(root.join(&rel)) else {
            continue;
        };
        for pkg in local_packages_in(&text) {
            if !ours.contains(&pkg) {
                found.push(ForeignLocal {
                    lockfile: rel.clone(),
                    package: pkg,
                });
            }
        }
    }
    found
}

/// `cargo xtask one-clone` — the verb. `true` when the tree is clean.
pub fn run(root: &Path) -> bool {
    let siblings = declared_siblings();
    let missing = missing_siblings(root, &siblings);
    let escapes = undeclared_escapes(root, &siblings);
    let foreign = foreign_lock_packages(root, &siblings);
    if missing.is_empty() && escapes.is_empty() && foreign.is_empty() {
        println!(
            "one-clone: OK — {} manifests and {} lockfiles, nothing reaches outside the tree \
             but into the declared siblings [{}]",
            manifests(root).len(),
            lockfiles(root).len(),
            siblings.join(", ")
        );
        return true;
    }
    for m in &missing {
        println!("one-clone: declared sibling `{m}` is not checked out beside this clone");
    }
    for e in &escapes {
        println!("one-clone: {}", e.describe());
    }
    for f in &foreign {
        println!("one-clone: {}", f.describe());
    }
    println!(
        "\ngunnar builds from ONE clone plus the siblings `[build.toolchain] siblings` \
         declares (crates/gunnar-recipe/src/recipe.rs). Take the dependency to a crates.io \
         VERSION, or — if it must be a path — declare its sibling in the recipe in the \
         SAME commit."
    );
    false
}