crates/gunnar-cli/tests/thin_client.rs
//! **The `linkage` claims and the tonic split that `.nornir/build-variants.toml`
//! declares, checked against the BINARIES.**
//!
//! Five cargo atoms, five `[[bin]]` targets over one `src/main.rs`, and three
//! claims that no TOML file can make true:
//!
//! ```text
//! gunnar client THIN --features client the git command, NO tonic
//! gunnar-cli client THIN --features client,control + the control plane, tonic
//! gunnar-solo solo fat --features server,client,control
//! ```
//!
//! ## The three things asserted here, and why none of them is a comment
//!
//! 1. **Both client atoms are thin.** Neither links `gunnar_cli::server`,
//! `gunnar_ssh`, `gunnar_wire`, `gunnar_grpc::service` or `gunnar_store` — and
//! the last of those is what matters beyond tidiness. `ServerStores::open`
//! takes the control repository's EXCLUSIVE lock, which a running server
//! already holds (`lib.rs`'s `Reflog` arm says so where it deliberately avoids
//! it), so a client that links it can become a second writer against a lock
//! that assumes one. It is gunnar's exact analogue of the `iceberg` /
//! `parquet` entries in the shared constellation list.
//!
//! 2. **`gunnar` carries no tonic and `gunnar-cli` does.** This is the whole
//! reason the two atoms exist, and the ruling behind them: `gunnar` is to be
//! what the `git` command is, and `gunnar-cli` the same over gRPC. Until
//! 2026-08-05 `gunnar-cli`'s `client` feature implied `control`, so every
//! client build paid for a gRPC stack in order to clone a repository, and the
//! split was not expressible at all. A claim that load-bearing has to be
//! checked in the artifact and in BOTH directions — a `gunnar` with tonic in
//! it, and a `gunnar-cli` without, are each their own defect.
//!
//! 3. **`gunnar-solo` still embeds.** The moment the fat atom stops carrying the
//! server, assertion 1 has quietly become vacuous. A guard that passes
//! everything proves nothing.
//!
//! ## The audit is SHARED — this file hand-rolls no symbol check
//!
//! [`LinkageAudit`] and [`THIN_FORBIDDEN_SYMBOLS`] live in
//! `nornir_build_thing::linkage`, the dep-light edda leaf that already owns the
//! `linkage = "thin" | "fat"` schema being checked, and it is reached by REGISTRY
//! version (`nornir-build-thing = "0.1.4"`), not by a sibling path — gunnar
//! resolves `nornir-git` the same way and builds in a checkout with nothing
//! beside it. It is a dev-dependency, so `cargo tree -e normal` is untouched and
//! the appliance graph `xtask/src/guard.rs` measures is unchanged.
//!
//! It also brings the trap this kind of check exists to fall into and does not:
//! the count is of DEMANGLED SYMBOLS, not substring occurrences, and compiler
//! destructor glue is reclassified rather than counted — one
//! `core::ptr::drop_glue::<…TowerToHyperService<…Server::serve_internal…>>` once
//! failed holger's thin tier for a `tonic::transport::server` it did not contain.
//!
//! ## ⚠ `[profile.release]` sets `strip = true`, and that would make this VACUOUS
//!
//! MEASURED 2026-08-05 on this repo's ordinary release artifact: `nm -C
//! target/release/gunnar` prints **0 lines**. The audit parses that binary
//! perfectly happily, finds no symbols, and reports `ThinAndClean` — a green with
//! no evidence in it, on a binary that could be linking the entire server.
//!
//! Two defences, and the second is the one that cannot be forgotten:
//!
//! 1. [`build`] sets `CARGO_PROFILE_RELEASE_STRIP=none`, so the audited artifact
//! carries the symbol table the shipped one has stripped. Nothing else about
//! the profile changes (`lto = "thin"`, `codegen-units = 1`, `opt-level` 3),
//! so the code linked in is the code that ships.
//! 2. Every audit is paired with a POSITIVE control on the same file
//! ([`symbols_were_actually_read`]): a family that must be present is looked
//! for, and an empty answer fails. A stripped binary therefore fails LOUDLY
//! instead of passing quietly — which is the difference between a guard and a
//! decoration.
//!
//! ## Running it
//!
//! Three release builds, so it is `#[ignore]`d:
//!
//! ```text
//! cargo test -p gunnar-cli --test thin_client -- --ignored --nocapture
//! ```
//!
//! Each atom builds into `target/thin-client-audit/<atom>`, its own directory:
//! the atoms have distinct bin names since 2026-08-05, but this test also builds
//! them with three different feature sets, and one shared target directory would
//! rebuild all three on every switch.
use std::path::{Path, PathBuf};
use nornir_build_thing::linkage::{
LinkageAudit, LinkageVerdict, ServiceShape, THIN_FORBIDDEN_SYMBOLS,
};
/// **gunnar's OWN server entry points**, added to the shared
/// [`THIN_FORBIDDEN_SYMBOLS`] through [`LinkageAudit::of_families`] rather than
/// pushed into a constellation-wide constant — per-app names do not belong there.
///
/// Each is a crate or module that only a SERVER needs. What is deliberately NOT
/// here is the client's legitimate freight:
///
/// | name | why it is NOT forbidden |
/// |---|---|
/// | `gunnar_grpc` (bare) | `gunnar-cli` links `sign_credential` out of it, and banning the crate would ban the signer |
/// | `gunnar_proto` | the generated gRPC CLIENT stubs |
/// | `gunnar_client` | it IS the client |
/// | `russh` | gunnar's client DIALS ssh; the server's use of it is caught by `gunnar_ssh` |
/// | `gix` | the client half: "the client is a normal git client and should feel like one" |
const GUNNAR_SERVER_SYMBOLS: [&str; 5] = [
// `serve`, and every admin verb that opens the store.
"gunnar_cli::server",
// The SSH listener.
"gunnar_ssh",
// The git server wire protocol (upload-pack / receive-pack, server side).
"gunnar_wire",
// The control plane's tonic SERVICE implementations — not the whole crate,
// which `gunnar-cli` legitimately links for the signer.
"gunnar_grpc::service",
// The STORAGE ENGINE, and the exclusive-lock second-writer hazard.
"gunnar_store",
];
/// **The control plane, as symbol families.** What `--features control` adds, and
/// what the `gunnar` atom must therefore not have a single one of.
///
/// `tonic::` and `prost::` carry their `::` on purpose: a bare `tonic` would also
/// match `tonic_build`, and this is a substring match. `hyper`, `h2`, `tower`,
/// `tokio` and `reqwest` are deliberately ABSENT — they are in BOTH graphs,
/// arriving through `reqwest` under `nornir-git`, which is git-client work. The
/// measured claim is *no tonic*, not *no async HTTP stack*, and a family list
/// that overreached here would fail the git client for being a git client.
const CONTROL_PLANE_SYMBOLS: [&str; 4] = ["tonic::", "prost::", "axum::", "gunnar_grpc"];
/// A family every honest gunnar CLIENT contains. Used as the positive control
/// that the symbol table was read at all — see [`symbols_were_actually_read`].
const CLIENT_SENTINEL: &str = "gunnar_client";
/// The shared constellation families plus gunnar's five.
fn families() -> Vec<&'static str> {
THIN_FORBIDDEN_SYMBOLS
.iter()
.copied()
.chain(GUNNAR_SERVER_SYMBOLS)
.collect()
}
fn manifest_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")
}
/// Build one atom's argv into a target directory of its own, WITH symbols, and
/// return the artifact path.
///
/// `atom` is both the variant name and the `--bin` name — that is the point of
/// the 2026-08-05 split, and passing one string for both is what makes this
/// unable to audit a different file from the one it asked for. `features` is the
/// cell's `features` list from `.nornir/build-variants.toml`, verbatim.
///
/// The two things this adds to a plain release build are the two the header
/// explains: a private `CARGO_TARGET_DIR`, and `CARGO_PROFILE_RELEASE_STRIP=none`
/// because the shipped profile strips and an audit of a stripped binary is a
/// green with nothing in it.
fn build(atom: &str, features: &str) -> PathBuf {
// Every shape is the one bin `gunnar-cli` since GUN-PRODUCTS (2026-10-30); `atom` names
// the shape and its own target directory, so no two shapes share an artifact path.
let bin_name = "gunnar-cli";
let target_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../target/thin-client-audit")
.join(atom);
let status = std::process::Command::new(env!("CARGO"))
.args([
"build",
"--release",
"--manifest-path",
manifest_path().to_str().unwrap(),
"-p",
"gunnar-cli",
"--bin",
bin_name,
"--no-default-features",
"--features",
features,
])
.env("CARGO_TARGET_DIR", &target_dir)
// See the header: `[profile.release]` has `strip = true`, and a stripped
// artifact has NO symbol table for the audit to read.
.env("CARGO_PROFILE_RELEASE_STRIP", "none")
.status()
.unwrap_or_else(|e| panic!("building atom '{atom}' failed to start: {e}"));
assert!(
status.success(),
"building atom '{atom}' (--bin {bin_name} --no-default-features --features {features}) failed"
);
let bin = target_dir.join("release").join(bin_name);
assert!(
bin.is_file(),
"cargo reported success and there is no artifact at {}. Every atom has its own \
`[[bin]]` since 2026-08-05; if this path were wrong the audit below would be \
reading another atom's file.",
bin.display()
);
bin
}
/// **The positive control, run against the SAME file the verdict came from.**
///
/// A `linkage` audit of a symbol-free binary is `ThinAndClean` — measured, parsed,
/// and empty of evidence. So every artifact audited here is also asked for a
/// family it MUST contain, and an empty answer is a failure. This is what makes
/// `strip = true` impossible to reintroduce silently.
fn symbols_were_actually_read(atom: &str, bin: &Path) {
let control =
LinkageAudit::of_families(atom, ServiceShape::FatService, bin, &[CLIENT_SENTINEL])
.unwrap_or_else(|| panic!("'{atom}': no artifact at {} to audit", bin.display()));
assert_eq!(
control.verdict(),
LinkageVerdict::EmbeddedAndEmbeds,
"'{atom}': the symbol read found no `{CLIENT_SENTINEL}` at all in {}.\n\
Every gunnar client contains it, so this is not a linkage result — it is an \
EMPTY SYMBOL TABLE. `[profile.release]` sets `strip = true`; this test must \
build with CARGO_PROFILE_RELEASE_STRIP=none or every verdict below is a green \
with no evidence in it.",
bin.display()
);
}
/// Audit `bin` as THIN against `fams`, and fail with `why` if it is not clean.
fn must_be_clean_of(atom: &str, bin: &Path, fams: &[&str], why: &str) {
let a = LinkageAudit::of_families(atom, ServiceShape::ThinStandalone, bin, fams)
.unwrap_or_else(|| panic!("'{atom}': no artifact to audit"));
println!("{}", a.line());
assert_eq!(
a.verdict(),
LinkageVerdict::ThinAndClean,
"'{atom}' LINKS {}. {why}",
a.families()
);
}
/// Audit `bin` as FAT against `fams`, and fail if it embeds none of them.
fn must_embed(atom: &str, bin: &Path, fams: &[&str], why: &str) -> LinkageAudit {
let a = LinkageAudit::of_families(atom, ServiceShape::FatService, bin, fams)
.unwrap_or_else(|| panic!("'{atom}': no artifact to audit"));
println!("{}", a.line());
assert_eq!(
a.verdict(),
LinkageVerdict::EmbeddedAndEmbeds,
"'{atom}' embeds NONE of {fams:?}. {why}"
);
a
}
/// **RED-when-broken: the two client atoms link no gunnar server, `gunnar` links
/// no tonic, `gunnar-cli` does, and `gunnar-solo` still embeds everything.**
///
/// MEASURED 2026-08-05 at `ab68deb` — this audit's own output, DEMANGLED
/// non-glue symbol counts, which read lower than a raw `nm -C | grep -c` on the
/// same files because destructor glue is reclassified rather than counted:
///
/// | family | `gunnar` | `gunnar-cli` | `gunnar-solo` |
/// |---|---|---|---|
/// | `gunnar_cli::server` | 0 | 0 | 475 |
/// | `gunnar_store` | 0 | 0 | 473 |
/// | `gunnar_ssh` | 0 | 0 | 287 |
/// | `gunnar_wire` | 0 | 0 | 181 |
/// | `gunnar_grpc::service` | 0 | 0 | 160 |
/// | `tonic::transport::server` | 0 | 0 | 79 |
/// | `iceberg` / `parquet` / `datafusion` | 0 | 0 | 0 |
/// | `tonic::` `prost::` `axum::` `gunnar_grpc` | **0** | 265 / 60 / — / 8 | present |
///
/// Shipped bytes, stripped, straight off `dwarves buildthing gunnar --suite
/// ORIGINAL`: `gunnar` 17 936 680, `gunnar-cli` 19 484 568 (+1 547 888),
/// `gunnar-solo` 27 725 776, `gunnar-server` 15 796 424 (musl, static-PIE),
/// `gunnar-service` 17 410 672.
///
/// ⚠ Those two blocks are a SNAPSHOT and they drift — every count above moved
/// between two runs an hour apart on the same day, as other work landed in the
/// halves these atoms link. Nothing here depends on the exact numbers: the
/// assertions below are all ZERO-versus-PRESENT, which is why they can be
/// trusted while the table beside them ages.
///
/// **Both directions, or it is a guess.** SEEN RED 2026-08-05, before any of this
/// was trusted:
///
/// * the thin atom's argv built as `server,client` while the row still said
/// `thin`: exit 101, *"declared THIN but LINKS tonic::transport::server×79,
/// gunnar_cli::server×471, gunnar_ssh×282, gunnar_wire×179,
/// gunnar_grpc::service×160, gunnar_store×473"*.
/// * `CARGO_PROFILE_RELEASE_STRIP=symbols` — the shipped profile's own
/// stripping, restored: exit 101 at [`symbols_were_actually_read`], *"the
/// symbol read found no `gunnar_client` at all … it is an EMPTY SYMBOL
/// TABLE"*. Without that control the same run prints *"declared THIN and
/// carries no server/engine symbols ✓"*.
#[test]
#[ignore = "release-builds three gunnar binaries; run with -- --ignored"]
fn the_client_atoms_are_thin_only_one_has_tonic_and_solo_still_embeds() {
// ── gunnar: THE GIT COMMAND. No server, and no control plane either. ──
let git = build("gunnar", "client");
symbols_were_actually_read("gunnar", &git);
must_be_clean_of(
"gunnar",
&git,
&families(),
"A client that carries gunnar's listeners is a server that has not been started \
yet, and one that carries `gunnar_store` can take the control repository's \
exclusive lock out from under a running server.",
);
must_be_clean_of(
"gunnar",
&git,
&CONTROL_PLANE_SYMBOLS,
"This atom is the git command — clone, fetch, push — and the whole reason it is a \
separate build point from `gunnar-cli` is that cloning a repository must not drag \
in a gRPC stack. If `control` has crept back into the `client` feature, that \
split is gone and only this line says so.",
);
// ── gunnar-cli: the same, PLUS the control plane. Still thin. ──
let cli = build("gunnar-cli", "client,control");
symbols_were_actually_read("gunnar-cli", &cli);
must_be_clean_of(
"gunnar-cli",
&cli,
&families(),
"It administers a server OVER THE WIRE; linking one is the opposite of that.",
);
// …and the other direction, which is what makes the assertion above mean
// something: this atom must ACTUALLY have the control plane. A `gunnar-cli`
// without tonic is not a leaner client, it is the `gunnar` atom under a second
// name — and then the split this file guards does not exist.
must_embed(
"gunnar-cli",
&cli,
&CONTROL_PLANE_SYMBOLS,
"It is the atom that carries `gunnar control …` over gRPC. If it embeds no tonic \
then it is a copy of the `gunnar` atom, and the `gunnar` assertion above has been \
comparing a binary with itself.",
);
// ── gunnar-solo: everything. The discriminating control for all of it. ──
let solo = build("gunnar-solo", "server,client,control");
symbols_were_actually_read("gunnar-solo", &solo);
let fat = must_embed(
"gunnar-solo",
&solo,
&families(),
"It is the SOLO atom and must actually embed its server.",
);
for must in GUNNAR_SERVER_SYMBOLS {
assert!(
fat.found.iter().any(|(f, _)| f == must),
"'gunnar-solo' does not carry `{must}`. Either the solo atom stopped embedding \
the server — in which case it is not solo — or `{must}` is no longer the name \
of that entry point, and the thin assertions above have been checking for a \
string that cannot appear. Found: {}",
fat.families()
);
}
}
/// **The guard's own guard, and it costs no build.** Every property this file
/// leans on is asserted directly rather than trusted: the family lists still name
/// what they must and still do not name the client's legitimate freight, an
/// unmeasured audit never passes, and — the one that matters most here — a
/// SYMBOL-FREE artifact does not come back green.
#[test]
fn the_family_lists_and_the_audit_still_refuse_what_gunnar_needs_them_to_refuse() {
let f = families();
for name in GUNNAR_SERVER_SYMBOLS {
assert!(
f.contains(&name),
"{name} is one of gunnar's server entry points"
);
}
// The client's freight. A guard on any of these would fail the atoms this
// file exists to certify.
for legitimate in [
"gunnar_client",
"gunnar_proto",
"gunnar_pack",
"gunnar_sendpack",
"russh",
"gix",
] {
assert!(
!f.contains(&legitimate),
"'{legitimate}' is CLIENT work — banning it would fail the thin atoms themselves"
);
}
// `gunnar_grpc::service` is narrower than `gunnar_grpc` in the THIN list on
// purpose: `gunnar-cli` links the crate for `sign_credential`, and that
// narrowing is what lets it keep the signer while still being thin.
assert!(
GUNNAR_SERVER_SYMBOLS.contains(&"gunnar_grpc::service"),
"the service impls are banned by module path, not by crate"
);
assert!(
!GUNNAR_SERVER_SYMBOLS.contains(&"gunnar_grpc"),
"a bare `gunnar_grpc` in the THIN list would fail `gunnar-cli` for its signer"
);
for shared in [
"iceberg",
"parquet",
"datafusion",
"tonic::transport::server",
] {
assert!(
f.contains(&shared),
"{shared} comes from the shared constellation list"
);
}
// The control-plane list. `tonic::` and `prost::` keep their `::`, and the
// stack the git client shares with it stays OUT — see the const's own note.
assert!(CONTROL_PLANE_SYMBOLS.contains(&"tonic::"));
for shared_with_the_git_client in ["hyper", "h2", "tower", "tokio", "reqwest"] {
assert!(
!CONTROL_PLANE_SYMBOLS.contains(&shared_with_the_git_client),
"'{shared_with_the_git_client}' is in BOTH graphs (reqwest, under nornir-git). \
Banning it from the `gunnar` atom would fail the git client for being a git \
client, and the measured claim is 'no tonic', not 'no async HTTP'."
);
}
// An artifact that was never built is not an audit at all.
assert!(
LinkageAudit::of_families(
"gunnar",
ServiceShape::ThinStandalone,
Path::new("/nonexistent-gunnar-thin-client"),
&f,
)
.is_none(),
"a missing binary yields no audit — the atom test turns that into a panic"
);
let lying = LinkageAudit {
variant: "gunnar".into(),
artifact: PathBuf::from("target/release/gunnar"),
bytes: 17_936_680,
declared: ServiceShape::ThinStandalone,
found: vec![("gunnar_store".into(), 473), ("gunnar_ssh".into(), 282)],
glue_only: Vec::new(),
measured: true,
};
assert_eq!(lying.verdict(), LinkageVerdict::ThinButLinks);
assert!(!lying.holds(), "{}", lying.line());
// **THE STRIPPED-BINARY HOLE, asserted as the algebra it is.**
// `[profile.release]` sets `strip = true`, so gunnar's shipped artifacts carry
// NO symbol table — the audit parses them (`measured = true`) and finds
// nothing. For a THIN claim that is indistinguishable from a clean pass, which
// is why `build()` turns stripping off and `symbols_were_actually_read`
// re-checks the same file. The fat pairing is the second net.
let stripped_thin = LinkageAudit {
found: Vec::new(),
..lying.clone()
};
assert_eq!(
stripped_thin.verdict(),
LinkageVerdict::ThinAndClean,
"this is the hazard, stated rather than discovered: a symbol-free binary reads as \
a clean thin client"
);
let stripped_fat = LinkageAudit {
declared: ServiceShape::FatService,
found: Vec::new(),
..lying.clone()
};
assert_eq!(stripped_fat.verdict(), LinkageVerdict::EmbeddedButEmpty);
assert!(
!stripped_fat.holds(),
"an atom that declares it embeds and measurably embeds nothing makes the thin \
check vacuous, so it must not hold"
);
let unmeasured = LinkageAudit {
measured: false,
..lying.clone()
};
assert_eq!(unmeasured.verdict(), LinkageVerdict::NotMeasured);
assert!(
!unmeasured.holds(),
"an unchecked claim is not a verified one"
);
// Destructor glue is not an embedded server — the false red that bit holger on
// 2026-08-01. gunnar links tonic in three of its five cargo atoms, so it is
// exactly as exposed.
let glue_only_thin = LinkageAudit {
declared: ServiceShape::ThinStandalone,
found: Vec::new(),
glue_only: vec![("tonic::transport::server".into(), 1)],
..lying.clone()
};
assert_eq!(glue_only_thin.verdict(), LinkageVerdict::ThinAndClean);
assert_eq!(
glue_only_thin.glue_only,
vec![("tonic::transport::server".to_string(), 1)],
"glue is reclassified, never dropped"
);
assert_eq!(
GUNNAR_SERVER_SYMBOLS.len(),
5,
"five server entry points; a shrinking list weakens the thin assertions"
);
}