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),
}
}