diff --git a/src/host/taint/mod.rs b/src/host/taint/mod.rs index 4d7f38a..9ee8b9c 100644 --- a/src/host/taint/mod.rs +++ b/src/host/taint/mod.rs @@ -381,36 +381,28 @@ pub fn evaluate( let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid); let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid); - let mut taint: BTreeMap = BTreeMap::new(); - let mut sticky_serials: BTreeSet = BTreeSet::new(); - - seed_local_roots(snapshot, ctx, &mut taint); - seed_sticky( + // Pass 1 — the fail-closed view. Every decision is made from this one, so + // "we could not see" counts as taint. + let (taint, sticky_serials) = compute_taint( snapshot, + ctx, &keys, - prior, &components, - &mut taint, - &mut sticky_serials, + prior, + Uncertainty::FailsClosed, ); - - // Monotone fixpoint: every step only adds taint, or lowers a node's - // reason priority, both of which are bounded. Link propagation and the - // owner bridge feed each other — a bridged output leg has downstream - // links, and a downstream monitor reader bridges to its own siblings — - // so neither can be run once. - let edges = downstream_edges(snapshot, &mut taint); - loop { - let mut changed = false; - changed |= propagate_links(&edges.edges, &mut taint); - changed |= propagate_owner_bridge(&keys, &components, &edges, &mut taint); - changed |= propagate_unresolved_owner(snapshot, &keys, &edges, &mut taint); - if !changed { - break; - } - } - let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials); + + // Pass 2 — the evidence-only view, and the only thing sticky state is + // ever built from (see [`Uncertainty`]). + let (evidence, _) = compute_taint( + snapshot, + ctx, + &keys, + &components, + prior, + Uncertainty::Ignored, + ); // ⚠️ Readiness gates **retirement only**, never addition (Codex rounds // 1 and 2, which caught the two halves of this in turn). An object // missing from an untrustworthy snapshot has not been observed to @@ -419,10 +411,105 @@ pub fn evaluate( // *observed* during a not-ready epoch is real — a reader can consume // and buffer the call and then vanish before readiness — so discarding // additions was the same defect pointing the other way. - let next_sticky = build_sticky(snapshot, &keys, &components, &taint, prior, ctx.graph_ready); + let next_sticky = build_sticky( + snapshot, + &keys, + &components, + &evidence, + prior, + ctx.graph_ready, + ); (decisions, next_sticky) } +/// Whether a pass treats "we could not see" as taint. +/// +/// **Both passes exist because stickiness is a claim about history, and +/// uncertainty is not history.** A node tainted only because the graph was +/// mid-enumeration has had nothing observed about it; remembering that as +/// taint forever is over-exclusion with no evidence behind it, and phase 3r's +/// bind-everything observer makes the window it happens in systematically +/// wide (every node is withheld until its bind resolves, so any link observed +/// across that gap raises [`Reason::UnresolvedAncestry`] on its input side). +/// Measured on a live desktop: a hardware sink acquired a permanent sticky +/// taint at every startup, from one link seen while its output node was still +/// unbound. +/// +/// Retiring by *reason code* is not enough, because uncertainty launders +/// itself: an unresolved node propagates [`Reason::TaintedUpstream`] to its +/// downstream, and that reason is indistinguishable from real contamination +/// once recorded. So the split is by **provenance** — the sticky pass never +/// raises an uncertainty root at all, and nothing derived from one can reach +/// it. Decisions are unaffected: they are made from the fail-closed pass, +/// which is unchanged. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Uncertainty { + /// Unresolved ancestry and an unbounded tainted reader are taint + /// (v3.4 §6.1, §6.1.1, §6.1.4). + FailsClosed, + /// Only positively observed contamination counts. + Ignored, +} + +/// One taint fixpoint over the snapshot. The `uncertainty` mode decides +/// whether absence of evidence is treated as evidence of contamination. +fn compute_taint( + snapshot: &GraphSnapshot, + ctx: &ExclusionCtx, + keys: &owner::OwnerKeyIndex, + components: &OwnerComponents, + prior: &StickyState, + uncertainty: Uncertainty, +) -> (BTreeMap, BTreeSet) { + let fails_closed = uncertainty == Uncertainty::FailsClosed; + let mut taint: BTreeMap = BTreeMap::new(); + let mut sticky_serials: BTreeSet = BTreeSet::new(); + + seed_local_roots(snapshot, ctx, &mut taint); + if fails_closed { + for serial in ambiguous_id_nodes(snapshot) { + raise(&mut taint, serial, Reason::UnresolvedAncestry); + } + } + seed_sticky( + snapshot, + keys, + prior, + components, + &mut taint, + &mut sticky_serials, + ); + + // Edges are built identically in both passes — receiver status is a + // topological fact and must not depend on the mode, or the owner bridge + // would see two different graphs. + let mut unresolved_input: BTreeSet = BTreeSet::new(); + let edges = downstream_edges(snapshot, &mut unresolved_input); + if fails_closed { + for serial in unresolved_input { + raise(&mut taint, serial, Reason::UnresolvedAncestry); + } + } + + // Monotone fixpoint: every step only adds taint, or lowers a node's + // reason priority, both of which are bounded. Link propagation and the + // owner bridge feed each other — a bridged output leg has downstream + // links, and a downstream monitor reader bridges to its own siblings — + // so neither can be run once. + loop { + let mut changed = false; + changed |= propagate_links(&edges.edges, &mut taint); + changed |= propagate_owner_bridge(keys, components, &edges, &mut taint); + if fails_closed { + changed |= propagate_unresolved_owner(snapshot, keys, &edges, &mut taint); + } + if !changed { + break; + } + } + (taint, sticky_serials) +} + /// Roots that are visible on the node itself. fn seed_local_roots( snapshot: &GraphSnapshot, @@ -433,14 +520,20 @@ fn seed_local_roots( if let Some(reason) = local_root_reason(node, ctx) { raise(taint, node.serial, reason); } - // A node whose own global id is ambiguous cannot be the reliable - // endpoint of any link, so its ancestry is unresolvable. - if snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous) { - raise(taint, node.serial, Reason::UnresolvedAncestry); - } } } +/// Nodes whose own global id is ambiguous: they cannot be the reliable +/// endpoint of any link, so their ancestry is unresolvable. Uncertainty, not +/// evidence — see [`Uncertainty`]. +fn ambiguous_id_nodes(snapshot: &GraphSnapshot) -> BTreeSet { + snapshot + .nodes() + .filter(|node| snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous)) + .map(|node| node.serial) + .collect() +} + fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option { if node.props.peerspeak_owned { return Some(Reason::PeerspeakOwned); @@ -563,7 +656,7 @@ fn nodes_of_client( /// `output node → input nodes`, resolving snapshot-local ids. An endpoint /// that does not resolve taints the *other* end as unresolved ancestry when /// that other end is the input side — we cannot know what is feeding it. -fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap) -> Edges { +fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet) -> Edges { let mut edges: BTreeMap> = BTreeMap::new(); let mut receivers: BTreeSet = BTreeSet::new(); for link in snapshot.links() { @@ -575,8 +668,10 @@ fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap { - // Something feeds this node and we cannot say what. - raise(taint, to, Reason::UnresolvedAncestry); + // Something feeds this node and we cannot say what. Reported + // rather than raised here, because whether "cannot say" is + // taint depends on which pass is running ([`Uncertainty`]). + unresolved_input.insert(to); receivers.insert(to); } (_, Some(IdLookup::Ambiguous)) => { diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs index f8ad8d1..d6bde50 100644 --- a/src/host/taint/tests.rs +++ b/src/host/taint/tests.rs @@ -820,6 +820,137 @@ fn an_ambiguous_recycled_global_id_fails_closed() { ); } +// ────────────────────────────────────────────────────────────────────── +// Uncertainty is not history — it never enters sticky state +// (round 9, from a live phase-5 audit run; see `Uncertainty` in mod.rs) +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn unresolved_ancestry_does_not_survive_being_resolved() { + // Measured live on a desktop: a link is observed while its output node is + // still unbound, the input side fails closed — correctly — and then that + // fail-closed mark became *sticky*, so a hardware sink stayed excluded for + // the process lifetime even after the node resolved and turned out to be + // an ordinary game. Phase 3r's bind-everything observer widens that window + // to every node, so this must clear. + let mut graph = Graph::new(); + let ghost = graph.dangling_id(); + let client = graph.client_of_app(6000); + let victim = graph.node("victim-in", MediaRole::StreamInput, app(client, 6000)); + let sibling = graph.node("victim-out", MediaRole::StreamOutput, app(client, 6000)); + graph.link_ids(ghost, victim.id); + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + let c = ctx(); + + // While the ancestry is genuinely unresolved, the decision is unchanged: + // fail closed, both the victim and its sibling excluded. + let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); + assert_partition( + &first, + &[("firefox", firefox)], + &[("victim-out", sibling, "tainted-owner-bridge")], + ); + assert_tainted(&first, victim, "unresolved-ancestry"); + + // The node behind that id turns up — nothing tainted, it was simply not + // observed yet. The uncertainty is gone, so nothing may remain of it. + let late_client = graph.client_of_app(7100); + let resolved = graph.node_with_id( + "was-unbound", + MediaRole::StreamOutput, + ghost, + app(late_client, 7100), + ); + let (second, _) = evaluate(&graph.build(), &c, &sticky); + assert_partition( + &second, + &[ + ("firefox", firefox), + ("victim-out", sibling), + ("was-unbound", resolved), + ], + &[], + ); +} + +#[test] +fn uncertainty_laundered_into_downstream_taint_is_not_sticky_either() { + // Retiring by reason *code* would not be enough: an unresolved node + // propagates `tainted-upstream`, which is indistinguishable from real + // contamination once recorded. The split has to be by provenance, so a + // node two hops from the uncertainty must clear too. + let mut graph = Graph::new(); + let ghost = graph.dangling_id(); + let forwarder_client = graph.client_of_app(6100); + let forwarder_in = graph.node( + "fwd-in", + MediaRole::StreamInput, + app(forwarder_client, 6100), + ); + let forwarder_out = graph.node( + "fwd-out", + MediaRole::StreamOutput, + app(forwarder_client, 6100), + ); + let downstream_client = graph.client_of_app(6200); + let downstream = graph.node("downstream", MediaRole::Sink, app(downstream_client, 6200)); + let downstream_leg = graph.node( + "downstream-out", + MediaRole::StreamOutput, + app(downstream_client, 6200), + ); + graph.link_ids(ghost, forwarder_in.id); + graph.link(forwarder_out, downstream); + let c = ctx(); + + let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); + assert_tainted(&first, forwarder_in, "unresolved-ancestry"); + assert_tainted(&first, downstream, "tainted-upstream"); + assert!( + first.candidates[&downstream_leg.serial].reason().is_some(), + "while the ancestry is unresolved the downstream owner is excluded too" + ); + + let late_client = graph.client_of_app(7200); + graph.node_with_id( + "was-unbound", + MediaRole::StreamOutput, + ghost, + app(late_client, 7200), + ); + let (second, _) = evaluate(&graph.build(), &c, &sticky); + assert_eq!( + second.candidates[&downstream_leg.serial].reason(), + None, + "nothing derived from the uncertainty may outlive it" + ); + assert_eq!( + second.candidates[&forwarder_out.serial].reason(), + None, + "including the unresolved node's own owner siblings" + ); +} + +#[test] +fn real_taint_is_still_sticky_when_its_topology_goes_away() { + // The other half of the same rule, stated positively: *evidence* is + // history and must survive. This is the guard on the change above — if + // provenance splitting ever leaks into the evidence path, peerspeak's own + // audio starts escaping. + let (graph, call, rec_in, rec_out, firefox) = sticky_scene(); + let c = ctx(); + let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); + let (second, _) = evaluate(&graph.build_without(&[rec_in]), &c, &sticky); + assert_partition( + &second, + &[("firefox", firefox)], + &[ + ("call", call, "peerspeak-owned"), + ("rec-out", rec_out, "tainted-owner-bridge"), + ], + ); +} + // ────────────────────────────────────────────────────────────────────── // Stickiness and lifetime-awareness (v3.4 §6.1.3) // ──────────────────────────────────────────────────────────────────────