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

nordisk/gunnar-ui

crates/gunnar-ui-server/tests/airgap_default_build.rs

//! **AIRGAP: gunnar-ui-server's default build links only the approved outbound clients**
//! (owner rule 2026-10-25: "the DEFAULT build is the AIRGAP install").
//!
//! The same guard as gunnar's `crates/gunnar-recipe/tests/airgap_default_builds.rs`, for the
//! console: its 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, only where
//! [`ALLOW`] names it, with its one-line reason. It fails on a new outbound crate and on an
//! allowance whose crate has gone. The shop relay (monetize-site-buy) is held out of the
//! default build by `default_build_links_no_shop.rs` (G-REDS); this guard does not repeat it.

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",
];

/// **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-ui's root, package)`.
const BUILDS: &[(&str, &str, Option<&str>)] = &[("gunnar-ui-server", "Cargo.toml", Some("gunnar-ui-server"))];

const TONIC: &str = "tonic client: own twin or own server (core)";

/// The approved exceptions: `(build, deny entry, reason)`.
const ALLOW: &[(&str, &str, &str)] = &[
    ("gunnar-ui-server", "tonic/channel", TONIC),
    ("gunnar-ui-server", "hyper/client", TONIC),
    ("gunnar-ui-server", "hyper-util/client-legacy", TONIC),
    ("gunnar-ui-server", "ureq", "edda nornir-docbook's remote-image fetch: linked, never called (every export passes no cache dir); edda has no feature to drop it"),
];

fn root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().expect("gunnar-ui'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 v2.12.1\n");
    let f = findings("gunnar-ui-server", &p);
    assert_eq!(f.len(), 1, "{f:?}");
    assert!(f[0].contains("`reqwest`"), "{f:?}");
}

#[test]
fn a_client_allowed_in_one_build_is_refused_in_another() {
    assert!(findings("gunnar-ui-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-ui-server", &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-ui-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");
    }
}