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."
))
}
pub trait BreachSink: Send + Sync {
fn route(&self, shield_name: &str, code: &str, reason: &str, candidate: &str)
-> Result<(), String>;
}
static BREACH_SINKS: LazyLock<RwLock<HashMap<String, Arc<dyn BreachSink>>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
static ESCALATION_QUEUE: LazyLock<RwLock<Option<Arc<dyn BreachSink>>>> =
LazyLock::new(|| RwLock::new(None));
pub fn register_breach_sink(
sink_name: impl Into<String>,
sink: Arc<dyn BreachSink>,
) -> Option<Arc<dyn BreachSink>> {
BREACH_SINKS
.write()
.expect("breach sink registry RwLock poisoned")
.insert(sink_name.into(), sink)
}
pub fn lookup_breach_sink(sink_name: &str) -> Option<Arc<dyn BreachSink>> {
BREACH_SINKS
.read()
.expect("breach sink registry RwLock poisoned")
.get(sink_name)
.cloned()
}
pub fn unregister_breach_sink(sink_name: &str) -> Option<Arc<dyn BreachSink>> {
BREACH_SINKS
.write()
.expect("breach sink registry RwLock poisoned")
.remove(sink_name)
}
pub fn set_escalation_queue(queue: Option<Arc<dyn BreachSink>>) {
*ESCALATION_QUEUE
.write()
.expect("escalation queue RwLock poisoned") = queue;
}
fn escalation_queue() -> Option<Arc<dyn BreachSink>> {
ESCALATION_QUEUE
.read()
.expect("escalation queue RwLock poisoned")
.clone()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BreachDisposition {
Halt { message: String },
Proceed { content: String },
}
fn mask_fields(value: &mut serde_json::Value, redact: &[String]) {
match value {
serde_json::Value::Object(map) => {
for (k, v) in map.iter_mut() {
if redact.iter().any(|r| r == k) {
*v = serde_json::Value::String("[REDACTED]".to_string());
} else {
mask_fields(v, redact);
}
}
}
serde_json::Value::Array(items) => {
for v in items.iter_mut() {
mask_fields(v, redact);
}
}
_ => {}
}
}
pub fn apply_on_breach(
shield_name: &str,
policy: Option<&crate::ir_nodes::IRBreachPolicy>,
scanner: &Arc<dyn ShieldScanner>,
candidate: &str,
code: &str,
reason: &str,
) -> BreachDisposition {
let halt = |message: String| BreachDisposition::Halt { message };
let base = format!("[{code}] {reason}");
let Some(policy) = policy else {
return halt(base);
};
match policy.on_breach.as_str() {
"quarantine" if policy.quarantine.is_empty() => halt(format!(
"{base} — `on_breach: quarantine` declares NO sink (axon-W012 warned at compile); halting (fail-closed, the pre-114.w behaviour)"
)),
"quarantine" => match lookup_breach_sink(&policy.quarantine) {
Some(sink) => match sink.route(shield_name, code, reason, candidate) {
Ok(()) => halt(format!(
"{base} — candidate quarantined to sink '{}' (recoverable; the \
emission itself is refused)",
policy.quarantine
)),
Err(e) => halt(format!(
"{base} — quarantine sink '{}' FAILED to record the candidate \
({e}); halting (a quarantine that cannot record must not \
pretend it did)",
policy.quarantine
)),
},
None => halt(format!(
"{base} — `on_breach: quarantine` names sink '{}', which is NOT \
mounted in this process; halting (fail-closed — a phantom \
quarantine would be a false sense of recovery)",
policy.quarantine
)),
},
"escalate" => match escalation_queue() {
Some(queue) => match queue.route(shield_name, code, reason, candidate) {
Ok(()) => halt(format!(
"{base} — escalated to the escalation queue (a human decides; \
the emission itself is refused)"
)),
Err(e) => halt(format!(
"{base} — escalation queue FAILED to record ({e}); halting"
)),
},
None => halt(format!(
"{base} — `on_breach: escalate` but NO escalation queue is mounted \
in this process; halting (fail-closed)"
)),
},
"deflect" => BreachDisposition::Proceed {
content: policy.deflect_message.clone(),
},
"sanitize_and_retry" => {
let Ok(mut value) = serde_json::from_str::<serde_json::Value>(candidate) else {
return halt(format!(
"{base} — `on_breach: sanitize_and_retry` but the candidate is \
not JSON, so the declared `redact:` fields cannot be masked; \
halting (fail-closed)"
));
};
mask_fields(&mut value, &policy.redact);
let masked = value.to_string();
let budget = policy.max_retries.max(1);
let ctx = ShieldScanContext::new(shield_name.to_string());
for _attempt in 0..budget {
match scanner.scan(&masked, &ctx) {
ShieldVerdict::Pass(content) => {
return BreachDisposition::Proceed { content };
}
ShieldVerdict::Reject { .. } => continue,
}
}
halt(format!(
"{base} — sanitize_and_retry exhausted {budget} attempt(s): the \
scanner still rejects after masking {:?}; halting",
policy.redact
))
}
_ => halt(base),
}
}
#[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());
}
}