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

nordisk/gunnar

installtest/src/lib.rs

//! gunnar's install tests: the shared half.
//!
//! Both arms ([`container`] and [`kvm`]) end in the same assertion, and it lives
//! here so there is exactly one of it: [`Probe::push_then_clone`] pushes
//! a fixture repository to a freshly installed gunnar with **stock `git` over
//! SSH**, clones it back with stock `git`, and compares the two **object
//! graphs**.
//!
//! # Why an object graph
//!
//! Three weaker assertions were available and all three are hollow:
//!
//! * **the process started.** `gunnar serve` opens its listener before it has
//!   read a single repository. A container that accepts TCP has proved that
//!   `bind(2)` works.
//! * **`git clone` exited zero.** Cloning an *empty* repository exits zero and
//!   leaves an empty directory. `xtask/src/images.rs` records this repository
//!   already shipping a guard that was green while the constraint it protected
//!   was broken, and [`Fault::EmptyRepo`] is the permanent red proof for it.
//! * **one file came back byte for byte.** Real, and what `xtask images`
//!   asserts, but it reads exactly one blob. It cannot tell a server that
//!   reconstructed the history from one that shipped a single object, and it
//!   says nothing about the trees, the commits or the second branch.
//!
//! The object graph is what was actually applied, and it is read back: every object id, type and
//! size the receiving end holds, plus the ref tips. If gunnar dropped a tree,
//! truncated a delta chain, mangled a commit or lost a branch, these two
//! listings differ.
//!
//! **And equality alone is still not enough.** Two *empty* graphs compare equal,
//! which is the identity-value trap in its purest form — a green nobody saw red. So
//! [`Graph::assert_matches`] refuses to accept a match below [`MIN_OBJECTS`],
//! and `Fault::EmptyRepo` proves that refusal fires.

use std::fmt::Write as _;
use std::io::Write as _;
use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};

/// The **bare** arm: gunnar as an ordinary process, with nothing wrapped around
/// it. It is also where the up/down lifecycle a human drives from a terminal
/// lives, and where the pressure ramp runs.
pub mod bare;
/// The **chain** arm: push → build → registry → verified pull, roadmap phase 5.
pub mod chain;
pub mod container;
pub mod control;
pub mod firmware;
pub mod installer_ssh;
pub mod iso;
pub mod kvm;
/// One derivation of "a target directory that belongs to this worktree", shared
/// with `xtask` by source include rather than by a dependency edge.
pub mod lane_dir;
pub mod medium;
pub mod mother_kvm;
pub mod pair;
pub mod subject;

/// The fixture repository is deliberately more than one blob: three commits over
/// a nested tree and a second branch. Anything below this many objects means the
/// fixture did not get built, and a comparison against it would be vacuous.
pub const MIN_OBJECTS: usize = 12;

// ── faults: the red-first switches, so no green goes unproven ───────────────

/// A deliberate defect injected into an otherwise-passing arm.
///
/// Every variant must turn the arm **red**. `cargo xtask install-test --red`
/// runs each of them in turn and fails if any of them comes out green. A harness
/// that cannot report red is worth nothing, and this repository has shipped
/// several that could not.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Fault {
    /// No fault: the arm must PASS.
    None,
    /// Point stock `git` at a port nothing is serving on. Proves the harness is
    /// actually talking to the instance it booted and not to something else that
    /// happens to be up on this machine.
    WrongPort,
    /// Start the instance with an exec path that does not exist. Proves the
    /// readiness wait reports the failure instead of timing out into a shrug.
    /// For the container arm it also proves a container that exits immediately
    /// is not mistaken for one coming up slowly.
    MissingBinary,
    /// Kill the service after it is ready and before the push. Proves the
    /// assertion is on the git exchange and not on a readiness flag latched
    /// earlier.
    KilledService,
    /// Push nothing and clone an **empty** repository. `git clone` exits zero,
    /// both object graphs are empty, and they compare **equal**. Only the
    /// [`MIN_OBJECTS`] floor can say no here, which is what proves the
    /// comparison is not sitting on an identity value.
    EmptyRepo,
}

impl Fault {
    pub const ALL_RED: &'static [Fault] = &[
        Fault::WrongPort,
        Fault::MissingBinary,
        Fault::KilledService,
        Fault::EmptyRepo,
    ];

    pub fn name(self) -> &'static str {
        match self {
            Fault::None => "none",
            Fault::WrongPort => "wrong-port",
            Fault::MissingBinary => "missing-binary",
            Fault::KilledService => "killed-service",
            Fault::EmptyRepo => "empty-repo",
        }
    }

    pub fn parse(s: &str) -> Result<Fault, String> {
        match s {
            "none" => Ok(Fault::None),
            "wrong-port" => Ok(Fault::WrongPort),
            "missing-binary" => Ok(Fault::MissingBinary),
            "killed-service" => Ok(Fault::KilledService),
            "empty-repo" => Ok(Fault::EmptyRepo),
            other => Err(format!(
                "unknown fault `{other}`; known: none, wrong-port, missing-binary, \
                 killed-service, empty-repo"
            )),
        }
    }
}

// ── the object graph ────────────────────────────────────────────────────────

/// Every object a repository holds, and where its refs point.
///
/// `objects` is `git cat-file --batch-all-objects --batch-check`, which reads the
/// **object database** rather than a working tree — so it is identical whether
/// the objects arrived loose, in one pack, or in four. `head` is the tip of
/// `refs/heads/main` and `branches` is every local head, because an object graph
/// that matches while a branch is missing is still a failed push.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Graph {
    pub objects: Vec<String>,
    pub head: String,
    pub branches: Vec<String>,
}

impl Graph {
    /// Read the graph out of `dir` with stock `git`.
    pub fn read(dir: &Path) -> Result<Graph, String> {
        let listing = git_out(
            dir,
            None,
            &[
                "cat-file",
                "--batch-all-objects",
                "--batch-check=%(objectname) %(objecttype) %(objectsize)",
            ],
        )?;
        let mut objects: Vec<String> = listing
            .lines()
            .map(|l| l.trim().to_string())
            .filter(|l| !l.is_empty())
            .collect();
        objects.sort();

        // `rev-parse` on a repository with no commits fails; that is the
        // EmptyRepo case and it must produce an empty head, not an error.
        let head = git_out(dir, None, &["rev-parse", "refs/heads/main"])
            .map(|s| s.trim().to_string())
            .unwrap_or_default();

        let mut branches: Vec<String> = git_out(
            dir,
            None,
            &[
                "for-each-ref",
                "--format=%(objectname) %(refname)",
                "refs/heads/",
            ],
        )
        .unwrap_or_default()
        .lines()
        .map(|l| l.trim().to_string())
        .filter(|l| !l.is_empty())
        .collect();
        branches.sort();

        Ok(Graph {
            objects,
            head,
            branches,
        })
    }

