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

nordisk/gunnar

xtask/tests/lock_current.rs

//! **RED-when-broken: every `Cargo.lock` in this repository is what cargo would
//! resolve today.**
//!
//! ## The defect this exists for
//!
//! Measured three times on the night of 2026-09-17. Every `cargo xtask reimage`
//! run came back with `deploy/upcloud/Cargo.lock` modified in the working tree —
//! `nornir-build-thing` moved `0.1.16 -> 0.1.18` with nobody asking — and the
//! NEXT re-image refused:
//!
//! ```text
//! xtask: REFUSED [gunnar-dirty] /home/rickard/git/gunnar has 1 uncommitted
//! change(s); the banner must name a commit that describes the ISO
//! ```
//!
//! That refusal is correct and must stay: a medium whose banner names a commit
//! that does not describe it is the exact confusion this repository has spent
//! whole sessions untangling. The defect was upstream of it.
//!
//! ## The cause, which is not "cargo is greedy"
//!
//! `efb6c29` took the build system to `0.1.17` across the tree. It committed the
//! root `Cargo.lock` and `installtest/Cargo.lock`. It did not commit
//! `deploy/upcloud/Cargo.lock`, because that is a DETACHED workspace — invisible
//! to `cargo metadata` at the root, so nothing that walks the crate graph could
//! notice it had been left behind.
//!
//! The result was a lock cargo could not obey: the entry said `0.1.16`, the
//! requirement beside it said `^0.1.17`, and `0.1.16` does not satisfy it. Cargo
//! was not choosing to upgrade — it had no legal lock to read, so it re-resolved
//! from scratch on every invocation and a free resolve takes the newest
//! compatible release. Reverting the file by hand restored the unsatisfiable
//! state, which is why one revert bought exactly one run and the drift came back
//! three times.
//!
//! An unsatisfiable lock is silent. A lock is only consulted; nothing shouts
//! when it is discarded. That is what makes this worth a test rather than a
//! comment: the symptom surfaced two verbs away from the cause, as a dirty-tree
//! refusal on a DIFFERENT run.
//!
//! ## Why the gate asks cargo instead of reading the file
//!
//! Every cheaper check is a re-implementation of semver resolution, and a
//! resolver written to guard a resolver agrees with it right up to the case that
//! matters. `xtask/src/vendor_patch.rs` already settled this argument here — its
//! own comment records that its file-reading arms "was green from 2026-08-23 to
//! 2026-08-25" over two locks that were fossils, and the arm that finally could
//! not be lied to was `cargo metadata --locked`. This gate is that arm, widened
//! from the two patched roots to every lockfile in the tree.
//!
//! ## The red half
//!
//! A gate over "did anything fail" goes green for two very different reasons:
//! every lock is current, or the walk found no locks at all. Both halves are
//! asserted by name:
//!
//!  * [`the_walk_actually_sees_the_detached_workspaces`] — the population is
//!    real and specifically contains `deploy/upcloud`, the one that drifted.
//!  * [`a_lock_behind_its_manifest_is_caught`] — a workspace built on disk with
//!    exactly the 2026-09-17 shape (a lock naming a version the manifest no
//!    longer accepts) is REPORTED, so "all current" is a verdict and not a
//!    silence. It uses a `path` dependency, so it proves the mechanism without
//!    the network.

use std::path::{Path, PathBuf};

use xtask::one_clone::lockfiles;

/// `CARGO_MANIFEST_DIR` is `<root>/xtask`.
fn root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("xtask has a parent")
        .to_path_buf()
}

fn cargo() -> String {
    std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string())
}

/// Lockfiles under `xtask/tests/fixtures/` are TEST DATA, not workspaces this
/// repository builds: `cve_vulnerable` names a knowingly vulnerable release and
/// `gix_porcelain_in_server` resolves `gix-features` from the registry on
/// purpose. Holding them to "what cargo would resolve today" would make them
/// stop being fixtures.
const FIXTURES: &str = "xtask/tests/fixtures/";

