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