    /// The assertion. Equality **plus** a non-vacuity floor.
    ///
    /// The floor is not decoration. Without it this function returns `Ok(())`
    /// for two empty repositories, which is precisely the shape of a server that
    /// accepted a push and stored nothing.
    pub fn assert_matches(&self, other: &Graph) -> Result<(), String> {
        if self.objects.len() < MIN_OBJECTS {
            return Err(format!(
                "the source graph holds {} object(s), below the floor of {MIN_OBJECTS}; \
                 a comparison against it proves nothing (two empty graphs are equal)",
                self.objects.len()
            ));
        }
        if self.head.is_empty() {
            return Err("the source has no refs/heads/main, so nothing was pushed".to_string());
        }
        if self.objects != other.objects {
            let missing: Vec<&String> = self
                .objects
                .iter()
                .filter(|o| !other.objects.contains(o))
                .collect();
            let extra: Vec<&String> = other
                .objects
                .iter()
                .filter(|o| !self.objects.contains(o))
                .collect();
            return Err(format!(
                "object graphs differ: {} pushed, {} came back; {} missing, {} unexpected\n\
                 first missing: {:?}\n  first extra: {:?}",
                self.objects.len(),
                other.objects.len(),
                missing.len(),
                extra.len(),
                missing.first(),
                extra.first()
            ));
        }
        if self.head != other.head {
            return Err(format!(
                "refs/heads/main differs: pushed {}, cloned {}",
                self.head, other.head
            ));
        }
        if self.branches != other.branches {
            return Err(format!(
                "the branch set differs: pushed {:?}, cloned {:?}",
                self.branches, other.branches
            ));
        }
        Ok(())
    }
}

// ── the probe ───────────────────────────────────────────────────────────────

/// A gunnar reachable over SSH at `127.0.0.1:port`, with `key` authorised on it.
///
/// Both arms build one of these and then run the identical exchange.
pub struct Probe {
    pub port: u16,
    pub key: PathBuf,
    pub work: PathBuf,
    /// `git@`, `gunnar@`, whatever. gunnar ignores it, identity comes from the
    /// key, but stock `git` insists on a user in an `ssh://` URL.
    pub user: String,
}

impl Probe {
    /// Where the host key the FIRST connection saw is pinned. See
    /// [`Probe::assert_one_server_answered`].
    pub fn known_hosts(&self) -> PathBuf {
        self.work.join("known_hosts")
    }

    /// The `GIT_SSH_COMMAND` both halves of the exchange run under.
    ///
    /// # `accept-new`, not `no`, and the file is not `/dev/null`
    ///
    /// It used to be `StrictHostKeyChecking=no` into `/dev/null`, which means
    /// *every* connection accepts *whatever* answers, silently, with no memory
    /// between them. That is not a theoretical hole in this repository: on
    /// 2026-08-04 a bench suite ran four arms, three of the four servers died on
    /// `EADDRINUSE` because the `pkill` pattern meant to clear the port matched
    /// nothing, and **every clone in all four arms was answered by the first
    /// arm's server** while the readiness check passed on all four. The tell was
    /// four byte-identical packs from four different stores.
    ///
    /// `accept-new` into a per-probe file makes the first connection *pin* the
    /// server's host key and every later one *verify* it. A second server on the
    /// same address is then a hard `ssh` failure instead of a silent
    /// substitution, and [`Probe::assert_one_server_answered`] proves the pin was
    /// really in force rather than assuming it.
    pub fn ssh_command(&self) -> String {
        format!(
            "ssh -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
             -o UserKnownHostsFile={} -o LogLevel=ERROR \
             -o ConnectTimeout=10 -o BatchMode=yes",
            self.key.display(),
            self.known_hosts().display()
        )
    }

    /// **Which server answered.** Read off the pin file, after the exchange.
    ///
    /// Returns the fingerprint of the one host key every connection in this
    /// exchange saw. Two answers here — or none — are both failures, and they
    /// are different failures:
    ///
    /// * **none** means nothing was ever pinned, so `accept-new` was not in
    ///   force and the exchange proved nothing about *who* it talked to. An
    ///   empty file is shaped exactly like a clean result, which is why it is
    ///   named rather than tolerated.
    /// * **two** means two different servers answered the same address inside
    ///   one exchange. `ssh` refuses the second, so in practice this is caught
    ///   as a connection failure first; the check is here so the *reason* is on
    ///   the record instead of being inferred from a git error.
    pub fn assert_one_server_answered(&self) -> Result<String, String> {
        let path = self.known_hosts();
        let text = std::fs::read_to_string(&path).unwrap_or_default();
        let keys: Vec<(String, String)> = text
            .lines()
            .map(str::trim)
            .filter(|l| !l.is_empty() && !l.starts_with('#'))
            .filter_map(|l| {
                let mut f = l.split_whitespace();
                let _host = f.next()?;
                let kind = f.next()?.to_string();
                let blob = f.next()?.to_string();
                Some((kind, blob))
            })
            .collect();
        let mut distinct = keys.clone();
        distinct.sort();
        distinct.dedup();
        match distinct.len() {
            1 => {
                let (kind, blob) = &distinct[0];
                let tail: String = blob.chars().skip(blob.len().saturating_sub(16)).collect();
                Ok(format!("{kind} …{tail} ({} entry(ies) pinned)", keys.len()))
            }
            0 => Err(format!(
                "no host key was pinned at {}, so nothing in this exchange verified WHICH \
                 server answered. Either no connection was made or the ssh command lost its \
                 `UserKnownHostsFile`.",
                path.display()
            )),
            n => Err(format!(
                "{n} DIFFERENT host keys were recorded at {}: more than one server answered \
                 this exchange, and the object graph below is not necessarily one server's.",
                path.display()
            )),
        }
    }

    pub fn url(&self, repo: &str, port: u16) -> String {
        format!("ssh://{}@127.0.0.1:{port}/{repo}.git", self.user)
    }