/// Every workspace root in this repository that owns a `Cargo.lock`, relative to
/// the repository root, `""` for the root workspace itself.
fn locked_roots(root: &Path) -> Vec<String> {
    lockfiles(root)
        .into_iter()
        .filter(|rel| !rel.starts_with(FIXTURES))
        .map(|rel| {
            rel.trim_end_matches("Cargo.lock")
                .trim_end_matches('/')
                .to_string()
        })
        .collect()
}

/// **THE GATE.** `cargo metadata --locked`, in every workspace root that has a
/// lock.
///
/// `--locked` makes cargo refuse to write the lock rather than silently
/// updating it, so a non-zero exit here means precisely one thing: this lock is
/// not what cargo would resolve, and the next command run in that directory
/// will rewrite it.
#[test]
fn every_lock_in_the_tree_is_what_cargo_would_resolve() {
    let root = root();
    let cargo = cargo();
    let mut stale: Vec<String> = Vec::new();

    for rel in locked_roots(&root) {
        let dir = if rel.is_empty() {
            root.clone()
        } else {
            root.join(&rel)
        };
        let where_ = if rel.is_empty() {
            "the repository root"
        } else {
            &rel
        };

        let out = std::process::Command::new(&cargo)
            .current_dir(&dir)
            .args(["metadata", "--format-version", "1", "--locked"])
            .output()
            .unwrap_or_else(|e| panic!("{where_}: running `{cargo} metadata`: {e}"));

        if !out.status.success() {
            let err = String::from_utf8_lossy(&out.stderr);
            let first = err
                .lines()
                .find(|l| l.trim_start().starts_with("error"))
                .unwrap_or_else(|| err.lines().next().unwrap_or("(no stderr)"));
            stale.push(format!("  {where_}/Cargo.lock\n      {}", first.trim()));
        }
    }

    assert!(
        stale.is_empty(),
        "these Cargo.lock files are NOT what cargo would resolve today:\n{}\n\n\
         Each one is rewritten by the next cargo command that touches its \
         workspace, and for `deploy/upcloud` that command is `cargo xtask \
         reimage` — which then leaves the tree dirty and makes the NEXT \
         re-image refuse with `[gunnar-dirty]`. Reverting the file restores the \
         same stale lock and buys exactly one run.\n\n\
         Fix: refresh the lock deliberately and commit it WITH the manifest \
         change that moved it — `cargo update -p <crate>@<old> --precise <new>` \
         in that directory for a single pin, and diff the package list before \
         and after so a refresh cannot smuggle in a new third-party crate. A \
         detached workspace does not follow the root's `cargo update`; it has to \
         be named.",
        stale.join("\n")
    );
}

/// ★ g-reds 1, G-B7: **every lock records its own workspace's patches as they are declared now.**
///
/// cargo writes each patch a workspace did not use into its lock as `[[patch.unused]]`. Until
/// G-B7 the estate's sibling table reached every workspace in the tree through the root's
/// `.cargo/config.toml`, fixtures included; it is gone, and each estate workspace names the
/// sibling patches it takes in its own manifest. A lock that still records a patch its manifest
/// does not declare (b4c335c2 `plugin-door-wire`, or the old include's 26) is rewritten by the
/// next build in its workspace: [`every_lock_in_the_tree_is_what_cargo_would_resolve`] cannot see
/// it (`--locked` accepts it), so the lists are compared here.
#[test]
fn every_lock_records_its_own_patches_as_they_are() {
    let root = root();
    let none = std::collections::BTreeSet::new();
    let mut drift = Vec::new();
    for rel in lockfiles(&root) {
        let lock = std::fs::read_to_string(root.join(&rel)).expect("a lock");
        let manifest = root
            .join(rel.trim_end_matches("Cargo.lock"))
            .join("Cargo.toml");
        let own =
            xtask::siblings::patch_names(&std::fs::read_to_string(&manifest).unwrap_or_default());
        for d in xtask::siblings::lock_drift(&lock, &none, &own) {
            drift.push(format!("  {rel} {d}"));
        }
    }
    assert!(
        drift.is_empty(),
        "these locks record patches their own manifest does not declare, so the next build in their \
         workspace rewrites them:\n{}\n\nFix: run `cargo metadata` in that workspace and commit the lock \
         with the manifest's change.",
        drift.join("\n")
    );
}

