use std::collections::HashMap;
use std::sync::{Arc, LazyLock, RwLock};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShieldScanContext {
pub shield_name: String,
}
impl ShieldScanContext {
pub fn new(shield_name: impl Into<String>) -> Self {
Self {
shield_name: shield_name.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShieldVerdict {
Pass(String),
Reject {
code: String,
reason: String,
},
}
impl ShieldVerdict {
pub fn pass(content: impl Into<String>) -> Self {
Self::Pass(content.into())
}
pub fn reject(code: impl Into<String>, reason: impl Into<String>) -> Self {
Self::Reject {
code: code.into(),
reason: reason.into(),
}
}
pub fn is_pass(&self) -> bool {
matches!(self, Self::Pass(_))
}
}
pub trait ShieldScanner: Send + Sync {
fn scan(&self, target: &str, ctx: &ShieldScanContext) -> ShieldVerdict;
}
static REGISTRY: LazyLock<RwLock<HashMap<String, Arc<dyn ShieldScanner>>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
pub fn register_shield_scanner(
shield_name: impl Into<String>,
scanner: Arc<dyn ShieldScanner>,
) -> Option<Arc<dyn ShieldScanner>> {
REGISTRY
.write()
.expect("shield registry RwLock poisoned")
.insert(shield_name.into(), scanner)
}
pub fn lookup_shield_scanner(shield_name: &str) -> Option<Arc<dyn ShieldScanner>> {
REGISTRY
.read()
.expect("shield registry RwLock poisoned")
.get(shield_name)
.cloned()
}
pub fn has_registered_scanners() -> bool {
!REGISTRY
.read()
.expect("shield registry RwLock poisoned")
.is_empty()
}
pub fn registered_shield_names() -> Vec<String> {
let mut names: Vec<String> = REGISTRY
.read()
.expect("shield registry RwLock poisoned")
.keys()
.cloned()
.collect();
names.sort();
names
}
pub fn unregister_shield_scanner(shield_name: &str) -> Option<Arc<dyn ShieldScanner>> {
REGISTRY
.write()
.expect("shield registry RwLock poisoned")
.remove(shield_name)
}
#[doc(hidden)]
pub fn clear_shield_registry() {
REGISTRY
.write()
.expect("shield registry RwLock poisoned")
.clear();
}
pub fn unscanned_extension_scan_categories(
ir: &crate::ir_nodes::IRProgram,
) -> Vec<(String, String)> {
let mut ext_cats: std::collections::HashSet<&str> = std::collections::HashSet::new();
for ext in &ir.extensions {
if ext.category == "scan" {
for m in &ext.members {
ext_cats.insert(m.name.as_str());
}
}
}
if ext_cats.is_empty() {
return Vec::new();
}
let mut violations = Vec::new();
for shield in &ir.shields {
if lookup_shield_scanner(&shield.name).is_some() {
continue;
}
for cat in &shield.scan {
if ext_cats.contains(cat.as_str()) {
violations.push((shield.name.clone(), cat.clone()));
}
}
}
violations
}
pub fn check_extension_scan_coverage(ir: &crate::ir_nodes::IRProgram) -> Result<(), String> {
let violations = unscanned_extension_scan_categories(ir);
if violations.is_empty() {
return Ok(());
}
let detail = violations
.iter()
.map(|(s, c)| format!("shield '{s}' → scan category '{c}'"))
.collect::<Vec<_>>()
.join("; ");
Err(format!(
"§Fase 53.e refusing to boot — extension scan categor(ies) declared but \
UNSCANNED (no scanner registered): {detail}. An `extension` scan category \
has no default meaning; serving it as a silent no-op would be a phantom \
guardrail. Register a scanner for the shield(s) or remove the category."
))
}
#[cfg(test)]
mod tests {
use super::*;
struct UppercaseScanner;
impl ShieldScanner for UppercaseScanner {
fn scan(&self, target: &str, _ctx: &ShieldScanContext) -> ShieldVerdict {
ShieldVerdict::pass(target.to_uppercase())
}
}
struct AlwaysReject;
impl ShieldScanner for AlwaysReject {
fn scan(&self, _target: &str, ctx: &ShieldScanContext) -> ShieldVerdict {
ShieldVerdict::reject(
format!("{}.blocked", ctx.shield_name),
"policy rejection (test)",
)
}
}
fn ir_from(src: &str) -> crate::ir_nodes::IRProgram {
let tokens = crate::lexer::Lexer::new(src, "<test>")
.tokenize()
.expect("lex");
let program = crate::parser::Parser::new(tokens).parse().expect("parse");
crate::ir_generator::IRGenerator::new().generate(&program)
}
struct PassScanner;
impl ShieldScanner for PassScanner {
fn scan(&self, target: &str, _ctx: &ShieldScanContext) -> ShieldVerdict {
ShieldVerdict::pass(target.to_string())
}
}
#[test]
fn canonical_category_without_scanner_is_not_a_violation() {
let ir = ir_from(
"shield T53e_canon { scan: [code_injection] strategy: pattern on_breach: halt }",
);
assert!(unscanned_extension_scan_categories(&ir).is_empty());
assert!(check_extension_scan_coverage(&ir).is_ok());
}
#[test]
fn extension_category_without_scanner_is_a_violation() {
let ir = ir_from(
"extension t53e_x { category: scan members: [ \"dunning_pressure\" ] }\n\
shield T53e_ghost { scan: [dunning_pressure] strategy: pattern on_breach: halt }",
);
let v = unscanned_extension_scan_categories(&ir);
assert_eq!(
v,
vec![("T53e_ghost".to_string(), "dunning_pressure".to_string())]
);
let err = check_extension_scan_coverage(&ir).expect_err("must refuse boot");
assert!(err.contains("phantom guardrail"), "got: {err}");
assert!(err.contains("dunning_pressure"), "got: {err}");
}
#[test]
fn extension_category_with_scanner_is_ok() {
const SHIELD: &str = "T53e_covered";
let _prev = register_shield_scanner(SHIELD, Arc::new(PassScanner));
let ir = ir_from(&format!(
"extension t53e_y {{ category: scan members: [ \"dunning_pressure\" ] }}\n\
shield {SHIELD} {{ scan: [dunning_pressure] strategy: pattern on_breach: halt }}"
));
let ok = check_extension_scan_coverage(&ir);
unregister_shield_scanner(SHIELD);
assert!(ok.is_ok(), "a registered scanner must cover the category: {ok:?}");
}
#[test]
fn register_lookup_roundtrip() {
const NAME: &str = "t_reg_roundtrip_upper";
assert!(lookup_shield_scanner(NAME).is_none());
let prev = register_shield_scanner(NAME, Arc::new(UppercaseScanner));
assert!(prev.is_none(), "first registration has no predecessor");
assert!(has_registered_scanners(), "at least our scanner is present");
let s = lookup_shield_scanner(NAME).expect("registered");
let v = s.scan("phi data", &ShieldScanContext::new(NAME));
assert_eq!(v, ShieldVerdict::Pass("PHI DATA".to_string()));
unregister_shield_scanner(NAME);
assert!(lookup_shield_scanner(NAME).is_none());
}
#[test]
fn last_wins_and_unregister() {
const NAME: &str = "t_reg_last_wins";
register_shield_scanner(NAME, Arc::new(UppercaseScanner));
let prev = register_shield_scanner(NAME, Arc::new(AlwaysReject));
assert!(prev.is_some(), "second registration returns the predecessor");
let s = lookup_shield_scanner(NAME).unwrap();
assert!(matches!(
s.scan("x", &ShieldScanContext::new(NAME)),
ShieldVerdict::Reject { .. }
));
let removed = unregister_shield_scanner(NAME);
assert!(removed.is_some());
assert!(lookup_shield_scanner(NAME).is_none());
}
#[test]
fn registered_names_includes_own_in_sorted_order() {
let names = ["t_names_zeta", "t_names_alpha", "t_names_mu"];
for n in names {
register_shield_scanner(n, Arc::new(UppercaseScanner));
}
let mut mine: Vec<String> = registered_shield_names()
.into_iter()
.filter(|n| n.starts_with("t_names_"))
.collect();
let mut expected = mine.clone();
expected.sort();
assert_eq!(mine, expected, "registered names must be returned sorted");
mine.sort();
assert_eq!(
mine,
vec![
"t_names_alpha".to_string(),
"t_names_mu".to_string(),
"t_names_zeta".to_string()
]
);
for n in names {
unregister_shield_scanner(n);
}
}
#[test]
fn verdict_constructors() {
assert!(ShieldVerdict::pass("ok").is_pass());
assert!(!ShieldVerdict::reject("c", "r").is_pass());
}
}