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

nordisk/gunnar

xtask/src/paid_guard.rs

//! **The free server links nothing paid, and a lone clone builds it** (round
//! 5.3.1, owner 2026-10-08).
//!
//! Two CI rows, both in `cargo xtask ci`:
//!
//! * **`free-tree`** — after `appliance-build` built `gunnar-server` with its
//!   default features, `cargo tree -p gunnar-server -e normal,build` must hold
//!   no PAID crate: nothing from `monetize-impl` (Vetra's private
//!   implementation, never published) or a `private-*` ops checkout, and none
//!   of the metered half (`monetize*`, `gunnar-ui*`, `gunnar-front`,
//!   `gunnar-monetize-guest`), which only the metered front may link. Each
//!   offender is named with the path that pulls it in (`cargo tree -i`).
//! * **`lone-clone`** — `git clone` of this commit ALONE (no sibling beside
//!   it), `cargo build --locked`, `cargo install --locked --path crates/gunnar-cli
//!   --features client` and the same for `crates/gunnar`, each binary run, and the
//!   installed server started until `GET /healthz` answers `ok` (G-BOTH-1,
//!   2026-10-26; the steps are `crate::both_ways::lone_steps`). The plain run is RED by
//!   its reason while the committed lock is the estate's; `--stage` (or
//!   `GUNNAR_LONE_STAGE=1`, the ci step `lone-clone-staged`) packages this workspace's
//!   crates from the clone and patches the ones crates.io lacks in with
//!   `--config patch.crates-io.<name>.path=…`.
use std::collections::BTreeSet;
use std::path::Path;
use std::process::Command;

/// **Why the lone-clone row does not run yet**, or `None` once it does. `None` since
/// 2026-10-26 (G-BOTH-1): the row runs on every `cargo xtask ci`. Its plain run takes the
/// committed root lock, which is the crates.io lock since G-B7 (see `crate::both_ways`); the
/// staged run (`lone-clone-staged`) packages this workspace's crates and runs the same
/// steps on them.
pub const LONE_CLONE_PAUSED: Option<&str> = None;

/// The crates of Vetra's private `monetize-impl` workspace (never published;
/// AGPL + commercial, `publish = false`) — paid wherever they turn up.
pub const PAID_NAMES: &[&str] = &[
    "bench_estate_order",
    "bench_monetize",
    "fake_stripe_serve",
    "grow-probe",
    "live_sandbox",
    "live_webhook",
    "monetize-cloud-impl",
    "monetize-front-refresh",
    "monetize-impl-build-thing",
    "monetize-pay-impl",
    "monetize-plugin-holger",
    "monetize-product-njord",
    "monetize-server-vetra",
    "monetize-signup-impl",
    "monetize-tax-impl",
    "upcloud-storm",
];

/// The metered half: linked by the paid front (`gunnar-front --features
/// metered`), never by the free server.
pub fn metered(name: &str) -> bool {
    name.starts_with("monetize")
        || name.starts_with("gunnar-ui")
        || name == "gunnar-front"
        || name == "gunnar-monetize-guest"
}

/// One crate the free server must not link.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Offender {
    pub name: String,
    pub version: String,
    /// Why it is paid, in words.
    pub why: &'static str,
}

/// One `cargo tree --prefix none --format "{p}"` line: `name vX.Y.Z [(source)] [(proc-macro)] [(*)]`.
fn package(line: &str) -> Option<(&str, &str, Option<&str>)> {
    let mut words = line.split_whitespace();
    let name = words.next()?;
    let version = words.next()?.strip_prefix('v')?;
    let source = line
        .find(" (")
        .map(|i| &line[i + 2..])
        .and_then(|s| s.split(')').next())
        .filter(|s| *s != "*" && *s != "proc-macro");
    Some((name, version, source))
}

