installtest/tests/exit_code_is_the_verdict.rs
//! **The exit code is the verdict, and nothing else was ever checking it.**
//!
//! Every proof in this crate asserted *text*. Not one asserted the number the
//! shell actually reads, so `gunnar-installtest` could have printed `FAILED` and
//! returned 0 for its whole life and every test here would have stayed green.
//!
//! That is not hypothetical. On 2026-08-04 a `gunnar bare up` against a
//! root-owned scratch root was reported as
//!
//! ```text
//! gunnar bare up: … Permission denied (os error 13)
//! bare: FAILED (exit status: 1)
//! EXIT=0
//! ```
//!
//! The binary was innocent — the measurement was `… 2>&1 | tail`, and `$?` in a
//! pipeline is the exit code of `tail`, not of the command. But nothing in this
//! repository could have told the difference, and that is the hole these tests
//! close: they assert [`std::process::ExitStatus::code`] directly, never a
//! pipeline, and never the printed word.
//!
//! # Why this is not a tautology
//!
//! A test that spawned the binary and asserted `!status.success()` on a run that
//! *cannot* succeed proves nothing: an unconditional `exit(1)` would pass it. So
//! every red below is paired with the corresponding **green** — the same binary,
//! the same argument shape, exit `0` — and the assertion is on the pair. A
//! verdict that cannot say PASS is as broken as one that cannot say FAIL, and
//! only the pair can see both.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
/// The binary under test, as cargo built it. Never a path guessed from
/// `target/`: a stale binary from a concurrent rebuild is the classic way one of
/// these proofs ends up measuring yesterday's code.
const EXE: &str = env!("CARGO_BIN_EXE_gunnar-installtest");
/// A scratch directory for one test, on md1 — never `/tmp`, which is RAM here.
fn scratch(name: &str) -> PathBuf {
let d = gunnar_installtest::expand_home("~/scratch/gunnar-installtest-exitcode").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).expect("scratch");
d
}
fn run(args: &[&str]) -> Output {
Command::new(EXE)
.args(args)
.output()
.expect("spawn gunnar-installtest")
}
fn code(out: &Output) -> i32 {
// `None` means killed by a signal, which is neither pass nor fail and must
// never be silently folded into one. -1 is not a code any path here returns.
out.status.code().unwrap_or(-1)
}
fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
/// **The subject, built ONCE per test binary.** Two tests below need an installed gunnar, and
/// each used to call `Subject::prepare` itself: two cold `cargo build --release` of
/// gunnar-service started in parallel, queued on one cargo build lock, and the pair ran into a
/// 50-minute timeout (measured 2026-09-15). A `OnceLock` makes the second test wait for the
/// first build instead of starting its own; `GUNNAR_INSTALLTEST_BINARY` still names a prebuilt
/// one (identity-checked by `prepare` like any other). A failed build is remembered, so the
/// second test fails with the same message instead of building again.
fn subject() -> &'static std::path::Path {
static SUBJECT: std::sync::OnceLock<Result<PathBuf, String>> = std::sync::OnceLock::new();
match SUBJECT
.get_or_init(|| gunnar_installtest::subject::Subject::prepare(None).map(|s| s.path))
{
Ok(p) => p,
Err(e) => panic!("no `gunnar` to test against: {e}"),
}
}
/// The `usage`/unknown-mode path: it prints, and it must also *return* failure.
///
/// RED-when-broken: change either arm of `main`'s `match` to `ExitCode::SUCCESS`
/// and this fires. It is the cheapest end-to-end statement that the process's
/// exit code is wired to the verdict at all.
#[test]
fn an_unknown_mode_exits_non_zero_and_a_known_one_does_not_take_that_path() {
let bad = run(&["definitely-not-a-mode"]);
assert_eq!(
code(&bad),
1,
"an unknown mode must exit 1, not print usage and exit 0.\nstderr:\n{}",
stderr(&bad)
);
assert!(
stderr(&bad).contains("unknown mode"),
"and it must say which: {}",
stderr(&bad)
);
// The pair. `status` against a root with no server recorded is a *different*
// failure — it proves the exit code tracks the specific fault rather than
// being stuck at 1 for everything.
let empty = scratch("status-empty");
let no_server = run(&["status", "--root", empty.to_str().unwrap()]);
assert_eq!(
code(&no_server),
1,
"`status` with nothing recorded is a failure too.\nstderr:\n{}",
stderr(&no_server)
);
assert!(
!stderr(&no_server).contains("unknown mode"),
"…but not the unknown-mode failure — the two must be distinguishable:\n{}",
stderr(&no_server)
);
}
/// **The one that was actually reported.** A scratch root this process cannot
/// write must fail *loudly and early*, with a non-zero exit code, naming the
/// directory — not a bare `os error 13` from four layers down.
///
/// The unwritable parent stands in for the root-owned
/// `~/scratch/gunnar-bare` an earlier `sudo systemd-run --scope`
/// left behind. It needs no sudo, so this runs anywhere.
///
/// RED-when-broken twice over: restore `let _ = std::fs::remove_dir_all(root)`
/// in `bare::Instance::up` and the message loses the root's name; return
/// `ExitCode::SUCCESS` from `operator`'s `Err` arm and the code goes to 0.
///
/// # Why it names its subject, like its pair does
///
/// It did not until 2026-08-05: it ran `up` with no `--binary` and let
/// `locate_binary` resolve one. That list used to end in the box-wide
/// `~/scratch/cargo/gunnar-install/release/gunnar`, which on the desktop
/// always holds *somebody's* build — so this test reached the unwritable-root
/// path on a binary from another worktree, which is the 33-hour-subject disease
/// happening inside the test that exists to prove exit codes. When the build
/// directory became per-worktree (`subject::target_dir_for`) the crutch went
/// away and it failed on "no gunnar binary to install" — an honest red for a
/// test that had never been about this tree.
#[test]
fn an_unwritable_scratch_root_exits_non_zero_and_names_the_directory() {
let gunnar = subject();
let parent = scratch("unwritable");
let root = parent.join("nested");
// Deny *this* process the right to create `nested`. The root itself must not
// exist: `up` removes and recreates its root, so a root that exists would be
// wiped and the permission bits with it — the first version of this probe
// was blind for exactly that reason and reported a healthy server.
set_mode(&parent, 0o555);
let out = run(&[
"up",
"--root",
root.to_str().unwrap(),
"--binary",
gunnar.to_str().unwrap(),
]);
let (c, err) = (code(&out), stderr(&out));
// Restore before asserting, so a failure does not leave an unwritable tree.
set_mode(&parent, 0o755);
assert_eq!(
c, 1,
"an unwritable scratch root must exit 1. This is the exact case that was \
reported as `EXIT=0`.\nstderr:\n{err}"
);
assert!(
err.contains(root.to_str().unwrap()) || err.contains(parent.to_str().unwrap()),
"the failure must NAME the directory, not just `os error 13`:\n{err}"
);
assert!(
err.contains("Permission denied"),
"and it must say what the kernel said:\n{err}"
);
// The diagnosis, not just the errno: `unusable_root` reports who owns the
// path that refused us. Without this line the message is the unactionable
// one an operator hit.
assert!(
err.contains("is owned by"),
"the failure must name the OWNER — that is what distinguishes a stale \
root-owned scratch dir from a gunnar bug:\n{err}"
);
}
/// The pair for the test above: the *same* code path, on a root this process CAN
/// write, must reach `ExitCode::SUCCESS`.
///
/// Without this, the assertion above is satisfied by a binary that fails
/// always — the "a test sitting on the identity value" trap. This is the only
/// test here that starts a real server, and it stops it again.
#[test]
fn a_writable_scratch_root_exits_zero_and_the_server_comes_back_down() {
let root = scratch("writable");
// **`Subject::prepare`, not `locate_binary`.** This is the whole reason
// `cargo xtask ci` used to refuse to run this crate, quoted from
// `xtask/src/main.rs` as it stood on 2026-08-04:
//
// > `installtest` does not BUILD the server it tests: it picks up whatever
// > `gunnar` binary is sitting in `$CARGO_TARGET_DIR/release/`, prints that
// > binary's age, and continues. MEASURED 2026-08-04: it ran a binary 1998
// > minutes old — 33 hours, built from a different checkout in a different
// > worktree — and failed with "the store lock was not released". That red
// > is a true statement about THAT binary and says nothing whatever about
// > this tree.
//
// `Subject::prepare` builds the binary and then makes it name this
// checkout's HEAD out of its own `--version`, so this test's verdict is
// about this tree. A missing or foreign binary is a hard error, never a
// skip: this crate's entire subject is an installed gunnar.
let gunnar = subject();
let up = run(&[
"up",
"--root",
root.to_str().unwrap(),
"--binary",
gunnar.to_str().unwrap(),
]);
let up_code = code(&up);
// Always tear down, whatever the assertion does.
let down = run(&["down", "--root", root.to_str().unwrap()]);
assert_eq!(
up_code,
0,
"a healthy `up` must exit 0 — otherwise the red above is met by a binary \
that simply always fails.\nstderr:\n{}",
stderr(&up)
);
assert_eq!(
code(&down),
0,
"and `down` must exit 0 once it has proven the port and lock were \
released.\nstderr:\n{}",
stderr(&down)
);
}
fn set_mode(p: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).expect("chmod");
}