use crate::load::load_entries;
use crate::matching::{best_tier, q_match};
use candor_report::ReportEntry;
use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
fn denied_layer(fname: &str, effect: &str, rules: &[candor_classify::policy::PolicyRule]) -> Option<String> {
for rule in rules {
let denies = if rule.effects.is_empty() {
effect != candor_classify::policy::UNKNOWN
} else {
rule.effects.contains(effect)
};
let in_scope = rule
.scope
.as_deref()
.is_none_or(|s| candor_classify::policy::scope_matches(fname, s));
if denies && in_scope {
return Some(rule.scope.clone().unwrap_or_default());
}
}
None
}
pub(crate) struct RemedyPlan<'a> {
func: &'a str,
effect: &'a str,
layer: String,
sites: BTreeSet<&'a str>,
denied_span: BTreeSet<&'a str>,
hoist_to: BTreeSet<&'a str>,
allow_edit: String,
}
impl RemedyPlan<'_> {
fn clean_hoist(&self) -> bool {
!self.hoist_to.is_empty()
}
fn dedup_key(&self) -> String {
format!(
"{}|{}|{:?}|{:?}",
self.effect,
self.layer,
self.sites.iter().collect::<Vec<_>>(),
self.hoist_to.iter().collect::<Vec<_>>()
)
}
fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"fn": self.func,
"effect": self.effect,
"layer": self.layer,
"cleanHoist": self.clean_hoist(),
"site": self.sites.iter().collect::<Vec<_>>(),
"deniedSpan": self.denied_span.iter().collect::<Vec<_>>(),
"hoistTo": self.hoist_to.iter().collect::<Vec<_>>(),
"policyAlternative": self.allow_edit,
})
}
fn render_text(&self, out: &mut String) {
use std::fmt::Write;
let layer_label = if self.layer.is_empty() { "this".to_string() } else { format!("`{}`", self.layer) };
let sitelist = if self.sites.is_empty() {
"(not a local source — a cross-crate or Unknown effect)".to_string()
} else {
self.sites.iter().map(|x| format!("`{x}`")).collect::<Vec<_>>().join(", ")
};
let _ = writeln!(out, "candor fix — hoist {} out of the {layer_label} boundary\n", self.effect);
let _ = writeln!(out, " The violation: `{}` performs {}, which the {layer_label} layer forbids.", self.func, self.effect);
let _ = writeln!(out, " Performed directly at: {sitelist}");
let span: Vec<_> = self.denied_span.iter().take(6).map(|x| format!("`{x}`")).collect();
let more = if self.denied_span.len() > 6 { ", …" } else { "" };
let _ = writeln!(out, " Forbidden across {} function(s) in the layer (they inherited it): {}{more}", self.denied_span.len(), span.join(", "));
let _ = writeln!(out);
if self.clean_hoist() {
let _ = writeln!(out, " THE FIX — hoist the effect to the boundary:");
let _ = writeln!(out, " · Perform {} at: {} (an allowed layer that already calls into the domain).", self.effect,
self.hoist_to.iter().map(|x| format!("`{x}`")).collect::<Vec<_>>().join(", "));
let _ = writeln!(out, " · Pass the result down as a parameter; the {} function(s) above then stay pure.", self.denied_span.len());
let _ = writeln!(out, " · Re-run the gate — the {layer_label} blast radius for {} should be empty.", self.effect);
let _ = writeln!(out);
let _ = writeln!(out, " ALTERNATIVE — if the {layer_label} layer is MEANT to perform {}, it's a policy bug,", self.effect);
let _ = writeln!(out, " not a code one: relax the boundary with `{}`.", self.allow_edit);
} else {
let _ = writeln!(out, " NO CLEAN HOIST — every caller up to the entry points is also in a {}-forbidding layer.", self.effect);
let _ = writeln!(out, " Two honest options:");
let _ = writeln!(out, " (a) Introduce a PORT: have the domain take an interface parameter (a trait) it receives,");
let _ = writeln!(out, " implemented by an adapter in an allowed layer that performs {} and injects the", self.effect);
let _ = writeln!(out, " result (dependency inversion) — the domain depends on the abstraction, not the I/O.");
let _ = writeln!(out, " (b) If the domain legitimately needs {}, relax the boundary: `{}`.", self.effect, self.allow_edit);
}
}
}
fn compute_remedy<'a>(
by_name: &HashMap<&'a str, &'a ReportEntry>,
rev: &BTreeMap<&'a str, Vec<&'a str>>,
rules: &[candor_classify::policy::PolicyRule],
start: &'a ReportEntry,
effect: &'a str,
layer: String,
) -> RemedyPlan<'a> {
let mut sites: BTreeSet<&str> = BTreeSet::new();
let mut fseen: BTreeSet<&str> = BTreeSet::new();
let mut q: VecDeque<&str> = VecDeque::new();
q.push_back(start.func.as_str());
fseen.insert(start.func.as_str());
while let Some(cur) = q.pop_front() {
let Some(f) = by_name.get(cur) else { continue };
if f.direct.iter().any(|e| e == effect) {
sites.insert(cur);
}
for c in &f.calls {
if let Some(cf) = by_name.get(c.as_str())
&& cf.inferred.iter().any(|e| e == effect)
&& fseen.insert(c.as_str())
{
q.push_back(c.as_str());
}
}
}
let anchors: Vec<&str> = if sites.is_empty() { vec![start.func.as_str()] } else { sites.iter().copied().collect() };
let mut denied_span: BTreeSet<&str> = BTreeSet::new();
let mut hoist_to: BTreeSet<&str> = BTreeSet::new();
let mut up: VecDeque<&str> = VecDeque::new();
for &a in &anchors {
if denied_layer(a, effect, rules).is_some() {
denied_span.insert(a); }
up.push_back(a);
}
while let Some(cur) = up.pop_front() {
if let Some(cs) = rev.get(cur) {
for &caller in cs {
if let Some(ce) = by_name.get(caller)
&& !ce.inferred.iter().any(|e| e == effect)
{
continue; }
if denied_layer(caller, effect, rules).is_some() {
if denied_span.insert(caller) {
up.push_back(caller); }
} else {
hoist_to.insert(caller); }
}
}
}
let allow_edit = if layer.is_empty() {
format!("allow {effect}")
} else {
format!("allow {effect} {layer}")
};
RemedyPlan { func: &start.func, effect, layer, sites, denied_span, hoist_to, allow_edit }
}
fn load_rules(policy_path: Option<String>) -> Result<Vec<candor_classify::policy::PolicyRule>, i32> {
let policy_path = policy_path.or_else(|| std::env::var("CANDOR_POLICY").ok());
let Some(pp) = policy_path else {
eprintln!("candor fix: a policy is required (pass a policy file or set CANDOR_POLICY) — the fix is the refactor that restores the boundary the edit crossed.");
return Err(2);
};
match std::fs::read_to_string(&pp) {
Ok(t) => Ok(candor_classify::policy::parse_policy(&t).rules),
Err(e) => {
eprintln!("candor: policy `{pp}` could not be read ({e}) — no fix computed.");
Err(2)
}
}
}
fn reverse_graph(entries: &[ReportEntry]) -> BTreeMap<&str, Vec<&str>> {
let mut rev: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for e in entries {
for c in &e.calls {
rev.entry(c.as_str()).or_default().push(e.func.as_str());
}
}
rev
}
pub(crate) fn cmd_fix(args: &[String]) -> i32 {
if args.len() < 3 {
eprintln!("usage: candor-query fix <prefix> <fn> <Effect> [policy-file] [0|1]");
return 2;
}
let (prefix, target, effect) = (&args[0], &args[1], args[2].as_str());
if candor_classify::cap_from_name(effect).is_none() && effect != "Unknown" {
eprintln!("candor: unknown effect `{effect}` (expected a candor effect name, e.g. Net/Fs/Db/Exec, or Unknown)");
return 2;
}
let mut policy_path: Option<String> = None;
let mut want_json = false;
for a in &args[3..] {
match a.as_str() {
"0" => want_json = false,
"1" | "--json" => want_json = true,
other => policy_path = Some(other.to_string()),
}
}
let rules = match load_rules(policy_path) {
Ok(r) => r,
Err(c) => return c,
};
let entries = load_entries(prefix);
if entries.is_empty() {
eprintln!("candor fix: no report for `{prefix}` — scan the crate first.");
return 2;
}
let by_name: HashMap<&str, &ReportEntry> = entries.iter().map(|e| (e.func.as_str(), e)).collect();
let tier = best_tier(entries.iter().map(|e| e.func.as_str()), target);
let Some(start) = entries
.iter()
.find(|e| &e.func == target)
.or_else(|| entries.iter().find(|e| q_match(&e.func, target, tier)))
else {
eprintln!("candor fix: no function matching `{target}`.");
return 2;
};
if !start.inferred.iter().any(|e| e == effect) {
println!("candor fix: `{}` does not perform {effect} — nothing to hoist.", start.func);
return 0;
}
let Some(layer) = denied_layer(&start.func, effect, &rules) else {
println!(
"candor fix: `{}` performs {effect}, but no policy forbids it there — the boundary isn't crossed, nothing to fix.",
start.func
);
return 0;
};
let rev = reverse_graph(&entries);
let plan = compute_remedy(&by_name, &rev, &rules, start, effect, layer);
if want_json {
println!("{}", serde_json::to_string_pretty(&plan.to_json()).unwrap());
} else {
let mut s = String::new();
plan.render_text(&mut s);
print!("{s}");
println!("\n (Advisory: candor names the shape, you write the code; the gate re-scan verifies the fix.)");
}
0
}
pub(crate) fn cmd_fix_gate(args: &[String]) -> i32 {
if args.is_empty() {
eprintln!("usage: candor-query fix-gate <prefix> [policy-file] [0|1]");
return 2;
}
let prefix = &args[0];
let mut policy_path: Option<String> = None;
let mut want_json = false;
for a in &args[1..] {
match a.as_str() {
"0" => want_json = false,
"1" | "--json" => want_json = true,
other => policy_path = Some(other.to_string()),
}
}
let rules = match load_rules(policy_path) {
Ok(r) => r,
Err(c) => return c,
};
let entries = load_entries(prefix);
if entries.is_empty() {
eprintln!("candor fix-gate: no report for `{prefix}` — scan the crate first.");
return 2;
}
let by_name: HashMap<&str, &ReportEntry> = entries.iter().map(|e| (e.func.as_str(), e)).collect();
let rev = reverse_graph(&entries);
let mut plans: BTreeMap<String, RemedyPlan> = BTreeMap::new();
for e in &entries {
for effect in &e.inferred {
if let Some(layer) = denied_layer(&e.func, effect, &rules) {
let plan = compute_remedy(&by_name, &rev, &rules, e, effect, layer);
plans.entry(plan.dedup_key()).or_insert(plan);
}
}
}
if want_json {
let remedies: Vec<_> = plans.values().map(|p| p.to_json()).collect();
let out = serde_json::json!({ "ok": remedies.is_empty(), "remedies": remedies });
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return 0;
}
if plans.is_empty() {
println!("candor fix-gate: no deny/pure boundary crossings in this report ✓");
return 0;
}
let n = plans.len();
println!(
"candor fix — {n} boundary {} for this change:\n",
if n == 1 { "remedy" } else { "remedies" }
);
for (i, p) in plans.values().enumerate() {
if i > 0 {
println!(" ────────────────────────────────────────");
}
let mut s = String::new();
p.render_text(&mut s);
print!("{s}");
}
println!("\n (Advisory: candor names the shape, you write the code; the gate re-scan verifies each fix.)");
0
}