Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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");
    }
}