use std::path::PathBuf;
use std::process::Command;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_candor-query")
}
struct Fixture {
dir: PathBuf,
prefix: String,
}
impl Fixture {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("candor-query-cli-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let prefix = dir.join("r").to_string_lossy().into_owned();
Fixture { dir, prefix }
}
fn write_report(&self) {
let report = r#"{
"candor": { "version": "scan-test", "toolchain": "stable", "spec": "0.7" },
"package": "rpt",
"functions": [
{ "fn": "inner", "loc": "src/lib.rs:2:1", "inferred": ["Fs"], "direct": ["Fs"], "hash": "rpt#inner", "paths": ["/x"] },
{ "fn": "outer", "loc": "src/lib.rs:1:1", "inferred": ["Fs"], "hash": "rpt#outer", "paths": ["/x"], "calls": ["inner"] }
]
}"#;
std::fs::write(format!("{}.rpt.scan.json", self.prefix), report).unwrap();
std::fs::write(format!("{}.rpt.scan.callgraph.json", self.prefix), r#"{"inner":[],"outer":["inner"]}"#).unwrap();
}
fn report_path(&self) -> String {
format!("{}.rpt.scan.json", self.prefix)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[test]
fn version_exits_0() {
for flag in ["--version", "-V"] {
let out = Command::new(bin()).arg(flag).output().expect("run candor-query");
assert_eq!(out.status.code(), Some(0), "{flag} must exit 0");
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
assert!(stdout.lines().next().unwrap_or("").starts_with("candor-query "),
"{flag} must print the build banner, got:\n{stdout}");
}
}
#[test]
fn help_exits_0() {
for flag in ["--help", "-h"] {
let out = Command::new(bin()).arg(flag).output().expect("run candor-query");
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_command_exits_2() {
let out = Command::new(bin()).arg("boguscmd").output().expect("run candor-query");
assert_eq!(out.status.code(), Some(2), "an unknown command must exit 2");
let stderr = String::from_utf8(out.stderr).expect("utf8 stderr");
assert!(stderr.contains("unknown command"), "must report `unknown command`, got:\n{stderr}");
}
#[test]
fn no_command_exits_2() {
let out = Command::new(bin()).output().expect("run candor-query");
assert_eq!(out.status.code(), Some(2), "no command must exit 2");
}
#[test]
fn corrupt_report_does_not_panic() {
let f = Fixture::new("corrupt");
std::fs::write(f.report_path(), "{ this is : not valid json @@@").unwrap();
let out = Command::new(bin()).arg("map").arg(&f.prefix).arg("0").output().expect("run candor-query");
assert_ne!(out.status.code(), Some(101), "a corrupt report must not panic the query");
let stderr = String::from_utf8(out.stderr).expect("utf8 stderr");
assert!(stderr.contains("failed to parse"), "a corrupt report must be disclosed on stderr, got:\n{stderr}");
}
#[test]
fn truncated_report_does_not_panic() {
let f = Fixture::new("truncated");
std::fs::write(f.report_path(), r#"{"candor":{"version":"scan-test","spec":"0.7","toolch"#).unwrap();
let out = Command::new(bin()).arg("map").arg(&f.prefix).arg("0").output().expect("run candor-query");
assert_ne!(out.status.code(), Some(101), "a truncated report must not panic the query");
let stderr = String::from_utf8(out.stderr).expect("utf8 stderr");
assert!(stderr.contains("failed to parse"), "a truncated report must be disclosed on stderr, got:\n{stderr}");
}
#[test]
fn nonexistent_prefix_fails_loud_exit_2() {
let f = Fixture::new("nopfx"); let out = Command::new(bin()).arg("map").arg(&f.prefix).arg("0").output().expect("run candor-query");
assert_eq!(out.status.code(), Some(2), "a prefix with no reports must exit 2");
let stderr = String::from_utf8(out.stderr).expect("utf8 stderr");
assert!(stderr.contains("no report files"), "must report `no report files`, got:\n{stderr}");
}
#[test]
fn parsepolicy_unreadable_exits_2() {
let f = Fixture::new("ppol");
let missing = f.dir.join("does-not-exist.policy");
let out = Command::new(bin())
.arg("parsepolicy")
.arg(missing.to_string_lossy().as_ref())
.output()
.expect("run candor-query");
assert_eq!(out.status.code(), Some(2), "parsepolicy over an unreadable file must exit 2");
let stderr = String::from_utf8(out.stderr).expect("utf8 stderr");
assert!(stderr.contains("cannot read policy"), "must report the read failure, got:\n{stderr}");
}
#[test]
fn parsepolicy_missing_arg_exits_2() {
let out = Command::new(bin()).arg("parsepolicy").output().expect("run candor-query");
assert_eq!(out.status.code(), Some(2), "parsepolicy with no path must exit 2 (usage)");
}
#[test]
fn parsepolicy_valid_dumps_json_exit_0() {
let f = Fixture::new("ppolok");
let pp = f.dir.join("p.policy");
std::fs::write(&pp, "deny Net Db domain\nallow Net in billing api.stripe.com\n").unwrap();
let out = Command::new(bin())
.arg("parsepolicy")
.arg(pp.to_string_lossy().as_ref())
.output()
.expect("run candor-query");
assert_eq!(out.status.code(), Some(0), "a valid parsepolicy must exit 0");
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("parsepolicy must emit JSON");
assert!(v.get("deny").is_some() && v.get("allow").is_some() && v.get("forbid").is_some(),
"parsepolicy JSON must carry deny/allow/forbid keys, got:\n{stdout}");
}
#[test]
fn whatif_nonexistent_policy_exits_2() {
let f = Fixture::new("wfpol");
f.write_report();
let bogus = f.dir.join("typo.policy");
let out = Command::new(bin())
.arg("whatif")
.arg(&f.prefix)
.arg("inner")
.arg("Net")
.arg(bogus.to_string_lossy().as_ref())
.arg("0")
.output()
.expect("run candor-query");
assert_eq!(out.status.code(), Some(2), "whatif with an unreadable policy must exit 2, not gate-green");
let stderr = String::from_utf8(out.stderr).expect("utf8 stderr");
assert!(stderr.contains("could not be read"), "must report the policy read failure, got:\n{stderr}");
}
fn write_containment_report(dir: &std::path::Path, name: &str, web_has_db: bool) -> String {
let prefix = dir.join(name).to_string_lossy().into_owned();
let web_direct = if web_has_db { r#","direct":["Db"]"# } else { "" };
let report = format!(
r#"{{"candor":{{"version":"scan-test","toolchain":"stable","spec":"0.8"}},"package":"cnt","functions":[
{{"fn":"app::data::save","inferred":["Db"],"direct":["Db"]}},
{{"fn":"app::web::page","inferred":["Db"]{web_direct}}}
]}}"#
);
std::fs::write(format!("{prefix}.cnt.scan.json"), report).unwrap();
prefix
}
#[test]
fn containment_ratchet_violation_exits_1_and_names_the_leak() {
let f = Fixture::new("cnt-leak");
let base = write_containment_report(&f.dir, "base", false);
let cur = write_containment_report(&f.dir, "cur", true);
let out = Command::new(bin()).arg("containment").arg(&cur).arg(&base).output().expect("run");
assert_eq!(out.status.code(), Some(1), "a containment leak must exit 1 (the ratchet bites)");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("[AS-EFF-010]"), "the ratchet line carries its AS-EFF code: {stdout}");
assert!(stdout.contains("Db → web"), "the leak names effect and layer: {stdout}");
}
#[test]
fn containment_ratchet_clean_exits_0() {
let f = Fixture::new("cnt-clean");
let base = write_containment_report(&f.dir, "base", true);
let cur = write_containment_report(&f.dir, "cur", true);
let out = Command::new(bin()).arg("containment").arg(&cur).arg(&base).output().expect("run");
assert_eq!(out.status.code(), Some(0), "an unchanged containment picture must exit 0");
let cur2 = write_containment_report(&f.dir, "cur2", false);
let out = Command::new(bin()).arg("containment").arg(&cur2).arg(&base).output().expect("run");
assert_eq!(out.status.code(), Some(0), "a cleanup (layer left) must exit 0");
assert!(String::from_utf8(out.stdout).unwrap().contains("improved"),
"the cleanup is noted informationally");
}
#[test]
fn gate_verdict_absent_parts_is_the_clean_verdict() {
let f = Fixture::new("gv-clean");
let out = Command::new(bin())
.arg("gate-verdict").arg(f.dir.join("nosuch.parts").to_string_lossy().as_ref()).arg("-")
.output().expect("run");
assert_eq!(out.status.code(), Some(0));
let v: serde_json::Value = serde_json::from_str(String::from_utf8(out.stdout).unwrap().trim()).unwrap();
assert_eq!(v["ok"], true);
assert_eq!(v["spec"], "0.8");
assert_eq!(v["violations"], serde_json::json!([]));
}
#[test]
fn gate_verdict_assembles_and_sorts_records_into_a_failing_verdict() {
let f = Fixture::new("gv-viol");
let parts = f.dir.join("gate.json.parts");
std::fs::write(&parts, concat!(
r#"{"rule":"AS-EFF-009","fn":"b","effects":[],"detail":"z"}"#, "\n",
r#"{"rule":"AS-EFF-006","fn":"a","effects":["Net"],"detail":"y"}"#, "\n",
)).unwrap();
let outfile = f.dir.join("gate.json");
let out = Command::new(bin())
.arg("gate-verdict").arg(parts.to_string_lossy().as_ref()).arg(outfile.to_string_lossy().as_ref())
.output().expect("run");
assert_eq!(out.status.code(), Some(0));
let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&outfile).unwrap()).unwrap();
assert_eq!(v["ok"], false);
let arr = v["violations"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["rule"], "AS-EFF-006", "records sort by (rule, detail)");
assert_eq!(arr[0]["fn"], "a");
assert_eq!(arr[0]["effects"], serde_json::json!(["Net"]));
}
#[test]
fn gate_verdict_corrupt_record_fails_closed() {
let f = Fixture::new("gv-corrupt");
let parts = f.dir.join("gate.json.parts");
std::fs::write(&parts, "{not json@@\n").unwrap();
let outfile = f.dir.join("gate.json");
let out = Command::new(bin())
.arg("gate-verdict").arg(parts.to_string_lossy().as_ref()).arg(outfile.to_string_lossy().as_ref())
.output().expect("run");
assert_eq!(out.status.code(), Some(2), "a corrupt record must fail closed");
assert!(!outfile.exists(), "no partial verdict may be written");
}
#[test]
fn reports_exists_and_backend_probe() {
let f = Fixture::new("rp-probe");
let out = Command::new(bin()).arg("reports").arg(&f.prefix).arg("--exists").output().expect("run");
assert_ne!(out.status.code(), Some(0), "no reports → --exists must be falsy");
f.write_report();
let out = Command::new(bin()).arg("reports").arg(&f.prefix).arg("--exists").output().expect("run");
assert_eq!(out.status.code(), Some(0), "a present report → --exists exit 0");
let out = Command::new(bin()).arg("reports").arg(&f.prefix).arg("--backend").output().expect("run");
assert_eq!(out.status.code(), Some(0));
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "scan",
"a `.scan.json` report probes as the scan backend");
}
#[test]
fn engine_version_reads_the_embedded_tag() {
let f = Fixture::new("ev-tag");
let lib = f.dir.join("libfake.so");
std::fs::write(&lib, b"\x7fELFjunk candor-build-version=abc1234 morejunk").unwrap();
let out = Command::new(bin()).arg("engine-version").arg(lib.to_string_lossy().as_ref()).output().expect("run");
assert_eq!(out.status.code(), Some(0));
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "abc1234");
let bare = f.dir.join("bare.so");
std::fs::write(&bare, b"nothing here").unwrap();
let out = Command::new(bin()).arg("engine-version").arg(bare.to_string_lossy().as_ref()).output().expect("run");
assert_ne!(out.status.code(), Some(0), "no embedded tag → non-zero");
}
#[test]
fn diff_reports_a_gained_effect() {
let f = Fixture::new("df-gain");
let base = f.dir.join("b").to_string_lossy().into_owned();
let cur = f.dir.join("c").to_string_lossy().into_owned();
let mk = |prefix: &str, effs: &str| {
std::fs::write(format!("{prefix}.d.scan.json"), format!(
r#"{{"candor":{{"version":"scan-test","toolchain":"stable","spec":"0.8"}},"functions":[
{{"fn":"worker","inferred":[{effs}],"direct":[{effs}]}}]}}"#)).unwrap();
};
mk(&base, r#""Fs""#);
mk(&cur, r#""Fs","Net""#);
let out = Command::new(bin())
.arg("diff").arg(&cur).arg(&base).arg("1").arg("v1").arg("v1")
.output().expect("run");
assert_eq!(out.status.code(), Some(0));
let v: serde_json::Value = serde_json::from_str(String::from_utf8(out.stdout).unwrap().trim()).unwrap();
let gained = v.pointer("/changed/0/gained").or_else(|| v.pointer("/gained"));
assert!(serde_json::to_string(&v).unwrap().contains("Net"),
"the +Net gain must appear in the diff JSON: {v} (gained={gained:?})");
}
#[test]
fn whatif_unknown_effect_exits_2() {
let f = Fixture::new("wfeff");
f.write_report();
let out = Command::new(bin())
.arg("whatif")
.arg(&f.prefix)
.arg("inner")
.arg("net")
.arg("0")
.output()
.expect("run candor-query");
assert_eq!(out.status.code(), Some(2), "whatif with an unknown effect must exit 2");
let stderr = String::from_utf8(out.stderr).expect("utf8 stderr");
assert!(stderr.contains("unknown effect"), "must report `unknown effect`, got:\n{stderr}");
}