use crate::*;
pub(crate) use candor_report::GateViolation;
#[allow(clippy::too_many_arguments)]
pub(crate) fn policy_violations(
policy_text: &str,
all: &[String],
inferred: &HashMap<String, BTreeSet<&'static str>>,
calls: &HashMap<String, BTreeSet<String>>,
hostsacc: &HashMap<String, BTreeSet<String>>,
cmdsacc: &HashMap<String, BTreeSet<String>>,
pathsacc: &HashMap<String, BTreeSet<String>>,
tablesacc: &HashMap<String, BTreeSet<String>>,
incompleteacc: &HashMap<String, BTreeSet<&'static str>>,
) -> Vec<GateViolation> {
use candor_classify::policy::{literal_allowed, parse_policy, scope_matches};
let p = parse_policy(policy_text);
let empty: BTreeSet<&'static str> = BTreeSet::new();
let mut out = Vec::new();
for q in all {
let inf = inferred.get(q).unwrap_or(&empty);
for r in &p.rules {
if let Some(s) = &r.scope {
if !scope_matches(q, s) {
continue;
}
}
let hits: Vec<&str> = if r.effects.is_empty() {
inf.iter().copied().filter(|e| *e != "Unknown").collect()
} else {
inf.iter().copied().filter(|e| r.effects.contains(e)).collect()
};
if !hits.is_empty() {
out.push(GateViolation {
rule: "AS-EFF-006".into(),
func: q.clone(),
effects: hits.iter().map(|s| s.to_string()).collect(),
detail: format!("`{q}` performs {{ {} }}, forbidden by policy: `{}`", hits.join(", "), r.raw),
});
}
}
for r in &p.allow_rules {
if let Some(s) = &r.scope {
if !scope_matches(q, s) {
continue;
}
}
if !inf.contains(r.effect) {
continue;
}
let lits = match r.effect {
"Net" => hostsacc.get(q),
"Exec" => cmdsacc.get(q),
"Db" => tablesacc.get(q),
_ => pathsacc.get(q),
};
let surface_incomplete = incompleteacc.get(q).is_some_and(|s| s.contains(r.effect));
match lits {
Some(ls) if !ls.is_empty() && !surface_incomplete => {
let bad: Vec<&str> =
ls.iter().filter(|l| !literal_allowed(r.effect, l, &r.literals)).map(String::as_str).collect();
if !bad.is_empty() {
out.push(GateViolation {
rule: "AS-EFF-008".into(),
func: q.clone(),
effects: vec![r.effect.to_string()],
detail: format!("`{q}` reaches {{ {} }} outside the allowlist: `{}`", bad.join(", "), r.raw),
});
}
}
_ => out.push(GateViolation {
rule: "AS-EFF-008".into(),
func: q.clone(),
effects: vec![r.effect.to_string()],
detail: format!("`{q}` performs {} with no visible literal — the surface cannot be certified: `{}`", r.effect, r.raw),
}),
}
}
for r in &p.layer_rules {
if !scope_matches(q, &r.from) {
continue;
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut stack: Vec<&str> = calls.get(q).map(|cs| cs.iter().map(String::as_str).collect()).unwrap_or_default();
let mut hit: Option<&str> = None;
while let Some(n) = stack.pop() {
if !seen.insert(n) {
continue;
}
if scope_matches(n, &r.to) {
hit = Some(n);
break;
}
if let Some(cs) = calls.get(n) {
stack.extend(cs.iter().map(String::as_str));
}
}
if let Some(h) = hit {
out.push(GateViolation {
rule: "AS-EFF-009".into(),
func: q.clone(),
effects: Vec::new(), detail: format!("`{q}` reaches into a forbidden layer (via `{h}`): `{}`", r.raw),
});
}
}
}
out.sort_by(|a, b| (a.rule.as_str(), a.detail.as_str()).cmp(&(b.rule.as_str(), b.detail.as_str())));
out
}
pub(crate) enum BaselineOutcome {
Inactive,
Invalid,
Checked(Vec<GateViolation>),
}
static NOTED_ABSENT: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> = std::sync::OnceLock::new();
pub(crate) fn check_baseline(
value: &str,
dir: &str,
crate_name: &str,
all: &[String],
inferred: &HashMap<String, BTreeSet<&'static str>>,
) -> BaselineOutcome {
if value.trim().is_empty() {
eprintln!(
"candor-scan: baseline is configured with an EMPTY value — failing (exit 2); the guard \
must not be silently skipped (set a report path/prefix, or remove the key)"
);
return BaselineOutcome::Invalid;
}
let file = if Path::new(value).is_file() {
value.to_string()
} else {
format!("{value}.{crate_name}.scan.json")
};
if !Path::new(&file).is_file() {
let noted = NOTED_ABSENT.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
if noted.lock().unwrap().insert(file.clone()) {
eprintln!(
"candor-scan: baseline {file} does not exist — the regression guard is not active \
(record one: candor-scan {dir} --out {value})"
);
}
return BaselineOutcome::Inactive;
}
let regen = format!("regenerate it with this build: candor-scan {dir} --out {value}");
let Ok(text) = std::fs::read_to_string(&file) else {
eprintln!("candor-scan: baseline {file} exists but could not be read — failing (exit 2), guard NOT evaluated; {regen}");
return BaselineOutcome::Invalid;
};
let entries = match candor_report::report_entries_counted(&text) {
Some((entries, 0)) => entries,
_ => {
eprintln!(
"candor-scan: baseline {file} exists but could not be parsed (corrupt/truncated?) — \
failing (exit 2), guard NOT evaluated; the guard must not silently pass on an \
unreadable baseline (the unreadable-policy class, §6.2); {regen}"
);
return BaselineOutcome::Invalid;
}
};
let this_build = format!("scan-{}", env!("CARGO_PKG_VERSION"));
match candor_report::report_version(&text) {
None => {
eprintln!(
"candor-scan: baseline {file} has no provenance header (a legacy/bare-array report) — \
a baseline is comparable only to its producing build (§2.1). Failing (exit 2); {regen}"
);
return BaselineOutcome::Invalid;
}
Some(v) if v != this_build => {
eprintln!(
"candor-scan: baseline {file} was produced by engine build {v} but this is build \
{this_build} — coverage changes reports, so an engine swap is baseline-invalidating \
and the gate cannot evaluate (exit 2, the unreadable-policy class; never a silent \
skip, never a bogus AS-EFF-005 wave). Regenerate deliberately with this build: \
candor-scan {dir} --out {value}"
);
return BaselineOutcome::Invalid;
}
Some(_) => {}
}
let mut base: HashMap<String, BTreeSet<String>> = HashMap::new();
for e in entries {
base.entry(e.func).or_default().extend(e.inferred);
}
let empty: BTreeSet<&'static str> = BTreeSet::new();
let mut out = Vec::new();
for q in all {
let Some(prior) = base.get(q) else { continue }; let gained: Vec<&str> =
inferred.get(q).unwrap_or(&empty).iter().copied().filter(|e| !prior.contains(*e)).collect();
if !gained.is_empty() {
out.push(GateViolation {
rule: "AS-EFF-005".into(),
func: q.clone(),
effects: gained.iter().map(|s| s.to_string()).collect(),
detail: format!(
"`{q}` gained effect {{ {} }} not present in the baseline; an existing function \
started performing a new effect",
gained.join(", ")
),
});
}
}
out.sort_by(|a, b| (a.rule.as_str(), a.detail.as_str()).cmp(&(b.rule.as_str(), b.detail.as_str())));
BaselineOutcome::Checked(out)
}
pub(crate) static GATE_JSON_PATH: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
pub(crate) static GATE_VIOLATIONS: std::sync::OnceLock<std::sync::Mutex<Vec<GateViolation>>> = std::sync::OnceLock::new();
pub(crate) fn record_gate_violations(violations: &[GateViolation]) {
if !matches!(GATE_JSON_PATH.get(), Some(Some(_))) {
return;
}
let acc = GATE_VIOLATIONS.get_or_init(|| std::sync::Mutex::new(Vec::new()));
acc.lock().unwrap().extend(violations.iter().cloned());
}
pub(crate) fn write_gate_json(exit_code: i32) {
let Some(Some(path)) = GATE_JSON_PATH.get() else { return };
if exit_code == 2 {
eprintln!("candor-scan: --gate-json not written — the scan/gate did not complete (exit 2)");
return;
}
let acc = GATE_VIOLATIONS.get_or_init(|| std::sync::Mutex::new(Vec::new()));
let mut violations = acc.lock().unwrap().clone();
match candor_report::gate_verdict_json(&mut violations) {
Ok(json) if path == "-" => println!("{json}"),
Ok(json) => {
if let Err(e) = candor_report::write_atomic(std::path::Path::new(path), format!("{json}\n").as_bytes()) {
eprintln!("candor-scan: could not write --gate-json {path}: {e}");
}
}
Err(e) => eprintln!("candor-scan: could not serialize gate verdict: {e}"),
}
}
pub(crate) fn host_part(h: &str) -> String {
let a = h.split_once("://").map(|(_, r)| r).unwrap_or(h);
let a = a.split('/').next().unwrap_or(a);
a.rsplit_once('@').map(|(_, h)| h).unwrap_or(a).to_string()
}