crates/gunnar-recipe/tests/airgap_default_builds.rs
//! **AIRGAP: Gunnar's default builds link only the approved outbound clients** (owner rule
//! 2026-10-25: "the DEFAULT build is the AIRGAP install").
//!
//! Each installable binary's cargo-DEFAULT build (`cargo tree -e normal,features`, offline,
//! `--locked`) may contain an outbound client crate, or the outbound half of a dual-use crate
//! (`tonic/channel`, `hyper/client`), only where [`ALLOW`] names it for that build, with its
//! one-line reason (approved by the lead lane). The guard fails on a new outbound crate, on a
//! listed one turning up in a build where it is not listed, and on an allowance whose crate
//! has gone (the list stays honest). gunnar-ui-server has its own copy of this guard.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
/// The outbound clients a default build may not link unless [`ALLOW`] names them.
const DENY: &[&str] = &[
"reqwest",
"ureq",
"isahc",
"curl",
"attohttpc",
"surf",
"hyper-rustls",
"hyper-tls",
"lettre",
"async-smtp",
"mail-send",
"instant-acme",
"rustls-acme",
"tokio-rustls-acme",
"hickory-resolver",
"trust-dns-resolver",
"tokio-tungstenite",
"async-nats",
"rumqttc",
"sentry",
"opentelemetry-otlp",
"ldap3",
"gix-transport",
"ssh2",
"git2",
"boringtun",
"tonic/channel",
"hyper/client",
"hyper-util/client-legacy",
// estate-clock's HTTPS Date sources (ureq): a box asks peer and SNTP sources only,
// unless its row names `clock-fetch`.
"estate-clock/fetch",
];
/// **Never in a default (airgap) build, by name, and no allowance can admit them** (owner rule
/// 7, 2026-10-26: "airgap = the DOWNSTREAM builds"): the public site and its SEO (the crate
/// carries IndexNow's ping and the crawler lists), the MCP connector, and the internet pull
/// clients (the forge import, `gunnar-forge`). They live behind their default-off features
/// (`public-site`, `mcp`, `forges`), and so do those features themselves.
const NEVER: &[&str] = &[
"gunnar-public-site",
"gunnar-mcp",
"gunnar-forge",
"gunnar-ui-server/public-site",
"gunnar-ui-server/mcp",
"gunnar-ui-server/forges",
"gunnar-front/public-site",
"gunnar-front/mcp",
"gunnar-front/forges",
];
/// `(label, manifest relative to gunnar's root, package)`: every binary a customer installs.
const BUILDS: &[(&str, &str, Option<&str>)] = &[
// The products (GUN-PRODUCTS, 2026-10-30): `cargo install gunnar` and `cargo install
// gunnar-cli` at their defaults. (The console's guard is gunnar-ui's own.)
("gunnar", "Cargo.toml", Some("gunnar")),
("gunnar-server", "Cargo.toml", Some("gunnar-server")),
("gunnar-cli", "Cargo.toml", Some("gunnar-cli")),
("gunnar-front", "crates/gunnar-front/Cargo.toml", None),
];
const TONIC: &str = "tonic client: own twin or own server (core)";
const CONTROL: &str = "tonic client: the control plane of the server named at --at (core)";
/// The approved exceptions: `(build, deny entry, reason)`.
const ALLOW: &[(&str, &str, &str)] = &[
("gunnar-server", "tonic/channel", TONIC),
("gunnar-server", "hyper/client", TONIC),
("gunnar-server", "hyper-util/client-legacy", TONIC),
("gunnar-cli", "gix-transport", "gunnar-cli's git-client stack: clone/fetch/push to the remote the user names (core)"),
// The product `gunnar-cli` carries the control plane since GUN-PRODUCTS (2026-10-30):
// its gRPC client dials the one server the person names at `--at`.
("gunnar-cli", "tonic/channel", CONTROL),
("gunnar-cli", "hyper/client", CONTROL),
("gunnar-cli", "hyper-util/client-legacy", CONTROL),
("gunnar", "tonic/channel", TONIC),
("gunnar", "hyper/client", TONIC),
("gunnar", "hyper-util/client-legacy", TONIC),
("gunnar-front", "tonic/channel", TONIC),
("gunnar-front", "hyper/client", TONIC),
("gunnar-front", "hyper-util/client-legacy", TONIC),
("gunnar-front", "boringtun", "the estate's WireGuard to its own boxes (core of the front)"),
("gunnar-front", "ureq", "gunnar-server's library (mirror fetch, SSO), linked for the PID-1 and pairing duties; the front never runs them"),
];
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("gunnar's root")
}
/// The crates and `crate/feature` pairs in a `cargo tree -e normal,features --prefix none` listing.
fn present(tree: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for line in tree.lines() {
let mut w = line.split_whitespace();
let Some(name) = w.next() else { continue };
match w.next() {
Some("feature") => {
if let Some(f) = w.next() {
out.insert(format!("{name}/{}", f.trim_matches('"')));
}
out.insert(name.to_owned());
}
Some(v) if v.starts_with('v') => {
out.insert(name.to_owned());
}
_ => {}
}
}
out
}
fn findings(label: &str, present: &BTreeSet<String>) -> Vec<String> {
let allowed: BTreeSet<&str> = ALLOW
.iter()
.filter(|(l, _, _)| *l == label)
.map(|(_, d, _)| *d)
.collect();
let mut out = Vec::new();
for n in NEVER {
if present.contains(*n) {
out.push(format!("{label}: the default build links `{n}`, which an airgap build may NEVER contain (owner rule 7)"));
}
}
for d in DENY {
if present.contains(*d) && !allowed.contains(d) {
out.push(format!("{label}: the default build links the outbound client `{d}`, and no approved reason allows it there"));
}
}
for a in allowed {
if !present.contains(a) {
out.push(format!(
"{label}: `{a}` is allowed but no longer in the default build; drop it from ALLOW"
));
}
}
out
}
fn tree(manifest: &str, package: Option<&str>) -> Result<String, String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
cmd.current_dir(root()).args([
"tree",
"--offline",
"--locked",
"-e",
"normal,features",
"--prefix",
"none",
"--manifest-path",
manifest,
]);
if let Some(p) = package {
cmd.args(["-p", p]);
}
let out = cmd
.output()
.map_err(|e| format!("{manifest}: cargo tree did not run: {e}"))?;
if !out.status.success() {
return Err(format!(
"{manifest}: cargo tree failed: {}",
String::from_utf8_lossy(&out.stderr)
));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
#[test]
fn every_default_build_links_only_the_approved_outbound_clients() {
let mut bad = Vec::new();
for (label, manifest, package) in BUILDS {
match tree(manifest, *package) {
Ok(t) => bad.extend(findings(label, &present(&t))),
Err(e) => bad.push(e),
}
}
assert!(
bad.is_empty(),
"a default build is not an airgap build:\n{}",
bad.join("\n")
);
}
#[test]
fn an_unlisted_client_is_refused_and_a_listed_one_is_not() {
let p = present("reqwest v0.13.5\ntonic feature \"channel\"\nhyper feature \"client\"\nhyper-util feature \"client-legacy\"\nureq v3.4.2\n");
let f = findings("gunnar-server", &p);
// A32-18: gunnar-server's default build links no ureq, so ureq there is refused too.
assert_eq!(f.len(), 2, "{f:?}");
assert!(f.iter().any(|x| x.contains("`reqwest`")), "{f:?}");
assert!(f.iter().any(|x| x.contains("`ureq`")), "{f:?}");
}
#[test]
fn a_client_allowed_in_one_build_is_refused_in_another() {
assert!(findings("gunnar-server", &present("boringtun v0.7.1\n"))
.iter()
.any(|f| f.contains("`boringtun`")));
}
/// ★ Owner rule 7: a fixture that adds one of the NEVER crates or features to a default build
/// is refused BY NAME, whatever the allowances say.
#[test]
fn the_public_site_mcp_and_the_forge_import_are_refused_by_name() {
for (line, name) in [
("gunnar-public-site v2.0.0\n", "gunnar-public-site"),
("gunnar-mcp v2.0.0\n", "gunnar-mcp"),
("gunnar-forge v1.4.0\n", "gunnar-forge"),
(
"gunnar-ui-server feature \"public-site\"\n",
"gunnar-ui-server/public-site",
),
("gunnar-ui-server feature \"mcp\"\n", "gunnar-ui-server/mcp"),
(
"gunnar-ui-server feature \"forges\"\n",
"gunnar-ui-server/forges",
),
] {
let f = findings("gunnar-front", &present(line));
assert!(
f.iter()
.any(|f| f.contains(&format!("`{name}`")) && f.contains("NEVER")),
"{name}: {f:?}"
);
}
for n in NEVER {
assert!(
!ALLOW.iter().any(|(_, d, _)| d == n),
"{n} may never be allowed"
);
}
}
#[test]
fn a_stale_allowance_is_a_finding() {
assert!(findings("gunnar-server", &BTreeSet::new())
.iter()
.any(|f| f.contains("no longer")));
}
#[test]
fn every_allowance_names_a_denied_entry_a_build_and_a_reason() {
for (l, d, r) in ALLOW {
assert!(DENY.contains(d), "{l}: {d} is not denied");
assert!(BUILDS.iter().any(|(b, _, _)| b == l), "{l}: no such build");
assert!(!r.trim().is_empty(), "{l}/{d}: no reason");
}
}