use crate::*;
pub(crate) fn load_policy_as_the_gate_does(
verb: &str,
policy_path: &str,
) -> Result<candor_classify::policy::ParsedPolicy, i32> {
let Ok(text) = std::fs::read_to_string(policy_path) else {
eprintln!("candor {verb}: policy `{policy_path}` could not be read — nothing computed (exit 2).");
return Err(2);
};
let aliases = candor_classify::policy::discover_config_text(std::path::Path::new(policy_path))
.map(|t| candor_classify::policy::parse_unknown_aliases(&t))
.unwrap_or_default();
let p = candor_classify::policy::parse_policy_with_aliases(&text, &aliases);
let fatal = p.fatal_messages();
if !fatal.is_empty() {
for e in &fatal {
eprintln!("candor {verb}: policy error — {e}");
}
eprintln!(
"candor {verb}: refusing to reason about a policy that cannot be honoured AS WRITTEN (exit \
2). The gate refuses it too, and answering here from a rule the gate will not apply is the \
worse failure: this is the verb consulted BEFORE the edit."
);
return Err(2);
}
Ok(p)
}
pub(crate) fn policy_asked_nothing(p: &candor_classify::policy::ParsedPolicy) -> bool {
p.rules.is_empty() && p.allow_rules.is_empty() && p.layer_rules.is_empty() && p.only_rules.is_empty()
}
pub(crate) fn emit_zero_rule_caveat(
verb: &str,
policy_path: &str,
want_json: bool,
comp: &crate::completeness::ReportCompleteness,
) {
let sentence = format!(
"candor {verb}: the policy at {policy_path} yielded NO RULES — every line was ignored, the \
file is empty, or it holds only comments. A policy with no rules ASKS NOTHING, so this verb \
has no answer to give relative to it: the result keys are withheld and this caveat stands in \
their place (SPEC §2 ⟨0.28⟩). `gate` refuses outright over this policy (exit 2). If you did \
not mean to gate, remove the policy configuration rather than pointing it at a file with no \
rules in it."
);
if want_json {
eprintln!("{sentence}");
let mut out = serde_json::json!({
"unevaluated": [ {
"rule": format!("(entire policy {policy_path} — no rules parsed)"),
"why": "the configured policy yielded zero rules, so nothing was evaluated and no \
rule can have passed",
} ],
});
comp.write_json(&mut out);
println!("{}", serde_json::to_string_pretty(&out).unwrap());
} else {
println!("{sentence}");
comp.print_note(
"this run's report is ALSO incomplete — the caveat above is not the only one",
&format!("{} Re-scan for a complete answer.", comp.gate_line()),
);
}
}
pub(crate) fn cmd_parsepolicy(args: &[String]) -> i32 {
let Some(path) = args.first() else {
eprintln!("usage: candor-query parsepolicy <policy-file>");
return 2;
};
let Ok(text) = std::fs::read_to_string(path) else {
eprintln!("candor: cannot read policy {path}");
return 2;
};
let aliases = candor_classify::policy::discover_config_text(std::path::Path::new(path))
.map(|t| candor_classify::policy::parse_unknown_aliases(&t))
.unwrap_or_default();
let p = candor_classify::policy::parse_policy_with_aliases(&text, &aliases);
let mut deny: Vec<serde_json::Value> = p
.rules
.iter()
.map(|r| {
let mut m = serde_json::json!({
"effects": r.effects.iter().copied().collect::<Vec<&str>>(),
"scope": r.scope.as_deref().unwrap_or(""),
});
if !r.unknown_classes.is_empty() {
let mut toks: Vec<&str> = r.unknown_classes.iter().map(|c| c.token()).collect();
toks.sort_unstable(); m["unknownClasses"] = serde_json::json!(toks);
}
if !r.net_classes.is_empty() {
let toks: Vec<&str> = r.net_classes.iter().map(String::as_str).collect(); m["netClasses"] = serde_json::json!(toks);
}
m
})
.collect();
let mut allow: Vec<serde_json::Value> = p
.allow_rules
.iter()
.map(|r| {
serde_json::json!({
"effect": r.effect,
"scope": r.scope.as_deref().unwrap_or(""),
"values": r.literals.iter().map(String::as_str).collect::<Vec<&str>>(),
})
})
.collect();
let mut forbid: Vec<serde_json::Value> =
p.layer_rules.iter().map(|r| serde_json::json!({ "from": r.from, "to": r.to })).collect();
deny.sort_by_key(|v| v.to_string());
allow.sort_by_key(|v| v.to_string());
forbid.sort_by_key(|v| v.to_string());
let mut only: Vec<serde_json::Value> = p
.only_rules
.iter()
.map(|r| serde_json::json!({ "from": r.from, "to": r.to }))
.collect();
only.sort_by_key(|v| v.to_string());
let errors: Vec<serde_json::Value> = p
.errors
.iter()
.map(|e| {
serde_json::json!({
"kind": e.kind,
"token": e.token,
"accepted": e.accepted,
"rule": e.rule,
"message": e.message,
})
})
.collect();
let mut doc = serde_json::json!({ "deny": deny, "allow": allow, "forbid": forbid, "only": only });
if !errors.is_empty() {
doc["errors"] = serde_json::Value::Array(errors);
}
println!("{doc}");
0
}
pub(crate) fn cmd_whatif(args: &[String]) -> i32 {
let g = parse(args, Shape { verb_args: 2, sentinel: true, has_policy: true, verb: "whatif" });
let (Some(target), Some(effect)) = (g.positional.first().cloned(), g.positional.get(1).cloned()) else {
eprintln!("usage: candor-query whatif <fn> <Effect> [--report <locator>] [--policy <file>] [--json]");
return 2;
};
let (target, effect) = (&target, &effect);
if candor_classify::cap_from_name(effect).is_none() && effect.as_str() != "Unknown" {
eprintln!("candor: unknown effect `{effect}` (expected a candor effect name, e.g. Net/Fs/Db/Exec, or Unknown)");
return 2;
}
let Some(prefix) = report_or_discover(&g) else {
eprintln!("candor: no report found (no --report and no .candor/ discovered) — scan the crate first.");
return 2;
};
let prefix = &prefix;
let want_json = g.want_json;
let policy_path: Option<String> = g.policy.clone().or_else(|| std::env::var("CANDOR_POLICY").ok());
let cg = load_callgraph(prefix);
if cg.is_empty() {
eprintln!("candor: no call-graph sidecar for `{prefix}` — scan the crate first.");
return 2;
}
let mut rev: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (caller, callees) in &cg {
for c in callees {
rev.entry(c.as_str()).or_default().push(caller.as_str());
}
}
let names: BTreeSet<&str> =
cg.keys().map(|s| s.as_str()).chain(cg.values().flatten().map(|s| s.as_str())).collect();
let tier = best_tier(names.iter().copied(), target);
let targets: Vec<&str> = names.iter().copied().filter(|n| q_match(n, target, tier)).collect();
if targets.is_empty() {
eprintln!("candor: no function matching `{target}` in the call graph.");
return 2;
}
let mut affected: BTreeSet<&str> = targets.iter().copied().collect();
let mut stack: Vec<&str> = targets.clone();
while let Some(n) = stack.pop() {
if let Some(cs) = rev.get(n) {
for &c in cs {
if affected.insert(c) {
stack.push(c);
}
}
}
}
let parsed = match policy_path.as_deref() {
None => None,
Some(p) => match load_policy_as_the_gate_does("whatif", p) {
Ok(pp) => Some((p.to_string(), pp)),
Err(code) => return code,
},
};
let rules = parsed.as_ref().map(|(_, pp)| pp.rules.clone());
let mut violations: Vec<(&str, String, Option<String>)> = Vec::new();
if let Some(rules) = &rules {
for fname in &affected {
for rule in rules {
let denies = if rule.effects.is_empty() {
effect != candor_classify::policy::UNKNOWN
} else {
rule.effects.contains(effect.as_str())
};
let in_scope =
rule.scope.as_deref().is_none_or(|s| candor_classify::policy::scope_matches(fname, s));
if denies && in_scope {
violations.push((fname, rule.raw.clone(), narrowing_condition(rule, effect)));
break;
}
}
}
}
let comp = match parsed.as_ref() {
Some((_, pp)) => crate::completeness::arm_unasked_rules(
crate::completeness::arm_unread(crate::completeness::report_completeness(prefix), pp),
pp,
),
None => crate::completeness::report_completeness(prefix),
};
comp.warn_unreadable("whatif");
if let Some((pp_path, _)) = parsed.as_ref().filter(|(_, pp)| policy_asked_nothing(pp)) {
emit_zero_rule_caveat("whatif", pp_path, want_json, &comp);
return 0;
}
if want_json {
let mut out = serde_json::json!({
"of": targets,
"effect": effect,
"affected": affected.iter().collect::<Vec<_>>(),
"violations": violations
.iter()
.map(|(f, r, cond)| {
let mut v = serde_json::json!({"fn": f, "rule": r});
if let Some(c) = cond {
v["conditional"] = serde_json::json!(c);
}
v
})
.collect::<Vec<_>>(),
});
if comp.must_hedge() {
comp.write_json(&mut out);
} else {
out["ok"] = serde_json::json!(violations.is_empty());
}
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return if violations.is_empty() { 0 } else { 1 };
}
println!("whatif: adding `{effect}` to `{}`", targets.join(", "));
println!(" → propagates to {} function(s) (the blast radius):", affected.len());
for f in &affected {
println!(" {f}");
}
comp.print_note(
"the blast radius above is computed over a universe candor cannot fully see",
"A caller living in one of those is INVISIBLE here. Re-scan for a complete answer.",
);
if rules.is_none() {
println!(" (no policy given — pass a policy file or set CANDOR_POLICY for the gate verdict)");
return 0;
}
if violations.is_empty() {
if comp.must_hedge() {
println!(
" · nothing candor COULD SEE violates a `deny`/`pure` boundary — but see the INCOMPLETE \
note above; this is not an all-clear."
);
} else {
println!(" ✓ within policy — this edit introduces no `deny`/`pure` boundary violation.");
}
0
} else {
println!(" ⚠ WOULD VIOLATE policy ({}) — run BEFORE the edit:", violations.len());
for (f, r, cond) in &violations {
println!(" [AS-EFF-006] `{f}` (rule: `{r}`)");
if let Some(c) = cond {
println!(" …IF {c}.");
println!(" This rule NARROWS, and the effect you have not written yet has no class to");
println!(" match — candor charges it fail-closed rather than guessing which you'd add.");
}
}
1
}
}
fn narrowing_condition(rule: &candor_classify::policy::PolicyRule, effect: &str) -> Option<String> {
if effect == candor_classify::policy::UNKNOWN && !rule.unknown_classes.is_empty() {
let mut t: Vec<&str> = rule.unknown_classes.iter().map(|c| c.token()).collect();
t.sort_unstable();
return Some(format!("the `Unknown` you introduce is of reason class {}", t.join(" / ")));
}
if effect == "Net" && !rule.net_classes.is_empty() {
let t: Vec<&str> = rule.net_classes.iter().map(String::as_str).collect(); return Some(format!("the `Net` you introduce reaches destination class {}", t.join(" / ")));
}
None
}
pub(crate) fn dropped_edges<'a>(
cur: &'a BTreeMap<String, Vec<String>>,
base: &'a BTreeMap<String, Vec<String>>,
) -> BTreeMap<&'a str, Vec<&'a str>> {
let mut dropped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (caller, base_callees) in base {
let now: BTreeSet<&str> =
cur.get(caller).map(|v| v.iter().map(String::as_str).collect()).unwrap_or_default();
let gone: Vec<&str> = base_callees.iter().map(String::as_str).filter(|c| !now.contains(c)).collect();
if !gone.is_empty() {
dropped.insert(caller.as_str(), gone);
}
}
dropped
}
pub(crate) fn cmd_rewire(args: &[String]) -> i32 {
let mut want_json = false;
let mut pos: Vec<&String> = Vec::new();
for a in args {
match a.as_str() {
"--json" => want_json = true,
"--text" | "--human" => {}
other if other.starts_with('-') && other.len() > 1 => {
let hint = if other == "--policy" { " — `rewire` is a descriptive query with no policy-relative verdict (its SPEC §3.1 JSON shape carries no policy-derived field); apply a policy to this report with `candor-query gate --report <locator> --policy <file>`, or use whatif/fix/fix-gate/unverified for a policy-relative pre-edit check." } else { "" };
eprintln!("candor-query rewire: unknown flag `{other}`{hint}\n known flags: --json");
return 2;
}
_ => pos.push(a),
}
}
if pos.len() < 2 {
eprintln!("usage: candor-query rewire <cur_prefix> <base_prefix> [--json]");
return 2;
}
let (cur_pre, base_pre) = (pos[0], pos[1]);
if pos.get(2).map(|s| s.as_str()) == Some("1") {
want_json = true;
}
let cur = load_callgraph(cur_pre);
let base = load_callgraph(base_pre);
if base.is_empty() {
eprintln!("candor: no baseline call graph at `{base_pre}` (need its `.callgraph.json` sidecar).");
return 2;
}
if cur.is_empty() {
eprintln!("candor: no current call graph at `{cur_pre}` (need its `.callgraph.json` sidecar).");
return 2;
}
let dropped = dropped_edges(&cur, &base);
if want_json {
let out = serde_json::json!({
"dropped": dropped.iter().map(|(c, g)| serde_json::json!({"caller": c, "no_longer_calls": g}))
.collect::<Vec<_>>(),
"ok": dropped.is_empty(),
});
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return if dropped.is_empty() { 0 } else { 1 };
}
if dropped.is_empty() {
println!(" no call edges dropped vs the baseline — nothing de-wired.");
return 0;
}
println!(
" {} function(s) DROPPED a call they made in the baseline — a 'fix' may have disconnected \
functionality (the effect gate can pass while the feature is broken; verify it still works):",
dropped.len()
);
for (caller, gone) in &dropped {
println!(" {caller} ⊘ no longer calls: {}", gone.join(", "));
}
1
}
pub(crate) fn cmd_gate_verdict(args: &[String]) -> i32 {
let mut report_loc: Option<String> = None;
let mut policy_loc: Option<String> = None;
let mut pos: Vec<&str> = Vec::new();
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--policy" {
match it.next() {
Some(l) if l == "-" || !l.starts_with('-') => policy_loc = Some(l.clone()),
Some(l) => {
eprintln!("candor-query: --policy was given no value — the next token `{l}` is a flag, not a path (a file really named that is spelled ./{l})");
return 2;
}
None => {
eprintln!("candor-query: --policy requires a file argument");
return 2;
}
}
} else if a == "--report" {
match it.next() {
Some(l) if l == "-" || !l.starts_with('-') => report_loc = Some(resolve_locator(l)),
Some(l) => {
eprintln!("candor-query: --report was given no value — the next token `{l}` is a flag, not a locator (a path really named that is spelled ./{l})");
return 2;
}
None => {
eprintln!("candor-query: --report requires a locator argument");
return 2;
}
}
} else {
pos.push(a.as_str());
}
}
let (Some(parts), Some(out)) = (pos.first().copied(), pos.get(1).copied()) else {
eprintln!("usage: candor-query gate-verdict <parts-file> <out-file|-> [--report <locator>] [--policy <file>]");
return 2;
};
let mut violations: Vec<candor_report::GateViolation> = Vec::new();
match std::fs::read_to_string(parts) {
Ok(text) => {
for line in text.lines().filter(|l| !l.trim().is_empty()) {
match serde_json::from_str(line) {
Ok(v) => violations.push(v),
Err(e) => {
eprintln!("candor-query: corrupt gate record in {parts} ({e}) — no faithful verdict exists");
return 2;
}
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => {
eprintln!("candor-query: cannot read {parts} ({e})");
return 2;
}
}
let coverage = report_loc.as_deref().and_then(load_coverage).filter(|c| !c.uncovered.is_empty()).map(|c| {
let mut packages: Vec<String> = c.uncovered.iter().map(|e| e.name.clone()).collect();
packages.sort();
candor_report::GateCoverage { uncovered: packages.len(), packages }
});
let ignored: Vec<candor_report::IgnoredLine> = match policy_loc.as_deref() {
None => Vec::new(),
Some(pl) => {
let Ok(text) = std::fs::read_to_string(pl) else {
eprintln!("candor-query: gate-verdict --policy {pl} could not be read — failing (exit 2)");
return 2;
};
let aliases = candor_classify::policy::discover_config_text(std::path::Path::new(pl))
.map(|t| candor_classify::policy::parse_unknown_aliases(&t))
.unwrap_or_default();
candor_classify::policy::parse_policy_silent(&text, &aliases)
.errors
.iter()
.filter(|e| !e.fatal)
.map(|e| candor_report::IgnoredLine {
line: e.line,
text: e.text.clone(),
reason: e.message.clone(),
})
.collect()
}
};
let json = match candor_report::gate_verdict_json_with_coverage_v28(&mut violations, coverage.as_ref(), &ignored) {
Ok(j) => j,
Err(e) => {
eprintln!("candor-query: could not serialize the gate verdict ({e})");
return 2;
}
};
if out == "-" {
println!("{json}");
return 0;
}
if let Err(e) = candor_report::write_atomic(Path::new(out), format!("{json}\n").as_bytes()) {
eprintln!("candor-query: could not write the gate verdict to {out} ({e})");
return 2;
}
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gate_verdict_report_flag_attaches_the_advisory_coverage_note() {
let dir = std::env::temp_dir().join(format!("candor-gvcov-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
let s = |p: &std::path::Path| p.to_string_lossy().into_owned();
let parts = dir.join("parts.ndjson");
std::fs::write(&parts, "{\"rule\":\"AS-EFF-006\",\"fn\":\"f\",\"effects\":[\"Net\"],\"detail\":\"d\"}\n")
.unwrap();
let covered = dir.join("rep-cov");
let _ = std::fs::create_dir_all(&covered);
std::fs::write(
covered.join("r.demo.scan.json"),
r#"{"candor":{"version":"v","toolchain":"t","spec": "0.23"},
"coverage":{"uncovered":[{"name":"somedep","calls":3},{"name":"anotherdep","calls":1}]},
"functions":[]}"#,
)
.unwrap();
let full = dir.join("rep-full");
let _ = std::fs::create_dir_all(&full);
std::fs::write(
full.join("r.demo.scan.json"),
r#"{"candor":{"version":"v","toolchain":"t","spec": "0.23"},"functions":[]}"#,
)
.unwrap();
let (plain, with_cov, fully) = (dir.join("v0.json"), dir.join("v1.json"), dir.join("v2.json"));
let args = |out: &std::path::Path, rep: Option<&std::path::Path>| -> Vec<String> {
let mut a = vec![s(&parts), s(out)];
if let Some(r) = rep {
a.push("--report".into());
a.push(s(&r.join("r")));
}
a
};
assert_eq!(cmd_gate_verdict(&args(&plain, None)), 0);
assert_eq!(cmd_gate_verdict(&args(&with_cov, Some(&covered))), 0, "same exit with the note");
assert_eq!(cmd_gate_verdict(&args(&fully, Some(&full))), 0);
let read = |p: &std::path::Path| std::fs::read_to_string(p).unwrap();
let (v0, v1) = (read(&plain), read(&with_cov));
let (j0, j1): (serde_json::Value, serde_json::Value) =
(serde_json::from_str(&v0).unwrap(), serde_json::from_str(&v1).unwrap());
for k in ["spec", "ok", "violations"] {
assert_eq!(j0[k], j1[k], "pinned verdict field `{k}` must be unchanged by the note");
}
assert_eq!(j0["ok"], false, "the violation still fails the verdict");
assert!(j0.get("coverage").is_none(), "no flag → no note (pre-⟨0.15⟩ shape): {v0}");
assert_eq!(j1["coverage"]["uncovered"], 2);
assert_eq!(j1["coverage"]["packages"], serde_json::json!(["anotherdep", "somedep"]));
assert_eq!(read(&fully), v0, "fully covered → byte-identical verdict");
let _ = std::fs::remove_dir_all(&dir);
}
}