use std::path::PathBuf;
use std::process::Command;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_candor-scan")
}
fn make_crate(name: &str, src: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("candor-scan-cli-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("src")).unwrap();
std::fs::write(d.join("Cargo.toml"), format!("[package]\nname = \"{name}\"\n")).unwrap();
std::fs::write(d.join("src/lib.rs"), src).unwrap();
d
}
#[test]
fn json_plus_policy_keeps_stdout_pure_json_and_routes_violations_to_stderr() {
let d = make_crate("jsonpol", "pub fn go() { std::process::Command::new(\"sh\").status().unwrap(); }");
let pp = d.join("candor.policy");
std::fs::write(&pp, "deny Exec\n").unwrap();
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--json")
.arg("--policy")
.arg(pp.to_string_lossy().as_ref())
.output()
.expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(1), "a deny-Exec violation must exit 1");
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
let parsed: Result<serde_json::Value, _> = serde_json::from_str(stdout.trim());
assert!(parsed.is_ok(), "stdout under --json --policy must parse as JSON, got:\n{stdout}");
let stderr = String::from_utf8(out.stderr).expect("utf8 stderr");
assert!(stderr.contains("AS-EFF") || stderr.contains("violation"),
"the policy violation must be reported on stderr, got stderr:\n{stderr}");
assert!(!stdout.contains("AS-EFF"),
"no policy/violation text may appear on the JSON stdout stream:\n{stdout}");
}
#[test]
fn valueless_trailing_policy_flag_errors_exit_2() {
let d = make_crate("nopolval", "pub fn go() {}");
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--policy") .output()
.expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(2), "a valueless --policy must exit 2, not silently skip the gate");
}
#[test]
fn unreadable_policy_exits_2() {
let d = make_crate("unreadpol", "pub fn go() {}");
let missing = d.join("does-not-exist.policy");
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--policy")
.arg(missing.to_string_lossy().as_ref())
.output()
.expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(2), "an unreadable policy must exit 2");
}
#[test]
fn json_plus_policy_over_unparseable_source_exits_2() {
let d = make_crate("brokenbin", "pub fn ok() {}\nthis is not valid rust @@@\n");
let pp = d.join("candor.policy");
std::fs::write(&pp, "deny Exec\n").unwrap();
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--json")
.arg("--policy")
.arg(pp.to_string_lossy().as_ref())
.output()
.expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(2),
"a gate over an unparseable source must exit 2, never green");
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
if !stdout.trim().is_empty() {
assert!(serde_json::from_str::<serde_json::Value>(stdout.trim()).is_ok(),
"any stdout under --json must remain valid JSON:\n{stdout}");
}
}
#[test]
fn bare_scan_writes_report_files_and_exits_0() {
let d = make_crate("bare", "pub fn go() { let _ = std::fs::read(\"/x\"); }");
let out = Command::new(bin()).arg(d.to_string_lossy().as_ref()).output().expect("run candor-scan");
assert_eq!(out.status.code(), Some(0), "a clean bare scan must exit 0");
assert!(d.join(".candor/report.bare.scan.json").is_file(), "bare scan must write the report file");
assert!(d.join(".candor/report.bare.scan.callgraph.json").is_file(), "bare scan must write the callgraph sidecar");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn json_prints_to_stdout_and_writes_no_files_exit_0() {
let d = make_crate("jsononly", "pub fn go() {}");
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--json")
.output()
.expect("run candor-scan");
assert_eq!(out.status.code(), Some(0), "a clean --json scan must exit 0");
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
assert!(serde_json::from_str::<serde_json::Value>(stdout.trim()).is_ok(),
"--json stdout must parse as JSON, got:\n{stdout}");
assert!(!d.join(".candor").exists(), "--json must NOT write any report files to disk");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn json_plus_clean_policy_is_pure_json_exit_0() {
let d = make_crate("jsonclean", "pub fn go() {}");
let pp = d.join("candor.policy");
std::fs::write(&pp, "deny Exec\n").unwrap();
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--json")
.arg("--policy")
.arg(pp.to_string_lossy().as_ref())
.output()
.expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(0), "a clean --json --policy run must exit 0");
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
assert!(serde_json::from_str::<serde_json::Value>(stdout.trim()).is_ok(),
"stdout under --json --policy (clean) must parse as JSON, got:\n{stdout}");
assert!(!stdout.contains('✓') && !stdout.contains("policy"),
"the gate's ✓ summary must be on stderr, not stdout:\n{stdout}");
}
#[test]
fn violating_policy_exits_1_clean_policy_exits_0() {
let d = make_crate("gate", "pub fn go() { let _ = std::fs::read(\"/x\"); }");
let violating = d.join("violating.policy");
std::fs::write(&violating, "deny Fs\n").unwrap();
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--policy")
.arg(violating.to_string_lossy().as_ref())
.output()
.expect("run candor-scan");
assert_eq!(out.status.code(), Some(1), "deny Fs over an Fs effect must exit 1");
let clean = d.join("clean.policy");
std::fs::write(&clean, "deny Exec\n").unwrap();
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--policy")
.arg(clean.to_string_lossy().as_ref())
.output()
.expect("run candor-scan");
assert_eq!(out.status.code(), Some(0), "deny Exec over an Fs-only crate must exit 0");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn version_prints_build_and_spec_exit_0() {
for flag in ["--version", "-V"] {
let out = Command::new(bin()).arg(flag).output().expect("run candor-scan");
assert_eq!(out.status.code(), Some(0), "{flag} must exit 0");
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
let first = stdout.lines().next().unwrap_or("");
assert!(first.starts_with("candor-scan ") && first.contains("(spec "),
"{flag} first line must be `candor-scan <ver> (spec <X>)`, got: {first}");
}
}
#[test]
fn help_prints_usage_exit_0() {
for flag in ["--help", "-h"] {
let out = Command::new(bin()).arg(flag).output().expect("run candor-scan");
assert_eq!(out.status.code(), Some(0), "{flag} must exit 0");
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
assert!(stdout.contains("USAGE"), "{flag} must print a USAGE line, got:\n{stdout}");
}
}
#[test]
fn unknown_flags_exit_2() {
for flag in ["--bogus", "-x"] {
let out = Command::new(bin()).arg(flag).output().expect("run candor-scan");
assert_eq!(out.status.code(), Some(2), "unknown flag {flag} must exit 2");
let stderr = String::from_utf8(out.stderr).expect("utf8 stderr");
assert!(stderr.contains("unknown flag"), "{flag} must report `unknown flag`, got:\n{stderr}");
}
}
#[test]
fn corrupt_random_bytes_source_does_not_panic() {
let d = std::env::temp_dir().join(format!("candor-scan-cli-randbytes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("src")).unwrap();
std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"randbytes\"\n").unwrap();
let garbage: Vec<u8> = (0u16..2048).map(|i| (i.wrapping_mul(37) ^ 0xA5) as u8).collect();
std::fs::write(d.join("src/lib.rs"), &garbage).unwrap();
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--json")
.output()
.expect("run candor-scan");
assert_ne!(out.status.code(), Some(101), "a random-bytes source must not panic the scanner");
let stdout = String::from_utf8_lossy(&out.stdout);
if !stdout.trim().is_empty() {
assert!(serde_json::from_str::<serde_json::Value>(stdout.trim()).is_ok(),
"--json over a garbage source must still emit valid JSON:\n{stdout}");
}
let pp = d.join("candor.policy");
std::fs::write(&pp, "deny Exec\n").unwrap();
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--policy")
.arg(pp.to_string_lossy().as_ref())
.output()
.expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(2), "a gate over an unparseable garbage source must exit 2, never green");
}
#[test]
fn empty_dir_scan_is_clean_exit_0() {
let d = std::env::temp_dir().join(format!("candor-scan-cli-emptydir-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--json")
.output()
.expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(0), "an empty dir must scan cleanly (exit 0)");
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
assert!(serde_json::from_str::<serde_json::Value>(stdout.trim()).is_ok(),
"--json over an empty dir must emit valid JSON:\n{stdout}");
}
#[test]
fn nonexistent_path_does_not_panic() {
let missing = std::env::temp_dir().join(format!("candor-scan-cli-no-such-{}-xyz", std::process::id()));
let _ = std::fs::remove_dir_all(&missing);
let out = Command::new(bin())
.arg(missing.to_string_lossy().as_ref())
.arg("--json")
.output()
.expect("run candor-scan");
assert_ne!(out.status.code(), Some(101), "a nonexistent path must not panic the scanner");
}
#[test]
fn gate_json_writes_the_structured_verdict_faithful_to_the_exit_code() {
let d = make_crate("gatejson", "pub fn go() { std::process::Command::new(\"sh\").status().unwrap(); }");
let pp = d.join("candor.policy");
std::fs::write(&pp, "deny Exec\n").unwrap();
let gp = d.join("gate.json");
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--policy").arg(pp.to_string_lossy().as_ref())
.arg("--gate-json").arg(gp.to_string_lossy().as_ref())
.output()
.expect("run candor-scan");
assert_eq!(out.status.code(), Some(1), "a deny-Exec violation must exit 1");
let verdict: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&gp).expect("gate.json written")).expect("valid JSON");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(verdict["spec"], "0.8", "verdict declares the spec version");
assert_eq!(verdict["ok"], false, "ok:false on a failing gate");
let viols = verdict["violations"].as_array().expect("violations array");
assert_eq!(viols.len(), 1, "one violation: {verdict}");
assert_eq!(viols[0]["rule"], "AS-EFF-006");
assert_eq!(viols[0]["fn"], "go");
assert_eq!(viols[0]["effects"], serde_json::json!(["Exec"]), "effects = the denied set");
}
#[test]
fn gate_json_valueless_fails_closed() {
let d = make_crate("gatejsonnoval", "pub fn go() {}");
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--gate-json")
.output()
.expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(2), "a valueless --gate-json must fail (exit 2)");
}
#[test]
fn gate_json_workspace_accumulates_across_members() {
let d = std::env::temp_dir().join(format!("candor-scan-cli-gatews-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("a_viol/src")).unwrap();
std::fs::create_dir_all(d.join("z_clean/src")).unwrap();
std::fs::write(d.join("Cargo.toml"), "[workspace]\nmembers = [\"a_viol\", \"z_clean\"]\n").unwrap();
std::fs::write(d.join("a_viol/Cargo.toml"), "[package]\nname = \"a_viol\"\n").unwrap();
std::fs::write(d.join("a_viol/src/lib.rs"), "pub fn fetch() { let _ = std::net::TcpStream::connect(\"x:80\"); }\n").unwrap();
std::fs::write(d.join("z_clean/Cargo.toml"), "[package]\nname = \"z_clean\"\n").unwrap();
std::fs::write(d.join("z_clean/src/lib.rs"), "pub fn add(a: i32, b: i32) -> i32 { a + b }\n").unwrap();
let pp = d.join("candor.policy");
std::fs::write(&pp, "deny Net\n").unwrap();
let gp = d.join("gate.json");
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--policy").arg(pp.to_string_lossy().as_ref())
.arg("--gate-json").arg(gp.to_string_lossy().as_ref())
.output()
.expect("run candor-scan");
assert_eq!(out.status.code(), Some(1), "the violating member fails the workspace gate");
let verdict: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&gp).expect("gate.json written")).expect("valid JSON");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(verdict["ok"], false,
"ok must agree with exit 1 — the clean last member must NOT overwrite the violator's verdict");
let viols = verdict["violations"].as_array().expect("violations array");
assert_eq!(viols.len(), 1, "the a_viol violation survives to the final verdict: {verdict}");
assert_eq!(viols[0]["fn"], "fetch");
assert_eq!(viols[0]["effects"], serde_json::json!(["Net"]));
}
#[test]
fn gate_json_rejects_a_flag_shaped_value_and_dash_stays_pure() {
let d = make_crate("gatejsondash", "pub fn go() { std::process::Command::new(\"sh\").status().unwrap(); }");
let pp = d.join("candor.policy");
std::fs::write(&pp, "deny Exec\n").unwrap();
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--gate-json").arg("--policy").arg(pp.to_string_lossy().as_ref())
.output().expect("run candor-scan");
assert_eq!(out.status.code(), Some(2), "a flag-shaped --gate-json value fails closed");
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--policy").arg(pp.to_string_lossy().as_ref())
.arg("--gate-json").arg("-")
.output().expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(1));
let stdout = String::from_utf8(out.stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout is pure verdict JSON");
assert_eq!(v["ok"], false);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("AS-EFF-006"), "the human AS-EFF line goes to stderr: {stderr}");
}
#[test]
fn candor_config_drives_the_gate_env_overrides_and_typo_fails_closed() {
let d = make_crate("cfggate", "pub fn go() { std::process::Command::new(\"sh\").status().unwrap(); }");
std::fs::create_dir_all(d.join(".candor")).unwrap();
let deny_exec = d.join("deny-exec.policy");
std::fs::write(&deny_exec, "deny Exec\n").unwrap();
let deny_net = d.join("deny-net.policy");
std::fs::write(&deny_net, "deny Net\n").unwrap();
std::fs::write(d.join(".candor/config"),
format!("policy {}\npolcy typo\n", deny_exec.display())).unwrap();
let out = Command::new(bin()).arg(d.to_string_lossy().as_ref()).output().expect("run");
assert_eq!(out.status.code(), Some(1), "the config-supplied deny-Exec gates the scan");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("unknown config key 'polcy'"), "typo protection warns: {stderr}");
let out = Command::new(bin()).arg(d.to_string_lossy().as_ref())
.env("CANDOR_POLICY", deny_net.to_string_lossy().as_ref())
.output().expect("run");
assert_eq!(out.status.code(), Some(0), "CANDOR_POLICY env overrides the config");
let out = Command::new(bin()).arg(d.to_string_lossy().as_ref())
.env("CANDOR_CONFIG", d.join("no-such").to_string_lossy().as_ref())
.output().expect("run");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(2), "a typo'd CANDOR_CONFIG must fail closed");
}
#[test]
fn kappa_ledger_honors_an_empty_chained_report_as_coverage() {
let d = std::env::temp_dir().join(format!("candor-scan-cli-kappaempty-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("src")).unwrap();
std::fs::write(d.join("Cargo.toml"),
"[package]\nname = \"kappaledger\"\n\n[dependencies]\ndepc = \"1\"\n").unwrap();
std::fs::write(d.join("src/lib.rs"), "pub fn use_dep() { depc::hit(); }\n").unwrap();
let rep = d.join("depc-purity.json");
std::fs::write(&rep, format!(r#"{{
"candor": {{"version": "scan-{}", "toolchain": "stable", "spec": "0.8"}},
"package": "depc",
"functions": []}}"#, env!("CARGO_PKG_VERSION"))).unwrap();
let out = Command::new(bin()).arg(d.to_string_lossy().as_ref()).arg("--json")
.output().expect("run candor-scan");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("κ doesn't know") && stderr.contains("depc"),
"without chaining, the called-but-unknown dep must be disclosed: {stderr}");
let out = Command::new(bin()).arg(d.to_string_lossy().as_ref()).arg("--json")
.env("CANDOR_DEPS", rep.to_string_lossy().as_ref())
.output().expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(0));
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(!stderr.contains("κ doesn't know"),
"an empty chained report is coverage — the ledger must stay quiet: {stderr}");
let v: serde_json::Value = serde_json::from_str(String::from_utf8(out.stdout).unwrap().trim())
.expect("pure JSON report");
assert!(v["functions"].as_array().unwrap().iter()
.all(|f| f["fn"].as_str() != Some("use_dep")),
"the call into the all-pure dep reads pure (omitted from the report): {v}");
}
#[test]
fn candor_config_relative_path_resolves_against_the_config_home_not_the_cwd() {
let d = make_crate("cfgrel", "pub fn go() { std::process::Command::new(\"sh\").status().unwrap(); }");
std::fs::create_dir_all(d.join(".candor")).unwrap();
std::fs::write(d.join(".candor/gate.pol"), "deny Exec\n").unwrap();
std::fs::write(d.join(".candor/config"), "policy .candor/gate.pol\n").unwrap();
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.current_dir(std::env::temp_dir())
.output().expect("run candor-scan");
assert_eq!(out.status.code(), Some(1),
"a home-relative `.candor/gate.pol` policy value must resolve and fire the gate");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("AS-EFF-006") || String::from_utf8_lossy(&out.stdout).contains("AS-EFF-006"),
"the deny-Exec violation must be reported: {stderr}");
std::fs::write(d.join("arch.policy"), "deny Exec\n").unwrap();
std::fs::write(d.join(".candor/config"), "policy arch.policy\n").unwrap();
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.current_dir(std::env::temp_dir())
.output().expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(1),
"a root-relative `arch.policy` value must resolve against the config home (gate fires)");
}
#[test]
fn candor_config_bare_policy_key_fails_loud() {
let d = make_crate("cfgbarepol", "pub fn go() { std::process::Command::new(\"sh\").status().unwrap(); }");
std::fs::create_dir_all(d.join(".candor")).unwrap();
std::fs::write(d.join(".candor/config"), "policy\n").unwrap();
let out = Command::new(bin()).arg(d.to_string_lossy().as_ref()).output().expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(2), "a bare `policy` config key must fail loud, never skip the gate");
}
#[test]
fn candor_config_recognized_but_unimplemented_key_warns_loudly() {
let d = make_crate("cfginert", "pub fn pure() -> u32 { 1 }");
std::fs::create_dir_all(d.join(".candor")).unwrap();
std::fs::write(d.join(".candor/config"), "baseline .candor/baseline\ntaint true\nstrict 1\n").unwrap();
let out = Command::new(bin()).arg(d.to_string_lossy().as_ref()).output().expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(0), "inert keys don't fail the scan (and an absent baseline is a note, not a failure)");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("config key 'taint' is recognized by the candor family but not implemented by candor-scan"),
"the inert `taint` key must be disclosed loudly: {stderr}");
assert!(stderr.contains("config key 'strict'"), "every inert recognized key is disclosed: {stderr}");
assert!(!stderr.contains("config key 'baseline'"),
"`baseline` is implemented now — it must not be disclosed as inert: {stderr}");
assert!(stderr.contains("regression guard is not active"),
"an absent configured baseline gets the adopt note: {stderr}");
}
fn scan_with_baseline(d: &std::path::Path, baseline: Option<&str>, args: &[&str]) -> (Option<i32>, String, String) {
let mut cmd = Command::new(bin());
cmd.arg(d.to_string_lossy().as_ref()).args(args);
cmd.env_remove("CANDOR_BASELINE").env_remove("CANDOR_POLICY").env_remove("CANDOR_CONFIG").env_remove("CANDOR_DEPS");
if let Some(b) = baseline {
cmd.env("CANDOR_BASELINE", b);
}
let out = cmd.output().expect("run candor-scan");
(out.status.code(), String::from_utf8(out.stdout).unwrap(), String::from_utf8(out.stderr).unwrap())
}
#[test]
fn baseline_guard_flags_a_gained_effect_exit_1_and_rides_the_gate_json() {
let d = make_crate("blratchet", "pub fn go() { let _ = std::fs::read(\"/x\"); }");
let pre = d.join("base");
let (rc, _, _) = scan_with_baseline(&d, None, &["--out", pre.to_string_lossy().as_ref()]);
assert_eq!(rc, Some(0), "recording the baseline is a plain scan");
std::fs::write(d.join("src/lib.rs"),
"pub fn go() { let _ = std::fs::read(\"/x\"); std::process::Command::new(\"sh\").status().unwrap(); }").unwrap();
let verdict = d.join("verdict.json");
let (rc, stdout, stderr) = scan_with_baseline(&d, Some(pre.to_string_lossy().as_ref()),
&["--gate-json", verdict.to_string_lossy().as_ref()]);
assert_eq!(rc, Some(1), "a gained effect is a violation (exit 1): {stderr}");
let all = format!("{stdout}{stderr}");
assert!(all.contains("[AS-EFF-005] `go` gained effect { Exec }"),
"the violation line names the fn and the gained effect: {all}");
let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&verdict).unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&d);
assert_eq!(v["ok"], serde_json::json!(false));
assert!(v["violations"].as_array().unwrap().iter().any(|gv|
gv["rule"] == "AS-EFF-005" && gv["fn"] == "go" && gv["effects"] == serde_json::json!(["Exec"])),
"AS-EFF-005 joins the structured verdict: {v}");
}
#[test]
fn baseline_guard_clean_compare_exits_0_and_new_fns_are_exempt() {
let d = make_crate("blclean", "pub fn go() { let _ = std::fs::read(\"/x\"); }");
let pre = d.join("base");
let (rc, _, _) = scan_with_baseline(&d, None, &["--out", pre.to_string_lossy().as_ref()]);
assert_eq!(rc, Some(0));
let (rc, _, stderr) = scan_with_baseline(&d, Some(pre.to_string_lossy().as_ref()), &[]);
assert_eq!(rc, Some(0), "an unchanged crate passes the ratchet: {stderr}");
assert!(stderr.contains("baseline guard ✓"), "the clean guard prints its receipt: {stderr}");
std::fs::write(d.join("src/lib.rs"),
"pub fn go() { let _ = std::fs::read(\"/x\"); }\npub fn newbie() { std::process::Command::new(\"sh\").status().unwrap(); }").unwrap();
let (rc, stdout, stderr) = scan_with_baseline(&d, Some(pre.to_string_lossy().as_ref()), &[]);
let _ = std::fs::remove_dir_all(&d);
assert_eq!(rc, Some(0), "a new fn is not a regression: {stderr}");
assert!(!format!("{stdout}{stderr}").contains("AS-EFF-005"), "no violation for new code: {stdout}{stderr}");
}
#[test]
fn baseline_guard_absent_file_notes_once_and_exit_unchanged() {
let d = make_crate("blabsent", "pub fn go() { let _ = std::fs::read(\"/x\"); }");
let (rc, _, stderr) = scan_with_baseline(&d, Some(d.join("nosuch").to_string_lossy().as_ref()), &[]);
let _ = std::fs::remove_dir_all(&d);
assert_eq!(rc, Some(0), "an absent baseline leaves the exit code unchanged: {stderr}");
assert!(stderr.contains("regression guard is not active") && stderr.contains("record one:"),
"the note says the guard is inactive and how to record a baseline: {stderr}");
}
#[test]
fn baseline_guard_version_mismatch_fails_closed_without_evaluating() {
let d = make_crate("blver", "pub fn go() { let _ = std::fs::read(\"/x\"); }");
let pre = d.join("base");
let (rc, _, _) = scan_with_baseline(&d, None, &["--out", pre.to_string_lossy().as_ref()]);
assert_eq!(rc, Some(0));
std::fs::write(d.join("src/lib.rs"),
"pub fn go() { let _ = std::fs::read(\"/x\"); std::process::Command::new(\"sh\").status().unwrap(); }").unwrap();
let file = d.join("base.blver.scan.json");
let mut v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
v["candor"]["version"] = serde_json::json!("scan-0.0.0-doctored");
std::fs::write(&file, serde_json::to_string(&v).unwrap()).unwrap();
let (rc, stdout, stderr) = scan_with_baseline(&d, Some(pre.to_string_lossy().as_ref()), &[]);
assert_eq!(rc, Some(2), "a producing-version mismatch is invalid gate input (exit 2): {stderr}");
assert!(stderr.contains("scan-0.0.0-doctored") && stderr.contains("cannot evaluate"),
"the diagnostic names both builds and refuses to evaluate: {stderr}");
assert!(!format!("{stdout}{stderr}").contains("[AS-EFF-005]"),
"no AS-EFF-005 violation may be emitted from a stale baseline: {stdout}{stderr}");
std::fs::write(&file, "[{\"fn\":\"go\",\"inferred\":[]}]").unwrap();
let (rc, stdout, stderr) = scan_with_baseline(&d, Some(pre.to_string_lossy().as_ref()), &[]);
let _ = std::fs::remove_dir_all(&d);
assert_eq!(rc, Some(2), "a provenance-less baseline cannot certify its build: {stderr}");
assert!(!format!("{stdout}{stderr}").contains("[AS-EFF-005]"), "never evaluated: {stdout}{stderr}");
}
#[test]
fn baseline_guard_unparseable_or_empty_value_fails_closed() {
let d = make_crate("blcorrupt", "pub fn go() { let _ = std::fs::read(\"/x\"); }");
let garbage = d.join("base.blcorrupt.scan.json");
std::fs::write(&garbage, "{ this is not json").unwrap();
let (rc, _, stderr) = scan_with_baseline(&d, Some(d.join("base").to_string_lossy().as_ref()), &[]);
assert_eq!(rc, Some(2), "a corrupt baseline is invalid gate input: {stderr}");
assert!(stderr.contains("could not be parsed"), "the diagnostic says why: {stderr}");
let (rc, _, stderr) = scan_with_baseline(&d, Some(""), &[]);
let _ = std::fs::remove_dir_all(&d);
assert_eq!(rc, Some(2), "a configured-but-empty baseline must not silently skip the guard: {stderr}");
assert!(stderr.contains("EMPTY value"), "the diagnostic names the empty value: {stderr}");
}
#[test]
fn baseline_guard_config_key_resolves_against_the_config_home_and_env_wins() {
let d = make_crate("blcfg", "pub fn go() { let _ = std::fs::read(\"/x\"); }");
std::fs::create_dir_all(d.join(".candor")).unwrap();
std::fs::write(d.join(".candor/config"), "baseline .candor/base\n").unwrap();
let (rc, _, _) = scan_with_baseline(&d, None,
&["--out", d.join(".candor/base").to_string_lossy().as_ref()]);
assert_eq!(rc, Some(0));
std::fs::write(d.join("src/lib.rs"),
"pub fn go() { let _ = std::fs::read(\"/x\"); std::process::Command::new(\"sh\").status().unwrap(); }").unwrap();
let out = Command::new(bin()).arg(d.to_string_lossy().as_ref())
.current_dir(std::env::temp_dir()).output().expect("run candor-scan");
assert_eq!(out.status.code(), Some(1),
"the config `baseline` key must activate the guard, home-anchored: {}",
String::from_utf8_lossy(&out.stderr));
assert!(String::from_utf8_lossy(&out.stdout).contains("[AS-EFF-005]"),
"the gain is reported: {}", String::from_utf8_lossy(&out.stdout));
let fresh = d.join("fresh");
let (rc, _, stderr) = scan_with_baseline(&d, Some(d.join("void").to_string_lossy().as_ref()),
&["--out", fresh.to_string_lossy().as_ref()]);
assert_eq!(rc, Some(0), "an absent env baseline overrides the config's firing one: {stderr}");
let (rc, _, stderr) = scan_with_baseline(&d, Some(fresh.to_string_lossy().as_ref()), &[]);
let _ = std::fs::remove_dir_all(&d);
assert_eq!(rc, Some(0), "CANDOR_BASELINE env overrides the config key: {stderr}");
}
#[test]
fn cfg_feature_gated_statements_scope_effects_to_the_default_build() {
let d = std::env::temp_dir().join(format!("candor-scan-cli-cfgfeat-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("src")).unwrap();
std::fs::write(
d.join("Cargo.toml"),
"[package]\nname = \"cfgfeat\"\n\n[features]\ndefault = [\"on\"]\non = [\"deep\"]\ndeep = []\noff = []\n",
)
.unwrap();
std::fs::write(
d.join("src/lib.rs"),
r#"
pub fn gated_out() {
#[cfg(feature = "off")]
{ let _ = std::process::Command::new("sh").status(); }
}
pub fn gated_out_let() {
#[cfg(feature = "off")]
let _x = std::fs::read("/x");
}
pub fn nested_out() {
#[cfg(all(feature = "on", feature = "off"))]
{ let _ = std::fs::read("/x"); }
}
pub fn unknown_kept() {
#[cfg(any(feature = "off", target_os = "linux"))]
{ let _ = std::net::TcpStream::connect("h:1"); }
}
pub fn nested_in() {
#[cfg(all(feature = "deep", not(feature = "off")))]
{ let _ = std::env::var("HOME"); }
}
"#,
)
.unwrap();
let out = Command::new(bin())
.arg(d.to_string_lossy().as_ref())
.arg("--json")
.env_remove("CANDOR_POLICY")
.env_remove("CANDOR_CONFIG")
.env_remove("CANDOR_DEPS")
.output()
.expect("run candor-scan");
let _ = std::fs::remove_dir_all(&d);
assert_eq!(out.status.code(), Some(0));
let v: serde_json::Value =
serde_json::from_str(String::from_utf8(out.stdout).unwrap().trim()).expect("json report");
let eff = |needle: &str| -> Vec<String> {
v["functions"].as_array().into_iter().flatten()
.filter(|f| f["fn"].as_str().is_some_and(|q| q.contains(needle)))
.flat_map(|f| f["inferred"].as_array().into_iter().flatten()
.filter_map(|e| e.as_str().map(String::from)).collect::<Vec<_>>())
.collect()
};
assert!(eff("gated_out").is_empty(), "a feature-inactive stmt fabricated its effect:\n{v}");
assert!(eff("nested_out").is_empty(), "all(on, off) is definite-false — Fs here is fabricated:\n{v}");
assert!(eff("unknown_kept").contains(&"Net".to_string()),
"any(false, unknown) is unknown — the stmt must be kept, Net lost:\n{v}");
assert!(eff("nested_in").contains(&"Env".to_string()),
"all(deep-active, not(off)) is definite-true — Env lost (default closure broken?):\n{v}");
}
fn make_registry(tag: &str, pkgs: &[(&str, &str, &str)]) -> PathBuf {
let ch = std::env::temp_dir().join(format!("candor-scan-cli-ch-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&ch);
let idx = ch.join("registry/src/index.crates.io-0000000000000000");
std::fs::create_dir_all(&idx).unwrap();
for (n, v, src) in pkgs {
let d = idx.join(format!("{n}-{v}"));
std::fs::create_dir_all(d.join("src")).unwrap();
std::fs::write(d.join("Cargo.toml"), format!("[package]\nname = \"{n}\"\n")).unwrap();
std::fs::write(d.join("src/lib.rs"), src).unwrap();
}
ch
}
fn write_lockfile(root: &std::path::Path, pkgs: &[(&str, &str, bool)]) {
let mut s = String::from("version = 3\n");
for (n, v, reg) in pkgs {
s.push_str(&format!("\n[[package]]\nname = \"{n}\"\nversion = \"{v}\"\n"));
if *reg {
s.push_str("source = \"registry+https://github.com/rust-lang/crates.io-index\"\n");
}
}
std::fs::write(root.join("Cargo.lock"), s).unwrap();
}
fn run_deps(dir: &std::path::Path, cargo_home: &std::path::Path, args: &[&str]) -> std::process::Output {
let mut c = Command::new(bin());
c.arg(dir.to_string_lossy().as_ref()).arg("--deps");
for a in args {
c.arg(a);
}
c.env("CARGO_HOME", cargo_home)
.env_remove("CANDOR_DEPS")
.env_remove("CANDOR_POLICY")
.env_remove("CANDOR_CONFIG");
c.output().expect("run candor-scan --deps")
}
#[test]
fn deps_without_cargo_lock_fails_closed_exit_2() {
let d = make_crate("depsnolock", "pub fn go() {}");
let ch = make_registry("nolock", &[]);
let out = run_deps(&d, &ch, &[]);
let _ = std::fs::remove_dir_all(&d);
let _ = std::fs::remove_dir_all(&ch);
assert_eq!(out.status.code(), Some(2), "--deps without a lockfile must exit 2");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("Cargo.lock") && stderr.contains("generate-lockfile"),
"the error must name the missing lockfile + the incantation, got:\n{stderr}");
}
#[test]
fn deps_scans_registry_tree_chains_effects_and_caches() {
let ch = make_registry("happy", &[(
"depx", "0.1.0",
r#"pub fn eff() { let _ = std::fs::read("/etc/depx.conf"); }"#,
)]);
let d = make_crate("depsroot", "pub fn uses() { depx::eff(); }");
std::fs::write(d.join("Cargo.toml"),
"[package]\nname = \"depsroot\"\n\n[dependencies]\ndepx = \"0.1.0\"\nghost = \"0.9.9\"\n").unwrap();
write_lockfile(&d, &[("depsroot", "0.1.0", false), ("depx", "0.1.0", true), ("ghost", "0.9.9", true)]);
let out = run_deps(&d, &ch, &["--json"]);
assert_eq!(out.status.code(), Some(0), "a clean --deps run must exit 0: {}",
String::from_utf8_lossy(&out.stderr));
assert!(d.join(".candor/deps/depx@0.1.0/report.depx.scan.json").is_file(),
"the dep report must be written under .candor/deps/<name>@<version>/");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("scanned 1 of 2 registry dependencies"),
"the summary counts scanned/locked (root pkg is not a registry dep), got:\n{stderr}");
assert!(stderr.contains("without a local checkout") && stderr.contains("ghost-0.9.9"),
"a lockfile dep with no checkout is DISCLOSED, not fatal, got:\n{stderr}");
let v: serde_json::Value =
serde_json::from_str(String::from_utf8(out.stdout).unwrap().trim()).expect("json report");
let uses = v["functions"].as_array().into_iter().flatten()
.find(|f| f["fn"].as_str() == Some("uses"))
.unwrap_or_else(|| panic!("`uses` missing from the chained report:\n{v}"));
assert!(uses["inferred"].as_array().is_some_and(|a| a.iter().any(|e| e == "Fs")),
"the dep's Fs must cross the crate boundary via the chain:\n{v}");
assert!(uses["paths"].as_array().is_some_and(|a| a.iter().any(|p| p == "/etc/depx.conf")),
"the dep's literal path surface must ride the join:\n{v}");
let out2 = run_deps(&d, &ch, &["--json"]);
assert_eq!(out2.status.code(), Some(0));
let stderr2 = String::from_utf8(out2.stderr).unwrap();
assert!(stderr2.contains("scanned 0 of 2") && stderr2.contains("1 already scanned — cached"),
"the second run must reuse the cached dep report, got:\n{stderr2}");
let _ = std::fs::remove_dir_all(&d);
let _ = std::fs::remove_dir_all(&ch);
}
#[test]
fn deps_dependency_scans_run_gate_free_but_root_gate_sees_the_chain() {
let ch = make_registry("gate", &[(
"depg", "0.2.0",
r#"pub fn spawn() { let _ = std::process::Command::new("sh").status(); }"#,
)]);
let d = make_crate("gatefree", "pub fn quiet() {}");
std::fs::write(d.join("Cargo.toml"),
"[package]\nname = \"gatefree\"\n\n[dependencies]\ndepg = \"0.2.0\"\n").unwrap();
write_lockfile(&d, &[("gatefree", "0.1.0", false), ("depg", "0.2.0", true)]);
let pol = d.join("candor.policy");
std::fs::write(&pol, "deny Exec\n").unwrap();
let out = run_deps(&d, &ch, &["--policy", pol.to_string_lossy().as_ref()]);
assert_eq!(out.status.code(), Some(0),
"dep scans must run GATE-FREE — the root policy fired on dependency internals:\n{}",
String::from_utf8_lossy(&out.stderr));
let _ = std::fs::remove_dir_all(&d);
let d2 = make_crate("gatechain", "pub fn uses() { depg::spawn(); }");
std::fs::write(d2.join("Cargo.toml"),
"[package]\nname = \"gatechain\"\n\n[dependencies]\ndepg = \"0.2.0\"\n").unwrap();
write_lockfile(&d2, &[("gatechain", "0.1.0", false), ("depg", "0.2.0", true)]);
let pol2 = d2.join("candor.policy");
std::fs::write(&pol2, "deny Exec\n").unwrap();
let out2 = run_deps(&d2, &ch, &["--policy", pol2.to_string_lossy().as_ref()]);
assert_eq!(out2.status.code(), Some(1),
"a chained-in Exec must fail the root gate (exit 1):\n{}",
String::from_utf8_lossy(&out2.stderr));
let stdout2 = String::from_utf8(out2.stdout).unwrap();
assert!(stdout2.contains("uses"), "the violation names the ROOT fn, got:\n{stdout2}");
let _ = std::fs::remove_dir_all(&d2);
let _ = std::fs::remove_dir_all(&ch);
}
#[test]
fn deps_workspace_root_fans_out_over_members() {
let ch = make_registry("wsfan", &[(
"depw", "1.0.0",
r#"pub fn tick() { let _ = std::env::var("TZ"); }"#,
)]);
let d = std::env::temp_dir().join(format!("candor-scan-cli-depsws-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("m1/src")).unwrap();
std::fs::write(d.join("Cargo.toml"), "[workspace]\nmembers = [\"m1\"]\n").unwrap();
std::fs::write(d.join("m1/Cargo.toml"),
"[package]\nname = \"m1\"\n\n[dependencies]\ndepw = \"1.0.0\"\n").unwrap();
std::fs::write(d.join("m1/src/lib.rs"), "pub fn go() { depw::tick(); }\n").unwrap();
write_lockfile(&d, &[("m1", "0.1.0", false), ("depw", "1.0.0", true)]);
let out = run_deps(&d, &ch, &[]);
assert_eq!(out.status.code(), Some(0), "{}", String::from_utf8_lossy(&out.stderr));
let rep = std::fs::read_to_string(d.join(".candor/report.m1.scan.json"))
.expect("member report must be written (the fan-out, not the pruned-empty root scan)");
assert!(rep.contains("\"go\"") && rep.contains("Env"),
"the member's chained dep effect is missing: {rep}");
let _ = std::fs::remove_dir_all(&d);
let _ = std::fs::remove_dir_all(&ch);
}
#[test]
fn deps_appends_candor_deps_env_reports_to_the_chain() {
let ch = make_registry("extra", &[]); let extra = std::env::temp_dir().join(format!("candor-scan-cli-extradep-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&extra);
std::fs::create_dir_all(&extra).unwrap();
let me = env!("CARGO_PKG_VERSION");
std::fs::write(extra.join("report.extdep.scan.json"), format!(r#"{{
"candor": {{"version": "scan-{me}", "toolchain": "stable", "spec": "0.8"}},
"package": "extdep",
"functions": [{{"fn": "ping", "inferred": ["Net"], "hash": "extdep#ping"}}]}}"#)).unwrap();
let d = make_crate("extroot", "pub fn calls() { extdep::ping(); }");
write_lockfile(&d, &[("extroot", "0.1.0", false)]);
let mut c = Command::new(bin());
c.arg(d.to_string_lossy().as_ref()).arg("--deps").arg("--json")
.env("CARGO_HOME", &ch)
.env("CANDOR_DEPS", extra.to_string_lossy().as_ref())
.env_remove("CANDOR_POLICY")
.env_remove("CANDOR_CONFIG");
let out = c.output().expect("run candor-scan --deps");
let _ = std::fs::remove_dir_all(&d);
let _ = std::fs::remove_dir_all(&extra);
let _ = std::fs::remove_dir_all(&ch);
assert_eq!(out.status.code(), Some(0), "{}", String::from_utf8_lossy(&out.stderr));
let v: serde_json::Value =
serde_json::from_str(String::from_utf8(out.stdout).unwrap().trim()).expect("json report");
let calls = v["functions"].as_array().into_iter().flatten()
.find(|f| f["fn"].as_str() == Some("calls"))
.unwrap_or_else(|| panic!("`calls` missing:\n{v}"));
assert!(calls["inferred"].as_array().is_some_and(|a| a.iter().any(|e| e == "Net")),
"a CANDOR_DEPS sibling report must still chain under --deps:\n{v}");
}