xtask/src/images.rs
//! `cargo xtask images`: prove the appliance images serve. It builds nothing.
//!
//! The appliance design opens with a claim: the Gunnar **server** is
//! built to run where there is no operating system under it, a single Rust
//! binary as PID 1 in a distroless image. Until something starts it somewhere
//! that is an argument, not a demonstration. The deliverable, in one line:
//! *"gunnar serves from a tunnr-style image"*.
//!
//! This task is what turns it into a fact, and the deliverable is **a working
//! `git clone`, not an image**. An image that builds and does not serve is worth
//! nothing, so nothing here reports success on a build.
//!
//! # ⚒ THE BUILD THING BUILDS. THIS MEASURES. There is no second builder.
//!
//! Until 2026-08-03 this file WAS a builder: it carried a Rust table of variants,
//! four Containerfiles under `containers/`, and a driver that shelled
//! `podman build` per variant. That was a second way to build gunnar's images
//! beside the one the constellation already has, with its own description of what
//! an appliance image is. Two descriptions of one thing is the twinning one-implementation
//! forbids. It is gone. The image is declared as data in
//! `.nornir/build-variants.toml` (`nornir-build-thing`'s `[[build.container]]`
//! schema).
//!
//! The one build door is dwarves' ⚒ Build Thing ▶ Build, on the `gunnar-image`
//! cell (`kind = "container"`, `container_ref = "gunnar-scratch"`).
//! `dwarves::assemble_cell` routes on `ArtifactKind`, builds the chained
//! `gunnar-server` cargo variant, stages its artifact and drives podman over the
//! REST API (bollard, zero-shell) — it materializes the Containerfile from the
//! declaration rather than reading one off disk. This file reads the SAME
//! declaration for the list of images, not a table of its own, and then measures
//! and proves them. If an image is missing it says so; it never produces one.
//!
//! ```text
//! cargo xtask images measure + prove every declared image
//! cargo xtask images --keep leave the containers and volumes running
//! cargo xtask images --only NAME one image (skips the red-first proofs)
//! ```
//!
//! # What is measured, and how
//!
//! | column | how |
//! |---|---|
//! | size | `podman image inspect --format {{.Size}}`, bytes on disk, uncompressed |
//! | files | non-directory entries in `podman export`'s tar, i.e. the real filesystem, not the base image's name |
//! | shell | the same tar, scanned for `sh`/`bash`/`dash`/`ash`/`busybox`/`ksh`/`zsh` |
//! | pkg | the same tar, scanned for `apk`/`apt`/`apt-get`/`dpkg`/`rpm`/`yum` |
//! | starts | the container is up AND the SSH port accepts a TCP connection |
//! | ready | wall time from `podman run -d` returning to that first accepted connection |
//! | rss | `podman stats --no-stream`, taken at rest after readiness |
//! | objs | objects in the clone's own object database, `git cat-file --batch-all-objects` |
//! | clone | stock `git push` then stock `git clone` over SSH, and the **object graph compared oid by oid** |
//!
//! The shell and package-manager scans read the exported filesystem rather than
//! trying to `exec` something: an image with no shell cannot be asked what it
//! contains, and "the exec failed" is shaped exactly like "the image is fine".
//!
//! # What "serves" means here, and why it is not the file bytes
//!
//! The clone probe used to compare `hello.txt`. That is applied output and it is
//! real, but it is one blob: a server that returned the right blob under a wrong
//! tree, or dropped an object the commit points at, would pass it. So the probe
//! now enumerates the object database on BOTH sides —
//! `git cat-file --batch-all-objects --batch-check='%(objectname) %(objecttype)
//! %(objectsize)'`, every object in the odb, in oid order — and requires the two
//! listings to be **identical, entry for entry**. The blob bytes are still
//! compared, because an identical graph of the wrong content is not a thing git
//! can produce but an identical graph of nothing is.
//!
//! ## The non-vacuity floor
//!
//! Two identical EMPTY listings compare equal. So the probe also refuses any graph
//! below [`MIN_OBJECTS`] — a commit, its tree and one blob, which is the least a
//! real push can produce. Without the floor a Gunnar serving an empty repository
//! passes: `git clone` exits 0, both sides enumerate nothing, and nothing is what
//! they agree on. `red_first` points the probe at exactly that case and requires
//! it to say no.
//!
//! # The guards are seen red before any green is believed
//!
//! Four reds, none optional, in [`red_first`]:
//!
//! * **no-shell**, against `docker.io/library/debian:trixie` — a stock base with
//! `bash`, `dash`, `apt` and `dpkg` in it. It is scanned with the same
//! [`export_contents`] + [`matching`] the candidate is, so a scan that matches
//! nothing at all cannot pass this. gunnar used to declare a sixth
//! `[[build.container]]` cell (`gunnar-fat`) that WAS this base with the real
//! binary copied in; the cell is gone with the base-image comparison, and the
//! fixture did not need to be a gunnar image — the property under test is a
//! filesystem scan, and the base alone is red on it.
//! * **the clone probe reports failure at all**, against a container that is not
//! Gunnar (`alpine:3` running `sleep 120` on the same published port).
//! * **the clone probe looks past the exit code**, against a real Gunnar serving
//! an EMPTY repository, where `git clone` exits 0 and produces nothing. Only
//! the floor above can say no to that.
//! * **the oid-by-oid comparison itself**, against a source given three objects
//! AFTER the push — a clone that clears the floor and whose `hello.txt` is
//! byte-correct, so nothing but the comparison can reject it. Without this the
//! comparison's two sides have only ever agreed, which is `assert!(true)` with
//! more steps.
//!
//! This repository already ships a guard
//! that asserted true things while the constraint it existed to protect was
//! broken, which is why none of the three is optional.
//!
//! # Why there are no crates in here
//!
//! `xtask` has zero dependencies on purpose (`xtask/Cargo.toml`): a workspace
//! member takes part in resolver-2 feature unification, so a dependency added
//! here could turn a feature on in a crate the appliance links, and the guard's
//! job is not to be the thing it guards against. Everything below is `std` plus
//! `podman`, `git`, `ssh-keygen` and `tar` as processes.
use std::fmt::Write as _;
use std::io::Write as _;
use std::net::{Shutdown, SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{Duration, Instant};
// ── the image list comes from the DECLARATION, never from a table in here ───
/// The declaration this file reads. It is the same file `nornir build --image`
/// reads, which is the point: one description of gunnar's images exists.
pub const DECLARATION: &str = ".nornir/build-variants.toml";
/// What this repository CLAIMS about one declared image.
///
/// The declaration says what is built; it has no field for *"this image contains
/// no shell"* and should not, because that is an assertion to be tested rather
/// than a build input. So the claims live here, keyed by the declared cell name,
/// and [`claims_for`] returns `None` for a name it does not know, which
/// [`load_variants`] turns into a hard error. A cell added to the declaration
/// with no claim beside it must NOT quietly become an image nothing asserts
/// anything about.
struct Claim {
/// Whether this image claims to contain no shell.
claims_no_shell: bool,
/// What this base carries that Gunnar does not need, stated up front and
/// then checked against the exported filesystem.
freight: &'static str,
}
fn claims_for(cell: &str) -> Option<Claim> {
match cell {
"gunnar-scratch" => Some(Claim {
claims_no_shell: true,
freight: "nothing: the image is one file",
}),
_ => None,
}
}
/// One declared image, as read out of [`DECLARATION`].
pub struct Variant {
/// The `[[build.container]]` cell name, the handle a `kind = "container"`
/// variant node's `container_ref` takes.
pub name: String,
/// The runtime base (`FROM`). `scratch` is the empty one.
pub base: String,
/// The image tag the declaration applies, which is what podman knows it by.
pub tag: String,
pub claims_no_shell: bool,
pub expected_freight: &'static str,
}
/// Read every `[[build.container]]` cell out of the declaration.
///
/// A deliberately minimal scan rather than a TOML parser: `xtask` has zero
/// dependencies on purpose (see the module header), and the shape being read is
/// three string keys under a known table header. `nornir` reads its own
/// `.cargo/config.toml` `target-dir` the same way and for the same reason.
///
/// The empty result is an ERROR, not an empty run: a scan that silently found no
/// images would report "every image passed" having measured nothing, which is the
/// exact failure mode `export_contents` guards against downstream.
pub fn load_variants(root: &Path) -> Result<Vec<Variant>, String> {
let path = root.join(DECLARATION);
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("cannot read the image declaration {}: {e}", path.display()))?;
struct Raw {
name: Option<String>,
base: Option<String>,
tag: Option<String>,
}
let mut cells: Vec<Raw> = Vec::new();
// `capturing` is cleared by ANY other table header, so the `name` of a
// `[[build.variant]]` can never be mistaken for an image, and the
// `[build.container.env]` / `.labels` / `.layers` sub-tables that follow a
// cell contribute nothing.
let mut capturing = false;
for line in text.lines() {
let line = line.trim();
if line.starts_with("[[build.container]]") {
cells.push(Raw {
name: None,
base: None,
tag: None,
});
capturing = true;
continue;
}
if line.starts_with('[') {
capturing = false;
continue;
}
if !capturing {
continue;
}
let Some(cell) = cells.last_mut() else {
continue;
};
if let Some(v) = quoted_value(line, "name") {
cell.name.get_or_insert(v);
} else if let Some(v) = quoted_value(line, "base") {
cell.base.get_or_insert(v);
} else if line.starts_with("tags") {
// `tags = ["gunnar-scratch:1.1.3"]`, the first entry is the primary
// reference, which is what `podman image inspect` is asked about.
if let Some(first) = line.split('"').nth(1) {
cell.tag.get_or_insert(first.to_string());
}
}
}
let mut out = Vec::new();
for raw in cells {
let name = raw
.name
.ok_or_else(|| format!("{DECLARATION}: a [[build.container]] cell has no name"))?;
let base = raw
.base
.clone()
.ok_or_else(|| format!("{DECLARATION}: container '{name}' declares no base"))?;
let tag = raw
.tag
.clone()
.ok_or_else(|| format!("{DECLARATION}: container '{name}' declares no tags"))?;
let claim = claims_for(&name).ok_or_else(|| {
format!(
"{DECLARATION} declares the image '{name}', and this task asserts nothing about \
it. Add it to `claims_for`, whether it claims to have no shell and what its \
base carries that gunnar does not need. An image nobody claims anything about \
is an image no guard can fail."
)
})?;
out.push(Variant {
name,
base,
tag,
claims_no_shell: claim.claims_no_shell,
expected_freight: claim.freight,
});
}
if out.is_empty() {
return Err(format!(
"{DECLARATION} declares no [[build.container]] cell, so there is nothing to measure \
, and a run that measured nothing would have printed an empty table and passed"
));
}
Ok(out)
}
/// `key = "value"` → `value`, for the minimal scan above.
fn quoted_value(line: &str, key: &str) -> Option<String> {
let rest = line.strip_prefix(key)?;
let rest = rest.trim_start();
let rest = rest.strip_prefix('=')?;
let mut parts = rest.split('"');
parts.next()?;
parts.next().map(|s| s.to_string())
}
/// Names that mean "there is a shell in this image".
const SHELL_NAMES: &[&str] = &["sh", "bash", "dash", "ash", "busybox", "ksh", "zsh", "tcsh"];
/// Names that mean "this image can install more software".
const PKG_NAMES: &[&str] = &["apk", "apt", "apt-get", "dpkg", "rpm", "yum", "dnf", "opkg"];
// ── the measurement of one variant ──────────────────────────────────────────
pub struct Measured {
pub name: String,
pub tag: String,
pub base: String,
pub size_bytes: u64,
pub files: usize,
pub shells: Vec<String>,
pub pkg_managers: Vec<String>,
pub starts: bool,
pub ready: Option<Duration>,
pub rss: Option<String>,
pub serves_clone: bool,
/// How many objects the CLONE's own object database held. Reported as a real
/// number rather than folded into `serves_clone`, so a run that passed the
/// floor by exactly one object is visible instead of merely green.
pub objects: usize,
/// The oid-by-oid verdict, spelled out for the report.
pub graph: String,
pub expected_freight: &'static str,
pub notes: Vec<String>,
}
impl Measured {
/// The variant passed everything it claims.
pub fn ok(&self, claims_no_shell: bool) -> bool {
self.starts && self.serves_clone && !(claims_no_shell && !self.shells.is_empty())
}
}
// ── entry point ─────────────────────────────────────────────────────────────
pub fn run(args: &[String]) -> Result<bool, String> {
let keep = args.iter().any(|a| a == "--keep");
let only = args
.iter()
.position(|a| a == "--only")
.and_then(|i| args.get(i + 1))
.cloned();
let root = crate::guard::workspace_root();
preflight(&root)?;
let declared = load_variants(&root)?;
eprintln!(
"{DECLARATION} declares {} image(s): {}",
declared.len(),
declared
.iter()
.map(|v| v.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
let wanted: Vec<&Variant> = declared
.iter()
.filter(|v| only.as_deref().is_none_or(|n| n == v.name))
.collect();
if wanted.is_empty() {
return Err(format!(
"--only {} matched no declared image; known: {}",
only.unwrap_or_default(),
declared
.iter()
.map(|v| v.name.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
// ── every image ─────────────────────────────────────────────────────────
let mut measured = Vec::new();
for v in &wanted {
eprintln!("\n── {} ──", v.name);
match measure(&root, v, keep) {
Ok(m) => measured.push((*v, m)),
Err(err) => return Err(format!("{}: {err}", v.name)),
}
}
// ── RED FIRST ───────────────────────────────────────────────────────────────
let reds = if only.is_none() {
eprintln!("\n── the guards, seen red ──");
Some(red_first(&root, &measured)?)
} else {
eprintln!("\nLAW 2 red-first proofs SKIPPED: --only measures a subset, and proof 3");
eprintln!("needs a candidate that served, to point the floor at.");
None
};
print!("{}", render(&measured, reds.as_deref()));
let all_ok = measured.iter().all(|(v, m)| m.ok(v.claims_no_shell));
let reds_ok = reds.as_ref().is_none_or(|r| r.iter().all(|p| p.fired));
Ok(all_ok && reds_ok)
}
fn preflight(root: &Path) -> Result<(), String> {
for (tool, args) in [
("podman", &["--version"][..]),
("git", &["--version"]),
("ssh-keygen", &["-?"]),
("tar", &["--version"]),
] {
let out = Command::new(tool).args(args).output();
let ok = match &out {
// Output, not exit status: `ssh-keygen -?` exits non-zero and
// prints its banner, and a tool that is absent produces an Err
// here rather than an empty success.
Ok(o) => !o.stdout.is_empty() || !o.stderr.is_empty(),
Err(_) => false,
};
if !ok {
return Err(format!(
"`{tool}` is not usable, and every measurement below depends on it"
));
}
}
if !root.join(DECLARATION).is_file() {
return Err(format!(
"{DECLARATION} is missing from the checkout, and it is where the images are declared"
));
}
Ok(())
}
// ── measure one variant ─────────────────────────────────────────────────────
fn measure(root: &Path, v: &Variant, keep: bool) -> Result<Measured, String> {
// THE IMAGE IS AN INPUT. This task builds nothing; dwarves' ⚒ Build Thing
// does, from the same declaration this list was read out of. A missing image
// is reported with the door that produces it rather than quietly produced
// here, because producing it here is what made this file a second builder.
let tag = v.tag.clone();
if image_size(&tag).is_err() {
return Err(format!(
"the declared image '{}' is not present as `{tag}`. Build it through the \
constellation's one build door — dwarves ⚒ Build Thing ▶ Build on the \
`kind = \"container\"` variant whose `container_ref` is '{}'.",
v.name, v.name
));
}
let size_bytes = image_size(&tag)?;
let contents = export_contents(root, &tag)?;
let shells = matching(&contents, SHELL_NAMES);
let pkg_managers = matching(&contents, PKG_NAMES);
let mut notes = Vec::new();
let run = Serving::start(root, &tag, &v.name)?;
let starts = run.as_ref().map(|r| r.ready.is_some()).unwrap_or(false);
let ready = run.as_ref().and_then(|r| r.ready);
let rss = run.as_ref().and_then(|r| r.rss.clone());
let mut serves_clone = false;
let mut objects = 0usize;
let mut graph = String::from("-");
match &run {
Some(r) if r.ready.is_some() => {
match r.clone_probe("demo/hello", CONTENT, Probe::PushAndClone) {
Ok(proof) => {
serves_clone = proof.served;
objects = proof.cloned.len();
graph = proof.detail.clone();
if !proof.served {
notes.push(proof.detail.clone());
}
// The graph itself, printed. A count is a summary; these are the
// oids that were compared, and they are what makes "identical"
// checkable by somebody reading the run rather than trusting it.
eprintln!(" object graph, oid by oid:");
for l in &proof.cloned {
let same = proof.pushed.as_ref().is_none_or(|p| p.contains(l));
eprintln!(" {} {l}", if same { "==" } else { "!!" });
}
}
Err(e) => {
notes.push(format!("clone failed: {e}"));
graph = format!("git failed: {}", first_line(&e));
}
}
}
Some(r) => notes.push(format!("never became ready: {}", r.why)),
None => notes.push("the container did not start at all".to_string()),
}
if let Some(r) = run {
if keep {
eprintln!(
" --keep: container {} is still running, volume {}",
r.container, r.volume
);
} else {
r.tear_down();
}
}
Ok(Measured {
name: v.name.clone(),
tag,
base: v.base.clone(),
size_bytes,
files: contents.len(),
shells,
pkg_managers,
starts,
ready,
rss,
serves_clone,
objects,
graph,
expected_freight: v.expected_freight,
notes,
})
}
fn image_size(tag: &str) -> Result<u64, String> {
let out = podman(&["image", "inspect", "--format", "{{.Size}}", tag])?;
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
text.parse()
.map_err(|_| format!("podman reported a size for {tag} that is not a number: {text:?}"))
}
/// Every path inside the image, read from `podman export`'s tar.
///
/// Not `podman run <image> ls`: an image with no shell and no coreutils cannot
/// be asked what it contains, and an `exec` that fails looks exactly like an
/// image that is clean. Exporting is the only reading that works identically on
/// `scratch` and on `debian:trixie`, which is what makes the column comparable.
fn export_contents(root: &Path, tag: &str) -> Result<Vec<String>, String> {
let scratch = root.join("target/xtask-images");
std::fs::create_dir_all(&scratch).map_err(|e| format!("{}: {e}", scratch.display()))?;
let tarball = scratch.join(format!("{}.tar", tag.replace(['/', ':'], "_")));
let out = podman(&["create", tag])?;
let ctr = String::from_utf8_lossy(&out.stdout).trim().to_string();
if ctr.is_empty() {
return Err(format!("podman create {tag} printed no container id"));
}
let export = podman(&["export", "-o", tarball.to_str().unwrap(), &ctr]);
let _ = podman(&["rm", "-f", &ctr]);
export?;
let listing = Command::new("tar")
.arg("-tf")
.arg(&tarball)
.output()
.map_err(|e| format!("could not run tar: {e}"))?;
let _ = std::fs::remove_file(&tarball);
if !listing.status.success() {
return Err(format!(
"tar could not read the exported {tag}: {}",
String::from_utf8_lossy(&listing.stderr).trim()
));
}
// DIRECTORIES ARE DROPPED, and the trailing slash `tar` prints is the only
// thing that distinguishes them, so it is read before it is trimmed, not
// after. Trimming first made the scan report `pkg: YES` for
// `gcr.io/distroless/static-debian12`, on the strength of the directories
// `/etc/dpkg` and `/var/lib/dpkg`. That image ships dpkg's *metadata* and
// no dpkg: there is no binary in it that can install anything, which is the
// property the column claims to measure. Symlinks keep no trailing slash
// and so survive, which matters, alpine's `/bin/sh` is a symlink to
// busybox and must still be found.
let paths: Vec<String> = String::from_utf8_lossy(&listing.stdout)
.lines()
.filter(|l| !l.ends_with('/'))
.map(|l| l.to_string())
.filter(|l| !l.is_empty())
.collect();
// The empty-graph trap, again: an export that produced nothing would make
// every scan below report "no shell found" for the same reason a broken
// `cargo tree` invocation once made the dependency guard report "clean".
if paths.is_empty() {
return Err(format!(
"the exported filesystem of {tag} is EMPTY, every content scan \
below would have reported a clean image without reading anything"
));
}
Ok(paths)
}
/// Paths whose file name is one of `names`, deduplicated.
fn matching(paths: &[String], names: &[&str]) -> Vec<String> {
let mut hits: Vec<String> = paths
.iter()
.filter(|p| {
p.rsplit('/')
.next()
.is_some_and(|base| names.contains(&base))
})
.map(|p| format!("/{}", p.trim_start_matches("./").trim_start_matches('/')))
.collect();
hits.sort();
hits.dedup();
hits
}
// ── a running container, and the clone that is the actual deliverable ───────
const CONTENT: &str = "gunnar served this from a container\n";
/// **The non-vacuity floor.** The fewest objects a real push can put in a
/// repository: one blob, the tree that names it, the commit that points at the
/// tree. A clone holding fewer than this did not receive a history, whatever
/// `git clone` exited with, and no oid-by-oid comparison below can say so —
/// two empty listings are equal. `red_first` proof 3 is this line seen red.
pub const MIN_OBJECTS: usize = 3;
/// One repository's OBJECT DATABASE, enumerated: one `oid type size` per line,
/// in oid order, for every object the odb holds.
///
/// `--batch-all-objects` and not `rev-list --objects`: the question is what the
/// server actually DELIVERED, not what is reachable from the refs it advertised.
/// An object dropped in transit is the failure being looked for, and a reachable-
/// set walk of a clone that is missing one cannot enumerate past the hole.
///
/// EMPTY IS AN ERROR at the call site, never here: `git` on a repository with no
/// objects exits 0 and prints nothing, which is shaped exactly like a clean read.
fn object_graph(dir: &Path, ssh: &str) -> Result<Vec<String>, String> {
let out = git(
dir,
ssh,
&[
"cat-file",
"--batch-all-objects",
"--batch-check=%(objectname) %(objecttype) %(objectsize)",
],
)?;
Ok(out
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect())
}
/// **What the probe does before it clones**, as a named case rather than a bool.
///
/// The comment on [`Serving::clone_probe`] records why this is not inferred from
/// the arguments: a guard whose behaviour is chosen by comparing one of its
/// arguments to a constant is a guard nobody can point anywhere. It is an enum
/// and not a `push: bool` for the same reason one step further out — the third
/// case is a push, so a boolean could not name it without lying about one of the
/// two that push.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Probe {
/// Push a commit, clone it back, compare the two graphs. The deliverable.
PushAndClone,
/// Clone only. No source side, so the graph comparison does not run and the
/// floor is the whole verdict. What the red-first probes use.
CloneOnly,
/// Push a commit, then commit a SECOND file that is never pushed, so the
/// source graph is a strict superset of anything the server can return.
///
/// This exists to see the graph comparison RED. Every other call has the two
/// sides agreeing, and a comparison that has only ever been run against
/// inputs it accepts is indistinguishable from `assert!(true)` — the exact
/// defect this file's own header warns about. The failure it stands in for is
/// real: a server that acknowledges a push and returns fewer objects than it
/// was given. The floor cannot catch that (the clone holds 3, well over
/// MIN_OBJECTS) and the byte comparison cannot either (hello.txt is there and
/// correct). Only the oid-by-oid comparison can.
PushThenOrphan,
}
/// What one push-and-clone round trip proved, as data rather than a bool.
///
/// `served` is the verdict; the rest is why, and it is carried out so the report
/// can print a real object count instead of a green tick. A `false` with 3
/// objects and a `false` with 0 are different failures.
pub struct CloneProof {
/// The whole claim: the graphs matched, the floor was cleared, and the blob
/// bytes are what was written.
pub served: bool,
/// The source side's graph, when this probe pushed one. `None` for the red-first
/// proofs, which push nothing and have no source to compare against.
pub pushed: Option<Vec<String>>,
/// The clone's graph, always read.
pub cloned: Vec<String>,
/// One line naming which of the three checks decided the verdict.
pub detail: String,
}
pub struct Serving {
pub container: String,
pub volume: String,
pub port: u16,
pub key: PathBuf,
pub ready: Option<Duration>,
pub rss: Option<String>,
pub why: String,
work: PathBuf,
}
impl Serving {
/// Bootstrap a data volume through the image's OWN verbs, then serve.
///
/// The bootstrap runs as separate `podman run` invocations because the
/// Arrow ref/secret log takes an `O_EXCL` lock at open
/// while the server holds the store lock, a CLI verb cannot run
/// beside a live `gunnar serve` on the same data root. Sequential
/// containers give the lock what it needs for free.
fn start(root: &Path, tag: &str, name: &str) -> Result<Option<Serving>, String> {
let work = root.join("target/xtask-images").join(name);
let _ = std::fs::remove_dir_all(&work);
std::fs::create_dir_all(&work).map_err(|e| format!("{}: {e}", work.display()))?;
// A client key. `ssh-keygen` is a TEST tool here, not a Gunnar
// subprocess, the zero-shell rule is about what the server does, and
// xtask already asserts this binary's presence in `preflight`.
let key = work.join("id_ed25519");
let kg = Command::new("ssh-keygen")
.args([
"-q",
"-t",
"ed25519",
"-N",
"",
"-C",
"gunnar-xtask-images",
"-f",
])
.arg(&key)
.output()
.map_err(|e| format!("ssh-keygen: {e}"))?;
if !key.exists() {
return Err(format!(
"ssh-keygen produced no key: {}",
String::from_utf8_lossy(&kg.stderr).trim()
));
}
let volume = format!("gunnar-xtask-{name}");
let _ = podman(&["volume", "rm", "-f", &volume]);
podman(&["volume", "create", &volume])?;
let mount = format!("{volume}:/data");
let keymount = format!("{}:/bootstrap:ro,z", work.display());
let bootstrap: &[&[&str]] = &[
&["namespace", "create", "demo"],
// sha1 so a stock `git clone` needs no object-format negotiation.
// D11 makes sha256 the default and the sha256 path is proven by
// `crates/gunnar-cli/tests/ssh_end_to_end.rs`; this task is about
// the IMAGE, and pinning one hash keeps the variants comparable.
&["repo", "create", "demo/hello", "--hash", "sha1", "--public"],
// The empty repository the red-first proof clones from: a clone
// that exits zero and produces nothing.
&["repo", "create", "demo/empty", "--hash", "sha1", "--public"],
&["principal", "add", "tester"],
&["key", "add", "tester", "@/bootstrap/id_ed25519.pub"],
&["grant", "tester", "write", "--repo", "demo/hello"],
];
for verbs in bootstrap {
let mut args = vec!["run", "--rm", "-v", &mount, "-v", &keymount, tag];
args.extend_from_slice(verbs);
let out = podman(&args).map_err(|e| format!("bootstrap `{}`: {e}", verbs.join(" ")))?;
let _ = out;
}
// Publish on an ephemeral loopback port and ask podman which one it
// chose, rather than guessing a free port and racing another job.
let out = podman(&[
"run",
"-d",
"-v",
&mount,
"-p",
"127.0.0.1::2222",
"--name",
&format!("gunnar-xtask-{name}"),
tag,
"serve",
"--no-grpc",
])?;
let container = String::from_utf8_lossy(&out.stdout).trim().to_string();
if container.is_empty() {
return Ok(None);
}
let started = Instant::now();
let port = match podman(&["port", &container, "2222/tcp"]) {
Ok(p) => String::from_utf8_lossy(&p.stdout)
.lines()
.next()
.and_then(|l| l.rsplit(':').next())
.and_then(|p| p.trim().parse::<u16>().ok()),
Err(_) => None,
};
let Some(port) = port else {
let why = logs(&container);
let _ = podman(&["rm", "-f", &container]);
let _ = podman(&["volume", "rm", "-f", &volume]);
return Err(format!("podman published no port for {tag}: {why}"));
};
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let mut ready = None;
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if let Ok(s) = TcpStream::connect_timeout(&addr, Duration::from_millis(250)) {
let _ = s.shutdown(Shutdown::Both);
ready = Some(started.elapsed());
break;
}
if !running(&container) {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let why = if ready.is_none() {
logs(&container)
} else {
String::new()
};
// At rest: readiness has happened and nothing has connected yet.
let rss = ready.and(rss_of(&container));
Ok(Some(Serving {
container,
volume,
port,
key,
ready,
rss,
why,
work,
}))
}
/// **The deliverable.** Stock `git` pushes over SSH, stock `git` clones back,
/// and the two OBJECT GRAPHS are compared oid by oid.
///
/// Three checks decide the verdict, and each exists because the one before it
/// can be passed by a server that served nothing:
///
/// 1. **the floor.** `cloned.len() >= `[`MIN_OBJECTS`]. A clone of an empty
/// repository exits zero and leaves an empty directory, so an exit-status
/// check reports success for a server that serves nothing — and so does an
/// oid-by-oid comparison of two empty listings.
/// 2. **the graph.** Every object in the source odb and every object in the
/// clone's odb, `oid type size`, in oid order, compared entry for entry.
/// Only run when this probe pushed: the red-first proofs have no source side.
/// 3. **the bytes.** `hello.txt` read back off the clone's worktree. An
/// identical graph of the wrong content is not something git can produce,
/// but the read costs nothing and it is the one check that touches the
/// checkout rather than the odb.
///
/// [`Probe`] is an explicit argument and not inferred from `content`. It was
/// inferred once, the push ran whenever `content == CONTENT`, and that is
/// precisely what made the third red-first proof unable to fire: pointing the
/// probe at an EMPTY repository with the normal expected bytes also asked
/// it to push into that repository, the push was refused for want of a
/// grant, and the run reported *"git itself failed, so the byte comparison
/// was never reached"* instead of the red it exists to produce. A guard
/// whose behaviour is chosen by comparing one of its arguments to a
/// constant is a guard nobody can point anywhere.
pub fn clone_probe(
&self,
repo: &str,
content: &str,
probe: Probe,
) -> Result<CloneProof, String> {
let ssh = format!(
"ssh -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
self.key.display()
);
let url = format!("ssh://git@127.0.0.1:{}/{repo}.git", self.port);
let mut pushed = None;
if probe != Probe::CloneOnly {
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", "xtask@gunnar.invalid"],
)?;
git(&src, &ssh, &["config", "user.name", "gunnar xtask"])?;
let mut f = std::fs::File::create(src.join("hello.txt"))
.map_err(|e| format!("hello.txt: {e}"))?;
f.write_all(content.as_bytes())
.map_err(|e| format!("hello.txt: {e}"))?;
drop(f);
git(&src, &ssh, &["add", "hello.txt"])?;
git(
&src,
&ssh,
&["commit", "--quiet", "-m", "served from a container"],
)?;
git(
&src,
&ssh,
&["push", "--quiet", &url, "HEAD:refs/heads/main"],
)?;
if probe == Probe::PushThenOrphan {
// AFTER the push, so the server never sees these three objects
// and cannot return them. A blob, the tree that names it and the
// commit: the source graph becomes a strict superset of the
// clone's, which is what the comparison must notice.
std::fs::write(src.join("orphan.txt"), b"never pushed\n")
.map_err(|e| format!("orphan.txt: {e}"))?;
git(&src, &ssh, &["add", "orphan.txt"])?;
git(&src, &ssh, &["commit", "--quiet", "-m", "orphan"])?;
}
// Read the SOURCE graph after the push, not before: what is being
// compared is what the server was given against what it gave back.
let g = object_graph(&src, &ssh)?;
if g.len() < MIN_OBJECTS {
// Not a verdict — a broken probe. The source is built three
// lines above out of one file and one commit; fewer than three
// objects here means the `git` calls above did nothing, and the
// comparison would then be between two things that are both
// empty for the same reason.
return Err(format!(
"the probe's OWN source repository holds {} object(s), below the floor of \
{MIN_OBJECTS}. Nothing was pushed, so nothing could be compared.",
g.len()
));
}
pushed = Some(g);
}
let dest = self.work.join(format!("clone-{}", repo.replace('/', "-")));
let _ = std::fs::remove_dir_all(&dest);
git(
&self.work,
&ssh,
&["clone", "--quiet", &url, dest.to_str().unwrap()],
)?;
let cloned = object_graph(&dest, &ssh)?;
// 1 — the floor. Before any comparison, because two empty listings agree.
if cloned.len() < MIN_OBJECTS {
return Ok(CloneProof {
served: false,
pushed,
detail: format!(
"the clone's object database holds {} object(s), below the floor of \
{MIN_OBJECTS}; `git clone` exited 0 and served no history",
cloned.len()
),
cloned,
});
}
// 2 — the graph, oid by oid.
if let Some(src_graph) = &pushed {
if src_graph != &cloned {
let mut missing: Vec<&str> = src_graph
.iter()
.filter(|l| !cloned.contains(l))
.map(String::as_str)
.collect();
let extra: Vec<&str> = cloned
.iter()
.filter(|l| !src_graph.contains(l))
.map(String::as_str)
.collect();
missing.truncate(4);
return Ok(CloneProof {
served: false,
detail: format!(
"the object graphs differ: {} pushed, {} cloned, {} missing from the \
clone [{}], {} it did not receive from us",
src_graph.len(),
cloned.len(),
src_graph.iter().filter(|l| !cloned.contains(l)).count(),
missing.join(" | "),
extra.len()
),
pushed,
cloned,
});
}
}
// 3 — the bytes on the checked-out worktree.
let bytes_ok = std::fs::read_to_string(dest.join("hello.txt"))
.map(|got| got == content)
.unwrap_or(false);
let n = cloned.len();
Ok(CloneProof {
served: bytes_ok,
detail: if bytes_ok {
match &pushed {
Some(_) => format!("{n} objects, identical oid for oid, and hello.txt matches"),
None => format!("{n} objects cleared the floor and hello.txt matches"),
}
} else {
"the object graph matched and hello.txt did not".to_string()
},
pushed,
cloned,
})
}
fn tear_down(self) {
let _ = podman(&["rm", "-f", &self.container]);
let _ = podman(&["volume", "rm", "-f", &self.volume]);
let _ = std::fs::remove_dir_all(&self.work);
}
}
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")
.args(args)
.output()
.map_err(|e| format!("git {}: {e}", args.join(" ")))?;
if !out.status.success() {
return Err(format!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn running(container: &str) -> bool {
podman(&["inspect", "--format", "{{.State.Running}}", container])
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
.unwrap_or(false)
}
fn logs(container: &str) -> String {
let out = Command::new("podman").args(["logs", container]).output();
match out {
Ok(o) => {
let mut s = String::from_utf8_lossy(&o.stderr).trim().to_string();
if s.is_empty() {
s = String::from_utf8_lossy(&o.stdout).trim().to_string();
}
s.lines().rev().take(4).collect::<Vec<_>>().join(" / ")
}
Err(e) => format!("(no logs: {e})"),
}
}
fn rss_of(container: &str) -> Option<String> {
let out = podman(&[
"stats",
"--no-stream",
"--format",
"{{.MemUsage}}",
container,
])
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let first = text.lines().find(|l| !l.trim().is_empty())?;
Some(first.split('/').next()?.trim().to_string())
}
fn podman(args: &[&str]) -> Result<Output, String> {
let out = Command::new("podman")
.args(args)
.output()
.map_err(|e| format!("podman {}: {e}", args.join(" ")))?;
if !out.status.success() {
return Err(format!(
"podman {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(out)
}
// ── the guards, seen red ─────────────────────────────────────────────
pub struct RedProof {
pub what: &'static str,
/// A `String` rather than a `&'static str`: two of the three proofs name the
/// variant they ran against, which is only known at run time.
pub against: String,
pub fired: bool,
pub detail: String,
}
/// Four reds, and none of them optional.
///
/// A guard that has only ever been run against inputs it accepts is
/// indistinguishable from a guard that accepts everything.
fn red_first(root: &Path, measured: &[(&Variant, Measured)]) -> Result<Vec<RedProof>, String> {
let mut proofs = Vec::new();
// ── 1, the no-shell scan, against an image that is nothing but shell ────────
//
// A STOCK BASE, not a gunnar cell. gunnar used to declare a sixth
// `[[build.container]]` — `gunnar-fat`, a full `debian:trixie` with the real
// binary copied in — purely so this proof had something to point at, and the
// cell went with the base-image comparison it belonged to. Nothing was lost:
// the property under test is a filesystem scan for shells and package
// managers, `debian:trixie` is red on both, and copying a 15 MB binary into it
// never contributed to that. It is scanned by the SAME `export_contents` +
// `matching` the candidate is, which is what makes it evidence about the real
// scan and not about a second one written for the occasion.
const SHELLFUL: &str = "docker.io/library/debian:trixie";
if image_size(SHELLFUL).is_err() {
// Pull rather than skip. A red-first proof that quietly does not run is
// the failure this whole section exists to prevent.
eprintln!(" pulling {SHELLFUL} for the no-shell red proof …");
podman(&["pull", "-q", SHELLFUL])
.map_err(|e| format!("cannot obtain the shellful fixture {SHELLFUL}: {e}"))?;
}
let fat = export_contents(root, SHELLFUL)?;
let fat_shells = matching(&fat, SHELL_NAMES);
let fat_pkg = matching(&fat, PKG_NAMES);
let fired = !fat_shells.is_empty() && !fat_pkg.is_empty();
proofs.push(RedProof {
what: "no-shell / no-package-manager scan rejects a base that has both",
against: format!("{SHELLFUL}, {} entries in `podman export`", fat.len()),
fired,
detail: if fired {
format!(
"found shells [{}] and package managers [{}]",
fat_shells.join(" "),
fat_pkg.join(" ")
)
} else {
format!(
"found {} shell(s) and {} package manager(s) in an image that ships bash, dash, \
apt and dpkg — the scan is blind",
fat_shells.len(),
fat_pkg.len()
)
},
});
// ── 2, the clone probe, against something that is not a git server at all ──
// This is the probe proving it CAN report failure.
let dead = "gunnar-xtask-red-nothing";
let _ = podman(&["rm", "-f", dead]);
let spun = podman(&[
"run",
"-d",
"--name",
dead,
"-p",
"127.0.0.1::2222",
"docker.io/library/alpine:3",
"sleep",
"120",
]);
match spun {
Ok(out) => {
let ctr = String::from_utf8_lossy(&out.stdout).trim().to_string();
let port = podman(&["port", &ctr, "2222/tcp"]).ok().and_then(|p| {
String::from_utf8_lossy(&p.stdout)
.lines()
.next()
.and_then(|l| l.rsplit(':').next())
.and_then(|s| s.trim().parse::<u16>().ok())
});
let mut detail = "could not read the published port".to_string();
let mut fired = false;
if let Some(port) = port {
let work = root.join("target/xtask-images/red-nothing");
let _ = std::fs::remove_dir_all(&work);
let _ = std::fs::create_dir_all(&work);
// A REAL key, generated here. This proof borrowed the `scratch`
// variant's key once, and `scratch` had already been torn down
// by the time it ran, so ssh refused on *"Identity file … not
// accessible"* and the proof went red without the probe ever
// reaching the container. That is a red for the wrong reason,
// which is the same defect as a green for the wrong reason: it
// says nothing about whether the clone probe can tell gunnar
// from `sleep 120`.
let key = work.join("id_ed25519");
let kg = Command::new("ssh-keygen")
.args([
"-q",
"-t",
"ed25519",
"-N",
"",
"-C",
"gunnar-xtask-red",
"-f",
])
.arg(&key)
.output()
.map_err(|e| format!("ssh-keygen: {e}"))?;
if !key.exists() {
return Err(format!(
"ssh-keygen produced no key for the red-nothing proof, so it could \
only have failed on the missing file: {}",
String::from_utf8_lossy(&kg.stderr).trim()
));
}
let probe = Serving {
container: ctr.clone(),
volume: String::new(),
port,
key,
ready: None,
rss: None,
why: String::new(),
work: work.clone(),
};
match probe.clone_probe("demo/hello", CONTENT, Probe::CloneOnly) {
Ok(p) if p.served => {
detail = format!(
"the clone probe SUCCEEDED against `sleep 120` with {} object(s)",
p.cloned.len()
)
}
Ok(p) => {
fired = true;
detail = format!("the probe said no: {}", p.detail);
}
Err(e) => {
fired = true;
detail = format!("git refused: {}", first_line(&e));
}
}
let _ = std::fs::remove_dir_all(&work);
}
let _ = podman(&["rm", "-f", &ctr]);
proofs.push(RedProof {
what: "clone probe rejects a container that is not gunnar",
against: "alpine:3 running `sleep 120` on the same published port".to_string(),
fired,
detail,
});
}
Err(e) => {
proofs.push(RedProof {
what: "clone probe rejects a container that is not gunnar",
against: "alpine:3 running `sleep 120`".to_string(),
fired: false,
detail: format!("the fixture could not be started: {e}"),
});
}
}
// ── 3, the FLOOR, against a REAL gunnar serving an EMPTY repository ────────
// `git clone` exits ZERO here and the two object graphs are equal, because
// both are empty. Only `MIN_OBJECTS` can say no, which is the difference
// between this guard and both a status check and a naive diff.
let candidate = measured
.iter()
.find(|(_, m)| m.serves_clone)
.map(|(v, _)| *v);
match candidate {
Some(v) => {
// THE DECLARED TAG, read off the variant. This line used to build a
// name — `format!("localhost/gunnar-{}:dev", v.name)` — which for a
// cell already called `gunnar-scratch` produced
// `localhost/gunnar-gunnar-scratch:dev`. No such image exists, so the
// fixture server could never start and the proof could only ever
// report "never became ready": a guard that cannot fire, in the
// function whose whole job is proving guards fire.
match Serving::start(root, &v.tag, "red-empty")? {
Some(server) if server.ready.is_some() => {
// `push: false` is the whole proof. The repository must
// STAY empty: what is under test is that a `git clone`
// which exits 0 and produces nothing is still rejected,
// and the expected bytes are the same `CONTENT` every
// other call expects, so the comparison is the identical
// one, only the server's answer differs.
let r = server.clone_probe("demo/empty", CONTENT, Probe::CloneOnly);
let (fired, detail) = match r {
Ok(p) if p.served => (
false,
format!(
"the probe reported the empty repository as SERVED ({} objects)",
p.cloned.len()
),
),
Ok(p) => (
// The red must be the FLOOR's red, not any red. A
// probe that said no because git errored would prove
// it can fail, not that it looks past exit code 0.
p.cloned.is_empty(),
format!(
"git clone exited 0 and the probe still said no: {}",
p.detail
),
),
Err(e) => (
false,
format!(
"git itself failed, so the floor was never reached: {}",
first_line(&e)
),
),
};
server.tear_down();
proofs.push(RedProof {
what: "clone probe looks past the exit code (the non-vacuity floor)",
against: format!("{} serving demo/empty, `git clone` exits 0", v.name),
fired,
detail,
});
}
other => {
if let Some(s) = other {
s.tear_down();
}
proofs.push(RedProof {
what: "clone probe looks past the exit code (the non-vacuity floor)",
against: "an empty repository on a real gunnar".to_string(),
fired: false,
detail: "the fixture server never became ready".to_string(),
});
}
}
}
None => proofs.push(RedProof {
what: "clone probe looks past the exit code (the non-vacuity floor)",
against: "an empty repository on a real gunnar".to_string(),
fired: false,
detail: "no variant served a clone, so there was nothing to point it at".to_string(),
}),
}
// ── 4, the GRAPH COMPARISON itself, seen red ──────────────────────────────
// The three proofs above exercise the scan, the probe's ability to fail,
// and the floor. NOT the oid-by-oid comparison: in every green run its two
// sides agree, and a comparison that has only ever seen inputs it accepts
// is indistinguishable from `assert!(true)`. So the source is given three
// objects the server was never sent, AFTER the push. The clone is a
// perfectly good 3-object clone — it clears the floor and hello.txt is
// byte-correct — so neither of the other two checks can say no. Only the
// comparison can.
match candidate {
Some(v) => match Serving::start(root, &v.tag, "red-orphan")? {
Some(server) if server.ready.is_some() => {
let r = server.clone_probe("demo/hello", CONTENT, Probe::PushThenOrphan);
let (fired, detail) = match r {
Ok(p) if p.served => (
false,
format!(
"the probe reported {} pushed / {} cloned as SERVED",
p.pushed.as_ref().map(Vec::len).unwrap_or(0),
p.cloned.len()
),
),
Ok(p) => {
// The red must be the COMPARISON's red. A clone that came
// back under the floor, or wrong bytes, would also be a
// `served: false` and would prove nothing about the graph.
let over_floor = p.cloned.len() >= MIN_OBJECTS;
let differ = p
.pushed
.as_ref()
.is_some_and(|src| src.len() > p.cloned.len());
(over_floor && differ, p.detail)
}
Err(e) => (
false,
format!(
"git itself failed, so the comparison was never reached: {}",
first_line(&e)
),
),
};
server.tear_down();
proofs.push(RedProof {
what: "the oid-by-oid comparison rejects a graph that is short an object",
against: format!(
"{}: three objects committed to the source AFTER the push, so the \
server cannot return them",
v.name
),
fired,
detail,
});
}
other => {
if let Some(s) = other {
s.tear_down();
}
proofs.push(RedProof {
what: "the oid-by-oid comparison rejects a graph that is short an object",
against: "a real gunnar with an orphaned source commit".to_string(),
fired: false,
detail: "the fixture server never became ready".to_string(),
});
}
},
None => proofs.push(RedProof {
what: "the oid-by-oid comparison rejects a graph that is short an object",
against: "a real gunnar with an orphaned source commit".to_string(),
fired: false,
detail: "no variant served a clone, so there was nothing to point it at".to_string(),
}),
}
Ok(proofs)
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or("").trim().to_string()
}
// ── the report ──────────────────────────────────────────────────────────────
/// Bytes, grouped, and NOT rounded to MB. `15.1 MB` is the same string for
/// 15,066,088 and for 15,083,583 bytes, and those are two different images: the
/// size column is a measurement of the artifact, so it prints the measurement.
fn bytes(n: u64) -> String {
let d = n.to_string();
let mut out = String::new();
for (i, c) in d.chars().enumerate() {
if i > 0 && (d.len() - i) % 3 == 0 {
out.push(' ');
}
out.push(c);
}
out
}
pub fn render(measured: &[(&Variant, Measured)], reds: Option<&[RedProof]>) -> String {
let mut s = String::new();
let _ = writeln!(s, "\n══ the appliance image ══\n");
// No `libc` column any more, and its absence is the finding it recorded.
// The images used to differ in libc as well as in base, so the size column
// compared two different binaries on five floors. Collapsing every cell onto
// the one static `gunnar-server` artifact is what made the column mean
// anything — and then made the five extra floors not worth declaring.
let _ = writeln!(
s,
"{:<20} {:>11} {:>7} {:>6} {:>6} {:>8} {:>9} {:>6} {:>7}",
"image", "size", "files", "shell", "pkg", "starts", "ready", "objs", "clone"
);
let _ = writeln!(s, "{}", "-".repeat(92));
for (v, m) in measured {
let _ = writeln!(
s,
"{:<20} {:>11} {:>7} {:>6} {:>6} {:>8} {:>9} {:>6} {:>7}",
m.name,
bytes(m.size_bytes),
m.files,
if m.shells.is_empty() { "none" } else { "YES" },
if m.pkg_managers.is_empty() {
"none"
} else {
"YES"
},
if m.starts { "yes" } else { "NO" },
m.ready
.map(|d| format!("{} ms", d.as_millis()))
.unwrap_or_else(|| "-".into()),
m.objects,
if m.serves_clone { "yes" } else { "NO" },
);
let _ = v;
}
let _ = writeln!(s);
for (v, m) in measured {
let _ = writeln!(s, " {} — {}", m.name, m.base);
let _ = writeln!(s, " rss at rest : {}", m.rss.as_deref().unwrap_or("-"));
let _ = writeln!(s, " carries : {}", m.expected_freight);
if !m.shells.is_empty() {
let _ = writeln!(s, " shells : {}", m.shells.join(" "));
}
if !m.pkg_managers.is_empty() {
let _ = writeln!(s, " pkg manager : {}", m.pkg_managers.join(" "));
}
if v.claims_no_shell && !m.shells.is_empty() {
let _ = writeln!(
s,
" ⛔ CLAIMS no shell and HAS one, the claim is false"
);
}
let _ = writeln!(s, " graph : {}", m.graph);
for n in &m.notes {
let _ = writeln!(s, " note : {n}");
}
let _ = writeln!(s);
}
if let Some(reds) = reds {
let _ = writeln!(s, "══ the guards, seen red ══\n");
for p in reds {
let _ = writeln!(
s,
" [{}] {}\n against: {}\n {}",
if p.fired {
"RED, as required"
} else {
"DID NOT FIRE"
},
p.what,
p.against,
p.detail
);
}
let _ = writeln!(s);
}
s
}
#[cfg(test)]
mod tests {
use super::*;
/// A scratch checkout holding one declaration, removed on drop.
struct Scratch(PathBuf);
impl Scratch {
fn with_declaration(name: &str, text: &str) -> Scratch {
let root =
std::env::temp_dir().join(format!("gunnar-images-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join(".nornir")).unwrap();
std::fs::write(root.join(DECLARATION), text).unwrap();
Scratch(root)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn measured(name: &str, shells: &[&str], starts: bool, serves: bool) -> Measured {
Measured {
name: name.to_string(),
tag: format!("{name}:1"),
base: "scratch".to_string(),
size_bytes: 15_066_088,
files: 1,
shells: shells.iter().map(|s| s.to_string()).collect(),
pkg_managers: Vec::new(),
starts,
ready: starts.then(|| Duration::from_millis(12)),
rss: None,
serves_clone: serves,
objects: if serves { 3 } else { 0 },
graph: "3 objects, oid for oid".to_string(),
expected_freight: "nothing: the image is one file",
notes: vec!["a note".to_string()],
}
}
fn variant(name: &str, claims_no_shell: bool) -> Variant {
Variant {
name: name.to_string(),
base: "scratch".to_string(),
tag: format!("{name}:1"),
claims_no_shell,
expected_freight: "nothing: the image is one file",
}
}
/// The repository's own declaration is what the task measures: one image,
/// claimed shell-free, and a `[[build.variant]]`'s `name` is not taken for one.
#[test]
fn the_declared_image_is_read_out_of_the_declaration() {
let v = load_variants(&crate::guard::workspace_root()).expect("the declaration loads");
let names: Vec<&str> = v.iter().map(|v| v.name.as_str()).collect();
assert_eq!(names, ["gunnar-scratch"]);
assert_eq!(v[0].base, "scratch");
assert!(v[0].tag.starts_with("gunnar-scratch:"), "{}", v[0].tag);
assert!(v[0].claims_no_shell);
}
/// The scan takes the first value of each key inside a cell and nothing from
/// the sub-tables that follow it.
#[test]
fn a_sub_table_cannot_rename_an_image() {
let s = Scratch::with_declaration(
"subtable",
"[[build.variant]]\nname = \"not-an-image\"\n\n\
[[build.container]]\nname = \"gunnar-scratch\"\nbase = \"scratch\"\n\
tags = [\"gunnar-scratch:9.9.9\", \"gunnar-scratch:latest\"]\n\
[build.container.env]\nname = \"impostor\"\nbase = \"debian\"\n",
);
let v = load_variants(&s.0).unwrap();
assert_eq!(v.len(), 1);
assert_eq!(
(v[0].name.as_str(), v[0].base.as_str(), v[0].tag.as_str()),
("gunnar-scratch", "scratch", "gunnar-scratch:9.9.9")
);
}
/// Every incomplete cell is refused by what it lacks, and so is a declaration
/// that declares nothing: a run over no image must never print a passing table.
#[test]
fn an_incomplete_or_empty_declaration_is_refused_by_name() {
let cases = [
("noname", "[[build.container]]\nbase = \"scratch\"\n", "has no name"),
("nobase", "[[build.container]]\nname = \"gunnar-scratch\"\ntags = [\"t:1\"]\n", "declares no base"),
("notags", "[[build.container]]\nname = \"gunnar-scratch\"\nbase = \"scratch\"\n", "declares no tags"),
("unclaimed", "[[build.container]]\nname = \"gunnar-debian\"\nbase = \"debian\"\ntags = [\"d:1\"]\n", "asserts nothing about"),
("empty", "[[build.variant]]\nname = \"gunnar-server\"\n", "declares no [[build.container]]"),
];
for (name, text, want) in cases {
let s = Scratch::with_declaration(name, text);
match load_variants(&s.0) {
Ok(v) => panic!("{name}: accepted {} image(s)", v.len()),
Err(e) => assert!(e.contains(want), "{name}: {e}"),
}
}
let missing =
std::env::temp_dir().join(format!("gunnar-images-none-{}", std::process::id()));
let e = load_variants(&missing)
.err()
.expect("no declaration at all");
assert!(e.contains("cannot read the image declaration"), "{e}");
}
#[test]
fn a_quoted_value_belongs_to_its_own_key_only() {
assert_eq!(
quoted_value("name = \"gunnar\"", "name").as_deref(),
Some("gunnar")
);
assert_eq!(
quoted_value("name=\"tight\"", "name").as_deref(),
Some("tight")
);
assert_eq!(quoted_value("names = \"other\"", "name"), None);
assert_eq!(quoted_value("base = \"scratch\"", "name"), None);
assert_eq!(quoted_value("name = unquoted", "name"), None);
}
/// A shell is found by its file name wherever it sits, once, and a name that
/// only contains one is not a shell.
#[test]
fn a_shell_is_matched_by_its_file_name() {
let paths: Vec<String> = [
"./bin/sh",
"bin/sh",
"usr/bin/bash",
"usr/share/doc/bash-completion",
"etc/zshrc",
"usr/bin/apt",
]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(matching(&paths, SHELL_NAMES), ["/bin/sh", "/usr/bin/bash"]);
assert_eq!(matching(&paths, PKG_NAMES), ["/usr/bin/apt"]);
assert!(matching(&[], SHELL_NAMES).is_empty());
}
#[test]
fn sizes_are_printed_exactly_in_groups_of_three() {
assert_eq!(bytes(0), "0");
assert_eq!(bytes(999), "999");
assert_eq!(bytes(1000), "1 000");
assert_eq!(bytes(15_066_088), "15 066 088");
assert_eq!(bytes(15_083_583), "15 083 583");
assert_eq!(first_line(" one \ntwo"), "one");
assert_eq!(first_line(""), "");
}
/// The verdict: an image passes only if it starts, serves the clone, and a
/// shell-free claim holds.
#[test]
fn a_false_no_shell_claim_fails_the_image() {
assert!(measured("a", &[], true, true).ok(true));
assert!(
!measured("a", &["/bin/sh"], true, true).ok(true),
"the claim is false"
);
assert!(
measured("a", &["/bin/sh"], true, true).ok(false),
"nothing was claimed"
);
assert!(
!measured("a", &[], false, true).ok(true),
"it never started"
);
assert!(
!measured("a", &[], true, false).ok(true),
"it served no clone"
);
}
/// The report names what failed, row by row, and prints the red-first proofs
/// with whether each one fired.
#[test]
fn the_report_names_a_false_claim_and_a_guard_that_did_not_fire() {
let good = variant("gunnar-scratch", true);
let bad = variant("gunnar-liar", true);
let rows = vec![
(&good, measured("gunnar-scratch", &[], true, true)),
(&bad, measured("gunnar-liar", &["/bin/sh"], false, false)),
];
let reds = vec![
RedProof {
what: "a shell in a no-shell image",
against: "gunnar-liar".to_string(),
fired: true,
detail: "found /bin/sh".to_string(),
},
RedProof {
what: "the object floor",
against: "gunnar-scratch".to_string(),
fired: false,
detail: "3 objects".to_string(),
},
];
let page = render(&rows, Some(&reds));
assert!(page.contains("15 066 088"), "{page}");
assert!(page.contains("12 ms"), "{page}");
assert!(page.contains("CLAIMS no shell and HAS one"), "{page}");
assert!(page.contains("shells : /bin/sh"), "{page}");
assert!(page.contains("note : a note"), "{page}");
assert!(
page.contains("[RED, as required] a shell in a no-shell image"),
"{page}"
);
assert!(page.contains("[DID NOT FIRE] the object floor"), "{page}");
let liar = page
.lines()
.find(|l| l.starts_with("gunnar-liar"))
.expect("its row");
assert!(liar.contains("YES") && liar.contains("NO"), "{liar}");
let quiet = render(&rows[..1], None);
assert!(!quiet.contains("the guards, seen red"), "{quiet}");
assert!(!quiet.contains("CLAIMS no shell"), "{quiet}");
}
}