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
}