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