/// Pure: **the paid crates in a `cargo tree` listing**, each once, by name.
pub fn offenders(tree: &str) -> Vec<Offender> {
    let mut seen = BTreeSet::new();
    let mut out = Vec::new();
    for (name, version, source) in tree.lines().filter_map(package) {
        let from_private =
            source.is_some_and(|s| s.contains("/monetize-impl/") || s.contains("/private-"));
        let why = if PAID_NAMES.contains(&name) || from_private {
            "Vetra's private monetize-impl (or a private ops checkout): never in a free build"
        } else if metered(name) {
            "the metered half (monetize, the console, the paid front): only `gunnar-front --features metered` links it"
        } else {
            continue;
        };
        if seen.insert(name.to_owned()) {
            out.push(Offender {
                name: name.to_owned(),
                version: version.to_owned(),
                why,
            });
        }
    }
    out
}

/// Pure: **the sibling checkouts a listing reaches by path** — the directory
/// beside `root` each path package lives in (`…/edda/crates/x` → `edda`).
pub fn path_siblings(tree: &str, root: &Path) -> BTreeSet<String> {
    let parent = root
        .parent()
        .map(|p| format!("{}/", p.display()))
        .unwrap_or_default();
    let own = format!("{}/", root.display());
    tree.lines()
        .filter_map(package)
        .filter_map(|(_, _, source)| source)
        .filter(|s| s.starts_with('/') && !s.starts_with(&own))
        .filter_map(|s| {
            s.strip_prefix(&parent)
                .and_then(|rest| rest.split('/').next())
                .map(str::to_owned)
        })
        .collect()
}

fn tree(root: &Path, cargo: &str, extra: &[&str]) -> Result<String, String> {
    let mut args = vec!["tree", "-p", "gunnar-server", "-e", "normal,build"];
    args.extend_from_slice(extra);
    let out = Command::new(cargo)
        .current_dir(root)
        .args(&args)
        .output()
        .map_err(|e| format!("{cargo} tree: {e}"))?;
    if !out.status.success() {
        return Err(format!(
            "{cargo} {}: {}",
            args.join(" "),
            String::from_utf8_lossy(&out.stderr).trim()
        ));
    }
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}

/// **`free-tree`**: the default-feature `gunnar-server` links nothing paid.
pub fn run(root: &Path, cargo: &str) -> bool {
    let listing = match tree(root, cargo, &["--prefix", "none", "--format", "{p}"]) {
        Ok(t) => t,
        Err(why) => {
            eprintln!("free-tree: {why}");
            return false;
        }
    };
    let bad = offenders(&listing);
    if bad.is_empty() {
        let n = listing.lines().filter(|l| !l.ends_with("(*)")).count();
        let sibs: Vec<String> = path_siblings(&listing, root).into_iter().collect();
        println!("free-tree: gunnar-server (default features) links {n} packages and no paid crate; by path it reaches {}", sibs.join(", "));
        return true;
    }
    for o in &bad {
        eprintln!(
            "free-tree: ✗ gunnar-server links {} v{} — {}",
            o.name, o.version, o.why
        );
        if let Ok(chain) = tree(root, cargo, &["-i", &o.name, "--depth", "6"]) {
            for line in chain.lines().take(12) {
                eprintln!("      {line}");
            }
        }
    }
    eprintln!("free-tree: {} paid crate(s) in the free server — make each optional behind the metered feature", bad.len());
    false
}

/// What the lone-clone row did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoneClone {
    /// Not run, and why (with the siblings it would need today).
    Skipped(String),
    Passed,
    Failed(String),
}

/// **`lone-clone`**: this commit, cloned with no sibling beside it, builds with
/// plain `cargo build --release` and the binary answers `/healthz`. SKIPPED while
/// [`LONE_CLONE_PAUSED`] says why, unless `force` or a `stage` of packaged crates
/// (which stands in for the crates.io publish) is given.
pub fn lone_clone(root: &Path, cargo: &str, force: bool) -> LoneClone {
    let staged = std::env::args().any(|a| a == "--stage")
        || std::env::var("GUNNAR_LONE_STAGE").is_ok_and(|v| !v.is_empty());
    lone_clone_with(root, cargo, force, staged)
}

