use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::process::Command;
use std::time::Instant;
use crate::ontology::sigma::{Sigma, SigmaError};
use crate::ontology::verdict::{Reason, Verdict};
use crate::proof_status::ProofLevel;
use super::finding::LintFinding;
use super::rules::RuleSeverity;
use super::{GateDetail, GateExtra, GateResult};
pub const EVIDENCE_KEYS: [&str; 3] = ["level", "mark", "provenance"];
pub const PROVENANCE_KEYS: [&str; 3] = ["wasGeneratedBy", "wasAttributedTo", "generatedAtTime"];
pub const GENERATED_BY_KEYS: [&str; 2] = ["command", "git_sha"];
pub const MARKS: [&str; 4] = ["V", "C", "A", "U"];
pub const LEVELS_SOURCE: &str = "enum";
#[derive(Debug)]
pub enum EvidenceOutcome {
NoSigma,
Malformed(SigmaError),
NoEvidence {
contracts_checked: usize,
},
Ran {
result: Box<GateResult>,
findings: Vec<LintFinding>,
},
}
struct PendingSha {
sha: String,
stem: String,
file: String,
}
#[must_use]
pub fn run_evidence_gate(contract_dir: &Path) -> EvidenceOutcome {
let start = Instant::now();
let sigma_path = contract_dir.join("ontology.yaml");
let Ok(text) = std::fs::read_to_string(&sigma_path) else {
return EvidenceOutcome::NoSigma;
};
let sigma = match Sigma::from_yaml(&text) {
Ok(s) => s,
Err(e) => return EvidenceOutcome::Malformed(e),
};
if let Err(e) = sigma.check_integrity() {
return EvidenceOutcome::Malformed(e);
}
let census_sha = census_git_sha(contract_dir);
let mut c = Census::default();
let mut files = Vec::new();
super::collect_yaml_files(contract_dir, &mut files);
files.sort();
for file in files.iter().filter(|f| **f != sigma_path) {
c.observe(&sigma, census_sha.as_deref(), file);
}
if c.carrying == 0 {
return EvidenceOutcome::NoEvidence {
contracts_checked: c.checked,
};
}
let unresolved = resolve_pending(
contract_dir,
std::mem::take(&mut c.pending),
&mut c.findings,
);
let Census {
findings,
checked,
carrying,
entity_types,
by_level,
..
} = c;
let violations = findings.len();
let passed = violations == 0 && unresolved == 0;
let verdict = if violations > 0 {
Verdict::Fail
} else if unresolved > 0 {
Verdict::Unknown(Reason::ToolAbsent)
} else {
Verdict::Pass
};
let duration = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let result = GateResult {
name: "evidence".into(),
passed,
skipped: false,
verdict,
duration_ms: duration,
detail: GateDetail::Validate {
contracts: checked,
errors: violations,
warnings: 0,
error_messages: findings.iter().map(|f| f.message.clone()).collect(),
},
extra: Some(GateExtra::Evidence {
contracts_checked: checked,
contracts_with_evidence: carrying,
entity_types_checked: entity_types.len(),
entity_types: entity_types.into_iter().collect(),
levels_source: LEVELS_SOURCE.to_string(),
by_level: by_level.iter().map(|(l, n)| format!("{l}={n}")).collect(),
census_git_sha: census_sha,
unresolved_git_shas: unresolved,
violations,
}),
};
EvidenceOutcome::Ran {
result: Box::new(result),
findings,
}
}
#[derive(Default)]
struct Census {
findings: Vec<LintFinding>,
pending: Vec<PendingSha>,
checked: usize,
carrying: usize,
entity_types: BTreeSet<String>,
by_level: BTreeMap<String, usize>,
}
impl Census {
fn observe(&mut self, sigma: &Sigma, census_sha: Option<&str>, file: &Path) {
let Some(doc) = std::fs::read_to_string(file)
.ok()
.and_then(|raw| serde_yaml::from_str::<serde_yaml::Value>(&raw).ok())
.filter(serde_yaml::Value::is_mapping)
else {
return;
};
self.checked += 1;
let Some(ev) = doc.get("evidence") else {
return;
};
self.carrying += 1;
let stem = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let before = self.findings.len();
let pending = check_evidence(sigma, census_sha, ev, &stem, file, &mut self.findings);
self.pending.extend(pending);
if let Some(t) = doc
.get("entity")
.and_then(|e| e.get("type"))
.and_then(serde_yaml::Value::as_str)
{
self.entity_types.insert(t.to_string());
}
if self.findings.len() == before {
if let Some(l) = ev.get("level").and_then(serde_yaml::Value::as_str) {
*self.by_level.entry(l.to_string()).or_default() += 1;
}
}
}
}
fn finding(rule: &str, msg: String, stem: &str, file: &Path) -> LintFinding {
let mut f = LintFinding::new(rule, RuleSeverity::Error, msg, file.display().to_string());
f.contract_stem = Some(stem.to_string());
f
}
fn closed_mapping<'a>(
v: &'a serde_yaml::Value,
path: &str,
allowed: &[&str],
stem: &str,
file: &Path,
out: &mut Vec<LintFinding>,
) -> Option<&'a serde_yaml::Mapping> {
let Some(map) = v.as_mapping() else {
out.push(finding(
"PV-ONT-017",
format!("`{path}` must be a mapping of {}", allowed.join(", ")),
stem,
file,
));
return None;
};
for key in map.keys() {
let name = key.as_str().unwrap_or("<non-string key>");
if !allowed.contains(&name) {
out.push(finding(
"PV-ONT-017",
format!(
"`{path}.{name}` is not a PROV-O evidence key (closed set: {})",
allowed.join(", ")
),
stem,
file,
));
}
}
Some(map)
}
fn check_evidence(
sigma: &Sigma,
census_sha: Option<&str>,
ev: &serde_yaml::Value,
stem: &str,
file: &Path,
out: &mut Vec<LintFinding>,
) -> Option<PendingSha> {
let map = closed_mapping(ev, "evidence", &EVIDENCE_KEYS, stem, file, out)?;
check_level(map.get("level"), stem, file, out);
let mark = check_mark(map.get("mark"), stem, file, out);
let Some(prov) = map.get("provenance") else {
out.push(finding(
"PV-ONT-021",
"`evidence.provenance` is missing — an evidence block names who produced the claim (`wasAttributedTo`)".to_string(),
stem,
file,
));
return None;
};
let prov = closed_mapping(
prov,
"evidence.provenance",
&PROVENANCE_KEYS,
stem,
file,
out,
)?;
check_attribution(sigma, prov, stem, file, out);
let generated_by = prov.get("wasGeneratedBy").and_then(|g| {
closed_mapping(
g,
"evidence.provenance.wasGeneratedBy",
&GENERATED_BY_KEYS,
stem,
file,
out,
)
});
let command = generated_by
.and_then(|g| g.get("command"))
.and_then(serde_yaml::Value::as_str)
.filter(|s| !s.trim().is_empty());
if matches!(mark, Some("C" | "V")) && command.is_none() {
out.push(finding(
"PV-ONT-019",
format!("a `[{}]` claim must name the command that produced it (`evidence.provenance.wasGeneratedBy.command`)", mark.unwrap_or_default()),
stem,
file,
));
}
if mark != Some("V") {
return None;
}
let sha = generated_by
.and_then(|g| g.get("git_sha"))
.and_then(serde_yaml::Value::as_str);
check_verified_sha(census_sha, sha, stem, file, out)
}
fn check_level(
level: Option<&serde_yaml::Value>,
stem: &str,
file: &Path,
out: &mut Vec<LintFinding>,
) {
let ok = level.is_some_and(|l| serde_yaml::from_value::<ProofLevel>(l.clone()).is_ok());
if !ok {
let shown = level.map_or_else(|| "missing".to_string(), |l| format!("{l:?}"));
out.push(finding(
"PV-ONT-018",
format!("`evidence.level` ({shown}) is not a ProofLevel — L1, L2, L3, L4 or L5"),
stem,
file,
));
}
}
fn check_mark<'a>(
mark: Option<&'a serde_yaml::Value>,
stem: &str,
file: &Path,
out: &mut Vec<LintFinding>,
) -> Option<&'a str> {
let m = mark?;
match m.as_str() {
Some(s) if MARKS.contains(&s) => Some(s),
_ => {
out.push(finding(
"PV-ONT-019",
format!("`evidence.mark` ({m:?}) is not a provenance mark — V, C, A or U"),
stem,
file,
));
None
}
}
}
fn check_attribution(
sigma: &Sigma,
prov: &serde_yaml::Mapping,
stem: &str,
file: &Path,
out: &mut Vec<LintFinding>,
) {
match prov.get("wasAttributedTo").and_then(serde_yaml::Value::as_str) {
Some(a) if sigma.agents.iter().any(|x| x == a) => {}
other => out.push(finding(
"PV-ONT-021",
format!(
"`evidence.provenance.wasAttributedTo` ({}) is not an agent Σ declares (agents: {}) — declare it in contracts/ontology.yaml",
other.unwrap_or("missing or not a string"),
sigma.agents.join(", ")
),
stem,
file,
)),
}
if let Some(t) = prov.get("generatedAtTime") {
if t.as_str().is_none_or(|s| s.trim().is_empty()) {
out.push(finding(
"PV-ONT-021",
"`evidence.provenance.generatedAtTime` must be a non-empty timestamp string"
.to_string(),
stem,
file,
));
}
}
}
fn is_full_sha(s: &str) -> bool {
s.len() == 40
&& s.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn check_verified_sha(
census_sha: Option<&str>,
sha: Option<&str>,
stem: &str,
file: &Path,
out: &mut Vec<LintFinding>,
) -> Option<PendingSha> {
let Some(sha) = sha.filter(|s| is_full_sha(s)) else {
out.push(finding(
"PV-ONT-020",
"a `[V]` claim must pin `evidence.provenance.wasGeneratedBy.git_sha` to a full 40-hex commit sha".to_string(),
stem,
file,
));
return None;
};
match census_sha {
Some(c) if c != sha => {
out.push(finding(
"PV-ONT-020",
format!("a `[V]` claim's git_sha {sha} is not the census's git_sha {c}"),
stem,
file,
));
None
}
Some(_) => None,
None => Some(PendingSha {
sha: sha.to_string(),
stem: stem.to_string(),
file: file.display().to_string(),
}),
}
}
fn census_git_sha(contract_dir: &Path) -> Option<String> {
let raw = std::fs::read_to_string(contract_dir.join("census.json")).ok()?;
let doc: serde_json::Value = serde_json::from_str(&raw).ok()?;
doc.get("git_sha")?.as_str().map(str::to_string)
}
fn resolve_pending(
contract_dir: &Path,
pending: Vec<PendingSha>,
out: &mut Vec<LintFinding>,
) -> usize {
if pending.is_empty() {
return 0;
}
let git = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(contract_dir)
.args(args)
.output()
.ok()
};
let shallow = match git(&["rev-parse", "--is-shallow-repository"]) {
Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim() == "true",
_ => return pending.len(),
};
let mut unresolved = 0;
for p in pending {
let spec = format!("{}^{{commit}}", p.sha);
match git(&["cat-file", "-e", &spec]) {
Some(o) if o.status.success() => {}
Some(_) if !shallow => {
let mut f = LintFinding::new(
"PV-ONT-020",
RuleSeverity::Error,
format!(
"a `[V]` claim's git_sha {} is not a commit this repository holds",
p.sha
),
p.file,
);
f.contract_stem = Some(p.stem);
out.push(f);
}
_ => unresolved += 1,
}
}
unresolved
}
#[cfg(test)]
#[path = "evidence_gate_tests.rs"]
mod tests;