//! The taint-engine fixture matrix — design v3.4 §12 plus the impl plan's //! degenerate-snapshot addition. //! //! **Every test asserts an exact partition of the whole candidate universe**, //! never a spot check on one named node. Checking only the node a test cares //! about constrains nothing about the others, and an exclude-everything //! implementation would pass. So each test names *every* `Stream/Output/Audio` //! node in its graph as either eligible or excluded-with-a-reason-code. //! //! Fixture realism that is load-bearing: an ordinary app holds **one client //! connection**, each pactl module holds its own, and every device node //! shares the session manager's. Getting that wrong makes the engine look //! broken (or, worse, makes a leak look fine). use std::collections::BTreeSet; use super::fixture::{Graph, NodeRef, PULSE_PID, app}; use super::owner::{OwnerKey, strongest_shared_key}; use super::snapshot::{MediaRole, NodeProps, PortDirection, Serial}; use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate}; fn ctx() -> ExclusionCtx { ExclusionCtx { aec_module_id: None, pipewire_pulse_pid: Some(PULSE_PID), pixelpass_owned: BTreeSet::new(), graph_ready: true, } } fn run(graph: &Graph, ctx: &ExclusionCtx) -> Decisions { evaluate(&graph.build(), ctx, &StickyState::default()).0 } /// Assert the complete candidate partition. `excluded` names reason codes. #[track_caller] fn assert_partition( decisions: &Decisions, eligible: &[(&str, NodeRef)], excluded: &[(&str, NodeRef, &str)], ) { let mut expected_eligible: Vec = eligible.iter().map(|(_, n)| n.serial).collect(); expected_eligible.sort_unstable(); let mut expected_excluded: Vec<(Serial, &str)> = excluded .iter() .map(|(_, n, code)| (n.serial, *code)) .collect(); expected_excluded.sort_unstable(); let label = |serial: Serial| -> String { eligible .iter() .map(|(name, n)| (*name, *n)) .chain(excluded.iter().map(|(name, n, _)| (*name, *n))) .find(|(_, n)| n.serial == serial) .map_or_else(|| format!(""), |(name, _)| name.into()) }; let actual: Vec = decisions .candidates .values() .map(|d| match d.reason() { None => format!("{} = eligible", label(d.serial)), Some(reason) => format!("{} = {}", label(d.serial), reason.code()), }) .collect(); assert_eq!( (decisions.eligible(), decisions.excluded()), (expected_eligible, expected_excluded), "actual decisions: {actual:#?}" ); } #[track_caller] fn assert_tainted(decisions: &Decisions, node: NodeRef, reason: &str) { let entry = decisions .taint .get(&node.serial) .unwrap_or_else(|| panic!("expected {:?} to be tainted", node.serial)); assert_eq!(entry.reason.code(), reason); } #[track_caller] fn assert_untainted(decisions: &Decisions, node: NodeRef) { assert_eq!( decisions.taint.get(&node.serial).map(|e| e.reason.code()), None, "expected {:?} to be untainted", node.serial ); } // ────────────────────────────────────────────────────────────────────── // Boundary / degenerate cases — the fail-closed default // ────────────────────────────────────────────────────────────────────── #[test] fn empty_snapshot_yields_nothing_eligible() { let decisions = run(&Graph::new(), &ctx()); assert!(decisions.candidates.is_empty()); assert!(decisions.eligible().is_empty()); } #[test] fn a_graph_with_only_untainted_streams_still_partitions() { // The other half of the degenerate case: "nothing eligible" must not be // reached by excluding everything unconditionally. let mut graph = Graph::new(); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); assert_partition(&run(&graph, &ctx()), &[("firefox", firefox)], &[]); } #[test] fn graph_not_ready_excludes_every_candidate() { let mut graph = Graph::new(); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); let decisions = run( &graph, &ExclusionCtx { graph_ready: false, ..ctx() }, ); assert_partition(&decisions, &[], &[("firefox", firefox, "graph-not-ready")]); } #[test] fn decisions_do_not_depend_on_insertion_order() { // The reported reason must come from an explicit priority, not from // traversal order, or the audit output is unstable between recomputes. let build = |reverse: bool| { let mut graph = Graph::new(); let sink = graph.device_node("sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); if reverse { graph.link(firefox, sink); graph.link(call, sink); } else { graph.link(call, sink); graph.link(firefox, sink); } run(&graph, &ctx()).excluded() }; assert_eq!(build(false), build(true)); } // ────────────────────────────────────────────────────────────────────── // Node-local roots (v3.4 §5.1, §5.2, §6.2, §6.6) // ────────────────────────────────────────────────────────────────────── #[test] fn peerspeak_tagged_nodes_are_excluded_and_plain_apps_are_not() { let mut graph = Graph::new(); let sink = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak-call", 7); let mpv = graph.peerspeak_node("peerspeak-mpv", 8); let notify = graph.peerspeak_node("peerspeak-notify", 9); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); for node in [call, mpv, notify, firefox] { graph.link(node, sink); } let decisions = run(&graph, &ctx()); assert_partition( &decisions, &[("firefox", firefox)], &[ ("call", call, "peerspeak-owned"), ("mpv", mpv, "peerspeak-owned"), ("notify", notify, "peerspeak-owned"), ], ); // The sink carried peerspeak audio, so it is tainted — that is what // makes every monitor reader downstream of it tainted too. assert_tainted(&decisions, sink, "tainted-upstream"); } #[test] fn aec_identity_is_exact_equality_and_other_modules_stay_eligible() { let mut graph = Graph::new(); let ours = graph.module_node("echo-cancel-playback", MediaRole::StreamOutput, 536_870_919); // A tunnel/RTP module: it has a `pulse.module.id`, just not ours. // "Has any pulse.module.id" is explicitly rejected as an exclusion rule // — such a module may be the only carrier of audio the user wants // shared (v3.4 §5.2 correction 2). let tunnel = graph.module_node("tunnel-out", MediaRole::StreamOutput, 536_870_921); let plain = graph.app_node("firefox", MediaRole::StreamOutput, 11114); let decisions = run( &graph, &ExclusionCtx { aec_module_id: Some(536_870_919), ..ctx() }, ); assert_partition( &decisions, &[("tunnel", tunnel), ("plain", plain)], &[("ours", ours, "aec-identity")], ); } #[test] fn aec_module_id_is_compared_beyond_u32() { // Guards the phase 0a widening at the engine boundary too: these two // differ only above bit 32. let big = u64::from(u32::MAX) + 7; let mut graph = Graph::new(); let ours = graph.module_node("aec", MediaRole::StreamOutput, big); let other = graph.module_node("other", MediaRole::StreamOutput, big ^ 0x1_0000_0000); let decisions = run( &graph, &ExclusionCtx { aec_module_id: Some(big), ..ctx() }, ); assert_partition( &decisions, &[("other", other)], &[("ours", ours, "aec-identity")], ); } #[test] fn foreign_echo_cancel_group_is_excluded_not_shared() { // Decision D3: warn and exclude. The group prefix is hazard detection, // never ownership — it cannot tell peerspeak's AEC from anyone else's. let mut graph = Graph::new(); let foreign = graph.group_node( "echo-cancel-playback", MediaRole::StreamOutput, "echo-cancel-9999-13", 4321, ); let plain = graph.app_node("firefox", MediaRole::StreamOutput, 11114); assert_partition( &run(&graph, &ctx()), &[("plain", plain)], &[("foreign", foreign, "foreign-echo-cancel")], ); } #[test] fn pixelpass_capture_sink_and_its_downstream_are_excluded() { // v3.4 §6.2 cycle prevention, and impl-plan §5.1 row 7: observing the // capture sink is not enough — a *downstream* candidate must be named, // or recognising `pixelpass_capture_*` as a mere sink name would pass // without any transitive propagation. let mut graph = Graph::new(); let other_host_sink = graph.module_node("pixelpass_capture_31337", MediaRole::Sink, 42); let fwd_in = graph.module_node("fwd-in", MediaRole::StreamInput, 77); let fwd_out = graph.module_node("fwd-out", MediaRole::StreamOutput, 77); graph.link(other_host_sink, fwd_in); let plain = graph.app_node("firefox", MediaRole::StreamOutput, 11114); let decisions = run(&graph, &ctx()); assert_partition( &decisions, &[("plain", plain)], &[("fwd-out", fwd_out, "tainted-owner-bridge")], ); assert_tainted(&decisions, other_host_sink, "pixelpass-owned"); assert_tainted(&decisions, fwd_in, "tainted-upstream"); } #[test] fn pixelpass_owned_serials_are_roots_even_without_a_capture_sink_name() { let mut graph = Graph::new(); let ours = graph.device_node("some-sink", MediaRole::Sink); let reader = graph.app_node("reader-in", MediaRole::StreamInput, 5150); let leg = graph.app_node("reader-out", MediaRole::StreamOutput, 5150); graph.link(ours, reader); let decisions = run( &graph, &ExclusionCtx { pixelpass_owned: BTreeSet::from([ours.serial]), ..ctx() }, ); assert_partition(&decisions, &[], &[("leg", leg, "tainted-owner-bridge")]); assert_tainted(&decisions, ours, "pixelpass-owned"); } #[test] fn port_exclusive_and_passthrough_are_local_exclusions() { let mut graph = Graph::new(); let exclusive = graph.app_node("exclusive", MediaRole::StreamOutput, 100); graph.port(exclusive, PortDirection::Out, true); let client = graph.client_of_app(101); let passthrough = graph.node( "passthrough", MediaRole::StreamOutput, NodeProps { passthrough: true, ..app(client, 101) }, ); let ok = graph.app_node("ok", MediaRole::StreamOutput, 102); graph.port(ok, PortDirection::Out, false); let decisions = run(&graph, &ctx()); assert_partition( &decisions, &[("ok", ok)], &[ ("exclusive", exclusive, "port-exclusive"), ("passthrough", passthrough, "passthrough"), ], ); // Neither is hazardous — an unlinkable stream must not taint anything. assert_untainted(&decisions, exclusive); assert_untainted(&decisions, passthrough); } #[test] fn a_node_with_no_props_at_all_is_eligible_and_non_streams_are_not_candidates() { let mut graph = Graph::new(); let bare = graph.node("bare", MediaRole::StreamOutput, NodeProps::default()); graph.node("video", MediaRole::Other, NodeProps::default()); graph.node("mic", MediaRole::Source, NodeProps::default()); assert_partition(&run(&graph, &ctx()), &[("bare", bare)], &[]); } // ────────────────────────────────────────────────────────────────────── // Edge type 2 — sink → monitor, free at node granularity // ────────────────────────────────────────────────────────────────────── #[test] fn taint_crosses_app_then_sink_then_monitor_reader() { let mut graph = Graph::new(); let sink = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, sink); // A recorder reading the sink's monitor. The monitor connection IS a // real Link whose output node is the sink itself, so no synthetic edge // is needed at node granularity. let rec_in = graph.app_node("recorder-in", MediaRole::StreamInput, 555); graph.link(sink, rec_in); let rec_out = graph.app_node("recorder-out", MediaRole::StreamOutput, 555); let decisions = run(&graph, &ctx()); assert_partition( &decisions, &[], &[ ("call", call, "peerspeak-owned"), ("rec-out", rec_out, "tainted-owner-bridge"), ], ); assert_tainted(&decisions, rec_in, "tainted-upstream"); } #[test] fn playing_into_a_tainted_sink_does_not_taint_the_player() { // Taint flows downstream only. Every app on the box plays into the same // sink peerspeak does; if that tainted them, nothing would ever be // shareable and the feature would ship as silence. let mut graph = Graph::new(); let sink = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); graph.link(call, sink); graph.link(firefox, sink); assert_partition( &run(&graph, &ctx()), &[("firefox", firefox)], &[("call", call, "peerspeak-owned")], ); } // ────────────────────────────────────────────────────────────────────── // Edge type 3 — the owner bridge (v3.4 §6.1.1, §6.1.2) // ────────────────────────────────────────────────────────────────────── /// The measured `module-null-sink` + `module-loopback` shape. Walking Links /// alone from the leaking output leg finds *no inbound links at all* — a /// dead end that reads as "clean". #[test] fn loopback_forwarder_output_leg_is_excluded_via_the_owner_bridge() { let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let null_sink = graph.module_node("fabletest_sink", MediaRole::Sink, 536_870_917); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, null_sink); // The tainted forwarder: legs share `pulse.module.id`, no Link between. let lb_in = graph.module_node("input.loopback", MediaRole::StreamInput, 536_870_918); let lb_out = graph.module_node("output.loopback", MediaRole::StreamOutput, 536_870_918); graph.link(null_sink, lb_in); graph.link(lb_out, hw); // The same shape with a clean input — impl-plan §5.1 row 1's eligible // half. Without it the test would also pass on "exclude all forwarders". let clean_sink = graph.device_node("clean-sink", MediaRole::Sink); let clean_in = graph.module_node("clean-in", MediaRole::StreamInput, 536_870_920); let clean_out = graph.module_node("clean-out", MediaRole::StreamOutput, 536_870_920); graph.link(clean_sink, clean_in); graph.link(clean_out, hw); let decisions = run(&graph, &ctx()); assert_partition( &decisions, &[("clean-out", clean_out)], &[ ("call", call, "peerspeak-owned"), ("lb-out", lb_out, "tainted-owner-bridge"), ], ); // ...and it was the bridge, naming the key — not a Link walk. assert_eq!( decisions.taint[&lb_out.serial].reason, Reason::TaintedOwnerBridge { key: Some(OwnerKey::PulseModuleId) } ); } #[test] fn easyeffects_shape_bridges_from_an_audio_sink_to_its_re_emitting_leg() { // The canonical installed case. The tainted member here is an // `Audio/Sink`, not a `Stream/Input/Audio` — if the bridge only fired // from stream inputs, EasyEffects would forward the whole call into the // share at full level. let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let ee_sink = graph.group_node("ee_sink", MediaRole::Sink, "ee", 4321); let ee_out = graph.group_node("easyeffects-out", MediaRole::StreamOutput, "ee", 4321); graph.link(ee_out, hw); let call = graph.peerspeak_node("peerspeak", 7); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); graph.link(call, ee_sink); graph.link(firefox, ee_sink); let decisions = run(&graph, &ctx()); assert_partition( &decisions, &[("firefox", firefox)], &[ ("call", call, "peerspeak-owned"), ("ee-out", ee_out, "tainted-owner-bridge"), ], ); assert_eq!( decisions.taint[&ee_out.serial].reason, Reason::TaintedOwnerBridge { key: Some(OwnerKey::LinkGroup) } ); } #[test] fn gstreamer_split_clients_bridge_on_process_id() { // The C2 refutation, measured: one `gst-launch pulsesrc ! pulsesink` // process produced TWO client objects (209 and 210) for its two legs. let mut graph = Graph::new(); let tainted_sink = graph.device_node("null-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, tainted_sink); let client_in = graph.client(Some(PULSE_PID)); let client_out = graph.client(Some(PULSE_PID)); let gst_in = graph.node("gst-in", MediaRole::StreamInput, app(client_in, 20172)); let gst_out = graph.node("gst-out", MediaRole::StreamOutput, app(client_out, 20172)); graph.link(tainted_sink, gst_in); // Control: the same split-client shape reading an *untainted* source. let mic = graph.device_node("mic", MediaRole::Source); let ok_client_in = graph.client(Some(PULSE_PID)); let ok_client_out = graph.client(Some(PULSE_PID)); let ok_in = graph.node("ok-in", MediaRole::StreamInput, app(ok_client_in, 30000)); let ok_out = graph.node("ok-out", MediaRole::StreamOutput, app(ok_client_out, 30000)); graph.link(mic, ok_in); let decisions = run(&graph, &ctx()); assert_partition( &decisions, &[("ok-out", ok_out)], &[ ("call", call, "peerspeak-owned"), ("gst-out", gst_out, "tainted-owner-bridge"), ], ); assert_eq!( decisions.taint[&gst_out.serial].reason, Reason::TaintedOwnerBridge { key: Some(OwnerKey::ProcessId) } ); } #[test] fn owner_key_union_falls_through_a_present_but_unequal_key() { // The §6.1.2 wording trap, asserted on the key resolver itself: a // first-present implementation stops at `client.id`, sees 209 != 210, // concludes "different owners", and the leak survives. let mut graph = Graph::new(); let a_client = graph.client(Some(PULSE_PID)); let b_client = graph.client(Some(PULSE_PID)); let a = graph.node("a", MediaRole::StreamInput, app(a_client, 20172)); let b = graph.node("b", MediaRole::StreamOutput, app(b_client, 20172)); let snapshot = graph.build(); let (a, b) = ( snapshot.node(a.serial).unwrap(), snapshot.node(b.serial).unwrap(), ); assert_ne!(a.props.client_id, b.props.client_id); assert_eq!( strongest_shared_key(a, b, Some(PULSE_PID)), Some(OwnerKey::ProcessId) ); } #[test] fn the_strongest_shared_key_wins_when_several_match() { let mut graph = Graph::new(); let a = graph.group_node("a", MediaRole::StreamInput, "g", 500); let b = graph.group_node("b", MediaRole::StreamOutput, "g", 500); let snapshot = graph.build(); assert_eq!( strongest_shared_key( snapshot.node(a.serial).unwrap(), snapshot.node(b.serial).unwrap(), Some(PULSE_PID) ), Some(OwnerKey::LinkGroup) ); } #[test] fn the_pipewire_pulse_pid_does_not_fuse_unrelated_modules() { // impl-plan §5.1 row 3: two Pulse modules, one tainted input. The other // module's output MUST stay eligible — that is the only thing that // makes wrong pipewire-pulse-PID fusion observable at all. let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, hw); let a_in = graph.module_node("a-in", MediaRole::StreamInput, 1); let a_out = graph.module_node("a-out", MediaRole::StreamOutput, 1); graph.link(hw, a_in); let mic = graph.device_node("mic", MediaRole::Source); let b_in = graph.module_node("b-in", MediaRole::StreamInput, 2); let b_out = graph.module_node("b-out", MediaRole::StreamOutput, 2); graph.link(mic, b_in); let decisions = run(&graph, &ctx()); assert_partition( &decisions, &[("b-out", b_out)], &[ ("call", call, "peerspeak-owned"), ("a-out", a_out, "tainted-owner-bridge"), ], ); assert_untainted(&decisions, b_in); } #[test] fn an_unknown_pipewire_pulse_pid_over_excludes_rather_than_leaks() { // v3.4 §6.1.2's failure-mode paragraph: if pixelpass cannot identify // the daemon PID, key 4 stops being suppressed, every module-created // stream fuses into one owner, and the result is broad over-exclusion — // annoying and safe. This test pins that direction; flipping it to // "leave key 4 out when the PID is unknown" would be a leak. let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, hw); let a_in = graph.module_node("a-in", MediaRole::StreamInput, 1); let a_out = graph.module_node("a-out", MediaRole::StreamOutput, 1); graph.link(hw, a_in); let mic = graph.device_node("mic", MediaRole::Source); let b_in = graph.module_node("b-in", MediaRole::StreamInput, 2); let b_out = graph.module_node("b-out", MediaRole::StreamOutput, 2); graph.link(mic, b_in); let decisions = run( &graph, &ExclusionCtx { pipewire_pulse_pid: None, ..ctx() }, ); assert_partition( &decisions, &[], &[ ("call", call, "peerspeak-owned"), ("a-out", a_out, "tainted-owner-bridge"), ("b-out", b_out, "tainted-owner-bridge"), ], ); } #[test] fn coarse_keys_do_not_bridge_device_nodes() { // ⚠️ A rule added during implementation, NOT present in design v3.4. // Every ALSA device node is created by one WirePlumber process, so all // of them share one `client.id` and one `application.process.id`. // peerspeak's playback taints the default sink on every recompute; if // coarse keys bridged devices, that taint would jump to the microphone // source, and then every app holding a mic would lose its playback — // the exact §6.1.1 catastrophe by another route. 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 decisions = run(&graph, &ctx()); assert_partition( &decisions, &[("ff-out", ff_out)], &[("call", call, "peerspeak-owned")], ); assert_tainted(&decisions, hw, "tainted-upstream"); assert_untainted(&decisions, mic); assert_untainted(&decisions, ff_in); } #[test] fn a_module_owned_device_still_bridges_on_its_strong_key() { // The other side of that rule: exception 2 must not disarm the bridge // for virtual sinks that genuinely belong to a module. let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); let virt = graph.module_node("virtual-sink", MediaRole::Sink, 900_001); let leg = graph.module_node("virtual-out", MediaRole::StreamOutput, 900_001); graph.link(call, virt); graph.link(leg, hw); let decisions = run(&graph, &ctx()); assert_partition( &decisions, &[], &[ ("call", call, "peerspeak-owned"), ("leg", leg, "tainted-owner-bridge"), ], ); } #[test] fn firefox_three_cases() { // v3.4 §6.1.1's table, as one graph with an exact partition. 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); // 1. music only let music = graph.app_node("ff-music", MediaRole::StreamOutput, 1001); graph.link(music, hw); // 2. a Meet call: a mic input leg on an untainted source let meet_in = graph.app_node("ff-meet-in", MediaRole::StreamInput, 1002); let meet_out = graph.app_node("ff-meet-out", MediaRole::StreamOutput, 1002); graph.link(mic, meet_in); graph.link(meet_out, hw); // 3. screen-sharing with desktop audio: an input leg on a tainted monitor let share_in = graph.app_node("ff-share-in", MediaRole::StreamInput, 1003); let share_out = graph.app_node("ff-share-out", MediaRole::StreamOutput, 1003); graph.link(hw, share_in); graph.link(share_out, hw); assert_partition( &run(&graph, &ctx()), &[("music", music), ("meet-out", meet_out)], &[ ("call", call, "peerspeak-owned"), ("share-out", share_out, "tainted-owner-bridge"), ], ); } #[test] fn an_unbounded_module_forwarder_fails_closed_and_sweeps_the_desktop() { // v3.4 §12: "a module forwarder with neither link-group nor // pulse.module.id ⇒ unresolved ⇒ excluded". Its process id is the // daemon's (so key 4 is suppressed) and its legs carry different // `client.id`s, so nothing can enumerate its siblings. // // ⚠️ Because the reading leg is itself *unbounded*, the whole desktop's // output is swept — an ordinary app is NOT spared (Codex round 4: a // real app can present no PID on its reader leg, so "unbounded ⇒ not an // app" is unsound; over-exclude instead). The trigger is anomalous: a // keyless reader actively consuming the call. let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, hw); let in_client = graph.client(Some(PULSE_PID)); let out_client = graph.client(Some(PULSE_PID)); let fwd_in = graph.node("fwd-in", MediaRole::StreamInput, app(in_client, PULSE_PID)); let fwd_out = graph.node( "fwd-out", MediaRole::StreamOutput, app(out_client, PULSE_PID), ); graph.link(hw, fwd_in); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); graph.link(firefox, hw); assert_partition( &run(&graph, &ctx()), &[], &[ ("call", call, "peerspeak-owned"), ("fwd-out", fwd_out, "unresolved-owner"), ("firefox", firefox, "unresolved-owner"), ], ); } #[test] fn a_bounded_tainted_reader_leaves_ordinary_apps_alone() { // The blast-radius guarantee survives for the *bounded* tier (round-1 // finding 4): a tainted reader with a real strong key excludes only the // unbounded output legs that could share its identity, not real apps. // Here an EasyEffects-shaped reader (link-group) reads the call, and its // own re-emitting leg is excluded via the bridge — but firefox is not. let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, hw); let ee_in = graph.group_node("ee-in", MediaRole::StreamInput, "ee", 5000); let ee_out = graph.group_node("ee-out", MediaRole::StreamOutput, "ee", 5000); graph.link(hw, ee_in); graph.link(ee_out, hw); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); graph.link(firefox, hw); assert_partition( &run(&graph, &ctx()), &[("firefox", firefox)], &[ ("call", call, "peerspeak-owned"), ("ee-out", ee_out, "tainted-owner-bridge"), ], ); } #[test] fn audio_duplex_over_taints_known_accepted() { // v3.4 §6.1 edge-type-2 caveat, asserted so that fixing it later is a // deliberate change and not a silent regression. A duplex device whose // playback side is tainted has its capture side treated as tainted too, // so an app recording from it loses its playback leg. let mut graph = Graph::new(); let duplex = graph.device_node("duplex-card", MediaRole::Duplex); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, duplex); let rec_in = graph.app_node("rec-in", MediaRole::StreamInput, 4444); let rec_out = graph.app_node("rec-out", MediaRole::StreamOutput, 4444); graph.link(duplex, rec_in); assert_partition( &run(&graph, &ctx()), &[], &[ ("call", call, "peerspeak-owned"), ("rec-out", rec_out, "tainted-owner-bridge"), ], ); } // ────────────────────────────────────────────────────────────────────── // Unresolved ancestry — fail closed (v3.4 §6.1.4) // ────────────────────────────────────────────────────────────────────── #[test] fn a_link_from_an_unknown_node_fails_closed() { let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let mystery_in = graph.app_node("mystery-in", MediaRole::StreamInput, 6000); let mystery_out = graph.app_node("mystery-out", MediaRole::StreamOutput, 6000); let ghost = graph.dangling_id(); graph.link_ids(ghost, mystery_in.id); graph.link(mystery_out, hw); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); let decisions = run(&graph, &ctx()); assert_partition( &decisions, &[("firefox", firefox)], &[("mystery-out", mystery_out, "tainted-owner-bridge")], ); assert_tainted(&decisions, mystery_in, "unresolved-ancestry"); } #[test] fn an_ambiguous_recycled_global_id_fails_closed() { // Two live nodes claiming one id means the observer missed a removal, // so every edge touching that id is untrustworthy. let mut graph = Graph::new(); let shared = graph.dangling_id(); let client = graph.client_of_app(7001); let first = graph.node_with_id("first", MediaRole::StreamOutput, shared, app(client, 7001)); let second = graph.node_with_id("second", MediaRole::StreamOutput, shared, app(client, 7002)); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); assert_partition( &run(&graph, &ctx()), &[("firefox", firefox)], &[ ("first", first, "unresolved-ancestry"), ("second", second, "unresolved-ancestry"), ], ); } // ────────────────────────────────────────────────────────────────────── // Stickiness and lifetime-awareness (v3.4 §6.1.3) // ────────────────────────────────────────────────────────────────────── /// The scene the sticky tests share: one owner with a tainted input leg and /// an output leg, plus an untouched bystander. fn sticky_scene() -> (Graph, NodeRef, NodeRef, NodeRef, NodeRef) { let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, hw); let rec_in = graph.app_node("rec-in", MediaRole::StreamInput, 8080); let rec_out = graph.app_node("rec-out", MediaRole::StreamOutput, 8080); graph.link(hw, rec_in); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); (graph, call, rec_in, rec_out, firefox) } #[test] fn sticky_taint_survives_the_tainted_input_leg_disappearing() { // The C3 buffered-audio defect: the recorder stops capturing, but its // 5-second ring buffer is still full of peerspeak's audio and no graph // event marks the moment it drains. let (graph, call, rec_in, rec_out, firefox) = sticky_scene(); let c = ctx(); let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); assert_partition( &first, &[("firefox", firefox)], &[ ("call", call, "peerspeak-owned"), ("rec-out", rec_out, "tainted-owner-bridge"), ], ); 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"), ], ); assert!( matches!( second.candidates[&rec_out.serial].eligibility, Eligibility::NotEligible { sticky: true, .. } ), "the second exclusion must be recorded as sticky, not re-derived" ); } #[test] fn a_new_leg_of_a_still_tainted_owner_inherits_the_taint() { // Stickiness is per *owner*, not per node: the recorder opening a // second output stream after its input leg closed must not escape. let (mut graph, _, rec_in, _, _) = sticky_scene(); let c = ctx(); let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); let late = graph.app_node("rec-out-2", MediaRole::StreamOutput, 8080); let (second, _) = evaluate(&graph.build_without(&[rec_in]), &c, &sticky); assert_eq!( second.candidates[&late.serial].reason().map(Reason::code), Some("tainted-owner-bridge") ); } #[test] fn sticky_taint_clears_once_every_owner_member_is_gone() { // Otherwise an app is unshareable forever, which is the over-exclusion // half of the trade and just as much a bug. let (mut graph, call, rec_in, rec_out, firefox) = sticky_scene(); let c = ctx(); let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); // Full teardown: both legs and the client object. let recorder_client = graph.client_of_app(8080); graph.drop_clients(&[recorder_client]); let torn_down = graph.build_without(&[rec_in, rec_out]); let (_, sticky) = evaluate(&torn_down, &c, &sticky); assert!( sticky .owners .iter() .all(|owner| !owner.members.contains(&ObjectRef::Node(rec_out.serial))), "the recorder's sticky entry should have been dropped: {sticky:#?}" ); // A brand-new recorder with the same PID starts clean. let fresh = graph.app_node("rec-out-fresh", MediaRole::StreamOutput, 8080); let (third, _) = evaluate(&graph.build_without(&[rec_in, rec_out]), &c, &sticky); assert_partition( &third, &[("firefox", firefox), ("fresh", fresh)], &[("call", call, "peerspeak-owned")], ); } #[test] fn recycled_ids_module_indices_and_link_groups_do_not_inherit_taint() { // Every one of these recycles on this stack — measured for module // indices, node ids and link-group strings. Only `object.serial` does // not, which is exactly why stickiness is keyed on it. let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, hw); let old_in = graph.group_node("old-in", MediaRole::StreamInput, "loopback-2541-13", 6100); let old_out = graph.group_node("old-out", MediaRole::StreamOutput, "loopback-2541-13", 6100); graph.link(hw, old_in); let c = ctx(); let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); assert_eq!( first.candidates[&old_out.serial].reason().map(Reason::code), Some("tainted-owner-bridge") ); // Teardown, then a brand-new module reusing the global id, the PID and // the link-group string verbatim — everything except the serial. let old_client = graph.client_of_app(6100); graph.drop_clients(&[old_client]); let reborn_client = graph.client(Some(PULSE_PID)); let reborn = graph.node_with_id( "reborn-out", MediaRole::StreamOutput, old_out.id, super::fixture::link_group("loopback-2541-13", reborn_client, 6100), ); let (second, _) = evaluate(&graph.build_without(&[old_in, old_out]), &c, &sticky); assert_partition( &second, &[("reborn", reborn)], &[("call", call, "peerspeak-owned")], ); } // ────────────────────────────────────────────────────────────────────── // Regressions from Codex round 1 — each of these was a leak or a lost // taint history, and each mutation-tests the fix that closed it. // ────────────────────────────────────────────────────────────────────── #[test] fn sticky_taint_survives_when_only_the_client_object_remains() { // Finding 1. An app can close every stream it holds while keeping its // PipeWire connection open, then open a fresh one — Firefox does this // constantly. Seeding sticky taint only from live *nodes* let the new // leg come back Eligible while the owner's buffers were still full of // the call. let (mut graph, call, rec_in, rec_out, firefox) = sticky_scene(); let c = ctx(); let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); // Both legs vanish; the client object does not. let late = graph.app_node("rec-out-late", MediaRole::StreamOutput, 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 an_inbound_linked_node_of_unknown_role_still_bridges() { // Finding 2. "Receives audio" cannot be inferred from `media.class` // alone: a node with an absent or unexpected class sits on a real // inbound link carrying the call, and if it cannot start an owner // bridge its sibling output leg re-emits the call as Eligible. let mut graph = Graph::new(); let sink = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, sink); let client = graph.client_of_app(8080); let unknown_in = graph.node("unknown-in", MediaRole::Other, app(client, 8080)); let leaked_out = graph.node("leaked-out", MediaRole::StreamOutput, app(client, 8080)); graph.link(sink, unknown_in); let decisions = run(&graph, &ctx()); assert_partition( &decisions, &[], &[ ("call", call, "peerspeak-owned"), ("leaked-out", leaked_out, "tainted-owner-bridge"), ], ); assert_tainted(&decisions, unknown_in, "tainted-upstream"); } #[test] fn a_candidate_that_is_itself_a_receiver_still_bridges_to_its_siblings() { // The other half of finding 2: a `Stream/Output/Audio` on an inbound // link is excluded by the link walk, but it must also be able to carry // taint across the owner bridge to its siblings. let mut graph = Graph::new(); let sink = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, sink); let odd_in = graph.app_node("odd-in", MediaRole::StreamOutput, 9100); let sibling = graph.app_node("sibling-out", MediaRole::StreamOutput, 9100); graph.link(sink, odd_in); assert_partition( &run(&graph, &ctx()), &[], &[ ("call", call, "peerspeak-owned"), ("odd-in", odd_in, "tainted-upstream"), ("sibling", sibling, "tainted-owner-bridge"), ], ); } #[test] fn a_native_virtual_sink_bridges_on_its_client() { // Finding 3. The device exception is keyed on `device.id`, not on the // node's role: an app that creates its own `Audio/Sink` plus a // re-emitting stream on one client has no `device.id`, no link-group // and no `pulse.module.id`, so `client.id` is the *only* thing relating // the two. Suppressing coarse keys by role stripped it and leaked the // whole call. let mut graph = Graph::new(); let hw = graph.device_node("hw", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); let virtual_sink = graph.native_virtual_node("native-virtual-sink", MediaRole::Sink, 4321); let reemitter = graph.native_virtual_node("native-output", MediaRole::StreamOutput, 4321); graph.link(call, virtual_sink); graph.link(reemitter, hw); let decisions = run(&graph, &ctx()); assert_partition( &decisions, &[], &[ ("call", call, "peerspeak-owned"), ("reemitter", reemitter, "tainted-owner-bridge"), ], ); assert_eq!( decisions.taint[&reemitter.serial].reason, Reason::TaintedOwnerBridge { key: Some(OwnerKey::ClientId) } ); } #[test] fn an_asymmetric_forwarder_fails_closed() { // Finding 4. The reader carries a `node.link-group` and is therefore // "bounded", but its re-emitting leg carries none, so the strong key // finds no sibling. Requiring the *source* to be unbounded let this // through as Eligible. let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, hw); let in_client = graph.client(Some(PULSE_PID)); let out_client = graph.client(Some(PULSE_PID)); let fwd_in = graph.node( "fwd-in", MediaRole::StreamInput, NodeProps { link_group: Some("asymmetric-forwarder".into()), client_id: Some(in_client), process_id: Some(PULSE_PID), ..NodeProps::default() }, ); let fwd_out = graph.node( "fwd-out", MediaRole::StreamOutput, app(out_client, PULSE_PID), ); graph.link(hw, fwd_in); // Blast radius control: a real app is bounded by its own PID. let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); graph.link(firefox, hw); assert_partition( &run(&graph, &ctx()), &[("firefox", firefox)], &[ ("call", call, "peerspeak-owned"), ("fwd-out", fwd_out, "unresolved-owner"), ], ); } #[test] fn a_not_ready_snapshot_never_retires_sticky_owners() { // Finding 5. `graph_ready == false` says "this view of the graph is not // trustworthy". Retiring sticky owners because their members are absent // from an untrustworthy snapshot erases the taint history, and the next // ready recompute hands back a clean bill of health for an owner that // never went away. let (graph, call, rec_in, rec_out, firefox) = sticky_scene(); let c = ctx(); let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); let not_ready = ExclusionCtx { graph_ready: false, ..ctx() }; let (_, carried) = evaluate( &super::snapshot::GraphSnapshot::default(), ¬_ready, &sticky, ); assert_eq!( carried, sticky, "a not-ready recompute must not edit history" ); let (after, _) = evaluate(&graph.build_without(&[rec_in]), &c, &carried); assert_partition( &after, &[("firefox", firefox)], &[ ("call", call, "peerspeak-owned"), ("rec-out", rec_out, "tainted-owner-bridge"), ], ); } #[test] fn taint_crosses_two_chained_forwarders() { // Finding 6. Every earlier fixture needed at most one owner hop, so // replacing the fixpoint with a single pass of each rule survived the // whole suite. Two chained forwarders need two. let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); let sink1 = graph.module_node("sink1", MediaRole::Sink, 100); graph.link(call, sink1); let fwd1_in = graph.module_node("fwd1-in", MediaRole::StreamInput, 101); let fwd1_out = graph.module_node("fwd1-out", MediaRole::StreamOutput, 101); graph.link(sink1, fwd1_in); let sink2 = graph.module_node("sink2", MediaRole::Sink, 102); graph.link(fwd1_out, sink2); let fwd2_in = graph.module_node("fwd2-in", MediaRole::StreamInput, 103); let fwd2_out = graph.module_node("fwd2-out", MediaRole::StreamOutput, 103); graph.link(sink2, fwd2_in); graph.link(fwd2_out, hw); // Negative control: an unrelated clean forwarder chain of the same shape. let clean_sink = graph.device_node("clean-sink", MediaRole::Sink); let clean_in = graph.module_node("clean-in", MediaRole::StreamInput, 200); let clean_out = graph.module_node("clean-out", MediaRole::StreamOutput, 200); graph.link(clean_sink, clean_in); graph.link(clean_out, hw); assert_partition( &run(&graph, &ctx()), &[("clean-out", clean_out)], &[ ("call", call, "peerspeak-owned"), ("fwd1-out", fwd1_out, "tainted-owner-bridge"), ("fwd2-out", fwd2_out, "tainted-owner-bridge"), ], ); } #[test] fn a_stronger_reason_replaces_a_weaker_one_regardless_of_arrival_order() { // Finding 7. The old determinism test reversed two links that never // competed, so a first-write-wins `raise()` survived it. Here a node is // reached by the owner bridge on the first fixpoint pass and by a // direct tainted link on the second; the direct link is the stronger // reason and must win. let build = |reverse: bool| { let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, hw); let rec_in = graph.app_node("rec-in", MediaRole::StreamInput, 5000); let mid = graph.app_node("mid-out", MediaRole::StreamOutput, 5000); let owned_sink = graph.native_virtual_node("owned-sink", MediaRole::Sink, 5000); if reverse { graph.link(mid, owned_sink); graph.link(hw, rec_in); } else { graph.link(hw, rec_in); graph.link(mid, owned_sink); } let decisions = run(&graph, &ctx()); ( decisions.taint[&owned_sink.serial].reason.code(), decisions.excluded(), ) }; let forward = build(false); assert_eq!( forward.0, "tainted-upstream", "bridged first, then reached directly — the direct link is stronger" ); assert_eq!(forward, build(true), "and it must not depend on link order"); } #[test] fn a_recycled_client_id_and_module_index_do_not_inherit_taint() { // Finding 8. The original recycling test never reused a // `pulse.module.id`, and its "fresh" client silently reused the dead // client's global id because the fixture cache was stale — so the node // pointed at a client object that no longer existed. let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, hw); let old_in = graph.module_node("old-in", MediaRole::StreamInput, 536_870_919); let old_out = graph.module_node("old-out", MediaRole::StreamOutput, 536_870_919); graph.link(hw, old_in); let c = ctx(); let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); assert_eq!( first.candidates[&old_out.serial].reason().map(Reason::code), Some("tainted-owner-bridge") ); // Full teardown, then a module that reuses the node id, the client id // and the module index verbatim. Only the serials are new — which is // the entire reason stickiness is keyed on them. let old_client = graph.client_of_module(536_870_919); graph.drop_clients(&[old_client]); let reborn_client = graph.client_with_id(old_client, Some(PULSE_PID)); let reborn = graph.node_with_id( "reborn-out", MediaRole::StreamOutput, old_out.id, super::fixture::pulse_module(reborn_client, 536_870_919, PULSE_PID), ); let (second, _) = evaluate(&graph.build_without(&[old_in, old_out]), &c, &sticky); assert_partition( &second, &[("reborn", reborn)], &[("call", call, "peerspeak-owned")], ); } #[test] fn a_deep_forwarder_chain_converges() { // Finding 9. The fixpoint is monotone so it terminates, but each pass // re-runs a full link BFS and a full owner scan, so a chain of owner // hops costs one pass per layer. This is the shape that would expose an // accidental blow-up; phase 5 measures the real distribution. const LAYERS: u64 = 60; let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); let mut upstream = graph.module_node("sink-0", MediaRole::Sink, 1); graph.link(call, upstream); let mut last_out = None; for layer in 1..=LAYERS { let module = 1000 + layer; let leg_in = graph.module_node(&format!("fwd-{layer}-in"), MediaRole::StreamInput, module); let leg_out = graph.module_node(&format!("fwd-{layer}-out"), MediaRole::StreamOutput, module); graph.link(upstream, leg_in); let next = graph.module_node(&format!("sink-{layer}"), MediaRole::Sink, 2000 + layer); graph.link(leg_out, next); upstream = next; last_out = Some(leg_out); } graph.link(last_out.expect("at least one layer"), hw); let decisions = run(&graph, &ctx()); assert_eq!( decisions.eligible(), Vec::new(), "every leg of the chain carries the call" ); assert_eq!( decisions.candidates.len(), LAYERS as usize + 1, "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"), ], ); } // ────────────────────────────────────────────────────────────────────── // Regressions from Codex round 3 (second verification round) // ────────────────────────────────────────────────────────────────────── #[test] fn an_inverse_asymmetric_forwarder_fails_closed() { // Round 3 finding 1 — the mirror of round-1 finding 4, and a real leak. // The reader is unbounded (client.id only, daemon PID suppressed) while // its re-emitting leg carries an *unmatched* strong key, so the leg was // "bounded" and stayed Eligible. When the reader itself cannot be // bounded, its sibling could be any output, so a strong key that does // not match it back proves nothing. let mut graph = Graph::new(); let hw = graph.device_node("hw", MediaRole::Sink); let call = graph.peerspeak_node("call", 7); graph.link(call, hw); let in_client = graph.client(Some(PULSE_PID)); let out_client = graph.client(Some(PULSE_PID)); let fwd_in = graph.node("fwd-in", MediaRole::StreamInput, app(in_client, PULSE_PID)); let fwd_out = graph.node( "fwd-out", MediaRole::StreamOutput, NodeProps { pulse_module_id: Some(77), client_id: Some(out_client), process_id: Some(PULSE_PID), ..NodeProps::default() }, ); graph.link(hw, fwd_in); let decisions = run(&graph, &ctx()); assert_eq!( decisions.candidates[&fwd_out.serial] .reason() .map(Reason::code), Some("unresolved-owner") ); assert!(decisions.eligible().is_empty()); } #[test] fn an_unbounded_tainted_reader_excludes_every_output() { // Round 4: conceded to Codex. An unbounded tainted reader could be a // real app that simply exposed no PID on its reader leg, so no output // property proves independence — exclude the whole candidate universe. let mut graph = Graph::new(); let hw = graph.device_node("hw", MediaRole::Sink); let call = graph.peerspeak_node("call", 7); graph.link(call, hw); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); graph.link(firefox, hw); // A bounded module leg — even a strong key does not spare it. let daemon_leg = graph.module_node("daemon-leg", MediaRole::StreamOutput, 88); graph.link(daemon_leg, hw); // Without an unbounded reader, both are eligible. assert_partition( &run(&graph, &ctx()), &[("firefox", firefox), ("daemon-leg", daemon_leg)], &[("call", call, "peerspeak-owned")], ); // Add a keyless reader with no PID at all — the exact shape the round-3 // narrowing would have mishandled. let keyless_client = graph.client(Some(PULSE_PID)); let leak_in = graph.node( "leak-in", MediaRole::StreamInput, NodeProps { client_id: Some(keyless_client), ..NodeProps::default() }, ); graph.link(hw, leak_in); assert_partition( &run(&graph, &ctx()), &[], &[ ("call", call, "peerspeak-owned"), ("firefox", firefox, "unresolved-owner"), ("daemon-leg", daemon_leg, "unresolved-owner"), ], ); } #[test] fn a_real_app_with_no_pid_on_its_reader_leg_does_not_leak() { // Codex round 4's exact counterexample to the narrowing I tried: one // process, reader leg with no PID (unbounded), output leg with a real // PID. The narrowing spared the output; the broad rule excludes it. let mut graph = Graph::new(); let hw = graph.device_node("hw", MediaRole::Sink); let call = graph.peerspeak_node("call", 7); graph.link(call, hw); let in_client = graph.client(Some(PULSE_PID)); let out_client = graph.client(Some(PULSE_PID)); let reader = graph.node( "reader", MediaRole::StreamInput, NodeProps { client_id: Some(in_client), ..NodeProps::default() }, ); let leaky_out = graph.node("leaky-out", MediaRole::StreamOutput, app(out_client, 4321)); graph.link(hw, reader); assert_partition( &run(&graph, &ctx()), &[], &[ ("call", call, "peerspeak-owned"), ("leaky-out", leaky_out, "unresolved-owner"), ], ); } #[test] fn a_strong_key_new_connection_of_a_still_tainted_owner_inherits_the_taint() { // Round 3 finding 5: a mutation that kept only PID fingerprints survived // the 49-test suite, because no fixture exercised a *strong-key* // fingerprint reaching a new connection. Here the owner is tainted via // its `pulse.module.id`, all its nodes vanish, its client stays live, // and a second client opens a new leg carrying the same module id. let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, hw); let mod_in = graph.module_node("mod-in", MediaRole::StreamInput, 77); let mod_out = graph.module_node("mod-out", MediaRole::StreamOutput, 77); graph.link(hw, mod_in); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); let c = ctx(); let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); // The module's original client stays live; a new connection carries the // same module id. The daemon PID is suppressed, so only the module-id // fingerprint can catch this. let new_client = graph.client(Some(PULSE_PID)); let late = graph.node( "mod-out-late", MediaRole::StreamOutput, super::fixture::pulse_module(new_client, 77, PULSE_PID), ); let (next, _) = evaluate(&graph.build_without(&[mod_in, mod_out]), &c, &sticky); assert_partition( &next, &[("firefox", firefox)], &[ ("call", call, "peerspeak-owned"), ("late", late, "tainted-owner-bridge"), ], ); } #[test] fn a_link_group_new_connection_of_a_still_tainted_owner_inherits_the_taint() { // Round 4 finding 3: a mutation dropping LinkGroup fingerprints (keeping // only module/PID) survived the 53-test suite, because the strong-key // fingerprint test used `pulse.module.id`, not `node.link-group`. Here a // link-group owner reads the call, its nodes vanish, its client stays // live, and a new client opens a leg with the same link-group and a // suppressed PID — only the link-group fingerprint can catch it. let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, hw); let g_in = graph.group_node("g-in", MediaRole::StreamInput, "filter-1", 6200); let g_out = graph.group_node("g-out", MediaRole::StreamOutput, "filter-1", 6200); graph.link(hw, g_in); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); let c = ctx(); let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); // New connection, same link-group, daemon PID (suppressed) so no usable // PID remains — the module/PID fingerprints cannot reach it. let new_client = graph.client(Some(PULSE_PID)); let late = graph.node( "g-out-late", MediaRole::StreamOutput, super::fixture::link_group("filter-1", new_client, PULSE_PID), ); let (next, _) = evaluate(&graph.build_without(&[g_in, g_out]), &c, &sticky); assert_partition( &next, &[("firefox", firefox)], &[ ("call", call, "peerspeak-owned"), ("late", late, "tainted-owner-bridge"), ], ); } #[test] fn a_local_root_receiver_bridges_without_an_inbound_link() { // Round 5 finding 2: a mutation deleting the *role-based* receiver // insertion survived all 55 tests, because every tested bridge source // also had an inbound Link that put it in `receivers` anyway. A // pixelpass capture sink is a taint root the moment it exists — before // anything links into it — and its owner's re-emitting leg must bridge // from it on the strength of its role alone. let mut graph = Graph::new(); // A capture sink (PixelpassOwned by name), sharing module id 55 with a // re-emitting output leg, and NO inbound link yet. let sink = graph.module_node("pixelpass_capture_4242", MediaRole::Sink, 55); let leg = graph.module_node("capture-reemit", MediaRole::StreamOutput, 55); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); let decisions = run(&graph, &ctx()); assert_partition( &decisions, &[("firefox", firefox)], &[("leg", leg, "tainted-owner-bridge")], ); assert_tainted(&decisions, sink, "pixelpass-owned"); } #[test] fn an_ambiguous_client_id_remembers_every_claimant_for_stickiness() { // Round 6 finding 2: nothing pinned the ambiguous-Client branch, so a // mutation remembering only the first claimant survived. Two live // clients claim one global id; the tainted owner's node references it. // If we remember only one and it is the one that later disappears, the // still-live claimant that reopens an output escapes. use super::snapshot::{ClientSnapshot, GlobalId}; let mut graph = Graph::new(); let hw = graph.device_node("hw-sink", MediaRole::Sink); let call = graph.peerspeak_node("peerspeak", 7); graph.link(call, hw); // Two clients share one global id (the observer saw an id collision). let shared_id = graph.dangling_id(); let client_a = graph.client_with_id(shared_id, Some(PULSE_PID)); let _client_b = graph.client_with_id(shared_id, Some(PULSE_PID)); assert_eq!(client_a, shared_id); // The tainted reader references that (ambiguous) client id, no PID/keys. let reader = graph.node( "reader", MediaRole::StreamInput, NodeProps { client_id: Some(shared_id), ..NodeProps::default() }, ); let out = graph.node( "out", MediaRole::StreamOutput, NodeProps { client_id: Some(shared_id), ..NodeProps::default() }, ); graph.link(hw, reader); let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); let c = ctx(); let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); assert_eq!( first.candidates[&out.serial].reason().map(Reason::code), Some("tainted-owner-bridge") ); // Both claimants must be remembered, or a mutation keeping only one // could drop the surviving owner. At least both client serials appear. let client_members: usize = sticky .owners .iter() .flat_map(|o| o.members.iter()) .filter(|m| matches!(m, super::ObjectRef::Client(_))) .count(); assert!( client_members >= 2, "both ambiguous-id clients should be remembered: {sticky:#?}" ); let _ = (ClientSnapshot { serial: Serial(0), id: GlobalId(0), sec_pid: None }, firefox); }