All five were reachable, all five now have a regression test, and each test was verified by injecting the mutation that reverts its fix. 1. Sticky taint ignored surviving Client members. An app can close every stream while keeping its PipeWire connection open and then open a new one — Firefox does this constantly — and the new leg came back Eligible while the owner's buffers still held the call. Sticky seeding now resolves live Client serials to their current nodes. 2. "Receives audio" was inferred from `media.class` alone, so a node with an absent or unexpected class sitting on a real inbound link could not start an owner bridge and its sibling re-emitted the call. A node is now a receiver if it appears as a resolved `link.input.node` OR has a receiving role. 3. The device-node coarse-key exception was keyed on `media.class` being `Audio/Sink|Source|Duplex`, which also stripped the only correlation a *native virtual sink* has (own client, no link-group, no module id). Now keyed on `device.id`, measured on the live graph as the exact discriminator: the 5 ALSA nodes carry device.id 43/45/46 and share `client.id` 42 (`WirePlumber [export]`); the 3 `support.null-audio-sink` nodes carry no device.id and hold their own clients. 4. The unbounded-owner backstop required the tainted *reader* to be unbounded. Properties can be asymmetric — a reader with a link-group whose re-emitting leg has none is bounded while its sibling is not findable — so that condition is dropped; targets stay restricted to unbounded output legs, which keeps the blast radius small. 5. A not-ready snapshot could retire sticky owners, erasing taint history on the strength of a graph already declared untrustworthy. `evaluate` now returns the prior state unchanged while `!graph_ready`. Test-quality findings, also fixed: - a single pass of each rule survived all 32 tests (every fixture needed at most one owner hop) → two-chained-forwarder test with a clean control, plus a 60-layer chain to catch an accidental blow-up - first-write-wins `raise()` survived → a node reached by bridge on one pass and by a direct link on the next must report the stronger reason - `drop_clients` left the fixture's client caches stale, so "a fresh client after teardown" was really a dangling id; the recycling row now reuses node id, client id AND `pulse.module.id` verbatim Also corrected the cost claim: this is O((V+E)·D) for owner-bridge depth D, not O(V+E) as v3.4 §6.4 states. Owner keys are now computed once per snapshot instead of per candidate pair. 42 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1278 lines
50 KiB
Rust
1278 lines
50 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"
|
|
);
|
|
}
|