/// **The population is real**, and contains the workspace that drifted.
///
/// Without this, a walk that skipped `target/` a little too eagerly — or one
/// that could not read the tree at all — would satisfy the gate above forever by
/// checking nothing.
#[test]
fn the_walk_actually_sees_the_detached_workspaces() {
    let roots = locked_roots(&root());

    for want in ["", "deploy/upcloud", "installtest", "bench"] {
        assert!(
            roots.iter().any(|r| r == want),
            "the lockfile walk did not find `{}`, so the gate is checking a \
             population that is missing the very workspaces detachment hides. \
             Found: {roots:?}",
            if want.is_empty() {
                "the repository root"
            } else {
                want
            }
        );
    }
    assert!(
        roots.len() >= 6,
        "only {} locked roots found; the detached workspaces are where the \
         stragglers hide and they are not optional. Found: {roots:?}",
        roots.len()
    );
}

/// **The gate fires on the real shape.** A workspace whose lock names a version
/// its manifest no longer accepts.
///
/// Built on disk out of two `path` crates so the proof needs no registry and no
/// network: the leaf is `0.2.0`, the lock says the root resolved `0.1.0`, and
/// `^0.2` cannot be satisfied by `0.1.0`. That is the 2026-09-17 shape exactly —
/// requirement moved, lock left behind — and cargo must answer by refusing to
/// proceed rather than by rewriting the file.
#[test]
fn a_lock_behind_its_manifest_is_caught() {
    let dir = std::env::temp_dir().join(format!("gunnar-lock-current-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    let leaf = dir.join("leaf");
    let subject = dir.join("subject");
    std::fs::create_dir_all(leaf.join("src")).expect("temp leaf");
    std::fs::create_dir_all(subject.join("src")).expect("temp subject");

    std::fs::write(
        leaf.join("Cargo.toml"),
        "[package]\nname = \"lock-current-leaf\"\nversion = \"0.2.0\"\nedition = \"2021\"\n\
         publish = false\n\n[workspace]\n",
    )
    .expect("leaf manifest");
    std::fs::write(leaf.join("src/lib.rs"), "").expect("leaf lib");

    std::fs::write(
        subject.join("Cargo.toml"),
        "[package]\nname = \"lock-current-subject\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\
         publish = false\n\n[workspace]\n\n[dependencies]\n\
         lock-current-leaf = { path = \"../leaf\", version = \"0.2\" }\n",
    )
    .expect("subject manifest");
    std::fs::write(subject.join("src/lib.rs"), "").expect("subject lib");

    // The lock a commit like efb6c29 leaves behind: the requirement moved to
    // `0.2`, this file still records the `0.1.0` resolve.
    std::fs::write(
        subject.join("Cargo.lock"),
        "# This file is automatically @generated by Cargo.\n\
         # It is not intended for manual editing.\nversion = 4\n\n\
         [[package]]\nname = \"lock-current-leaf\"\nversion = \"0.1.0\"\n\n\
         [[package]]\nname = \"lock-current-subject\"\nversion = \"0.0.0\"\n\
         dependencies = [\n \"lock-current-leaf\",\n]\n",
    )
    .expect("stale lock");

    let cargo = cargo();
    let out = std::process::Command::new(&cargo)
        .current_dir(&subject)
        .args(["metadata", "--format-version", "1", "--locked", "--offline"])
        .output()
        .expect("running cargo metadata on the fixture");

    let status = out.status;
    let err = String::from_utf8_lossy(&out.stderr).into_owned();
    let _ = std::fs::remove_dir_all(&dir);

    assert!(
        !status.success(),
        "`cargo metadata --locked` ACCEPTED a lock naming `lock-current-leaf \
         0.1.0` under a `^0.2` requirement. If cargo ever stops treating an \
         unsatisfiable lock as an error, `--locked` in \
         `xtask/src/reinstall.rs` stops protecting the re-image and the gate \
         above is measuring nothing.\n{err}"
    );
    assert!(
        err.contains("--locked") || err.contains("lock file"),
        "cargo refused, but not for the lock: the fixture is proving something \
         other than what it claims.\n{err}"
    );
}