host/audit: name the owner key the bridge resolved on
Impl plan section 5.1 row 1 asserts "reason = owner bridge, naming the key",
and the record could not express that: Reason::code collapses
TaintedOwnerBridge { key } to one string, so an exclusion that arrived across a
named owner key was indistinguishable from one that arrived by an incidental
link walk reaching the same verdict. Telling those apart is the entire point of
the row.
OwnerKey::code already documented itself as ending up in the phase 5 audit
output; it was simply never wired to it. Adds owner_key to AuditRow and
TaintRow, omitted when absent, and absent is meaningful: a bridge whose tainted
member shared no key directly reached the node transitively, so there is no
single key to name and naming one would be a false diagnosis.
Read-only and diagnostic-only. New test mutation-verified (stubbing the key to
None fails it); 221 tests green, clippy clean.
This commit is contained in:
+36
-5
@@ -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<String>,
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user