    /// **The deliverable.** Build a fixture repository, push it over SSH, clone
    /// it back, and compare the object graphs.
    ///
    /// `fault` decides what is sabotaged. With [`Fault::None`] this must return
    /// `Ok(report)`; with anything else it must return `Err`.
    pub fn push_then_clone(&self, repo: &str, fault: Fault) -> Result<Exchange, String> {
        let ssh = self.ssh_command();
        let port = if fault == Fault::WrongPort {
            // A port that is bound by nobody. Taking one from the kernel and
            // dropping the listener is racier than a fixed number in theory and
            // far less racy in practice than picking 1 and hoping.
            free_port()?
        } else {
            self.port
        };
        let url = self.url(repo, port);

        // ── the fixture ─────────────────────────────────────────────────────
        let src = self.work.join("src");
        let _ = std::fs::remove_dir_all(&src);
        std::fs::create_dir_all(&src).map_err(|e| format!("{}: {e}", src.display()))?;
        git(&src, &ssh, &["init", "--quiet", "--object-format=sha1"])?;
        git(
            &src,
            &ssh,
            &["config", "user.email", "installtest@gunnar.invalid"],
        )?;
        git(&src, &ssh, &["config", "user.name", "gunnar install test"])?;
        git(&src, &ssh, &["symbolic-ref", "HEAD", "refs/heads/main"])?;

        if fault != Fault::EmptyRepo {
            build_fixture(&src, &ssh)?;
        }

        let pushed = Graph::read(&src)?;

        // ── push ────────────────────────────────────────────────────────────
        let push_started = Instant::now();
        if fault != Fault::EmptyRepo {
            git(
                &src,
                &ssh,
                &["push", "--quiet", &url, "refs/heads/main:refs/heads/main"],
            )?;
            git(
                &src,
                &ssh,
                &["push", "--quiet", &url, "refs/heads/side:refs/heads/side"],
            )?;
        }
        let push_ms = push_started.elapsed().as_millis();

        // ── clone ───────────────────────────────────────────────────────────
        let dest = self.work.join(format!("clone-{}", repo.replace('/', "-")));
        let _ = std::fs::remove_dir_all(&dest);
        let clone_started = Instant::now();
        git(
            &self.work,
            &ssh,
            &["clone", "--quiet", &url, dest.to_str().unwrap()],
        )?;
        let clone_ms = clone_started.elapsed().as_millis();

        // A clone lands the other branches under refs/remotes/origin/. Make them
        // local so the two graphs are read the same way on both sides.
        for b in &["side"] {
            let _ = git(
                &dest,
                &ssh,
                &["branch", b, &format!("refs/remotes/origin/{b}")],
            );
        }

        let cloned = Graph::read(&dest)?;
        pushed.assert_matches(&cloned)?;

        // `fsck --full --strict` on what came back. The graph comparison says
        // the same object NAMES arrived; fsck says the BYTES behind them decode,
        // that every link resolves, and that nothing is dangling — the one thing
        // an oid listing cannot tell you, because an oid is a name and
        // `cat-file --batch-check` reads the index, not the object.
        let fsck = git(
            &dest,
            &ssh,
            &["fsck", "--full", "--strict", "--no-progress"],
        )?;
        // fsck is quiet on a clean repository and prints on stdout only for
        // findings; `git()` already turned a non-zero exit into an Err.
        if fsck.to_lowercase().contains("error") {
            return Err(format!("`git fsck --full --strict` on the clone: {fsck}"));
        }

        // …and WHO answered. Last, so a failure here reads as "the exchange
        // completed but against an unproven peer" rather than hiding the git
        // failure that would otherwise be reported first.
        let host_key = self.assert_one_server_answered()?;

        Ok(Exchange {
            objects: pushed.objects.len(),
            head: pushed.head.clone(),
            push_ms,
            clone_ms,
            host_key,
        })
    }
}

// ── the wider git matrix ────────────────────────────────────────────────────

/// One git operation, and what it proved.
///
/// `detail` is the **applied output** — an oid, a ref count, a peeled tag, a
/// negotiation round count — never "exit status 0". A git verb that exits zero
/// having done nothing is the failure mode this whole crate exists to catch, and
/// `clone` on an empty repository is the standing example.
#[derive(Clone, Debug)]
pub struct GitOp {
    pub name: &'static str,
    pub detail: String,
    pub ms: u128,
}

/// Every ref the server reports, as `(oid, refname)`.
fn ls_remote(
    dir: &Path,
    ssh: &str,
    url: &str,
    extra: &[&str],
) -> Result<Vec<(String, String)>, String> {
    let mut args = vec!["ls-remote"];
    args.extend_from_slice(extra);
    args.push(url);
    let out = git(dir, ssh, &args)?;
    Ok(out
        .lines()
        .filter_map(|l| {
            let mut f = l.split_whitespace();
            Some((f.next()?.to_string(), f.next()?.to_string()))
        })
        .collect())
}

/// Drop ANSI CSI escape sequences from a `tracing` log line.
///
/// `gunnar_cli::init_tracing` colours its output whether or not the sink is a
/// terminal, so a log redirected to a file reads `\x1b[3mrounds\x1b[0m\x1b[2m=\x1b[0m3`.
/// The field name and its `=` are therefore **not adjacent**, and a parser
/// looking for the literal `"rounds="` matches nothing — which it does silently,
/// returning "no such field" for a field that is right there. Measured here on
/// 2026-08-03: [`Probe::git_matrix`]'s negotiation step failed with "the server
/// log has no upload_pack line" against a log containing several.

pub fn strip_ansi(line: &str) -> String {
    let mut out = String::with_capacity(line.len());
    let mut chars = line.chars();
    while let Some(c) = chars.next() {
        if c != '\x1b' {
            out.push(c);
            continue;
        }
        // CSI: ESC '[' … final byte in @..~
        if chars.next() != Some('[') {
            continue;
        }
        for c in chars.by_ref() {
            if ('@'..='~').contains(&c) {
                break;
            }
        }
    }
    out
}

/// A numeric `key=N` field out of a log line, after the escapes are gone.
pub fn log_field(line: &str, key: &str) -> Option<u64> {
    let plain = strip_ansi(line);
    let at = plain.find(key)? + key.len();
    let digits: String = plain[at..]
        .chars()
        .take_while(|c| c.is_ascii_digit())
        .collect();
    digits.parse().ok()
}

/// Every `git.upload_pack.served` line in a server log, as `(rounds, common)`.
///
/// This is how a negotiation is proved to have happened at all. A `git fetch`
/// that resolves in one shot exits zero and looks identical from the client
/// side to one that took six rounds; only the server knows, and it says so.
///
/// The whole list rather than the last line, because the **count** is what tells
/// a new line from a re-read of an old one, and two upload-packs can legitimately
/// log identical numbers. See [`Probe::git_matrix`]'s negotiation step.
pub fn upload_pack_lines(log: &str) -> Vec<(u64, u64)> {
    log.lines()
        .filter(|l| l.contains("upload_pack.served"))
        .filter_map(|l| Some((log_field(l, "rounds=")?, log_field(l, "common=")?)))
        .collect()
}

/// The `rounds=` and `common=` of the LAST `git.upload_pack.served` line.
pub fn last_upload_pack(log: &str) -> Option<(u64, u64)> {
    upload_pack_lines(log).pop()
}

/// A server log, however it is kept.
///
/// The bare and KVM arms have a FILE. The appliance does not: it has no
/// filesystem to write one to and no shell to redirect one with, so its log is
/// its serial console, which lives in the QEMU reader thread's buffer and is
/// read by calling for it. A `&Path` cannot express that, and the attempt to
/// paper over it — mirroring the console to a file on a 150 ms timer — produced
/// exactly the failure this indirection removes: MEASURED 2026-08-05, the ISO
/// arm's negotiation step read the file after the fetch, the fetch's line had
/// not been mirrored yet, and the step reported
///
/// ```text
/// the fetch negotiated 0 round(s), no more than the 0 a plain clone takes
/// ```
///
/// which is a statement about a log that was behind, dressed as a statement
/// about git.
pub struct ServerLog<'a>(pub Box<dyn Fn() -> String + 'a>);

