The verification round earned its place: five of the six round-1 fixes were partial, and two of the gaps were worse than the bugs they replaced. 1. ⚠️ The round-1 sticky fix smuggled the suppressed key back in. `client_serials_of` recorded the shared `WirePlumber [export]` client as a member of a tainted hardware sink's owner, so the *second* recompute expanded that client to every sound card on the box, tainted the microphone, and excluded every app holding one — the §6.1.1 catastrophe arriving one epoch late instead of never. `client.id` may now only be recorded, or expanded, for nodes where it is a usable owner key. The regression test evaluates an unchanged snapshot three times: a correct engine's answer must not drift when nothing has. 2. Sticky followed a surviving *connection*, not a surviving *owner*. A process can leave one client idle and open a second — GStreamer opens one per stream as a matter of course — and the new leg escaped. `StickyOwner` now carries owner **fingerprints** (strong keys and a usable PID, never `client.id`), applied only while some serial member is still live, so a recyclable key cannot resurrect a dead owner. 3. An **ambiguous** link input endpoint tainted every claimant but made none of them a receiver, so their sibling output legs stayed eligible. Taint without receiver status cannot start an owner bridge. 4. `device.id` is a raw observation, not the classification the coarse-key exception needs — PipeWire defines it only as "the Device this node belongs to", so a forwarding node carrying one would have lost both its owner keys and its ability to trip the backstop. Replaced by `session_device`, a phase-3 obligation (`device.id` AND `device.api`) documented to fail closed when it cannot classify. 5. Readiness now gates sticky **retirement only**. Round 1 stopped a not-ready epoch erasing history; it also stopped it recording any, so a reader could consume and buffer the call during that epoch, vanish before readiness, and leave its output eligible. 6. Added the unresolved-output-plus-unknown-role fixture: deleting one `receivers.insert` survived all 42 previous tests. Mutation-verified: 7/7 reverts killed by their intended test. Two attempts did not land first time and both were my error, not the engine's — the client-key guard is applied at two sites so removing one is not a revert (removing the pair is, and that is killed), and the fingerprint-lifetime test put the recycled node in a snapshot *after* the entry had already been retired, so the guard was never consulted. Rewritten to place it in the same snapshot that first sees the owner gone. Cost comment corrected again, to O(D·(V+E+Σ|sources|·|targets|)). 49 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1493 lines
58 KiB
Rust
1493 lines
58 KiB
Rust
//! 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<Serial> = 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!("<unnamed {serial:?}>"), |(name, _)| name.into())
|
|
};
|
|
let actual: Vec<String> = 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() {
|
|
// 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.
|
|
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);
|
|
|
|
// The blast radius must stay small: an ordinary app is bounded by its
|
|
// own PID and must not be swept up.
|
|
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 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"),
|
|
],
|
|
);
|
|
}
|