diff --git a/src/host/audit/mod.rs b/src/host/audit/mod.rs index b76dfc9..7e3ec67 100644 --- a/src/host/audit/mod.rs +++ b/src/host/audit/mod.rs @@ -74,8 +74,9 @@ use serde::Serialize; use crate::host::aec::{AecConfig, AecState, AecValidator}; use crate::host::observer::{EventKind, Millis, Projection, Readiness}; +use crate::host::taint::owner::OwnerKey; use crate::host::taint::snapshot::Serial; -use crate::host::taint::{Decisions, Eligibility, ExclusionCtx, StickyState, evaluate}; +use crate::host::taint::{Decisions, Eligibility, ExclusionCtx, Reason, StickyState, evaluate}; /// How long the AEC validator may sit in `Validating` after the graph first /// reports ready before failing closed. Generous relative to the observer's own @@ -181,6 +182,16 @@ pub struct AuditRow { pub eligible: bool, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<&'static str>, + /// The owner key that carried the taint across, when the reason is + /// `tainted-owner-bridge` *and* the tainted member shared a key directly. + /// + /// §5.1 row 1 asserts "reason = owner bridge, **naming the key**" — the + /// point being that the exclusion is provably the owner bridge on a + /// specific key rather than an incidental link walk that happens to reach + /// the same verdict. [`Reason::code`] collapses the payload, so without + /// this field that row cannot be asserted from the record at all. + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_key: Option<&'static str>, /// The exclusion was carried over from a previous snapshot rather than /// derived from the current topology (phase-2 stickiness). pub sticky: bool, @@ -195,9 +206,25 @@ pub struct TaintRow { pub serial: u64, pub name: Option, pub reason: &'static str, + /// As [`AuditRow::owner_key`]. Present here too because the bridge that + /// matters for a row's diagnosis is often on a non-candidate node. + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_key: Option<&'static str>, pub sticky: bool, } +/// The owner key a `tainted-owner-bridge` reason resolved on, if it named one. +/// +/// `None` for every other reason, and also for a bridge whose tainted member +/// shared no key *directly* — the taint reached it transitively, so there is no +/// single key to name and inventing one would be a false diagnosis. +fn owner_key_of(reason: Reason) -> Option<&'static str> { + match reason { + Reason::TaintedOwnerBridge { key } => key.map(OwnerKey::code), + _ => None, + } +} + /// A node carrying a peerspeak ownership carrier on a role the engine does not /// honour it on (round 10, R10-1). `role` is the point of the row: it says /// which non-producer role the tag turned up on, which is what distinguishes a @@ -424,11 +451,13 @@ fn build_body( // engine would have passed — otherwise a shut gate would erase every // reason code in the record and the matrix would stop constraining // the engine at all. - let (eligible, reason, sticky) = match decision.eligibility { - Eligibility::NotEligible { reason, sticky } => (false, Some(reason.code()), sticky), + let (eligible, reason, owner_key, sticky) = match decision.eligibility { + Eligibility::NotEligible { reason, sticky } => { + (false, Some(reason.code()), owner_key_of(reason), sticky) + } Eligibility::Eligible => match gate_reason { - Some(gate) => (false, Some(gate.code()), false), - None => (true, None, false), + Some(gate) => (false, Some(gate.code()), None, false), + None => (true, None, None, false), }, }; AuditRow { @@ -436,6 +465,7 @@ fn build_body( name: decision.name.clone(), eligible, reason, + owner_key, sticky, } }) @@ -450,6 +480,7 @@ fn build_body( serial: serial.0, name: node_name(projection, serial), reason: entry.reason.code(), + owner_key: owner_key_of(entry.reason), sticky: entry.sticky, }) .collect(); diff --git a/src/host/audit/tests.rs b/src/host/audit/tests.rs index 80fc890..7c6ec78 100644 --- a/src/host/audit/tests.rs +++ b/src/host/audit/tests.rs @@ -529,6 +529,53 @@ fn row_1_owner_bridge_forwarder_with_an_untainted_control() { ); } +/// §5.1 row 1's other half: the record must **name the key** the bridge +/// resolved on, not merely say "owner bridge". +/// +/// Without this the row is unassertable from the record: `Reason::code` +/// collapses `TaintedOwnerBridge { key }` to one string, so an exclusion that +/// arrived by an incidental link walk and one that arrived across a named owner +/// key are indistinguishable — and the row exists precisely to tell them apart. +/// The fixture's forwarder legs are joined by `pulse.module.id`, so that is the +/// key that must be reported. +#[test] +fn row_1_names_the_owner_key_the_bridge_resolved_on() { + let mut graph = Graph::new(); + let call = graph.peerspeak_node("peerspeak-call", 200); + let sink = graph.module_node("tainted-null-sink", MediaRole::Sink, 30); + graph.link(call, sink); + let capture = graph.module_node("tainted-loopback-capture", MediaRole::StreamInput, 30); + let _playback = graph.module_node("tainted-loopback-playback", MediaRole::StreamOutput, 30); + graph.link(sink, capture); + + let body = observe(&mut auditor_off(), &ready(graph.build()), 0) + .record + .body; + + let playback = body + .candidates + .iter() + .find(|row| row.name.as_deref() == Some("tainted-loopback-playback")) + .expect("the forwarder's playback leg is a candidate"); + assert_eq!(playback.reason, Some("tainted-owner-bridge")); + assert_eq!( + playback.owner_key, + Some("pulse.module.id"), + "the bridge key must be named in the record, not collapsed into the reason code" + ); + + // And it stays absent everywhere it would be a false diagnosis: the tag + // exclusion is not a bridge at all. + let call_name = owned_name("peerspeak-call", 200); + let tagged = body + .candidates + .iter() + .find(|row| row.name.as_deref() == Some(call_name.as_str())) + .expect("the tagged call playback is a candidate"); + assert_eq!(tagged.reason, Some("peerspeak-owned")); + assert_eq!(tagged.owner_key, None); +} + /// §5.1 row 3: two Pulse modules, one tainted input. **The other module's output /// must be eligible** — this is the row that makes a wrong pipewire-pulse-PID /// fusion observable, because fusing all Pulse-created nodes into one owner