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::aec::{AecConfig, AecState, AecValidator};
|
||||||
use crate::host::observer::{EventKind, Millis, Projection, Readiness};
|
use crate::host::observer::{EventKind, Millis, Projection, Readiness};
|
||||||
|
use crate::host::taint::owner::OwnerKey;
|
||||||
use crate::host::taint::snapshot::Serial;
|
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
|
/// 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
|
/// reports ready before failing closed. Generous relative to the observer's own
|
||||||
@@ -181,6 +182,16 @@ pub struct AuditRow {
|
|||||||
pub eligible: bool,
|
pub eligible: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub reason: Option<&'static str>,
|
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
|
/// The exclusion was carried over from a previous snapshot rather than
|
||||||
/// derived from the current topology (phase-2 stickiness).
|
/// derived from the current topology (phase-2 stickiness).
|
||||||
pub sticky: bool,
|
pub sticky: bool,
|
||||||
@@ -195,9 +206,25 @@ pub struct TaintRow {
|
|||||||
pub serial: u64,
|
pub serial: u64,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub reason: &'static str,
|
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,
|
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
|
/// 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
|
/// 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
|
/// 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
|
// engine would have passed — otherwise a shut gate would erase every
|
||||||
// reason code in the record and the matrix would stop constraining
|
// reason code in the record and the matrix would stop constraining
|
||||||
// the engine at all.
|
// the engine at all.
|
||||||
let (eligible, reason, sticky) = match decision.eligibility {
|
let (eligible, reason, owner_key, sticky) = match decision.eligibility {
|
||||||
Eligibility::NotEligible { reason, sticky } => (false, Some(reason.code()), sticky),
|
Eligibility::NotEligible { reason, sticky } => {
|
||||||
|
(false, Some(reason.code()), owner_key_of(reason), sticky)
|
||||||
|
}
|
||||||
Eligibility::Eligible => match gate_reason {
|
Eligibility::Eligible => match gate_reason {
|
||||||
Some(gate) => (false, Some(gate.code()), false),
|
Some(gate) => (false, Some(gate.code()), None, false),
|
||||||
None => (true, None, false),
|
None => (true, None, None, false),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
AuditRow {
|
AuditRow {
|
||||||
@@ -436,6 +465,7 @@ fn build_body(
|
|||||||
name: decision.name.clone(),
|
name: decision.name.clone(),
|
||||||
eligible,
|
eligible,
|
||||||
reason,
|
reason,
|
||||||
|
owner_key,
|
||||||
sticky,
|
sticky,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -450,6 +480,7 @@ fn build_body(
|
|||||||
serial: serial.0,
|
serial: serial.0,
|
||||||
name: node_name(projection, serial),
|
name: node_name(projection, serial),
|
||||||
reason: entry.reason.code(),
|
reason: entry.reason.code(),
|
||||||
|
owner_key: owner_key_of(entry.reason),
|
||||||
sticky: entry.sticky,
|
sticky: entry.sticky,
|
||||||
})
|
})
|
||||||
.collect();
|
.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
|
/// §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
|
/// 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
|
/// fusion observable, because fusing all Pulse-created nodes into one owner
|
||||||
|
|||||||
Reference in New Issue
Block a user