diff --git a/src/host/taint/fixture.rs b/src/host/taint/fixture.rs index b927975..ec3f2bd 100644 --- a/src/host/taint/fixture.rs +++ b/src/host/taint/fixture.rs @@ -131,8 +131,16 @@ impl Graph { id } }; - let device_id = self.id(); - self.node(name, role, device(session, SESSION_PID, device_id)) + self.node(name, role, device(session, SESSION_PID)) + } + + /// A node that *belongs to* a Device but is not a passive device node — + /// a filter associated with a card. Phase 3 must not classify this as a + /// session device, or it loses both its coarse owner keys and its + /// ability to trip the fail-closed backstop. + pub fn device_associated_filter(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef { + let client = self.client_of_app(pid); + self.node(name, role, app(client, pid)) } /// A **virtual** sink an application created natively: an `Audio/Sink` @@ -313,11 +321,11 @@ pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps { /// here is deliberately *more* pessimistic than reality — it hands the /// engine a second coarse key it could fuse devices on, so a test that /// passes here also passes against the real props. -pub fn device(session_client: GlobalId, session_pid: u32, device_id: GlobalId) -> NodeProps { +pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps { NodeProps { client_id: Some(session_client), process_id: Some(session_pid), - device_id: Some(device_id), + session_device: true, ..NodeProps::default() } } diff --git a/src/host/taint/mod.rs b/src/host/taint/mod.rs index 0ede728..5789f92 100644 --- a/src/host/taint/mod.rs +++ b/src/host/taint/mod.rs @@ -202,6 +202,17 @@ pub struct StickyOwner { /// accumulates: that is what makes "clear only once all member objects /// have disappeared" true across churn. pub members: BTreeSet, + /// Owner keys remembered across connections — strong keys and a usable + /// process id, never `client.id`. Applied only while some serial member + /// above is still live, which is what keeps a recyclable key from + /// resurrecting a dead owner. + /// + /// Needed because a live Client is not the same thing as a live owner: + /// a process can leave one connection idle and open a second, and + /// GStreamer opens one connection per stream as a matter of course, so + /// following connections alone lets the next leg escape (Codex round 2, + /// finding 2). + pub fingerprints: BTreeSet, /// The reason recorded for each node that was tainted in its own right. /// Kept per node rather than collapsed to one owner-wide reason, or a /// forwarder's output leg inherits its *input* leg's `tainted-upstream` @@ -309,14 +320,16 @@ impl Decisions { /// no incremental dirty-set. /// /// ⚠️ **Cost is not O(V+E), despite what v3.4 §6.4 says.** Each fixpoint -/// pass re-runs a full link BFS *and* a full owner scan, and one pass is -/// consumed per layer of owner-bridge hops, so it is O((V+E)·D) for a -/// bridge depth D. D is 1 for every topology observed so far and 2 for a -/// forwarder feeding a forwarder; a 60-layer chain is covered by a test -/// purely to catch an accidental blow-up. Phase 5 records the real -/// recompute-duration distribution and maximum, which is what the "full -/// recompute is fine for v1" claim should actually rest on — a measured -/// headroom, not a node count. +/// pass re-runs a full link BFS *and* a full owner scan, and the bridge +/// scans every tainted source in a component for each target, so the bound +/// is `O(D · (V + E + Σ_C |sources_C|·|targets_C|))` — worst case +/// `O(D · (V² + E))` — for an owner-bridge depth D. D is 1 for every +/// topology observed so far and 2 for a forwarder feeding a forwarder, and +/// components on a real desktop are two or three nodes; the quadratic term +/// needs one owner with many legs. A 60-layer chain test guards the depth +/// dimension only. Phase 5 records the real recompute-duration +/// distribution and maximum, which is what "full recompute is fine for v1" +/// should rest on — measured headroom, not a node count. pub fn evaluate( snapshot: &GraphSnapshot, ctx: &ExclusionCtx, @@ -331,6 +344,7 @@ pub fn evaluate( seed_local_roots(snapshot, ctx, &mut taint); seed_sticky( snapshot, + &keys, prior, &components, &mut taint, @@ -354,17 +368,15 @@ pub fn evaluate( } let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials); - let next_sticky = if ctx.graph_ready { - build_sticky(snapshot, &components, &taint, prior) - } else { - // ⚠️ Never retire a sticky owner on the strength of a snapshot we - // have already declared untrustworthy (Codex round 1). An object - // missing from a partial graph has not been observed to disappear, - // and `build_sticky` drops entries whose members are all absent — - // so one not-ready recompute could erase the taint history and the - // next ready one would hand back a clean bill of health. - prior.clone() - }; + // ⚠️ 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 + // disappear, so retiring on that basis erases history and the next + // ready recompute hands back a clean bill of health. But taint + // *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); (decisions, next_sticky) } @@ -415,8 +427,13 @@ fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option } /// Carry taint forward from previous snapshots (v3.4 §6.1.3). +/// +/// An owner is re-seeded from three kinds of evidence, all lifetime-scoped +/// to a still-live member: its own surviving nodes, nodes on a surviving +/// **Client**, and nodes presenting a remembered owner **fingerprint**. fn seed_sticky( snapshot: &GraphSnapshot, + keys: &owner::OwnerKeyIndex, prior: &StickyState, components: &OwnerComponents, taint: &mut BTreeMap, @@ -431,17 +448,29 @@ fn seed_sticky( live_nodes.push(*serial); } } - // ⚠️ A surviving **Client** re-seeds too. An app can close + // A surviving **Client** re-seeds too. An app can close // every stream it had while keeping its PipeWire connection // open, then open a fresh one — Firefox does exactly this. - // Seeding only from live nodes let that new leg come back - // Eligible while the owner was still, by v3.4 §6.1.3's own - // rule, tainted (its buffers outlive its streams). ObjectRef::Client(serial) => { - live_nodes.extend(nodes_of_client(snapshot, *serial)); + live_nodes.extend(nodes_of_client(snapshot, keys, *serial)); } } } + if live_nodes.is_empty() && !entry.members.iter().any(|m| is_live(snapshot, *m)) { + // Nothing of this owner remains; its fingerprints are just + // recyclable strings now and must not be applied to anyone. + continue; + } + // Fingerprints reach a *new connection* of the same still-live + // process, which neither of the two paths above can see. + for fingerprint in &entry.fingerprints { + live_nodes.extend( + snapshot + .nodes() + .filter(|node| keys.has_fingerprint(node.serial, fingerprint)) + .map(|node| node.serial), + ); + } // The owner is sticky, not the individual node: a leg that appears // later in the same still-live owner inherits the taint. for serial in live_nodes { @@ -458,7 +487,21 @@ fn seed_sticky( /// Nodes currently attached to a client, by the client's **serial**. The /// client's snapshot-local id is resolved fresh each time, so a recycled id /// can never resurrect a dead owner. -fn nodes_of_client(snapshot: &GraphSnapshot, client: Serial) -> Vec { +/// +/// Nodes for which `client.id` is not a usable owner key — session-manager +/// device nodes — are excluded, or the shared `WirePlumber [export]` Client +/// would drag every sound card on the box into one sticky owner. +/// +/// The same gate is applied when *recording* clients into a sticky entry +/// (`owner::client_serials_of`). Either one alone closes the leak; both are +/// kept because they answer different questions ("may this client be +/// remembered?" and "may this client speak for that node?"), and the +/// regression test kills the removal of the pair. +fn nodes_of_client( + snapshot: &GraphSnapshot, + keys: &owner::OwnerKeyIndex, + client: Serial, +) -> Vec { let Some(id) = snapshot .clients() .find(|c| c.serial == client) @@ -466,12 +509,10 @@ fn nodes_of_client(snapshot: &GraphSnapshot, client: Serial) -> Vec { else { return Vec::new(); }; - // If that id is ambiguous, two clients claim it and we cannot say which - // nodes belong to the sticky one — so claim them all, which is the - // fail-closed direction. snapshot .nodes() .filter(|node| node.props.client_id == Some(id)) + .filter(|node| keys.uses_client_key(node.serial)) .map(|node| node.serial) .collect() } @@ -495,6 +536,19 @@ fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap { + // Several nodes claim the input id and we cannot say which + // one this link feeds, so every claimant is a receiver. + // They are already tainted as unresolved by their own + // ambiguous id — but taint without receiver status cannot + // start an owner bridge, so their sibling output legs stayed + // Eligible (Codex round 2, finding 3). + receivers.extend( + snapshot + .nodes_with_id(link.input_node) + .map(|node| node.serial), + ); + } _ => {} } } @@ -620,7 +674,7 @@ fn propagate_unresolved_owner( taint: &mut BTreeMap, ) -> bool { let tainted_reader = snapshot.nodes().any(|node| { - node.props.device_id.is_none() + !node.props.session_device && edges.receivers.contains(&node.serial) && taint.get(&node.serial).is_some_and(|r| r.propagates()) }); @@ -708,20 +762,24 @@ fn local_exclusion(snapshot: &GraphSnapshot, node: &NodeSnapshot) -> Option, prior: &StickyState, + retire_absent: bool, ) -> StickyState { let mut entries: Vec = Vec::new(); // Carry forward prior entries that still have at least one live member. // An entry with none is gone for good: serials never recycle, so a - // vanished member can never come back. + // vanished member can never come back — but only a *trustworthy* + // snapshot is allowed to conclude that a member is absent. for entry in &prior.owners { - if entry - .members - .iter() - .any(|member| is_live(snapshot, *member)) + if !retire_absent + || entry + .members + .iter() + .any(|member| is_live(snapshot, *member)) { entries.push(entry.clone()); } @@ -742,12 +800,17 @@ fn build_sticky( } let mut refs: BTreeSet = members.iter().map(|s| ObjectRef::Node(*s)).collect(); refs.extend( - owner::client_serials_of(snapshot, members) + owner::client_serials_of(snapshot, keys, members) .into_iter() .map(ObjectRef::Client), ); + let fingerprints = members + .iter() + .flat_map(|serial| keys.fingerprints(*serial)) + .collect(); entries.push(StickyOwner { members: refs, + fingerprints, node_reasons, }); } @@ -791,6 +854,7 @@ fn merge_overlapping(mut entries: Vec) -> Vec { .or_insert(reason); } entry.members.extend(other.members); + entry.fingerprints.extend(other.fingerprints); absorbed = true; } } diff --git a/src/host/taint/owner.rs b/src/host/taint/owner.rs index 37fabe4..94de20e 100644 --- a/src/host/taint/owner.rs +++ b/src/host/taint/owner.rs @@ -114,9 +114,9 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option) -> Vec<(OwnerKe if let Some(module) = node.props.pulse_module_id { out.push((OwnerKey::PulseModuleId, KeyValue::Num(module))); } - // Exception 2: coarse keys never bridge nodes exported from a real - // Device — they all share the session manager's client. - if node.props.device_id.is_some() { + // Exception 2: coarse keys never bridge passive session-manager device + // nodes — they all share the session manager's client. + if node.props.session_device { return out; } if let Some(client) = node.props.client_id { @@ -191,6 +191,53 @@ impl OwnerKeyIndex { }) } + /// Is `client.id` a usable owner key for this node? + /// + /// ⚠️ Load-bearing for sticky state. A device node's `client.id` is + /// suppressed by exception 2, so recording the session manager's Client + /// as a *member* of a tainted device's sticky owner would smuggle the + /// suppressed key back in: the next recompute would expand that Client + /// to every hardware node on the box — the microphone included — and + /// the §6.1.1 catastrophe would arrive one epoch late instead of never. + /// (Codex round 2, finding 1.) + pub fn uses_client_key(&self, serial: Serial) -> bool { + self.keys + .get(&serial) + .is_some_and(|keys| keys.iter().any(|(key, _)| *key == OwnerKey::ClientId)) + } + + /// The owner keys that are safe to remember *across* connections, for + /// sticky taint: the strong keys plus a usable process id. + /// + /// `client.id` is deliberately excluded — it identifies a *connection*, + /// and the whole point of a fingerprint is to survive one process + /// closing a connection and opening another. A live Client member is + /// what covers the same-connection case, precisely. + /// + /// These are recyclable strings and numbers, so they are only ever + /// applied while some **serial** member of the owner is still live + /// (v3.4 §6.1.3): while the process is alive, its PID cannot have been + /// handed to anyone else. + pub fn fingerprints(&self, serial: Serial) -> Vec { + self.keys + .get(&serial) + .map(|keys| { + keys.iter() + .filter(|(key, _)| *key != OwnerKey::ClientId) + .map(|(key, value)| Fingerprint(*key, value.clone())) + .collect() + }) + .unwrap_or_default() + } + + /// Does this node currently present `fingerprint`? + pub fn has_fingerprint(&self, serial: Serial, fingerprint: &Fingerprint) -> bool { + self.keys.get(&serial).is_some_and(|keys| { + keys.iter() + .any(|(key, value)| *key == fingerprint.0 && *value == fingerprint.1) + }) + } + /// See [`owner_is_bounded`]. pub fn is_bounded(&self, serial: Serial) -> bool { self.keys @@ -218,6 +265,10 @@ pub fn strongest_shared_key( }) } +/// A remembered owner key — see [`OwnerKeyIndex::fingerprints`]. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub struct Fingerprint(OwnerKey, KeyValue); + /// Nodes partitioned into owner components. #[derive(Clone, Debug, Default)] pub struct OwnerComponents { @@ -315,15 +366,23 @@ impl UnionFind { /// Client objects belonging to an owner component, so sticky taint can be /// keyed on every object that constitutes the owner (v3.4 §6.1.3: clear the /// entry only once **all** member objects are gone). -pub fn client_serials_of(snapshot: &GraphSnapshot, nodes: &[Serial]) -> Vec { +pub fn client_serials_of( + snapshot: &GraphSnapshot, + keys: &OwnerKeyIndex, + nodes: &[Serial], +) -> Vec { let mut out: Vec = nodes .iter() + // Only nodes for which `client.id` is a *usable* owner key. See + // `uses_client_key`: recording a device node's shared session-manager + // Client here would defeat exception 2 on the next recompute. + .filter(|serial| keys.uses_client_key(**serial)) .filter_map(|serial| snapshot.node(*serial)) .filter_map(|node| node.props.client_id) - .filter_map(|id: GlobalId| match snapshot.client_by_id(id) { - Some(super::snapshot::IdLookup::Unique(serial)) => Some(serial), - _ => None, - }) + // An ambiguous client id means two Clients claim it and we cannot + // say which one is ours, so remember both: an entry that recorded + // neither could be retired while its owner was still live. + .flat_map(|id: GlobalId| snapshot.clients_with_id(id).map(|client| client.serial)) .collect(); out.sort_unstable(); out.dedup(); diff --git a/src/host/taint/snapshot.rs b/src/host/taint/snapshot.rs index 6ddcba6..716f567 100644 --- a/src/host/taint/snapshot.rs +++ b/src/host/taint/snapshot.rs @@ -116,16 +116,28 @@ pub struct NodeProps { /// The stream negotiated an encoded/passthrough format; a second link /// would refuse or corrupt it (v3.4 §6.2). pub passthrough: bool, - /// `device.id` — set only on nodes exported from a real `Device` - /// object by the session manager. + /// This node is a **passive device node exported by the session + /// manager** — a real sound card's sink or source, not something that + /// forwards audio. /// - /// ✅ **Measured 2026-07-21**: this is the discriminator between "a - /// hardware device node, one of a set that all share WirePlumber's - /// `client.id`" and "a virtual sink with its own client". The five - /// ALSA nodes carry `device.id` 43/45/46 with `device.api=alsa`; the - /// three `support.null-audio-sink` nodes carry none. See - /// [`super::owner`] exception 2, which turns on exactly this. - pub device_id: Option, + /// ⚠️ **A classification the observer owes, not a raw property.** The + /// first cut set this from bare `device.id`, and Codex refuted it: + /// PipeWire defines `device.id` only as "the Device this Node belongs + /// to", which does not promise the node is passive. A forwarding node + /// that happens to carry one would silently lose its coarse owner keys + /// *and* its ability to trip the fail-closed backstop — two protections + /// at once. **Phase 3 must require `device.id` AND `device.api`** + /// (measured: `device.api=alsa` on all five real ALSA nodes, absent on + /// every `support.null-audio-sink`), and should treat anything it + /// cannot positively classify as *not* a device — that is the + /// fail-closed direction here, because the flag only ever *removes* + /// taint mechanisms. + /// + /// What it is for: every device node on the box shares the session + /// manager's `client.id` (measured: 42, `WirePlumber [export]`), so + /// coarse owner keys must not bridge them. See [`super::owner`] + /// exception 2. + pub session_device: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -255,6 +267,18 @@ impl GraphSnapshot { self.client_ids.get(&id).copied() } + /// Every node claiming a global id. More than one means the id is + /// [`IdLookup::Ambiguous`] and each claimant must be treated as a + /// possible endpoint of any link naming it. + pub fn nodes_with_id(&self, id: GlobalId) -> impl Iterator { + self.nodes.values().filter(move |node| node.id == id) + } + + /// Every client claiming a global id — same fail-closed reasoning. + pub fn clients_with_id(&self, id: GlobalId) -> impl Iterator { + self.clients.values().filter(move |client| client.id == id) + } + /// Ports belonging to a node, by the node's snapshot-local id. pub fn ports_of(&self, node: GlobalId) -> impl Iterator { self.ports.values().filter(move |p| p.node == node) diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs index 1105676..e2d215b 100644 --- a/src/host/taint/tests.rs +++ b/src/host/taint/tests.rs @@ -1275,3 +1275,218 @@ fn a_deep_forwarder_chain_converges() { "the whole chain plus peerspeak's own playback" ); } + +// ────────────────────────────────────────────────────────────────────── +// Regressions from Codex round 2 — the verification round, where five of +// the round-1 fixes turned out to be partial. Two of these are worse than +// the bugs they were meant to close. +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn repeated_evaluation_does_not_contaminate_devices_through_sticky_clients() { + // Round 2 finding 1, and the sharpest one so far: the round-1 sticky + // fix smuggled the suppressed key back in. Recording the shared + // `WirePlumber [export]` Client as a member of the tainted hardware + // sink's owner meant the *second* recompute expanded that Client to + // every sound card on the box, tainted the microphone, and excluded + // every app holding one. The single-evaluate test could not see it. + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let mic = graph.device_node("mic", MediaRole::Source); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, hw); + let ff_in = graph.app_node("firefox-mic", MediaRole::StreamInput, 11114); + let ff_out = graph.app_node("firefox-out", MediaRole::StreamOutput, 11114); + graph.link(mic, ff_in); + + let snapshot = graph.build(); + let c = ctx(); + let (first, sticky) = evaluate(&snapshot, &c, &StickyState::default()); + assert_partition( + &first, + &[("ff-out", ff_out)], + &[("call", call, "peerspeak-owned")], + ); + + // The identical graph, evaluated again. Nothing changed, so nothing + // about the answer may change either. + let (second, sticky) = evaluate(&snapshot, &c, &sticky); + assert_partition( + &second, + &[("ff-out", ff_out)], + &[("call", call, "peerspeak-owned")], + ); + assert_untainted(&second, mic); + + // And it must not drift on the third, either. + let (third, _) = evaluate(&snapshot, &c, &sticky); + assert_partition( + &third, + &[("ff-out", ff_out)], + &[("call", call, "peerspeak-owned")], + ); +} + +#[test] +fn a_second_connection_of_a_still_tainted_process_inherits_the_taint() { + // Round 2 finding 2. Following a surviving *connection* is not the same + // as following a surviving *owner*: the process leaves its first client + // idle and opens a second one, which the client expansion cannot see. + // GStreamer opens one connection per stream as a matter of course. + let (mut graph, call, rec_in, rec_out, firefox) = sticky_scene(); + let c = ctx(); + let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); + + let client_b = graph.client(Some(PULSE_PID)); + let late = graph.node("rec-out-late", MediaRole::StreamOutput, app(client_b, 8080)); + let (next, _) = evaluate(&graph.build_without(&[rec_in, rec_out]), &c, &sticky); + assert_partition( + &next, + &[("firefox", firefox)], + &[ + ("call", call, "peerspeak-owned"), + ("late", late, "tainted-owner-bridge"), + ], + ); +} + +#[test] +fn a_fingerprint_does_not_outlive_its_owner() { + // The other side of that fix. A fingerprint is a recyclable PID, so it + // may only be applied while some *serial* member of the owner is still + // live; once the owner is fully gone, a new process handed the same PID + // must start clean. + // + // ⚠️ The recycled node must exist in the **same** snapshot that first + // sees the owner fully gone. A later snapshot proves nothing: the entry + // has been retired by then, so the liveness guard is never consulted + // and the test passes no matter what it does. (The first version of + // this test made exactly that mistake and survived the mutation that + // deletes the guard.) + let (mut graph, call, rec_in, rec_out, firefox) = sticky_scene(); + let c = ctx(); + let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); + + let recorder_client = graph.client_of_app(8080); + graph.drop_clients(&[recorder_client]); + // A different process that happens to be handed the same PID, present + // in the very snapshot where the old owner disappears. + let reborn_client = graph.client(Some(PULSE_PID)); + let reborn = graph.node("reborn", MediaRole::StreamOutput, app(reborn_client, 8080)); + + let (after, _) = evaluate(&graph.build_without(&[rec_in, rec_out]), &c, &sticky); + assert_partition( + &after, + &[("firefox", firefox), ("reborn", reborn)], + &[("call", call, "peerspeak-owned")], + ); +} + +#[test] +fn an_ambiguous_input_endpoint_makes_every_claimant_a_receiver() { + // Round 2 finding 3. Both claimants were already tainted as unresolved + // through their own ambiguous id — but taint without receiver status + // cannot start an owner bridge, so both sibling output legs stayed + // Eligible while one of them was re-emitting the call. + let mut graph = Graph::new(); + let call = graph.peerspeak_node("call", 7); + let shared = graph.dangling_id(); + let ca = graph.client_of_app(8000); + let cb = graph.client_of_app(9000); + let in_a = graph.node_with_id("in-a", MediaRole::Other, shared, app(ca, 8000)); + let in_b = graph.node_with_id("in-b", MediaRole::Other, shared, app(cb, 9000)); + let out_a = graph.node("out-a", MediaRole::StreamOutput, app(ca, 8000)); + let out_b = graph.node("out-b", MediaRole::StreamOutput, app(cb, 9000)); + graph.link_ids(call.id, shared); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[], + &[ + ("call", call, "peerspeak-owned"), + ("out-a", out_a, "tainted-owner-bridge"), + ("out-b", out_b, "tainted-owner-bridge"), + ], + ); + assert_tainted(&decisions, in_a, "unresolved-ancestry"); + assert_tainted(&decisions, in_b, "unresolved-ancestry"); +} + +#[test] +fn an_unresolved_output_endpoint_to_an_unknown_role_node_still_bridges() { + // Round 2 finding 6: a mutation the 42-test suite survived. The + // unknown-role test used a *resolved* output endpoint, and the + // unresolved-output test used a `StreamInput`, which the role union put + // back into `receivers` anyway — so deleting the receiver insert from + // the unresolved-output arm changed nothing. This fixture needs both. + let mut graph = Graph::new(); + let ghost = graph.dangling_id(); + let client = graph.client_of_app(8080); + let odd = graph.node("odd", MediaRole::Other, app(client, 8080)); + let out = graph.node("out", MediaRole::StreamOutput, app(client, 8080)); + graph.link_ids(ghost, odd.id); + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[("firefox", firefox)], + &[("out", out, "tainted-owner-bridge")], + ); + assert_tainted(&decisions, odd, "unresolved-ancestry"); +} + +#[test] +fn a_device_associated_filter_still_bridges_on_its_client() { + // Round 2 finding 4. `device.id` alone does not mean "passive device + // node" — PipeWire defines it only as the Device a node belongs to. A + // filter associated with a card would have lost both its coarse owner + // keys *and* its ability to trip the fail-closed backstop, so the flag + // is now a classification phase 3 owes (device.id AND device.api), and + // anything unclassified is treated as not-a-device. + let mut graph = Graph::new(); + let call = graph.peerspeak_node("call", 7); + let filter_in = graph.device_associated_filter("card-filter-in", MediaRole::Sink, 4321); + let leaked_out = + graph.device_associated_filter("card-filter-out", MediaRole::StreamOutput, 4321); + graph.link(call, filter_in); + + assert_partition( + &run(&graph, &ctx()), + &[], + &[ + ("call", call, "peerspeak-owned"), + ("filter-out", leaked_out, "tainted-owner-bridge"), + ], + ); +} + +#[test] +fn a_not_ready_snapshot_still_records_new_taint() { + // Round 2 finding 5: the round-1 fix stopped a not-ready epoch erasing + // history, but also stopped it *recording* any. A reader can consume + // and buffer the call during that epoch and vanish before readiness, + // and its output leg came back Eligible. Readiness gates retirement + // only. + let (graph, call, rec_in, rec_out, firefox) = sticky_scene(); + let not_ready = ExclusionCtx { + graph_ready: false, + ..ctx() + }; + let (_, sticky) = evaluate(&graph.build(), ¬_ready, &StickyState::default()); + assert!( + !sticky.is_empty(), + "taint observed during a not-ready epoch is still taint" + ); + + let (ready, _) = evaluate(&graph.build_without(&[rec_in]), &ctx(), &sticky); + assert_partition( + &ready, + &[("firefox", firefox)], + &[ + ("call", call, "peerspeak-owned"), + ("rec-out", rec_out, "tainted-owner-bridge"), + ], + ); +}