host/taint: pure graph model + taint engine (phase 2)
Implements design v3.4 §6.1–§6.1.3 behind a fixture test surface. No
PipeWire types in any signature; nothing here links against libpipewire.
Not wired into anything yet — phase 3's registry observer is what will
feed it, so the module is `#![allow(dead_code)]` for now.
evaluate(&GraphSnapshot, &ExclusionCtx, &StickyState)
-> (Decisions, StickyState)
- snapshot.rs: owned Node/Port/Link/Client model keyed on `Serial`
(object.serial, 64-bit, identity) with `GlobalId` retained strictly as
a snapshot-local lookup key. Two live objects claiming one id resolve
as `Ambiguous`, which fails closed.
- owner.rs: the owner bridge — the key union (link-group, pulse.module.id,
client.id, application.process.id) with equality-not-first-present
semantics, transitive union-find components, and both suppression rules.
- mod.rs: monotone fixpoint over link edges, the conditional owner bridge
(gated on the tainted member being one that *receives* audio) and the
unbounded-owner backstop, then sticky merge. Stable `Reason` codes with
an explicit priority so the reported reason never depends on traversal
order.
Three judgement calls that go beyond what v3.4 spells out, all flagged
in the source:
1. Coarse keys (client.id, application.process.id) may not bridge
device-role nodes. Every ALSA device is created by one WirePlumber
process, so they share a client and a PID; peerspeak's playback taints
the default sink on every recompute, and without this rule that taint
reaches the microphone source and then every app holding a mic loses
its playback — the §6.1.1 catastrophe by another route.
2. "Owner is bounded" is not "has a usable key": client.id alone does not
bound an owner (the measured GStreamer split-client refutation), so
the fail-closed backstop keys on strong keys or a usable PID.
3. Sticky entries record a reason per node rather than one per owner, so
a forwarder's output leg keeps `tainted-owner-bridge` instead of
inheriting its input leg's `tainted-upstream`.
32 fixture tests, each asserting an exact partition of the full candidate
universe rather than spot-checking named nodes: v3.4 §12's matrix, the
impl plan's degenerate-snapshot boundary, and the eligible half of every
scenario so an exclude-everything build fails.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,926 @@
|
||||
//! 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")],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user