impl<'a> ServerLog<'a> {
    /// A log kept in a file.
    pub fn file(path: &'a Path) -> ServerLog<'a> {
        ServerLog(Box::new(move || {
            std::fs::read_to_string(path).unwrap_or_default()
        }))
    }

    /// A log read live from wherever it is.
    pub fn live(f: impl Fn() -> String + 'a) -> ServerLog<'a> {
        ServerLog(Box::new(f))
    }

    pub fn read(&self) -> String {
        (self.0)()
    }
}

impl Probe {
    /// **Does it respond to git?** — asked eight ways, with stock `git`.
    ///
    /// [`Probe::push_then_clone`] proves a push and a clone. That leaves most of
    /// what a git server has to do untested: tags are a second object type on a
    /// second ref namespace, a delete is a ref update to the zero oid, a force
    /// push is the one ref update the server is supposed to *refuse* by default,
    /// and a fetch that needs negotiation is the only path that exercises
    /// `have`/`ACK` at all.
    ///
    /// Every step asserts applied state. The two that carry their own red inside
    /// the green are worth naming:
    ///
    ///  * **`non_ff_rejected`** requires a plain push of a rewritten branch to
    ///    FAIL. A server that accepts it has silently destroyed history, and it
    ///    would pass any assertion phrased as "the push worked".
    ///  * **`fetch_negotiated`** requires the server's own `rounds=` to be
    ///    greater than the `rounds=0` a plain clone produces. Without that
    ///    comparison the step passes against a fixture that is not divergent,
    ///    which is the identity-value trap: a fetch with nothing to negotiate is
    ///    indistinguishable from a negotiation that never ran.
    ///
    /// `log` is the server's log. Without it the negotiation step cannot be
    /// proved and is refused rather than skipped.
    pub fn git_matrix(&self, repo: &str, log: &ServerLog<'_>) -> Result<Vec<GitOp>, String> {
        let ssh = self.ssh_command();
        let url = self.url(repo, self.port);
        let mut ops: Vec<GitOp> = Vec::new();
        let mut step = |name: &'static str,
                        f: &mut dyn FnMut() -> Result<String, String>|
         -> Result<(), String> {
            let started = Instant::now();
            let detail = f().map_err(|e| format!("{name}: {e}"))?;
            ops.push(GitOp {
                name,
                detail,
                ms: started.elapsed().as_millis(),
            });
            Ok(())
        };

        let src = self.work.join("src");
        if !src.is_dir() {
            return Err(format!(
                "{} does not exist; git_matrix runs after push_then_clone has built and \
                 pushed the fixture",
                src.display()
            ));
        }

        // ── 1. ls-remote ────────────────────────────────────────────────────
        let head = git(&src, &ssh, &["rev-parse", "refs/heads/main"])?
            .trim()
            .to_string();
        step("ls_remote", &mut || {
            let refs = ls_remote(&src, &ssh, &url, &[])?;
            let main = refs
                .iter()
                .find(|(_, r)| r == "refs/heads/main")
                .ok_or("the server reports no refs/heads/main")?;
            if main.0 != head {
                return Err(format!(
                    "refs/heads/main is {} on the server, {head} locally",
                    main.0
                ));
            }
            if refs.len() < 2 {
                return Err(format!(
                    "the server reports {} ref(s); the fixture pushed main and side, so a \
                     listing this short means the comparison is vacuous",
                    refs.len()
                ));
            }
            Ok(format!("{} refs, main at {}", refs.len(), &head[..12]))
        })?;

        // ── 2. an annotated tag, and the server must be able to PEEL it ─────
        step("push_tag", &mut || {
            let _ = git(&src, &ssh, &["tag", "-d", "v1"]);
            git(
                &src,
                &ssh,
                &["tag", "-a", "v1", "-m", "the first tag", "refs/heads/main"],
            )?;
            let tag_oid = git(&src, &ssh, &["rev-parse", "refs/tags/v1"])?
                .trim()
                .to_string();
            if tag_oid == head {
                return Err(
                    "`v1` is a lightweight tag, not an annotated one, so pushing it \
                            proves nothing about tag OBJECTS"
                        .to_string(),
                );
            }
            git(&src, &ssh, &["push", "--quiet", &url, "refs/tags/v1"])?;
            let refs = ls_remote(&src, &ssh, &url, &["--tags"])?;
            let listed = refs
                .iter()
                .find(|(_, r)| r == "refs/tags/v1")
                .ok_or("the server does not report refs/tags/v1")?;
            if listed.0 != tag_oid {
                return Err(format!(
                    "refs/tags/v1 is {} on the server, {tag_oid} locally",
                    listed.0
                ));
            }
            // The peeled entry is the applied output that matters: it can only
            // be produced by a server that holds the tag OBJECT and read the
            // commit out of it. A server that stored the ref and dropped the
            // object lists the tag and cannot peel it.
            let peeled = refs.iter().find(|(_, r)| r == "refs/tags/v1^{}").ok_or(
                "the server lists refs/tags/v1 but cannot peel it, so it does not \
                        hold the tag object",
            )?;
            if peeled.0 != head {
                return Err(format!(
                    "refs/tags/v1^{{}} peels to {}, not to main at {head}",
                    peeled.0
                ));
            }
            Ok(format!(
                "annotated tag {} peels to {}",
                &tag_oid[..12],
                &head[..12]
            ))
        })?;

        // ── 3. the tag object survives a clone ──────────────────────────────
        let tag_clone = self.work.join("clone-tags");
        step("clone_with_tags", &mut || {
            let _ = std::fs::remove_dir_all(&tag_clone);
            git(
                &self.work,
                &ssh,
                &["clone", "--quiet", &url, tag_clone.to_str().unwrap()],
            )?;
            let tag_oid = git(&src, &ssh, &["rev-parse", "refs/tags/v1"])?
                .trim()
                .to_string();
            let graph = Graph::read(&tag_clone)?;
            if !graph.objects.iter().any(|o| o.starts_with(&tag_oid)) {
                return Err(format!(
                    "the clone's object database does not hold the tag object {tag_oid}; it \
                     has {} objects",
                    graph.objects.len()
                ));
            }
            let kind = git(&tag_clone, &ssh, &["cat-file", "-t", &tag_oid])?
                .trim()
                .to_string();
            if kind != "tag" {
                return Err(format!(
                    "{tag_oid} came back as a `{kind}`, not a tag object"
                ));
            }
            Ok(format!(
                "{} objects incl. tag object {}",
                graph.objects.len(),
                &tag_oid[..12]
            ))
        })?;

        // ── 4. a new branch ─────────────────────────────────────────────────
        let before = ls_remote(&src, &ssh, &url, &[])?.len();
        step("push_branch", &mut || {
            git(
                &src,
                &ssh,
                &["push", "--quiet", &url, "refs/heads/main:refs/heads/topic"],
            )?;
            let refs = ls_remote(&src, &ssh, &url, &[])?;
            let topic = refs
                .iter()
                .find(|(_, r)| r == "refs/heads/topic")
                .ok_or("the server does not report refs/heads/topic after the push")?;
            if topic.0 != head {
                return Err(format!(
                    "refs/heads/topic is {}, not main's {head}",
                    topic.0
                ));
            }
            Ok(format!(
                "refs/heads/topic at {} ({} refs)",
                &head[..12],
                refs.len()
            ))
        })?;

        // ── 5. deleting it ──────────────────────────────────────────────────
        step("delete_branch", &mut || {
            git(&src, &ssh, &["push", "--quiet", &url, ":refs/heads/topic"])?;
            let refs = ls_remote(&src, &ssh, &url, &[])?;
            if refs.iter().any(|(_, r)| r == "refs/heads/topic") {
                return Err("refs/heads/topic is still there after a delete push".to_string());
            }
            if refs.len() != before {
                return Err(format!(
                    "the ref count went {before} → {} across a create and a delete; the \
                     delete removed the wrong number of refs",
                    refs.len()
                ));
            }
            Ok(format!(
                "refs/heads/topic gone, back to {} refs",
                refs.len()
            ))
        })?;

        // ── 6. a non-fast-forward push must be REFUSED ──────────────────────
        //
        // The green with a red inside it. `git push` without `--force` on a
        // rewritten branch is the one ref update a git server has to say no to,
        // and a server that says yes has destroyed history while exiting zero.
        git(
            &src,
            &ssh,
            &["push", "--quiet", &url, "refs/heads/main:refs/heads/rewind"],
        )?;
        let rewritten = {
            // A commit that is NOT a descendant of `head`: a fresh root commit,
            // built on the same tree so only the history differs.
            git(
                &src,
                &ssh,
                &["checkout", "--quiet", "--orphan", "rewind-local"],
            )?;
            git(
                &src,
                &ssh,
                &["commit", "--quiet", "-m", "an unrelated root"],
            )?;
            let oid = git(&src, &ssh, &["rev-parse", "HEAD"])?.trim().to_string();
            git(&src, &ssh, &["checkout", "--quiet", "main"])?;
            oid
        };
        if rewritten == head {
            return Err(
                "the rewritten commit equals main; the non-fast-forward step would \
                        be vacuous"
                    .to_string(),
            );
        }
        step("non_ff_rejected", &mut || {
            let refspec = format!("refs/heads/rewind-local:refs/heads/rewind");
            match git(&src, &ssh, &["push", "--quiet", &url, &refspec]) {
                Ok(_) => Err(
                    "the server ACCEPTED a non-fast-forward push without --force; \
                              it has silently discarded the old history"
                        .to_string(),
                ),
                Err(e) => {
                    let refs = ls_remote(&src, &ssh, &url, &[])?;
                    let rewind = refs
                        .iter()
                        .find(|(_, r)| r == "refs/heads/rewind")
                        .ok_or("refs/heads/rewind vanished during the refused push")?;
                    if rewind.0 != head {
                        return Err(format!(
                            "the push was refused but refs/heads/rewind moved to {} anyway",
                            rewind.0
                        ));
                    }
                    let first = e
                        .lines()
                        .find(|l| l.contains("rejected") || l.contains("non-fast"))
                        .unwrap_or("rejected")
                        .trim()
                        .to_string();
                    Ok(format!("refused, ref still at {} — {}", &head[..12], first))
                }
            }
        })?;

        // ── 7. and --force must go through ──────────────────────────────────
        step("force_push", &mut || {
            let refspec = format!("refs/heads/rewind-local:refs/heads/rewind");
            git(&src, &ssh, &["push", "--quiet", "--force", &url, &refspec])?;
            let refs = ls_remote(&src, &ssh, &url, &[])?;
            let rewind = refs
                .iter()
                .find(|(_, r)| r == "refs/heads/rewind")
                .ok_or("refs/heads/rewind is gone after the force push")?;
            if rewind.0 != rewritten {
                return Err(format!(
                    "after --force refs/heads/rewind is {}, not the rewritten {rewritten}",
                    rewind.0
                ));
            }
            Ok(format!(
                "{} → {} (not a descendant)",
                &head[..12],
                &rewritten[..12]
            ))
        })?;

        // ── 8. a fetch that has to negotiate ────────────────────────────────
        //
        // The clone in step 3 is the control: the server logged `rounds=0` for
        // it, because a clone has nothing to negotiate about. This step must
        // beat that, or the fixture is not divergent and the step is measuring
        // the happy path under another name.
        let before_log = upload_pack_lines(&log.read());
        let (clone_rounds, _) = before_log.last().copied().unwrap_or((0, 0));
        let served_before = before_log.len();
        step("fetch_negotiated", &mut || {
            // The client diverges: many commits the server has never seen, on
            // top of history it shares. `git` offers `have`s newest first, so a
            // long divergent tip forces several rounds before a common commit
            // is even mentioned.
            git(
                &tag_clone,
                &ssh,
                &["config", "user.email", "neg@gunnar.invalid"],
            )?;
            git(&tag_clone, &ssh, &["config", "user.name", "negotiation"])?;
            git(
                &tag_clone,
                &ssh,
                &["checkout", "--quiet", "-b", "divergent"],
            )?;
            for i in 0..64 {
                write_file(
                    &tag_clone.join("neg.txt"),
                    &format!("divergent commit {i}\n"),
                )?;
                git(&tag_clone, &ssh, &["add", "neg.txt"])?;
                git(
                    &tag_clone,
                    &ssh,
                    &["commit", "--quiet", "-m", &format!("client {i}")],
                )?;
            }
            // Meanwhile the server moves on, from the other checkout.
            for i in 0..3 {
                write_file(&src.join("server.txt"), &format!("server commit {i}\n"))?;
                git(&src, &ssh, &["add", "server.txt"])?;
                git(
                    &src,
                    &ssh,
                    &["commit", "--quiet", "-m", &format!("server {i}")],
                )?;
            }
            git(
                &src,
                &ssh,
                &["push", "--quiet", &url, "refs/heads/main:refs/heads/ahead"],
            )?;
            let ahead = git(&src, &ssh, &["rev-parse", "refs/heads/main"])?
                .trim()
                .to_string();

            let before = Graph::read(&tag_clone)?.objects.len();
            git(
                &tag_clone,
                &ssh,
                &[
                    "fetch",
                    "--quiet",
                    &url,
                    "refs/heads/ahead:refs/heads/ahead",
                ],
            )?;
            let after = Graph::read(&tag_clone)?.objects.len();
            if after <= before {
                return Err(format!(
                    "the fetch brought no objects ({before} → {after}), so it transferred \
                     nothing and there was nothing to negotiate"
                ));
            }
            let fetched = git(&tag_clone, &ssh, &["rev-parse", "refs/heads/ahead"])?
                .trim()
                .to_string();
            if fetched != ahead {
                return Err(format!(
                    "refs/heads/ahead fetched as {fetched}, server has {ahead}"
                ));
            }
            // Wait for the server to LOG the fetch, rather than reading
            // whatever the log happens to hold the instant the client returns.
            // A log is written by the server after it has finished serving, and
            // on the appliance it travels over a serial line — so "the newest
            // line I can see" and "the line for the exchange that just
            // happened" are not the same thing, and reading the first as the
            // second is how this step reported `rounds=0` for a fetch that
            // really negotiated. The COUNT is what distinguishes them.
            let deadline = Instant::now() + Duration::from_secs(15);
            let served = loop {
                let lines = upload_pack_lines(&log.read());
                if lines.len() > served_before {
                    break lines;
                }
                if Instant::now() >= deadline {
                    return Err(format!(
                        "the fetch transferred {} objects and the server logged no \
                         upload_pack line for it within 15 s ({served_before} before, \
                         {} now). The negotiation cannot be proved from a log that does \
                         not describe it, and an absent line is not a zero.",
                        after - before,
                        lines.len()
                    ));
                }
                std::thread::sleep(Duration::from_millis(100));
            };
            let (rounds, common) = *served.last().expect("longer than served_before");
            if rounds <= clone_rounds {
                return Err(format!(
                    "the fetch negotiated {rounds} round(s), no more than the {clone_rounds} \
                     a plain clone takes; the fixture is not divergent and this step proves \
                     nothing about negotiation"
                ));
            }
            if common == 0 {
                return Err(format!(
                    "the server found {common} commits in common over {rounds} rounds; a \
                     negotiation that agrees on nothing is a full clone with extra steps"
                ));
            }
            Ok(format!(
                "{rounds} rounds (clone took {clone_rounds}), {common} common, {} → {after} objects",
                before
            ))
        })?;

        Ok(ops)
    }
}

/// What one successful push/clone exchange measured.
#[derive(Clone, Debug)]
pub struct Exchange {
    pub objects: usize,
    pub head: String,
    pub push_ms: u128,
    pub clone_ms: u128,
    /// The one host key every connection in this exchange was verified against —
    /// i.e. WHICH server answered. See [`Probe::assert_one_server_answered`].
    pub host_key: String,
}

/// Three commits, a nested tree, a second branch. Enough that the graph
/// comparison is a statement about a history rather than about one blob.
fn build_fixture(src: &Path, ssh: &str) -> Result<(), String> {
    write_file(&src.join("README.md"), "gunnar install test\n")?;
    std::fs::create_dir_all(src.join("deep/nested")).map_err(|e| format!("deep/nested: {e}"))?;
    write_file(
        &src.join("deep/nested/leaf.txt"),
        "a leaf under two trees\n",
    )?;
    git(src, ssh, &["add", "README.md", "deep"])?;
    git(src, ssh, &["commit", "--quiet", "-m", "first"])?;

    write_file(&src.join("deep/nested/leaf.txt"), "the leaf, edited\n")?;
    write_file(&src.join("second.txt"), "a second blob\n")?;
    git(src, ssh, &["add", "second.txt", "deep"])?;
    git(src, ssh, &["commit", "--quiet", "-m", "second"])?;

    // A second branch, so a push that carries only the checked-out ref is caught.
    git(src, ssh, &["checkout", "--quiet", "-b", "side"])?;
    write_file(&src.join("on-side.txt"), "only on the side branch\n")?;
    git(src, ssh, &["add", "on-side.txt"])?;
    git(src, ssh, &["commit", "--quiet", "-m", "third, on side"])?;
    git(src, ssh, &["checkout", "--quiet", "main"])?;
    Ok(())
}

fn write_file(path: &Path, content: &str) -> Result<(), String> {
    let mut f = std::fs::File::create(path).map_err(|e| format!("{}: {e}", path.display()))?;
    f.write_all(content.as_bytes())
        .map_err(|e| format!("{}: {e}", path.display()))
}

// ── stock git, and nothing else ─────────────────────────────────────────────

pub fn git(dir: &Path, ssh: &str, args: &[&str]) -> Result<String, String> {
    let out = Command::new("git")
        .current_dir(dir)
        .env("GIT_SSH_COMMAND", ssh)
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .args(args)
        .output()
        .map_err(|e| format!("git {}: {e}", args.join(" ")))?;
    if !out.status.success() {
        return Err(format!(
            "git {} failed ({}): {}",
            args.join(" "),
            out.status,
            String::from_utf8_lossy(&out.stderr).trim()
        ));
    }
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}

fn git_out(dir: &Path, ssh: Option<&str>, args: &[&str]) -> Result<String, String> {
    git(dir, ssh.unwrap_or("ssh"), args)
}

/// Generate a client key pair. `ssh-keygen` is a **test** tool here — gunnar's
/// zero-shell rule is about what the server does, not about how it is
/// proven.
pub fn keygen(path: &Path) -> Result<(), String> {
    let _ = std::fs::remove_file(path);
    let _ = std::fs::remove_file(path.with_extension("pub"));
    let out = Command::new("ssh-keygen")
        .args([
            "-q",
            "-t",
            "ed25519",
            "-N",
            "",
            "-C",
            "gunnar-installtest",
            "-f",
        ])
        .arg(path)
        .output()
        .map_err(|e| format!("ssh-keygen: {e}"))?;
    if !path.exists() {
        return Err(format!(
            "ssh-keygen produced no key at {}: {}",
            path.display(),
            String::from_utf8_lossy(&out.stderr).trim()
        ));
    }
    Ok(())
}

/// A TCP port the kernel says is free right now.
pub fn free_port() -> Result<u16, String> {
    let l = TcpListener::bind("127.0.0.1:0").map_err(|e| format!("bind :0: {e}"))?;
    let port = l
        .local_addr()
        .map_err(|e| format!("local_addr: {e}"))?
        .port();
    drop(l);
    Ok(port)
}

/// Wait until **gunnar's own SSH server** answers on `127.0.0.1:port`, or
/// `budget` runs out.
///
/// # A TCP accept is not readiness, and this was measured here
///
/// The obvious check — `TcpStream::connect` succeeds — is worthless against
/// both of this crate's targets, and not for a subtle reason:
///
/// * **podman publishes the host port immediately.** Its rootless port
///   forwarder binds and accepts the moment the container is created, long
///   before anything inside has called `bind(2)`. Measured while writing this
///   file: the connect succeeded at **0 ms** against a container whose gunnar
///   had already exited with a usage error. A bare accept reported that dead
///   container as ready.
/// * **QEMU's user-mode `hostfwd` does the same.** SLIRP accepts on the host
///   side and only then tries to reach the guest.
///
/// So the readiness signal is **applied output**: the connection is opened and
/// the server's SSH identification string is read off it. `SSH-2.0-…` cannot be
/// produced by a port forwarder, only by an SSH implementation that is running,
/// which here is `gunnar-ssh` itself.
///
/// `alive` is polled every round: an instance that has *died* must report that
/// now rather than burn the whole budget and then report a timeout, because the
/// two have very different causes and the message is what a reader acts on.
pub fn wait_for_ssh(
    port: u16,
    budget: Duration,
    mut alive: impl FnMut() -> Result<(), String>,
) -> Result<Duration, String> {
    let addr: SocketAddr = ([127, 0, 0, 1], port).into();
    let started = Instant::now();
    let deadline = started + budget;
    let mut last;
    loop {
        match ssh_banner(&addr) {
            Ok(banner) if banner.starts_with("SSH-") => return Ok(started.elapsed()),
            Ok(banner) => {
                last = format!("something answered but it is not SSH: {banner:?}");
            }
            Err(e) => last = e,
        }
        alive()?;
        if Instant::now() >= deadline {
            return Err(format!(
                "no SSH server answered on 127.0.0.1:{port} within {budget:?}; last: {last}"
            ));
        }
        std::thread::sleep(Duration::from_millis(100));
    }
}

/// Open a connection and read the peer's SSH identification string.
pub(crate) fn ssh_banner(addr: &SocketAddr) -> Result<String, String> {
    use std::io::Read as _;
    let mut s = TcpStream::connect_timeout(addr, Duration::from_millis(500))
        .map_err(|e| format!("connect: {e}"))?;
    s.set_read_timeout(Some(Duration::from_millis(1500)))
        .map_err(|e| format!("set_read_timeout: {e}"))?;
    let mut buf = [0u8; 64];
    let n = s.read(&mut buf).map_err(|e| format!("read: {e}"))?;
    let _ = s.shutdown(Shutdown::Both);
    if n == 0 {
        return Err("the peer accepted and then closed without a banner".to_string());
    }
    Ok(String::from_utf8_lossy(&buf[..n]).trim_end().to_string())
}

// ── the report ──────────────────────────────────────────────────────────────

/// One arm's verdict.
pub struct ArmResult {
    pub arm: &'static str,
    pub fault: Fault,
    pub outcome: Result<Exchange, String>,
    pub ready: Option<Duration>,
    /// What each phase of the install cost, in the order it happened.
    ///
    /// This is the arm's own accounting, and it exists so the bench arms in
    /// `bench/examples/nornir-bench.rs` measure the **same run** the test
    /// asserts, rather than a second, separately-timed one that could drift from
    /// it. A number and an assertion that disagree about which run they describe
    /// is a whole class of wrong report.
    pub stages: Vec<(&'static str, u128)>,
    /// This run must come out **red** for a reason [`Fault`] does not name.
    ///
    /// The bare arm has a second red matrix, [`bare::LifeFault`], covering the
    /// lifecycle — teardown, the port, the stale-lock reclaim — which means
    /// nothing to a container and so does not belong in the shared `Fault`
    /// enum. Without this flag such a run carries `Fault::None` and
    /// [`ArmResult::as_demanded`] would demand that it PASS, i.e. the harness
    /// would report a red-first proof that fired as a failure.
    pub demands_red: bool,
}

/// Collects `(stage, millis)` as an arm runs.
#[derive(Default)]
pub struct Stages(pub Vec<(&'static str, u128)>);

impl Stages {
    /// Time `f`, record it under `name`, and hand back whatever `f` returned.
    pub fn time<T>(&mut self, name: &'static str, f: impl FnOnce() -> T) -> T {
        let started = Instant::now();
        let out = f();
        self.0.push((name, started.elapsed().as_millis()));
        out
    }

    pub fn record(&mut self, name: &'static str, d: Duration) {
        self.0.push((name, d.as_millis()));
    }

    /// One stage's cost, or `0` if it never ran.
    pub fn get(&self, name: &str) -> u128 {
        self.0
            .iter()
            .find(|(n, _)| *n == name)
            .map(|(_, ms)| *ms)
            .unwrap_or(0)
    }
}

impl ArmResult {
    /// Did this run do what its fault demands? `Fault::None` with no lifecycle
    /// fault must be `Ok`; anything carrying a deliberate defect must be `Err`.
    pub fn as_demanded(&self) -> bool {
        if self.fault == Fault::None && !self.demands_red {
            self.outcome.is_ok()
        } else {
            self.outcome.is_err()
        }
    }
}

pub fn render(results: &[ArmResult]) -> String {
    let mut s = String::new();
    let _ = writeln!(s, "\n══ gunnar install tests ══\n");
    for r in results {
        let deliberate = r.fault != Fault::None || r.demands_red;
        let verdict = match (deliberate, &r.outcome) {
            (false, Ok(_)) => "PASS",
            (false, Err(_)) => "FAIL",
            (true, Err(_)) => "RED, as required",
            (true, Ok(_)) => "DID NOT FIRE",
        };
        let _ = writeln!(s, "  [{verdict}] {} · fault={}", r.arm, r.fault.name());
        if let Some(d) = r.ready {
            let _ = writeln!(s, "        ready in    : {} ms", d.as_millis());
        }
        match &r.outcome {
            Ok(x) => {
                let _ = writeln!(
                    s,
                    "        object graph: {} objects matched, head {}",
                    x.objects,
                    &x.head[..x.head.len().min(12)]
                );
                let _ = writeln!(s, "        push {} ms · clone {} ms", x.push_ms, x.clone_ms);
                // WHICH server answered, on the row. Without it "the object
                // graph matched" is a statement about *a* server.
                let _ = writeln!(s, "        answered by : {}", x.host_key);
            }
            Err(e) => {
                for line in e.lines().take(4) {
                    let _ = writeln!(s, "        {line}");
                }
            }
        }
        if !r.stages.is_empty() {
            let _ = writeln!(
                s,
                "        stages      : {}",
                r.stages
                    .iter()
                    .map(|(n, ms)| format!("{n} {ms} ms"))
                    .collect::<Vec<_>>()
                    .join(" · ")
            );
        }
        let _ = writeln!(s);
    }
    let bad: Vec<&ArmResult> = results.iter().filter(|r| !r.as_demanded()).collect();
    if bad.is_empty() {
        let _ = writeln!(
            s,
            "install tests: PASS, every arm did what its fault demands \
             ({} run, {} of them red-first proofs)",
            results.len(),
            results
                .iter()
                .filter(|r| r.fault != Fault::None || r.demands_red)
                .count()
        );
    } else {
        let _ = writeln!(s, "install tests: FAIL");
        for r in bad {
            let _ = writeln!(
                s,
                "  {} · fault={} did not behave as demanded",
                r.arm,
                r.fault.name()
            );
        }
    }
    s
}

// ── the bootstrap, written once for both arms ───────────────────────────────

/// The token both arms replace with the path the authorised public key landed
/// at inside the instance — `/bootstrap/id_ed25519.pub` in the container, a file
/// under the data root in the guest.
pub const KEYFILE: &str = "@KEYFILE@";

/// The verbs that turn an empty data root into a server with one writable
/// repository, one deliberately empty one, and one authorised key.
///
/// One list for both arms, written once. If the container arm and the KVM arm
/// bootstrapped differently, a failure in one of them could be a difference in
/// setup rather than a difference in the thing under test.
pub fn bootstrap_plan() -> Vec<Vec<&'static str>> {
    vec![
        vec!["namespace", "create", "demo"],
        // sha1, so stock `git clone` needs no object-format negotiation. The
        // server default is sha256 and `crates/gunnar-cli/tests/ssh_end_to_end.rs`
        // proves that path; these arms are about the install.
        vec!["repo", "create", "demo/hello", "--hash", "sha1"],
        // The repository `Fault::EmptyRepo` clones: real, reachable, and empty.
        vec!["repo", "create", "demo/empty", "--hash", "sha1"],
        vec!["principal", "add", "tester"],
        vec!["key", "add", "tester", KEYFILE],
        vec!["grant", "tester", "write", "--repo", "demo/hello"],
        vec!["grant", "tester", "write", "--repo", "demo/empty"],
    ]
}

/// The repository root, found by walking up from this file's crate directory.
pub fn workspace_root() -> PathBuf {
    let here = Path::new(env!("CARGO_MANIFEST_DIR"));
    here.parent()
        .map(Path::to_path_buf)
        .unwrap_or_else(|| here.to_path_buf())
}

// ── the "which server answered" guard, seen going red ───────────────────────

#[cfg(test)]
mod which_server_answered {
    use super::*;

    /// A scratch directory that removes itself. `tempfile` is deliberately not a
    /// dependency of this crate.
    struct Scratch(PathBuf);
    impl Drop for Scratch {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    fn probe(name: &str, known_hosts: &str) -> (Probe, Scratch) {
        let dir = expand_home("~/scratch/gunnar-installtest-unit").join(name);
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).expect("scratch");
        std::fs::write(dir.join("known_hosts"), known_hosts).expect("known_hosts");
        (
            Probe {
                port: 1,
                key: dir.join("id"),
                work: dir.clone(),
                user: "git".into(),
            },
            Scratch(dir),
        )
    }

    const KEY_A: &str =
        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
    const KEY_B: &str =
        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";

    /// All three outcomes, because a check that answered "one server" to
    /// everything — or "not one server" to everything — would satisfy any single
    /// case on its own. The middle one is the green; the outer two are the reds
    /// it has to be able to produce.
    #[test]
    fn none_pinned_and_two_pinned_are_both_refused_and_one_is_not() {
        // ONE server: the ordinary case, and the only Ok.
        let (p, _s) = probe("one", &format!("[127.0.0.1]:2200 {KEY_A}\n"));
        let ok = p
            .assert_one_server_answered()
            .expect("one key is one server");
        assert!(ok.contains("ssh-ed25519"), "{ok}");

        // The same server on two ports — a restart, or a second probe against
        // the same appliance. Still ONE server, and it must not be mistaken for
        // two: only the KEY is compared, never the host field.
        let (p, _s) = probe(
            "one-two-ports",
            &format!("[127.0.0.1]:2200 {KEY_A}\n[127.0.0.1]:2201 {KEY_A}\n"),
        );
        assert!(p.assert_one_server_answered().is_ok());

        // NOTHING pinned. This is what an ssh command that lost its
        // `UserKnownHostsFile` leaves behind, and an empty file is shaped
        // exactly like a clean result.
        let (p, _s) = probe("none", "\n# a comment and nothing else\n");
        let err = p
            .assert_one_server_answered()
            .expect_err("no pinned key proves nothing about who answered");
        assert!(err.contains("WHICH server"), "{err}");

        // TWO servers, which is the failure this exists for: on 2026-08-04 four
        // bench arms were all answered by the first arm's server while liveness
        // passed on all four.
        let (p, _s) = probe(
            "two",
            &format!("[127.0.0.1]:2200 {KEY_A}\n[127.0.0.1]:2200 {KEY_B}\n"),
        );
        let err = p
            .assert_one_server_answered()
            .expect_err("two different keys are two different servers");
        assert!(err.contains("2 DIFFERENT host keys"), "{err}");
    }

    /// The pin has to be REQUESTED, or the check above can never see anything.
    /// `StrictHostKeyChecking=no` into `/dev/null` — what this crate did until
    /// 2026-08-05 — accepts whatever answers, every time, and remembers nothing.
    #[test]
    fn the_ssh_command_pins_the_host_key_into_the_probes_own_file() {
        let (p, _s) = probe("cmd", "");
        let cmd = p.ssh_command();
        assert!(
            cmd.contains("StrictHostKeyChecking=accept-new"),
            "the first connection must PIN, not shrug: {cmd}"
        );
        assert!(
            !cmd.contains("UserKnownHostsFile=/dev/null"),
            "a pin written to /dev/null is not a pin: {cmd}"
        );
        assert!(
            cmd.contains(&p.known_hosts().display().to_string()),
            "and it must be the probe's OWN file, not the operator's: {cmd}"
        );
    }
}

/// **The drive's payload, or the reason it cannot run** (2026-09-22, T17).
///
/// `None` when `bin` is a file. Otherwise the refusal the drive PANICS with:
/// it names the missing path, `GUNNAR_EXE_TARGET_DIR` (the env that moves
/// where it is looked for) and the exact build line. It used to be a skip row
/// and `ok`, so a run with no payload exited green having proved nothing.
pub fn missing_payload_refusal(
    bin: &std::path::Path,
    build_line: &str,
    target_dir: &std::path::Path,
) -> Option<String> {
    if bin.is_file() {
        return None;
    }
    Some(format!(
        "the installer drive has NO PAYLOAD: {} does not exist. Build it with `{build_line}` and \
         CARGO_TARGET_DIR={} (tests/exe_drive.rs builds it), or point GUNNAR_EXE_TARGET_DIR at the \
         target dir that holds it. This is a failure, not a skip: without the payload nothing below \
         is proven",
        bin.display(),
        target_dir.display()
    ))
}

#[cfg(test)]
mod missing_payload_tests {
    use super::*;

    #[test]
    fn a_missing_payload_is_refused_by_path_and_env_and_a_present_one_is_not() {
        let dir = tempfile::tempdir().unwrap();
        let bin = dir
            .path()
            .join("x86_64-unknown-linux-musl/release/gunnar-server");
        let why = missing_payload_refusal(&bin, "cargo build -p gunnar-server", dir.path())
            .expect("missing");
        assert!(why.contains(&bin.display().to_string()), "{why}");
        assert!(
            why.contains("GUNNAR_EXE_TARGET_DIR") && why.contains("cargo build -p gunnar-server"),
            "{why}"
        );
        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
        std::fs::write(&bin, b"x").unwrap();
        assert_eq!(missing_payload_refusal(&bin, "", dir.path()), None);
    }
}

/// **`~` in a path literal, expanded against `$HOME`.**
///
/// Every scratch and target path in this repository is written as `~/…` rather
/// than as one developer's absolute home: a path with a username baked into it
/// is a path that is wrong for everybody else, and it does not belong in a
/// public tree. `~` is a SHELL convention, so nothing expands it inside a Rust
/// string — this function is the one place that does, and every literal that is
/// used as a real filesystem path goes through it.
///
/// A path that does not start with `~/` is returned unchanged, so a caller may
/// pass an absolute override straight through.
///
/// # ⚠ A KNOWN TWIN — `nornir_xtask_support::expand_home` is the keep
///
/// Byte-identical to `xtask/src/lib.rs`'s copy and to the canonical body in
/// `edda/crates/nornir-xtask-support` (md5 `8e5f9c67` on the extracted fn);
/// `lane_dir.rs`'s `home_expanded` in THIS crate is a fifth restatement, cited
/// where it stands. `nornir mimir analyze duplicates` clustered them on
/// 2026-09-10 and put the keep in the edda leaf, beside the other std-only path
/// primitives; `bench/` and `deploy/upcloud/` are now `pub use`s of it.
///
/// This one is a REPOINT that has not happened yet rather than a refusal: this
/// crate already declares `edda` a mandatory sibling checkout, so the dependency
/// is free here. It was left in place only because `installtest/Cargo.toml` was
/// being rewritten by another lane the day the sweep ran, and editing a manifest
/// out from under a live lane trades one defect for a worse one. The one-line
/// change is `nornir-xtask-support = { path = "../../edda/crates/nornir-xtask-support" }`
/// plus `pub use nornir_xtask_support::expand_home;` here.
pub fn expand_home(p: &str) -> std::path::PathBuf {
    match p.strip_prefix("~/") {
        Some(rest) => {
            std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()))
                .join(rest)
        }
        None => std::path::PathBuf::from(p),
    }
}