use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use super::model::{Category, ScenarioResult, Verdict};
pub const CANARY_IDS: &[&str] = &["VFS-TRAV-001", "ENV-LEAK-001", "PROC-ESC-001"];
pub fn min_pass_per_category() -> BTreeMap<Category, usize> {
BTreeMap::from([
(Category::FsRead, 1),
(Category::FsWrite, 1),
(Category::Net, 1),
(Category::Proc, 1),
(Category::Secrets, 1),
(Category::Spawn, 1),
])
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GateReport {
pub status: String,
pub passed: usize,
pub failed: usize,
pub inconclusive: usize,
pub not_applicable: usize,
pub blocking: Vec<String>,
pub results: Vec<ScenarioResult>,
}
impl GateReport {
pub fn summary(&self) -> String {
format!(
"verify-ng gate: {} (pass={} fail={} inconclusive={} n/a={})",
self.status, self.passed, self.failed, self.inconclusive, self.not_applicable
)
}
}
pub fn evaluate_gate(
results: &[ScenarioResult],
na_evidence: &BTreeMap<String, Vec<String>>,
registry_hash: &str,
) -> GateReport {
let mut blocking = Vec::new();
let mut passed = 0;
let mut failed = 0;
let mut inconclusive = 0;
let mut not_applicable = 0;
let mut pass_per_category: BTreeMap<Category, usize> = BTreeMap::new();
let mut seen: BTreeSet<&str> = BTreeSet::new();
for r in results {
match r.verdict {
Verdict::Pass => {
passed += 1;
*pass_per_category.entry(r.category).or_insert(0) += 1;
}
Verdict::Fail => failed += 1,
Verdict::Inconclusive => inconclusive += 1,
Verdict::NotApplicable => not_applicable += 1,
}
if r.blocks_release() {
blocking.push(r.id.clone());
}
if r.verdict == Verdict::NotApplicable {
let has_evidence = na_evidence
.get(&r.id)
.map(|e| !e.is_empty())
.unwrap_or(false);
if !has_evidence {
blocking.push(format!("{}:N/A-without-evidence", r.id));
}
}
seen.insert(r.id.as_str());
}
for canary in CANARY_IDS {
match results.iter().find(|r| &r.id == canary) {
Some(r) if r.verdict == Verdict::Pass => {}
Some(r) => blocking.push(format!("{canary}:canary-{:?}", r.verdict)),
None => blocking.push(format!("{canary}:canary-missing")),
}
}
for (category, min) in min_pass_per_category() {
let got = pass_per_category.get(&category).copied().unwrap_or(0);
if got < min {
blocking.push(format!("{}:only-{got}-pass-min-{min}", category.label()));
}
}
let status = if blocking.is_empty() {
"pass"
} else {
"failed"
};
let _ = registry_hash;
GateReport {
status: status.to_string(),
passed,
failed,
inconclusive,
not_applicable,
blocking,
results: results.to_vec(),
}
}
pub fn gate_exit_code(report: &GateReport) -> i32 {
if report.status == "pass" {
0
} else {
1
}
}
#[cfg(test)]
mod exit_tests {
use super::*;
use crate::verify_ng::model::ClaimStrength;
fn result(id: &str, category: Category, verdict: Verdict) -> ScenarioResult {
ScenarioResult {
id: id.to_string(),
category,
strength: ClaimStrength::Strong,
verdict,
detail: String::new(),
}
}
fn full_pass_set() -> Vec<ScenarioResult> {
vec![
result("VFS-TRAV-001", Category::FsRead, Verdict::Pass),
result("VFS-W-1", Category::FsWrite, Verdict::Pass),
result("NET-DNS-IPV6-001", Category::Net, Verdict::Pass),
result("PROC-ESC-001", Category::Proc, Verdict::Pass),
result("ENV-LEAK-001", Category::Secrets, Verdict::Pass),
result("RACE-BINDING-001", Category::Spawn, Verdict::Pass),
]
}
#[test]
fn gate_vacuum_001_empty_suite_fails() {
let report = evaluate_gate(&[], &BTreeMap::new(), "reg");
assert_eq!(report.status, "failed");
assert_eq!(gate_exit_code(&report), 1);
assert!(report.blocking.iter().any(|b| b.contains("canary-missing")));
}
#[test]
fn all_na_suite_fails() {
let results = vec![
result("VFS-TRAV-001", Category::FsRead, Verdict::NotApplicable),
result("ENV-LEAK-001", Category::Secrets, Verdict::NotApplicable),
];
let mut ev = BTreeMap::new();
ev.insert(
"VFS-TRAV-001".to_string(),
vec!["netns: absent (x)".to_string()],
);
ev.insert(
"ENV-LEAK-001".to_string(),
vec!["spawn: absent (y)".to_string()],
);
let report = evaluate_gate(&results, &ev, "reg");
assert_eq!(report.status, "failed");
}
#[test]
fn na_without_evidence_blocks() {
let mut results = full_pass_set();
results.push(result(
"WIN-WSL-001",
Category::FsRead,
Verdict::NotApplicable,
));
let report = evaluate_gate(&results, &BTreeMap::new(), "reg");
assert_eq!(report.status, "failed");
assert!(report
.blocking
.iter()
.any(|b| b.contains("N/A-without-evidence")));
}
#[test]
fn full_pass_set_passes() {
let mut results = full_pass_set();
results.push(result("WIN-WSL-001", Category::Aux, Verdict::NotApplicable));
let mut ev = BTreeMap::new();
ev.insert(
"WIN-WSL-001".to_string(),
vec!["wsl: absent (unmapped)".to_string()],
);
let report = evaluate_gate(&results, &ev, "reg");
assert_eq!(report.status, "pass");
assert_eq!(gate_exit_code(&report), 0);
}
#[test]
fn inconclusive_in_blocker_fails() {
let mut results = full_pass_set();
results[2] = result("NET-DNS-IPV6-001", Category::Net, Verdict::Inconclusive);
let report = evaluate_gate(&results, &BTreeMap::new(), "reg");
assert_eq!(report.status, "failed");
}
}