/// [`lone_clone`], plain or staged: `git clone` of this commit alone, `cargo build
/// --locked`, `cargo install --locked --path crates/gunnar-cli --features client` and
/// `--path crates/gunnar` (`crate::both_ways::lone_steps`), each binary run, and the
/// installed server booted until `/healthz` answers `ok`.
pub fn lone_clone_with(root: &Path, cargo: &str, force: bool, staged: bool) -> LoneClone {
    if let (Some(why), false, false) = (LONE_CLONE_PAUSED, force, staged) {
        return LoneClone::Skipped(format!("{why}. Run it before the publish with `--stage`"));
    }
    let work = std::env::var("GUNNAR_BOTH_WAYS_WORK")
        .ok()
        .filter(|v| !v.is_empty())
        .map(std::path::PathBuf::from)
        .unwrap_or_else(std::env::temp_dir)
        .join(format!("gunnar-lone-clone-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&work);
    let verdict = match crate::both_ways::lone_clone(root, cargo, staged, &work) {
        (crate::both_ways::Verdict::Failed(why), _) => LoneClone::Failed(why),
        (crate::both_ways::Verdict::Passed(said), Some(server)) => {
            eprintln!("lone-clone: {said}");
            boot(&server, &work.join("data"))
        }
        (crate::both_ways::Verdict::Passed(_), None) => {
            LoneClone::Failed("no installed server to boot".into())
        }
    };
    let _ = std::fs::remove_dir_all(&work);
    verdict
}

/// The `--config patch.crates-io.<name>.path="<dir>"` arguments for every unpacked
/// crate (`<name>-<version>/`) in `stage`.
pub fn stage_patches(stage: &Path) -> Result<Vec<String>, String> {
    let mut out = Vec::new();
    let mut entries: Vec<_> = std::fs::read_dir(stage)
        .map_err(|e| format!("read {}: {e}", stage.display()))?
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.join("Cargo.toml").is_file())
        .collect();
    entries.sort();
    for p in entries {
        let file = p
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or_default()
            .to_string();
        let Some((name, _version)) = file.rsplit_once('-') else {
            continue;
        };
        out.push("--config".to_string());
        out.push(format!(
            "patch.crates-io.{name}.path={:?}",
            p.display().to_string()
        ));
    }
    Ok(out)
}

/// Start the built server on free loopback ports and ask `GET /healthz`.
fn boot(bin: &Path, data: &Path) -> LoneClone {
    use std::io::{Read, Write};
    let free = || {
        std::net::TcpListener::bind("127.0.0.1:0")
            .and_then(|l| l.local_addr())
            .map(|a| a.port())
    };
    let (Ok(ssh), Ok(http), Ok(grpc)) = (free(), free(), free()) else {
        return LoneClone::Failed("no free loopback port".into());
    };
    let child = Command::new(bin)
        .args([
            "serve",
            "--ssh-bind",
            &format!("127.0.0.1:{ssh}"),
            "--http-bind",
            &format!("127.0.0.1:{http}"),
        ])
        .args(["--grpc-bind", &format!("127.0.0.1:{grpc}")])
        .env("GUNNAR_DATA_ROOT", data)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn();
    let mut child = match child {
        Ok(c) => c,
        Err(e) => return LoneClone::Failed(format!("start {}: {e}", bin.display())),
    };
    let mut answer = String::new();
    for _ in 0..60 {
        std::thread::sleep(std::time::Duration::from_millis(500));
        if let Ok(mut s) = std::net::TcpStream::connect(("127.0.0.1", http)) {
            let _ = s.write_all(
                b"GET /healthz HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
            );
            let _ = s.read_to_string(&mut answer);
            break;
        }
    }
    // Our own child only: never a pattern kill.
    let _ = child.kill();
    let _ = child.wait();
    if answer.starts_with("HTTP/1.1 200") && answer.ends_with("ok\n") {
        eprintln!("lone-clone: the installed gunnar answered /healthz on 127.0.0.1:{http}: ok");
        LoneClone::Passed
    } else {
        LoneClone::Failed(format!("/healthz on 127.0.0.1:{http} answered {answer:?}"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const ROOT: &str = "/w/gunnar";

    /// ★ **free-tree names each paid crate once** — monetize-impl's by name or
    /// by its checkout, the metered half by name — and passes a clean listing.
    #[test]
    fn the_free_tree_names_every_paid_crate_once_and_passes_a_clean_one() {
        let clean = "gunnar-server v0.9.0 (/w/gunnar/crates/gunnar-server)\nedda-admit v0.1.0 (/w/edda/crates/admit)\nserde v1.0.228\nserde_derive v1.0.228 (proc-macro)\ngunnar-core v0.9.0 (/w/gunnar/crates/gunnar-core) (*)\n";
        assert!(offenders(clean).is_empty());
        let dirty = format!(
            "{clean}monetize-pay-impl v0.3.0 (/w/monetize-impl/crates/pay)\nmonetize-pay-impl v0.3.0 (/w/monetize-impl/crates/pay) (*)\nsecret-thing v0.1.0 (/w/private-gunnar-ops/x)\nmonetize-proto v0.4.0\ngunnar-ui-server v0.2.0 (/w/gunnar-ui/crates/gunnar-ui-server)\n"
        );
        let names: Vec<String> = offenders(&dirty).into_iter().map(|o| o.name).collect();
        assert_eq!(
            names,
            [
                "monetize-pay-impl",
                "secret-thing",
                "monetize-proto",
                "gunnar-ui-server"
            ]
        );
        assert!(
            offenders(&dirty)[2].why.contains("metered"),
            "a published monetize crate is the metered half"
        );
    }

    /// The siblings a listing reaches by path, by checkout name — never the
    /// repository itself, never a registry crate.
    #[test]
    fn the_siblings_reached_by_path_are_named_by_checkout() {
        let t = "gunnar-server v0.9.0 (/w/gunnar/crates/gunnar-server)\nedda-admit v0.1.0 (/w/edda/crates/admit)\nnordisk-pairing v0.5.11 (/w/Skidbladnir/crates/installer/nordisk-pairing)\nserde v1.0.228\nedda-x v0.1.0 (/w/edda/crates/x) (*)\n";
        let s: Vec<String> = path_siblings(t, Path::new(ROOT)).into_iter().collect();
        assert_eq!(s, ["Skidbladnir", "edda"]);
    }

    /// The lone-clone row runs (G-BOTH-1): it is paused by no reason.
    #[test]
    fn the_lone_clone_row_is_not_skipped() {
        assert_eq!(LONE_CLONE_PAUSED, None);
    }

    #[test]
    fn a_stage_patches_in_each_unpacked_crate_by_name() {
        let d = std::env::temp_dir().join(format!("xtask-stage-{}", std::process::id()));
        for c in [
            "gunnar-server-1.1.0",
            "nordisk-pairing-0.5.25",
            "not-a-crate",
        ] {
            std::fs::create_dir_all(d.join(c)).unwrap();
        }
        std::fs::write(d.join("gunnar-server-1.1.0/Cargo.toml"), "").unwrap();
        std::fs::write(d.join("nordisk-pairing-0.5.25/Cargo.toml"), "").unwrap();
        let p = stage_patches(&d).unwrap();
        let _ = std::fs::remove_dir_all(&d);
        assert_eq!(p.len(), 4);
        assert!(
            p[1].starts_with("patch.crates-io.gunnar-server.path="),
            "{}",
            p[1]
        );
        assert!(
            p[3].starts_with("patch.crates-io.nordisk-pairing.path="),
            "{}",
            p[3]
        );
    }
}