uitest/src/rig.rs
//! The rig: a gunnar server and a console, both on loopback, both this
//! process's children, both gone when it exits.
//!
//! Why a rig and not the live site. The matrix mints invites, revokes
//! credentials, creates and deletes repositories, and — the situation that
//! matters most — proves that the break-glass admin token STOPS being accepted
//! once an admin passkey exists. None of that is something to do to a running
//! site, and all of it is exactly what nobody tests by hand.
//!
//! Shelling out to `gunnar-cli`, `git` and `ssh-keygen` is the same exemption
//! `sitetest/` records: the pure-Rust law is about PRODUCT code, and here those
//! binaries ARE the thing under test — the tools a person actually runs.
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::http;
/// The product the bench monetize sells: a selling console names it
/// (`GUNNAR_UI_PRODUCT`, OM-9 — no default product).
const BENCH_PRODUCT: &str = "gunnar";
pub struct Rig {
pub work: PathBuf,
pub ssh: u16,
pub grpc: u16,
pub ui: u16,
pub token: String,
pub admin_key: PathBuf,
/// A key registered with NOTHING, so a situation can hand it a grant and
/// then make stock git prove the grant is real.
pub tenant_key: PathBuf,
pub cli: String,
/// Behind a lock so `restart_server` can take `&self`: every situation in
/// the matrix is handed a `&Rig`, and threading `&mut` through all of them
/// to stop one process would be a large change for a small reason.
server: Mutex<Option<Child>>,
console: Option<Child>,
/// **The bench monetize with the FAKE Stripe** (`--bench-monetize`), when
/// the rig runs one: the metered console sells through it, and its stripe
/// vendor talks to an in-process `FakeStripe` — never Stripe, never a live
/// key. `None` = a community rig, and every buy row says it did not run.
pub bench: Option<String>,
/// The bench's control door (`<endpoint>.control`): the vendor's side of
/// a checkout — pay it, expire it — and a cap granted at once.
pub bench_control: Option<String>,
bench_child: Option<Child>,
/// The bench monetize's signing key (hex), handed to gunnar on every
/// start once the bench is up.
monetize_pubkey: Option<String>,
/// **The fake company identity provider** (`--fake-idp`, company SSO):
/// gunnar-server's `fake_idp` example — a fake OpenID provider and a fake
/// SAML IdP on loopback, never a real one. `None`: the SSO rows say they
/// did not run.
pub fake_idp: Option<FakeIdp>,
fake_idp_child: Option<Child>,
/// **A SOLD (provided) Gunnar's console** (one shop, 2026-10-07): a second
/// console beside the first — the same gunnar, the same monetize, its own
/// state and port — told it is acme's, sold by `https://gunnar.rs`
/// (`GUNNAR_UI_PROVIDER`). Started by [`Rig::start_provided`].
provided: Option<(u16, Child)>,
/// What the first console was started with, for the second.
console_with: Option<ConsoleWith>,
/// What the bench monetize was started with (its binary, the appliance's
/// key), for a second bench beside it.
bench_with: Option<(String, String)>,
/// **A console whose monetize sells NO private estates** (the sign-up
/// page, owner 2026-10-08: test2's monetize has no `[estate_orders]`): a
/// second bench monetize started `--no-estates`, and a console over it.
/// Started by [`Rig::start_unsold`]: `(port, console, bench)`.
unsold: Option<(u16, Child, Child)>,
/// **A console whose monetize has `[estate_orders]` over a cloud that
/// lays no estate** (round 5.3, test2 2026-10-08: every step of the
/// ladder unavailable, "the upcloud cloud cannot lay an estate"): a third
/// bench started `--estates-unlaid`, and a console over it. Started by
/// [`Rig::start_unlaid`]: `(port, console, bench)`.
unlaid: Option<(u16, Child, Child)>,
keep: bool,
}
/// What [`Rig::up`] started the console with: the second one copies it.
struct ConsoleWith {
ui_bin: String,
ui_dir: Option<PathBuf>,
ui_key: PathBuf,
guest_key: PathBuf,
monetize: Option<(String, String)>,
}
/// What the fake identity provider printed when it came up.
#[derive(Debug, Clone)]
pub struct FakeIdp {
pub oidc_issuer: String,
pub client_id: String,
/// The client secret the rig handed it (a test value, never a real one).
pub client_secret: String,
#[allow(dead_code)] // the SAML half is driven by gunnar-server tests/sso.rs and the estate-box check
pub saml_metadata_url: String,
pub control: String,
}
fn free_port() -> Result<u16, String> {
let l = TcpListener::bind("127.0.0.1:0").map_err(|e| format!("bind 0: {e}"))?;
let p = l.local_addr().map_err(|e| format!("local_addr: {e}"))?.port();
Ok(p)
}
/// Run a child and return its output.
///
/// Callers set `current_dir` to the rig's work directory without exception:
/// `gunnar-cli control bootstrap` writes its client certificate and PRIVATE KEY
/// into the current directory, and on 2026-08-30 that meant three `.pem` files
/// appearing inside this crate and being committed by a careless `git add`.
/// They were throwaway material for a rig that no longer exists, but the fix is
/// not to remember to delete them — it is that no child ever runs here.
fn run(what: &str, cmd: &mut Command) -> Result<String, String> {
let out = cmd.output().map_err(|e| format!("{what}: {e}"))?;
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if out.status.success() {
Ok(stdout + &stderr)
} else {
Err(format!("{what} exited {}: {}", out.status, http::cut(&(stdout + &stderr), 400)))
}
}
/// Start `gunnar serve` on the rig's ports. One writer, because the restart
/// below has to produce the SAME server and not a lookalike.
fn spawn_server(cli: &str, work: &Path, ssh: u16, grpc: u16, monetize_pubkey: Option<&str>) -> Result<Child, String> {
let mut cmd = Command::new(cli);
// The bench monetize's public key, when the rig sells through one: a
// metered gunnar refuses every entitlement fact it holds no key for.
match monetize_pubkey {
// …and a metered gunnar holding a monetize key refuses to start
// without the page a lapsed tenant is sent to.
Some(k) => cmd.env("GUNNAR_MONETIZE_PUBKEY", k).env("GUNNAR_BILLING_URL", "http://localhost/account/storage?tenant={tenant}"),
None => cmd.env_remove("GUNNAR_MONETIZE_PUBKEY").env_remove("GUNNAR_BILLING_URL"),
};
// A twin that stops polling is SILENT after this bound (gunnar's own,
// shortened for the rig): the live Machine card's row waits for gunnar's
// verdict, never decides one (`a-twin-that-stops-goes-red-in-the-open-card`).
cmd.env("GUNNAR_HEARTBEAT_SILENT_MS", "5000");
// Company SSO (2026-10-05): the rig's fake identity provider listens on
// loopback over plain http; gunnar reaches it only through this test door.
cmd.env("GUNNAR_SSO_ALLOW_LOOPBACK_HTTP", "1");
// …and its network is ON (A34-16: off, airgap, unless the operator turns it on), with the
// rig's own resolver named for a domain's DNS proof (A34-5, RA5-24: none is a default).
cmd.env("GUNNAR_SSO_EGRESS", "on");
cmd.env("GUNNAR_SSO_DNS_RESOLVER", crate::doh::url()?);
cmd
.current_dir(work)
.args(["serve", "--data-root"])
.arg(work.join("data"))
.args([
"--ssh-bind",
&format!("127.0.0.1:{ssh}"),
"--grpc-bind",
&format!("127.0.0.1:{grpc}"),
])
// Appended, not truncated: a restart must not throw away the log of
// the run before it, which is where the reason for a restart failure
// would be.
.stdout(Stdio::from(
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(work.join("server.log"))
.map_err(|e| e.to_string())?,
))
.stderr(Stdio::from(
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(work.join("server.err"))
.map_err(|e| e.to_string())?,
))
.spawn()
.map_err(|e| format!("spawn {cli} serve: {e}"))
}
/// Wait until gunnar answers on the control plane.
///
/// `control status` answers with no credential, so it is the honest readiness
/// probe: it is the same call a stranger makes.
fn wait_for_server(cli: &str, work: &Path, at: &str) -> Result<(), String> {
// 30 s, or `GUNNAR_UITEST_START_S` on a build host shared with other
// work (MEASURED 2026-10-17 on dwarf at load 35: 32 s from the layout
// line to the sealed secret store on a first start).
let wait = std::env::var("GUNNAR_UITEST_START_S").ok().and_then(|v| v.parse().ok()).unwrap_or(30);
let deadline = Instant::now() + Duration::from_secs(wait);
loop {
if run(
"control status",
Command::new(cli).current_dir(work).args(["control", "status", "--at", at]),
)
.is_ok()
{
return Ok(());
}
if Instant::now() > deadline {
return Err(format!(
"gunnar did not answer on {at} within {wait} s: {}",
http::cut(&std::fs::read_to_string(work.join("server.err")).unwrap_or_default(), 300)
));
}
std::thread::sleep(Duration::from_millis(200));
}
}
impl Rig {
pub fn up(cli: &str, ui_bin: &str, ui_dir: Option<&Path>, work: &Path, keep: bool, bench_bin: Option<&str>) -> Result<Rig, String> {
// Every run starts from nothing, because half the matrix is only true
// once: a server can be bootstrapped exactly once, ever, and the admin
// token retires the first time an admin passkey exists. Reusing a work
// directory turns both of those situations into failures that look
// like bugs.
//
// The marker is the safety catch: this deletes a directory, so it
// refuses any directory it did not create itself.
let marker = work.join(".gunnar-uitest");
if work.exists() {
if !marker.exists() {
return Err(format!(
"{} already exists and was not made by gunnar-uitest; \
point --work somewhere else rather than have this delete it",
work.display()
));
}
std::fs::remove_dir_all(work).map_err(|e| format!("clearing {}: {e}", work.display()))?;
}
std::fs::create_dir_all(work.join("data")).map_err(|e| format!("work dir: {e}"))?;
std::fs::write(&marker, "gunnar-uitest scratch\n").map_err(|e| format!("marker: {e}"))?;
let (ssh, grpc, ui) = (free_port()?, free_port()?, free_port()?);
let admin_key = work.join("admin");
let ui_key = work.join("ui-id");
let tenant_key = work.join("tenant");
// The GUEST: the grantless principal the console serves strangers as.
// A real deployment has one (gunnar.rs does), and without it the
// console redirects every visitor without a session to /login, so
// nothing a stranger sees — the repository list, a public graph, the
// top bar's "Log in" button — can be exercised here. The rig ran
// without one until 2026-09-10, which is why the button that did
// nothing on gunnar.rs was never red in this suite.
let guest_key = work.join("guest");
for k in [&admin_key, &ui_key, &tenant_key, &guest_key] {
let _ = std::fs::remove_file(k);
let _ = std::fs::remove_file(k.with_extension("pub"));
run(
"ssh-keygen",
Command::new("ssh-keygen")
.current_dir(work)
.args(["-q", "-t", "ed25519", "-N", "", "-C", "gunnar-uitest", "-f"])
.arg(k),
)?;
}
// ★ **At least 32 characters, and that is the SERVER's rule, not a
// preference here.** `uitest-<pid>` was thirteen or fourteen, and the
// console refuses to start on a short break-glass token by name — so
// every rig run died in `Rig::up` with "the console did not serve
// /health within 30 s", thirty seconds of nothing and the real sentence
// buried in the console's own stderr. The API matrix and the whole
// browser half have been unreachable since that check landed.
//
// Unique per run, because two rigs on one box must not share a
// credential, and the pid alone stops being unique the moment one is
// reused: the clock goes in beside it, zero-padded so the LENGTH does
// not depend on how large either number happens to be.
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let token = format!("uitest-{:0>10}-{:0>24}", std::process::id(), nanos);
let server = spawn_server(cli, work, ssh, grpc, None)?;
let mut rig = Rig {
work: work.to_path_buf(),
ssh,
grpc,
ui,
token,
admin_key,
tenant_key,
cli: cli.to_owned(),
server: Mutex::new(Some(server)),
console: None,
bench: None,
bench_control: None,
bench_child: None,
monetize_pubkey: None,
fake_idp: None,
fake_idp_child: None,
provided: None,
console_with: None,
bench_with: None,
unsold: None,
unlaid: None,
keep,
};
let at = format!("127.0.0.1:{grpc}");
wait_for_server(cli, work, &at)?;
run(
"control bootstrap",
Command::new(cli)
.current_dir(work)
.args(["control", "bootstrap", "alice", "-i"])
.arg(&rig.admin_key)
.args(["--at", &at]),
)?;
run(
"control user add guest",
Command::new(cli)
.current_dir(work)
.args(["control", "user", "add", "guest", "--key"])
.arg(format!("@{}", guest_key.with_extension("pub").display()))
.arg("-i")
.arg(&rig.admin_key)
.args(["--at", &at]),
)?;
// ── The bench monetize, when asked for ───────────────────────────
//
// It verifies the appliance's tickets with the appliance's OWN key,
// read off the box the way an operator wires a real monetize
// (`control appliance-identity`), and its checkout returns to this
// console's `/paid/{reference}`.
let mut monetize: Option<(String, String)> = None;
if let Some(bin) = bench_bin {
let key = run(
"control appliance-identity",
Command::new(cli)
.current_dir(work)
.args(["control", "appliance-identity", "-i"])
.arg(&rig.admin_key)
.args(["--at", &at]),
)?;
let key = key
.lines()
.map(str::trim)
.find(|l| l.len() == 64 && l.chars().all(|c| c.is_ascii_hexdigit()))
.ok_or_else(|| format!("appliance-identity printed no key: {}", http::cut(&key, 200)))?
.to_owned();
rig.bench_with = Some((bin.to_owned(), key.clone()));
let bearer = format!("bench-{:0>40}", nanos);
let endpoint_file = work.join("bench-endpoint");
let _ = std::fs::remove_file(&endpoint_file);
let child = Command::new(bin)
.current_dir(work)
.args(["--bearer", &bearer, "--appliance-key", &key])
.args(["--success-url", &format!("http://localhost:{ui}/paid/{{reference}}")])
.arg("--endpoint-file")
.arg(&endpoint_file)
// "Add users": what monetize grants (the room, the users and
// seats) lands on THIS gunnar, signed with the rig's admin key.
.args(["--gunnar", &at, "--gunnar-key"])
.arg(&rig.admin_key)
.stdout(Stdio::from(std::fs::File::create(work.join("bench.log")).map_err(|e| e.to_string())?))
.stderr(Stdio::from(std::fs::File::create(work.join("bench.err")).map_err(|e| e.to_string())?))
.spawn()
.map_err(|e| format!("spawn the bench monetize {bin}: {e}"))?;
rig.bench_child = Some(child);
let deadline = Instant::now() + Duration::from_secs(30);
let endpoint = loop {
if let Ok(e) = std::fs::read_to_string(&endpoint_file) {
if !e.trim().is_empty() {
break e.trim().trim_start_matches("http://").to_owned();
}
}
if Instant::now() > deadline {
return Err(format!(
"the bench monetize did not serve within 30 s: {}",
http::cut(&std::fs::read_to_string(work.join("bench.err")).unwrap_or_default(), 300)
));
}
std::thread::sleep(Duration::from_millis(200));
};
let pubkey = std::fs::read_to_string(endpoint_file.with_extension("pubkey"))
.map_err(|e| format!("the bench monetize wrote no public key: {e}"))?
.trim()
.to_owned();
rig.monetize_pubkey = Some(pubkey);
rig.bench_control = std::fs::read_to_string(endpoint_file.with_extension("control"))
.ok()
.map(|c| c.trim().trim_start_matches("http://").to_owned());
// gunnar again, now holding monetize's key.
rig.restart_server()?;
rig.bench = Some(endpoint.clone());
monetize = Some((endpoint, bearer));
}
let mut enroll = Command::new(ui_bin);
enroll
.current_dir(work)
.args(["enroll", "--admin-key"])
.arg(&rig.admin_key)
.args(["--name", "uitest-console"])
.env("GUNNAR_UI_CONTROL", &at)
.env("GUNNAR_UI_IDENTITY", &ui_key)
.env("GUNNAR_UI_STATE", work.join("state.redb"))
.env_remove("GUNNAR_UI_WEBAUTHN_RP_ID")
.env_remove("GUNNAR_UI_ADMIN_TOKEN");
run("gunnar-ui-server enroll", &mut enroll)?;
let bind = format!("127.0.0.1:{ui}");
let mut console = Command::new(ui_bin);
console
.current_dir(work)
.env("GUNNAR_UI_CONTROL", &at)
.env("GUNNAR_UI_IDENTITY", &ui_key)
.env("GUNNAR_UI_GUEST_IDENTITY", &guest_key)
.env("GUNNAR_UI_STATE", work.join("state.redb"))
.env("GUNNAR_UI_BIND", &bind)
.env("GUNNAR_UI_ADMIN_TOKEN", &rig.token)
// The fake company IdP is on loopback over http: the console's test door
// lets a sign-in send the browser there (as gunnar's, above).
.env("GUNNAR_UI_SSO_ALLOW_LOOPBACK_HTTP", "1")
// `localhost` is a secure context in Chrome, so WebAuthn works
// over plain http here exactly as it does over https on the site.
// It must be a NAME: the console refuses a dotted-quad RP id,
// because WebAuthn's relying-party id is a domain and an IP has
// none — which is why the rig binds 127.0.0.1 but the browser is
// sent to localhost.
.env("GUNNAR_UI_WEBAUTHN_RP_ID", "localhost")
.env("GUNNAR_UI_WEBAUTHN_ORIGIN", format!("http://localhost:{ui}"))
.env("GUNNAR_UI_WEBAUTHN_RP_NAME", "gunnar uitest")
// **Told it is acme's private Gunnar** (the sign-up page,
// 2026-10-07), so the page's "Grow acme's Gunnar" — COMING, no
// button — can be drawn and held. Display only: nothing else in
// the console reads it, and this console still sells as gunnar.rs
// does (its monetize), which is what the page's other modes need.
.env("GUNNAR_UI_INSTANCE", "acme")
.stdout(Stdio::from(std::fs::File::create(work.join("ui.log")).map_err(|e| e.to_string())?))
.stderr(Stdio::from(std::fs::File::create(work.join("ui.err")).map_err(|e| e.to_string())?));
match &monetize {
Some((endpoint, bearer)) => {
console.env("GUNNAR_UI_MONETIZE", endpoint).env("GUNNAR_UI_MONETIZE_TOKEN", bearer).env("GUNNAR_UI_PRODUCT", BENCH_PRODUCT);
}
None => {
console.env_remove("GUNNAR_UI_MONETIZE").env_remove("GUNNAR_UI_MONETIZE_TOKEN");
}
}
if let Some(d) = ui_dir {
console.env("GUNNAR_UI_DIR", d);
} else {
console.env_remove("GUNNAR_UI_DIR");
}
rig.console = Some(console.spawn().map_err(|e| format!("spawn console: {e}"))?);
rig.console_with = Some(ConsoleWith {
ui_bin: ui_bin.to_owned(),
ui_dir: ui_dir.map(Path::to_path_buf),
ui_key: ui_key.clone(),
guest_key: guest_key.clone(),
monetize: monetize.clone(),
});
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Ok(r) = http::req(&bind, "GET", "/health", &[], None) {
if r.status == 200 {
break;
}
}
if Instant::now() > deadline {
return Err(format!(
"the console did not serve /health on {bind} within 30 s: {}",
http::cut(&std::fs::read_to_string(work.join("ui.err")).unwrap_or_default(), 300)
));
}
std::thread::sleep(Duration::from_millis(200));
}
Ok(rig)
}
/// One POST to the bench's control door: `(status, body)`.
pub fn bench_post(&self, path: &str) -> Result<(u16, String), String> {
let at = self.bench_control.as_deref().ok_or("no bench control door (no --bench-monetize, or a bench without one)")?;
let resp = http::req(at, "POST", path, &[], Some(""))?;
Ok((resp.status, resp.body.clone()))
}
pub fn ui_addr(&self) -> String {
format!("127.0.0.1:{}", self.ui)
}
/// What the BROWSER must use. Not interchangeable with [`Rig::ui_addr`]:
/// a passkey is scoped to the origin, so a page opened on 127.0.0.1 could
/// never assert a credential registered on localhost.
pub fn origin(&self) -> String {
format!("http://localhost:{}", self.ui)
}
pub fn at(&self) -> String {
format!("127.0.0.1:{}", self.grpc)
}
/// Stop gunnar and start it again on the same ports and the same data.
///
/// The console is deliberately left running: the whole point is what the
/// BFF does when the server it is subscribed to goes away and comes back
/// with its event sequence beginning at 1 again. Killing the console too
/// would test a fresh page load, which is the case that was never broken.
pub fn restart_server(&self) -> Result<(), String> {
{
let mut held = self.server.lock().unwrap_or_else(|e| e.into_inner());
if let Some(mut server) = held.take() {
server.kill().map_err(|e| format!("stopping gunnar: {e}"))?;
server.wait().map_err(|e| format!("reaping gunnar: {e}"))?;
}
*held = Some(spawn_server(&self.cli, &self.work, self.ssh, self.grpc, self.monetize_pubkey.as_deref())?);
}
wait_for_server(&self.cli, &self.work, &self.at())
}
/// Stop gunnar, sign a transparency tree head over everything pushed so
/// far (`gunnar attest head`), and start it again.
///
/// Out of process, and with the server DOWN, on purpose: the control
/// database takes an exclusive lock, so `attest head` is an operator verb
/// for a quiet moment — this is exactly how an operator runs it. The chain
/// situations need a signed head because an inclusion proof without one is
/// arithmetic the server did about itself, and gunnar keeps the log link a
/// named hole until somebody signs.
pub fn attest_head(&self) -> Result<String, String> {
let signed;
{
let mut held = self.server.lock().unwrap_or_else(|e| e.into_inner());
if let Some(mut server) = held.take() {
server.kill().map_err(|e| format!("stopping gunnar: {e}"))?;
server.wait().map_err(|e| format!("reaping gunnar: {e}"))?;
}
signed = run(
"attest head",
Command::new(&self.cli)
.current_dir(&self.work)
.args(["attest", "head", "--data-root"])
.arg(self.work.join("data")),
)?;
*held = Some(spawn_server(&self.cli, &self.work, self.ssh, self.grpc, self.monetize_pubkey.as_deref())?);
}
wait_for_server(&self.cli, &self.work, &self.at())?;
Ok(signed)
}
/// `gunnar-cli control …` as the bootstrapped admin.
pub fn control(&self, args: &[&str]) -> Result<String, String> {
self.control_as(&self.admin_key, args)
}
/// `gunnar-cli control` as somebody OTHER than the rig's admin.
///
/// The same exemption `git_as` records, and for the same reason: a grant
/// that lists correctly and opens nothing is the failure that matters, and
/// the only way to ask whether gunnar enforces a grant is to make the call
/// with a credential that does not hold it. The admin path above delegates
/// here so there is one place that knows how a control call is spelled.
pub fn control_as(&self, key: &Path, args: &[&str]) -> Result<String, String> {
let mut c = Command::new(&self.cli);
c.current_dir(&self.work).arg("control").args(args).arg("-i").arg(key).args(["--at", &self.at()]);
run("control", &mut c)
}
/// stock `git`, pointed at the rig's SSH port with the rig's admin key.
pub fn git(&self, dir: &Path, args: &[&str]) -> Result<String, String> {
self.git_as(&self.admin_key, dir, args)
}
/// stock `git` as somebody else — the only honest way to ask whether a
/// grant the console just made actually opens anything.
pub fn git_as(&self, key: &Path, dir: &Path, args: &[&str]) -> Result<String, String> {
self.ensure_sha256_target(dir, args);
let mut c = Command::new("git");
c.current_dir(dir).args(args).env(
"GIT_SSH_COMMAND",
format!(
"ssh -i {} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null",
key.display()
),
);
run(&format!("git {}", args.join(" ")), &mut c)
}
/// stock `git` offering NO key at all — gunnar's anonymous door.
///
/// Offering an UNREGISTERED key is a different thing entirely: ssh fails at
/// publickey auth before any repository ACL is consulted, so a row written
/// that way tests key registration and never reaches visibility. The
/// options are the ones `gunnar/sitetest`'s `ssh_cmd_anonymous` already
/// established — one implementation, not a lookalike.
pub fn git_anon(&self, dir: &Path, args: &[&str]) -> Result<String, String> {
let mut c = Command::new("git");
c.current_dir(dir).args(args).env(
"GIT_SSH_COMMAND",
"ssh -o IdentitiesOnly=yes -o IdentityAgent=none -o PubkeyAuthentication=no \
-o PreferredAuthentications=none -o BatchMode=yes -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null",
);
run(&format!("git {}", args.join(" ")), &mut c)
}
pub fn remote_as(&self, user: &str, path: &str) -> String {
format!("ssh://{user}@127.0.0.1:{}/{path}", self.ssh)
}
/// **A SHA-256 working tree asks for its repository by name first.**
///
/// The rig's fixtures are SHA-256 (`git init --object-format=sha256`), and
/// gunnar's first-push door infers SHA-1 — stock `git init` — because git's
/// v1 receive-pack advertises the object format before the client says a
/// word (gunnar-ssh `push_create_spec`). A SHA-256 push to a repository
/// that does not exist yet is therefore refused with "the receiving end
/// does not support this repository's hash algorithm" (MEASURED dwarf
/// 2026-10-01: 19 rows red, the whole walk stopped at the fixture). So the
/// rig does what an operator does and what gunnar's own refusal text says:
/// `repo create <path> --hash sha256` before the push. An "exists" answer
/// is the ordinary case for every push after the first and is ignored; any
/// other refusal is left for the push itself to report.
fn ensure_sha256_target(&self, dir: &Path, args: &[&str]) {
if args.first() != Some(&"push") {
return;
}
let at = format!("127.0.0.1:{}/", self.ssh);
let Some(path) = args.iter().filter(|a| a.starts_with("ssh://")).find_map(|a| a.split_once(&at).map(|(_, p)| p.trim_end_matches(".git").to_owned())) else {
return;
};
let format = Command::new("git").current_dir(dir).args(["rev-parse", "--show-object-format"]).output();
if format.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "sha256").unwrap_or(false) {
let _ = self.control(&["repo", "create", &path, "--hash", "sha256"]);
}
}
pub fn remote(&self, path: &str) -> String {
format!("ssh://alice@127.0.0.1:{}/{path}", self.ssh)
}
}
impl Rig {
/// **Start the fake company identity provider** (`--fake-idp <path>`):
/// read its one line of JSON, keep the child until the rig goes.
pub fn start_fake_idp(&mut self, bin: &str) -> Result<(), String> {
use std::io::BufRead as _;
let secret = format!("uitest-oidc-secret-{}", std::process::id());
let mut child = Command::new(bin)
.current_dir(&self.work)
.args(["--client-id", "uitest-console", "--client-secret", &secret])
.stdout(Stdio::piped())
.stderr(Stdio::from(std::fs::File::create(self.work.join("fake-idp.err")).map_err(|e| e.to_string())?))
.spawn()
.map_err(|e| format!("spawn {bin}: {e}"))?;
let stdout = child.stdout.take().ok_or("the fake IdP has no stdout")?;
let mut line = String::new();
std::io::BufReader::new(stdout).read_line(&mut line).map_err(|e| format!("the fake IdP said nothing: {e}"))?;
let j = crate::json::parse(&line).map_err(|e| format!("the fake IdP's line is not JSON: {e}: {line:?}"))?;
self.fake_idp = Some(FakeIdp {
oidc_issuer: j.text("oidc_issuer"),
client_id: j.text("client_id"),
client_secret: secret,
saml_metadata_url: j.text("saml_metadata_url"),
control: j.text("control"),
});
self.fake_idp_child = Some(child);
Ok(())
}
}
impl Rig {
/// **Start a SOLD (provided) Gunnar's console** beside the first (one
/// shop, 2026-10-07): the same gunnar and monetize, its own state and
/// port, `GUNNAR_UI_PROVIDER=<provider>` and `GUNNAR_UI_INSTANCE=acme`, as
/// the kit feeds a laid estate's front. Its break-glass token is the
/// rig's (its own state holds no admin passkey).
pub fn start_provided(&mut self, provider: &str) -> Result<(), String> {
let w = self.console_with.as_ref().ok_or("the first console was never started")?;
let port = free_port()?;
let bind = format!("127.0.0.1:{port}");
let mut console = Command::new(&w.ui_bin);
console
.current_dir(&self.work)
.env("GUNNAR_UI_CONTROL", self.at())
.env("GUNNAR_UI_IDENTITY", &w.ui_key)
.env("GUNNAR_UI_GUEST_IDENTITY", &w.guest_key)
.env("GUNNAR_UI_STATE", self.work.join("state-provided.redb"))
.env("GUNNAR_UI_BIND", &bind)
.env("GUNNAR_UI_ADMIN_TOKEN", &self.token)
.env("GUNNAR_UI_WEBAUTHN_RP_ID", "localhost")
.env("GUNNAR_UI_WEBAUTHN_ORIGIN", format!("http://localhost:{port}"))
.env("GUNNAR_UI_WEBAUTHN_RP_NAME", "gunnar uitest (sold)")
.env("GUNNAR_UI_INSTANCE", "acme")
.env("GUNNAR_UI_PROVIDER", provider)
.stdout(Stdio::from(std::fs::File::create(self.work.join("ui-provided.log")).map_err(|e| e.to_string())?))
.stderr(Stdio::from(std::fs::File::create(self.work.join("ui-provided.err")).map_err(|e| e.to_string())?));
match &w.monetize {
Some((endpoint, bearer)) => {
console.env("GUNNAR_UI_MONETIZE", endpoint).env("GUNNAR_UI_MONETIZE_TOKEN", bearer).env("GUNNAR_UI_PRODUCT", BENCH_PRODUCT);
}
None => return Err("no monetize behind the rig (--bench-monetize): a sold Gunnar's console runs one".into()),
}
match &w.ui_dir {
Some(d) => console.env("GUNNAR_UI_DIR", d),
None => console.env_remove("GUNNAR_UI_DIR"),
};
let child = console.spawn().map_err(|e| format!("spawn the sold Gunnar's console: {e}"))?;
self.provided = Some((port, child));
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Ok(r) = http::req(&bind, "GET", "/health", &[], None) {
if r.status == 200 {
return Ok(());
}
}
if Instant::now() > deadline {
return Err(format!(
"the sold Gunnar's console did not serve /health on {bind} within 30 s: {}",
http::cut(&std::fs::read_to_string(self.work.join("ui-provided.err")).unwrap_or_default(), 300)
));
}
std::thread::sleep(Duration::from_millis(200));
}
}
/// **Start a console whose monetize sells no private estates** (the
/// sign-up page, owner 2026-10-08): a second bench monetize,
/// `--no-estates` (no `[estate_orders]`, as test2's monetize), on its own
/// endpoint, and a console over it — the same gunnar, its own state and
/// port, a passkey door, so it sells an account and nothing private.
pub fn start_unsold(&mut self) -> Result<(), String> {
let started = self.start_beside_a_bench("unsold", "--no-estates", "sells no private Gunnar")?;
self.unsold = Some(started);
Ok(())
}
/// **Start a console whose monetize's `[estate_orders]` can sell nothing**
/// (round 5.3, test2 2026-10-08): a third bench monetize,
/// `--estates-unlaid` — `[estate_orders]` set over a cloud that lays no
/// estate, so every step of the ladder is unavailable — and a console
/// over it, as [`Rig::start_unsold`]'s.
pub fn start_unlaid(&mut self) -> Result<(), String> {
let started = self.start_beside_a_bench("unlaid", "--estates-unlaid", "lays no private Gunnar")?;
self.unlaid = Some(started);
Ok(())
}
/// A bench monetize started with `flag`, on its own endpoint, and a console
/// over it (files and state named by `tag`), serving: `(port, console, bench)`.
fn start_beside_a_bench(&self, tag: &str, flag: &str, words: &str) -> Result<(u16, Child, Child), String> {
let (bin, key) = self.bench_with.clone().ok_or("no bench monetize behind the rig (--bench-monetize)")?;
let w = self.console_with.as_ref().ok_or("the first console was never started")?;
let port = free_port()?;
let endpoint_file = self.work.join(format!("bench-{tag}-endpoint"));
let _ = std::fs::remove_file(&endpoint_file);
let bearer = format!("bench-{tag}-{}", std::process::id());
let mut bench = Command::new(&bin)
.current_dir(&self.work)
.args(["--bearer", &bearer, "--appliance-key", &key, flag])
.args(["--success-url", &format!("http://localhost:{port}/paid/{{reference}}")])
.arg("--endpoint-file")
.arg(&endpoint_file)
.stdout(Stdio::from(std::fs::File::create(self.work.join(format!("bench-{tag}.log"))).map_err(|e| e.to_string())?))
.stderr(Stdio::from(std::fs::File::create(self.work.join(format!("bench-{tag}.err"))).map_err(|e| e.to_string())?))
.spawn()
.map_err(|e| format!("spawn the bench monetize {flag} {bin}: {e}"))?;
let deadline = Instant::now() + Duration::from_secs(30);
let endpoint = loop {
if let Ok(e) = std::fs::read_to_string(&endpoint_file) {
if !e.trim().is_empty() {
break e.trim().trim_start_matches("http://").to_owned();
}
}
if Instant::now() > deadline {
let _ = bench.kill();
return Err(format!("the bench monetize {flag} did not serve within 30 s: {}", http::cut(&std::fs::read_to_string(self.work.join(format!("bench-{tag}.err"))).unwrap_or_default(), 300)));
}
std::thread::sleep(Duration::from_millis(200));
};
let bind = format!("127.0.0.1:{port}");
let mut console = Command::new(&w.ui_bin);
console
.current_dir(&self.work)
.env("GUNNAR_UI_CONTROL", self.at())
.env("GUNNAR_UI_IDENTITY", &w.ui_key)
.env("GUNNAR_UI_GUEST_IDENTITY", &w.guest_key)
.env("GUNNAR_UI_STATE", self.work.join(format!("state-{tag}.redb")))
.env("GUNNAR_UI_BIND", &bind)
.env("GUNNAR_UI_ADMIN_TOKEN", &self.token)
.env("GUNNAR_UI_WEBAUTHN_RP_ID", "localhost")
.env("GUNNAR_UI_WEBAUTHN_ORIGIN", format!("http://localhost:{port}"))
.env("GUNNAR_UI_WEBAUTHN_RP_NAME", format!("gunnar uitest ({words})"))
.env("GUNNAR_UI_MONETIZE", &endpoint)
.env("GUNNAR_UI_MONETIZE_TOKEN", &bearer)
.env("GUNNAR_UI_PRODUCT", BENCH_PRODUCT)
.env_remove("GUNNAR_UI_INSTANCE")
.env_remove("GUNNAR_UI_PROVIDER")
.stdout(Stdio::from(std::fs::File::create(self.work.join(format!("ui-{tag}.log"))).map_err(|e| e.to_string())?))
.stderr(Stdio::from(std::fs::File::create(self.work.join(format!("ui-{tag}.err"))).map_err(|e| e.to_string())?));
match &w.ui_dir {
Some(d) => console.env("GUNNAR_UI_DIR", d),
None => console.env_remove("GUNNAR_UI_DIR"),
};
let mut child = match console.spawn() {
Ok(c) => c,
Err(e) => {
let _ = bench.kill();
let _ = bench.wait();
return Err(format!("spawn the {tag} console: {e}"));
}
};
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Ok(r) = http::req(&bind, "GET", "/health", &[], None) {
if r.status == 200 {
return Ok((port, child, bench));
}
}
if Instant::now() > deadline {
for c in [&mut child, &mut bench] {
let _ = c.kill();
let _ = c.wait();
}
return Err(format!(
"the {tag} console did not serve /health on {bind} within 30 s: {}",
http::cut(&std::fs::read_to_string(self.work.join(format!("ui-{tag}.err"))).unwrap_or_default(), 300)
));
}
std::thread::sleep(Duration::from_millis(200));
}
}
/// The console that sells no private Gunnar, for an API row.
pub fn unsold_addr(&self) -> Option<String> {
self.unsold.as_ref().map(|(p, _, _)| format!("127.0.0.1:{p}"))
}
/// The console that sells no private Gunnar, for the browser.
pub fn unsold_origin(&self) -> Option<String> {
self.unsold.as_ref().map(|(p, _, _)| format!("http://localhost:{p}"))
}
/// The console whose monetize's cloud lays no estate, for an API row.
pub fn unlaid_addr(&self) -> Option<String> {
self.unlaid.as_ref().map(|(p, _, _)| format!("127.0.0.1:{p}"))
}
/// The console whose monetize's cloud lays no estate, for the browser.
pub fn unlaid_origin(&self) -> Option<String> {
self.unlaid.as_ref().map(|(p, _, _)| format!("http://localhost:{p}"))
}
/// The sold Gunnar's console, for an API row: `127.0.0.1:<port>`.
pub fn provided_addr(&self) -> Option<String> {
self.provided.as_ref().map(|(p, _)| format!("127.0.0.1:{p}"))
}
/// The sold Gunnar's console, for the browser: `http://localhost:<port>`.
pub fn provided_origin(&self) -> Option<String> {
self.provided.as_ref().map(|(p, _)| format!("http://localhost:{p}"))
}
}
impl Drop for Rig {
fn drop(&mut self) {
if let Some((_, c)) = self.provided.as_mut() {
let _ = c.kill();
let _ = c.wait();
}
for (_, console, bench) in [self.unsold.as_mut(), self.unlaid.as_mut()].into_iter().flatten() {
for c in [console, bench] {
let _ = c.kill();
let _ = c.wait();
}
}
let mut server = self.server.lock().unwrap_or_else(|e| e.into_inner());
for c in [self.console.as_mut(), server.as_mut(), self.bench_child.as_mut(), self.fake_idp_child.as_mut()].into_iter().flatten() {
let _ = c.kill();
let _ = c.wait();
}
drop(server);
if !self.keep {
let _ = std::fs::remove_dir_all(self.work.join("data"));
}
}
}