Files
pixelpass/src/host/taint/tests.rs
T
molluskandClaude Opus 5 d9ef38c1c5 repair: a pid is not an owner — modules carry a machine/boot/namespace token
Closes the last blocking finding: a pid is only a number, and the same number is
a different process in a different pid namespace. Repair running inside a
container that can reach the host's Pulse socket saw a live host's modules, asked
about that pid in its own namespace, was told nothing existed, and unloaded a
running host's audio. No negative signal closes that — `NSpid == 1` does not prove
the initial namespace, since its leftmost value is relative to whichever procfs
was mounted.

So the module now carries the answer with it. Every module a host loads gets
`pixelpass.owner=<version>-<machine>-<boot>-<pid_ns>-<nonce>`, and repair only
asks about a pid when all three identities match its own. Anything else is
reported and left alone, and its pid is never even looked up — asking is the bug,
because the answer would be meaningless.

**Untagged modules are refused by default.** Everything loaded before tokens
existed is unattributable, so `--repair` now lists those and does nothing, with
`--repair-legacy-untagged` to opt into the old pid-only heuristic after seeing the
candidates. That is a deliberate loss of reach: the failure being optimised
against is a false-positive destructive repair, and leaving an old orphan behind
is recoverable where destroying live routing is not. A foreign token is refused
even with the flag, since the flag speaks to missing evidence, not wrong evidence.

The vehicle was verified on the live server before anything was built on it: all
three shapes accept a property-list argument (`sink_properties`,
`sink_input_properties`, `source_output_properties`), the recorded argument comes
back byte-identical — so exact-form matching still holds — and the property really
lands on the resulting sink, sink-input and source-output.

**Audit gate passed, with the variable isolated.** The token rides on real graph
objects that phases 2/3 observe, so the partition had to be re-measured. Running
the same fixture with and without tokens gives an identical partition: 2 eligible
(FFXIV, Chromium), 2 excluded with the same `tainted-owner-bridge` reason, and the
same six-entry taint set. Everything that differs from the empty-graph baseline is
the fixture's own doing — a local-monitor loopback genuinely bridges our owned sink
into the real default sink — and none of it is the property's. Attributing that to
the token without the untokened control would have been the mistake.

A side benefit: the per-load nonce narrows the ABA window I previously documented
as unclosable. Two loads by the same pid no longer render byte-identical
arguments, so a fingerprint taken from one no longer matches the other.

Six new tests, three mutation-verified gates: `can_judge` always true, `pid_ns`
dropped from the comparison, and untagged treated as judgeable regardless of
policy — each killed by its own test. ⚠️ The third "survived" on first run because
my mutation script's indentation did not match and the edit silently did nothing;
the re-run asserts the file actually changed. A mutation that was never applied
proves the same amount as no mutation at all.

Field-verified live, three fixtures for one dead pid in one run: tokened with this
machine's identity is cleaned, tokened with a foreign pid namespace is left alone
and reported (and the legacy flag does not override it), and untagged is refused
then cleaned only when asked. The two older field fixtures were tokenised too —
without that the A/B test would have failed and the reference-gate test would have
passed for the wrong reason, which is a vacuous gate in the harness rather than the
code.

253 tests, clippy clean, fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:57:55 -04:00

2695 lines
106 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::{OwnerCtx, 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");
}
/// Each ownership carrier must work **alone** (v3.5 §5.1).
///
/// ⚠️ The phase-3r lesson, applied deliberately: a gate that asserts a value
/// two sources can satisfy gates neither. `peerspeak_tagged_nodes_…` above
/// uses nodes carrying both carriers, so it would keep passing if either
/// were deleted. These are the rows that actually pin them.
#[test]
fn either_ownership_carrier_alone_taints_the_node() {
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
// Carrier 1: the property, on a node whose name says nothing.
let prop_only = graph.peerspeak_node_prop_only("some-playback-stream", 7);
// Carrier 2: the name prefix, property absent — the F1 case.
let name_only = graph.peerspeak_node_name_only("mpv", 31_284);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
for node in [prop_only, name_only, firefox] {
graph.link(node, sink);
}
assert_partition(
&run(&graph, &ctx()),
&[("firefox", firefox)],
&[
("prop_only", prop_only, "peerspeak-owned"),
("name_only", name_only, "peerspeak-owned"),
],
);
}
/// **R10-1, the F2 fix.** Neither carrier is a security boundary — both are
/// strings any unprivileged process can set on its own node — so the tag is
/// honoured only on `Stream/Output/Audio`, the one role peerspeak ever tags.
///
/// Without the restriction, a tagged `Stream/Input/Audio` **with no links at
/// all** is a tainted *reader* (`receivers` includes nodes by role, no link
/// required), and an unbounded one, so `propagate_unresolved_owner` fails
/// every candidate on the machine closed. That is a whole-feature denial from
/// an unprivileged process, reproduced live during the phase-1 review.
#[test]
fn an_ownership_tag_on_a_non_producer_is_not_a_taint_root() {
for role in [
MediaRole::StreamInput,
MediaRole::Sink,
MediaRole::Source,
MediaRole::Duplex,
MediaRole::Other,
] {
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
graph.link(firefox, sink);
// Deliberately unlinked: the F2 shape needs no edges whatsoever.
let impostor = graph.peerspeak_tagged_node("rogue", role, 4_242);
let decisions = run(&graph, &ctx());
assert_untainted(&decisions, impostor);
assert!(
decisions.taint.is_empty(),
"{role:?} impostor tainted something: {:?}",
decisions.taint.keys().collect::<Vec<_>>()
);
// The whole point: the eligible half stays non-empty.
assert_partition(&decisions, &[("firefox", firefox)], &[]);
}
}
/// **The live F2 reproduction, verbatim.** The measured impostor was an
/// *unbounded* reader — `client.id` present, `application.process.id` absent
/// — which is what turns "one bogus tainted node" into "nothing on this
/// machine is shareable": `propagate_unresolved_owner` cannot prove any
/// candidate independent of a reader it cannot attribute to an owner.
///
/// Measured before the fix: `BASELINE eligible=1 excluded=[]` →
/// `WITH IMPOSTOR eligible=0 excluded=[firefox → unresolved-owner]`.
///
/// Distinct from the row above, which uses a *bounded* impostor and so would
/// still pass if only the cheap half of the fix were present.
#[test]
fn an_unbounded_tagged_impostor_cannot_exclude_a_bystander_app() {
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
let mpv = graph.app_node("mpv", MediaRole::StreamOutput, 31_284);
for node in [firefox, mpv] {
graph.link(node, sink);
}
let baseline = run(&graph, &ctx());
assert_partition(&baseline, &[("firefox", firefox), ("mpv", mpv)], &[]);
// Both carriers, no pid, no links — everything an unprivileged process
// can arrange for itself in one `pw-cli` invocation.
let rogue_client = graph.client(Some(PULSE_PID));
let impostor = graph.node(
&format!("{}rogue_4242", super::PEERSPEAK_OWNED_NODE_PREFIX),
MediaRole::StreamInput,
NodeProps {
peerspeak_owned: true,
client_id: Some(rogue_client),
..NodeProps::default()
},
);
let decisions = run(&graph, &ctx());
assert_untainted(&decisions, impostor);
assert_partition(&decisions, &[("firefox", firefox), ("mpv", mpv)], &[]);
}
/// A tag that R10-1 ignores is still reported, so that neither a peerspeak
/// tagging bug nor an impersonation attempt is silent.
#[test]
fn ignored_ownership_tags_are_surfaced_for_diagnostics() {
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("call", 7);
graph.link(call, sink);
let impostor = graph.peerspeak_tagged_node("rogue", MediaRole::StreamInput, 4_242);
let snapshot = graph.build();
let misplaced: Vec<Serial> = super::misplaced_ownership_tags(&snapshot)
.iter()
.map(|node| node.serial)
.collect();
// Exactly the ignored one: the honoured producer is not "misplaced".
assert_eq!(misplaced, vec![impostor.serial]);
assert_ne!(impostor.serial, call.serial);
}
/// The prefix is a **prefix**, not a substring: an unrelated app must not be
/// excluded because the literal appears somewhere in its name. Over-exclusion
/// is the safe direction, but it is still wrong, and the phase-5 gate now
/// asserts exact partitions in both halves.
#[test]
fn the_owned_prefix_matches_only_at_the_start_of_node_name() {
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
let impostor = graph.app_node(
&format!("recorder-of-{}stuff", super::PEERSPEAK_OWNED_NODE_PREFIX),
MediaRole::StreamOutput,
11_114,
);
graph.link(impostor, sink);
assert_partition(&run(&graph, &ctx()), &[("impostor", impostor)], &[]);
}
/// The consumer half of the cross-repo contract test (impl plan §3
/// requirement 2). peerspeak runs the mirror of this against a byte-identical
/// copy of the same file, and asserts the environment a real child `Command`
/// would carry produces exactly these literals.
///
/// This proves the two repos agree on the *literals*. That pixelpass actually
/// *listens* is proven by the two carrier tests above, and against the live
/// graph by the phase 5 dry-run.
#[test]
fn ownership_carriers_match_the_cross_repo_fixture() {
const FIXTURE: &str = include_str!("../../../tests/fixtures/ownership-tag-contract.txt");
let pinned: Vec<(&str, &str)> = FIXTURE
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| line.split_once('=').expect("fixture line is key=value"))
.collect();
// ⚠️ Refuse a duplicated key rather than resolving it (Codex phase-1
// review, finding 3). This side takes the first match and peerspeak's
// took the last, so a duplicate in a byte-identical file could leave both
// repos green having selected *different* contracts.
for (index, (key, _)) in pinned.iter().enumerate() {
assert!(
!pinned[..index].iter().any(|(seen, _)| seen == key),
"fixture defines {key:?} twice; the two repos would disagree on which wins"
);
}
let get = |key: &str| -> &str {
pinned
.iter()
.find(|(k, _)| *k == key)
.unwrap_or_else(|| panic!("fixture has no key {key:?}"))
.1
};
assert_eq!(super::PEERSPEAK_OWNED_PROP, get("prop_key"));
assert_eq!(super::PEERSPEAK_OWNED_NODE_PREFIX, get("node_name_prefix"));
// ⚠️ **Equality, and that is now the whole rule**: carrier 1 is matched
// exactly, not as "anything but false/0" (round 10, R10-4). This assert
// used to be followed by a weaker `value != "false" && value != "0"`
// check, which described a leniency that no longer exists — the round-10
// review's finding 6, and a real trap: a future producer reading the old
// fixture prose could emit "true" and silently lose this carrier.
//
// That this consumer actually *listens* to the fixture's value, through
// the production observer wiring rather than a helper, is asserted by
// `observer::adapter::tests::the_fixture_value_is_the_only_owned_spelling`.
assert_eq!(super::PEERSPEAK_OWNED_VALUE, get("prop_value"));
// And the fixture's own worked example must be one this engine excludes,
// through carrier 2, exactly as written in the shared file.
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
let example = graph.app_node(get("node_name_example"), MediaRole::StreamOutput, 31_284);
graph.link(example, sink);
assert_partition(
&run(&graph, &ctx()),
&[],
&[("example", example, "peerspeak-owned")],
);
}
#[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);
let owner_ctx = OwnerCtx::new(&snapshot, Some(PULSE_PID));
assert_eq!(
strongest_shared_key(a, b, &owner_ctx),
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();
let owner_ctx = OwnerCtx::new(&snapshot, Some(PULSE_PID));
assert_eq!(
strongest_shared_key(
snapshot.node(a.serial).unwrap(),
snapshot.node(b.serial).unwrap(),
&owner_ctx
),
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);
}
/// **R10-3, the fix.** A native PipeWire client puts no
/// `application.process.id` on its node — only `client.id` — so before the
/// Client fallback it had no key 4, was therefore *unbounded*, and
/// `propagate_unresolved_owner` excluded it the moment any tainted reader
/// existed anywhere on the machine.
///
/// Measured live: an untagged mpv was eligible alone, and became
/// `unresolved-owner` the instant peerspeak played audio. Since peerspeak
/// playing audio is the only situation in which this feature runs at all, that
/// amounted to "native-PipeWire apps are never shareable".
#[test]
fn a_native_client_is_bounded_by_its_clients_sec_pid() {
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);
// The tainted reader that arms the unresolved-owner arm. Bounded itself
// (a real pid), exactly as the live `sunshine` was — so this is the
// bounded-reader arm, not the keyless-reader one.
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
graph.link(hw, sunshine);
// mpv on its default ao: client.id only, pid on the Client.
let mpv = graph.native_client_node("mpv", MediaRole::StreamOutput, 31_284);
graph.link(mpv, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", mpv)],
&[("call", call, "peerspeak-owned")],
);
}
/// The fallback must bridge a native app's *own* legs, or it has bought
/// boundedness without buying correctness: an app that reads the call and
/// re-emits it on a second native node would be declared clean.
#[test]
fn the_sec_pid_fallback_still_bridges_a_native_apps_own_legs() {
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);
// One native process, two nodes, no link between them — the forwarder
// shape, in the native flavour.
let leg_in = graph.native_client_node("forwarder-in", MediaRole::StreamInput, 50_000);
let leg_out = graph.native_client_node("forwarder-out", MediaRole::StreamOutput, 50_000);
graph.link(hw, leg_in);
let decisions = run(&graph, &ctx());
assert_tainted(&decisions, leg_out, "tainted-owner-bridge");
assert_partition(
&decisions,
&[],
&[
("call", call, "peerspeak-owned"),
("forwarder-out", leg_out, "tainted-owner-bridge"),
],
);
}
/// **The risk the fallback creates, and the guard on it.** Every
/// Pulse-emulated Client carries pipewire-pulse's own PID as `sec_pid` —
/// measured, 15 unrelated Clients sharing 2528 on this host. An unguarded
/// fallback would give all of them key 4 with the *same* value and fuse them
/// into one owner, so a single tainted Pulse app would exclude every other
/// Pulse app on the machine.
///
/// Exception 1 therefore applies to the fallback exactly as it does to the
/// node's own property. Without that, this row goes red.
#[test]
fn the_sec_pid_fallback_does_not_fuse_every_pulse_client() {
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);
// Three unrelated Pulse-emulated apps, each on its own Client, none
// exposing a node-level pid — so each can only reach key 4 through its
// Client, whose sec_pid is the daemon's.
let pulse_app = |graph: &mut Graph, name: &str, role| {
let client = graph.client(Some(PULSE_PID));
graph.node(
name,
role,
NodeProps {
client_id: Some(client),
..NodeProps::default()
},
)
};
// One of them reads the tainted sink; the other two must not care.
let reader = pulse_app(&mut graph, "recorder", MediaRole::StreamInput);
graph.link(hw, reader);
let other_a = pulse_app(&mut graph, "player-a", MediaRole::StreamOutput);
let other_b = pulse_app(&mut graph, "player-b", MediaRole::StreamOutput);
let decisions = run(&graph, &ctx());
// They are unbounded (`client.id` alone never bounds an owner), so the
// fail-closed arm still excludes them — but as `unresolved-owner`, NOT as
// `tainted-owner-bridge`. That distinction is the whole assertion: a
// bridge reason here would mean the daemon pid had fused three unrelated
// applications into one owner, and unlike fail-closed exclusion, fusion
// does not go away when the apps are given real pids
// (`distinct_sec_pids_bound_each_native_app_separately` is that half).
assert_tainted(&decisions, other_a, "unresolved-owner");
assert_tainted(&decisions, other_b, "unresolved-owner");
for node in [other_a, other_b] {
assert_ne!(
decisions.taint.get(&node.serial).map(|e| e.reason.code()),
Some("tainted-owner-bridge"),
"the daemon pid must not bridge unrelated Pulse clients"
);
}
}
/// The same three apps, given **real per-app** `sec_pid`s: now the fallback
/// fires, all three are bounded, and only the one actually reading the call is
/// affected. This is the row that proves the guard above suppresses the daemon
/// pid *specifically* rather than disabling the fallback outright.
#[test]
fn distinct_sec_pids_bound_each_native_app_separately() {
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 reader = graph.native_client_node("recorder", MediaRole::StreamInput, 6_001);
graph.link(hw, reader);
let other_a = graph.native_client_node("player-a", MediaRole::StreamOutput, 6_002);
let other_b = graph.native_client_node("player-b", MediaRole::StreamOutput, 6_003);
assert_partition(
&run(&graph, &ctx()),
&[("player-a", other_a), ("player-b", other_b)],
&[("call", call, "peerspeak-owned")],
);
}
/// An **ambiguous** `client.id` — two live Clients claiming it, meaning the
/// observer missed a removal — must not yield a fallback pid. Inventing an
/// owner key is the one direction that can *reduce* taint, so resolving the
/// ambiguity by coin toss is the wrong kind of guess.
#[test]
fn an_ambiguous_client_id_yields_no_fallback_pid() {
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 sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
graph.link(hw, sunshine);
// Two Clients, one id, distinct real pids.
let shared_id = graph.client(Some(6_010));
graph.client_with_id(shared_id, Some(6_011));
let app = graph.node(
"native-app",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(shared_id),
..NodeProps::default()
},
);
graph.link(app, hw);
// Unbounded ⇒ fails closed, exactly as before R10-3.
assert_partition(
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("native-app", app, "unresolved-owner"),
],
);
}
/// The ambiguity guard must not depend on the *first* Client claiming an id
/// having a `sec_pid`.
///
/// Found by auditing R10-3 rather than by a failing case: the first cut
/// detected a duplicate id by looking it up in the pid map, which is only
/// populated for Clients that carry a pid at all. A pid-less Client therefore
/// left no trace, and the next Client claiming the same id was treated as
/// unique — resolving an ambiguous id, which is exactly the guess the guard
/// exists to refuse. Pid-less Clients are ordinary here (`device_node`'s
/// session client is one), so this is reachable, not theoretical.
#[test]
fn a_pidless_first_client_still_makes_its_id_ambiguous() {
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 sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
graph.link(hw, sunshine);
// First claimant has NO sec_pid; second has one.
let shared_id = graph.client(None);
graph.client_with_id(shared_id, Some(6_011));
let app = graph.node(
"native-app",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(shared_id),
..NodeProps::default()
},
);
graph.link(app, hw);
assert_partition(
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("native-app", app, "unresolved-owner"),
],
);
}
/// A process using **two** Clients cannot escape the bridge by presenting a
/// bogus pid on one leg and none on the other.
///
/// ⚠️ **This is the round-10 review's finding 1, and it was a real leak while
/// key 4 was `node.or_else(client)`.** The node's `application.process.id` is
/// client-controlled; the Client's `pipewire.sec.pid` is protected. Letting
/// the node's value *replace* the Client's meant the reader was bounded by
/// `12_345` and the output leg by `50_000`, so they shared no key, did not
/// bridge, and — both being bounded — neither tripped the unbounded sweep.
/// The output stayed eligible while re-emitting the call.
///
/// Carrying both values fixes it: the two legs share the Client pid.
///
/// Reachability, stated honestly: `evaluate()` today is reached only by the
/// dry-run audit, which creates no links, so this could not echo on this
/// branch. It becomes live the moment phase 6 consumes these decisions.
#[test]
fn one_process_with_two_clients_cannot_split_its_pid_to_escape_the_bridge() {
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);
// One native process, two Clients, one protected pid.
let reader_client = graph.client(Some(50_000));
let output_client = graph.client(Some(50_000));
// Its reading leg claims a pid that is not its own.
let reader = graph.node(
"two-client-reader",
MediaRole::StreamInput,
NodeProps {
client_id: Some(reader_client),
process_id: Some(12_345),
..NodeProps::default()
},
);
graph.link(hw, reader);
// Its re-emitting leg claims no pid at all.
let output = graph.node(
"two-client-output",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(output_client),
process_id: None,
..NodeProps::default()
},
);
graph.link(output, hw);
// A genuinely unrelated app must survive, or "exclude everything" would
// pass this test — the §5.1 eligible-half rule.
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
graph.link(bystander, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("two-client-output", output, "tainted-owner-bridge"),
],
);
}
/// The node's own `application.process.id` is used even when its Client's
/// `sec_pid` is the daemon's — the single most common shape here, since a
/// Pulse-emulated node's pid is the app's while its Client's is
/// pipewire-pulse's.
///
/// ⚠️ Both values are now carried (round-10 review, finding 1), so this is no
/// longer "the node's wins" but "exception 1 is applied per value": the
/// daemon's `sec_pid` is dropped and the node's real pid is kept, leaving the
/// same single key as before.
#[test]
fn the_nodes_own_process_id_wins_over_its_clients() {
let mut graph = Graph::new();
// `app_node` is exactly that shape: node pid 11_114, Client sec_pid
// PULSE_PID. If the Client's won, exception 1 would suppress key 4 and
// this node would be unbounded.
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
graph.link(hw, sunshine);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
graph.link(firefox, hw);
assert_partition(
&run(&graph, &ctx()),
&[("firefox", firefox)],
&[("call", call, "peerspeak-owned")],
);
}
// ──────────────────────────────────────────────────────────────────────
// F11-1 — a self-claimed pid is not provenance: the five Client cases
// ──────────────────────────────────────────────────────────────────────
/// The scaffold every F11-1 row needs: peerspeak's call reaching the hardware
/// sink, a **bounded** tainted reader, and an ordinary bystander.
///
/// ⚠️ The reader must be *bounded* (`sunshine` carries a real pid). An
/// unbounded tainted reader trips `propagate_unresolved_owner`'s other tier,
/// which sweeps **every** output candidate on the box regardless of its own
/// keys — the three "unbounded" rows below would then pass without testing
/// anything. The bystander is the other half of that guard: it is bounded via
/// the ordinary Pulse shape, so an implementation that unbounded everything
/// fails every row instead of passing three of them.
///
/// Returns the graph, the hardware sink to hang nodes off, and the two nodes
/// every row must name in its partition.
fn armed_with_a_bounded_reader() -> (Graph, 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 sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
graph.link(hw, sunshine);
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
graph.link(bystander, hw);
(graph, hw, call, bystander)
}
/// Case 1 of 5 — **Client absent.** A node that names no Client at all has
/// nothing but its own word for who owns it, so it cannot be bounded.
#[test]
fn an_absent_client_leaves_a_self_claimed_pid_unbounded() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let orphan = graph.node(
"no-client",
MediaRole::StreamOutput,
NodeProps {
process_id: Some(70_001),
..NodeProps::default()
},
);
graph.link(orphan, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("no-client", orphan, "unresolved-owner"),
],
);
}
/// Case 2 of 5 — **Client ambiguous.** Two live Clients claim the id, so the
/// observer missed a removal and we do not know who owns this node. A
/// self-claimed pid must not paper over that: this is step 2 of the recorded
/// leak path, and before F11-1 the claim bounded the node and spared it.
#[test]
fn an_ambiguous_client_leaves_a_self_claimed_pid_unbounded() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let shared_id = graph.client(Some(70_010));
graph.client_with_id(shared_id, Some(70_011));
let app = graph.node(
"ambiguous-client",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(shared_id),
process_id: Some(70_012),
..NodeProps::default()
},
);
graph.link(app, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("ambiguous-client", app, "unresolved-owner"),
],
);
}
/// Case 3 of 5 — **Client unique but pid-less**, and *the row that decides
/// which rule is implemented*.
///
/// A unique Client object exists, so "resolved = a unique Client exists" would
/// call this node bounded — leaving the self-claimed-pid hole wide open under a
/// rule that looks like it closed it. `sec_pid` is what carries protected
/// identity, so `None` means unresolved, and pid-less Clients are ordinary
/// (the session manager's is one).
///
/// A two-case absent/resolved matrix skips this silently. That is why it is
/// written out.
#[test]
fn a_unique_but_pidless_client_leaves_a_self_claimed_pid_unbounded() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let pidless = graph.client(None);
let app = graph.node(
"pidless-client",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(pidless),
process_id: Some(70_020),
..NodeProps::default()
},
);
graph.link(app, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("pidless-client", app, "unresolved-owner"),
],
);
}
/// Case 4 of 5 — **Client resolved, native.** `pipewire.sec.pid` is the app's
/// own, so provenance and key 4 are the same value and the node is bounded
/// without claiming anything itself.
#[test]
fn a_resolved_native_client_bounds_its_node() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let mpv2 = graph.native_client_node("mpv-native", MediaRole::StreamOutput, 70_030);
graph.link(mpv2, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander), ("mpv-native", mpv2)],
&[("call", call, "peerspeak-owned")],
);
}
/// Case 5 of 5 — **Client resolved to pipewire-pulse.** The row that stops
/// this rule from being the blunt fix.
///
/// Every Pulse-emulated app looks like this: the Client's `sec_pid` is the
/// daemon's — suppressed as a *grouping* key, because it would fuse fifteen
/// unrelated apps — while the node's own `application.process.id` is the app's.
/// Provenance is read **before** that suppression, so the app keeps its bound
/// and stays eligible. Reading it after would unbound every Pulse app on the
/// box and empty the eligible half of the §5.1 matrix, which is the §6.1.1
/// catastrophe arriving through the boundedness door.
#[test]
fn a_client_resolving_to_pipewire_pulse_still_bounds_its_node() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 70_040);
graph.link(firefox, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander), ("firefox", firefox)],
&[("call", call, "peerspeak-owned")],
);
}
/// **The recorded leak path, end to end** (round 11 review, finding 1).
///
/// One process, two Clients. Its reading leg claims the daemon's pid — which
/// exception 1 suppresses — while its Client holds a real protected pid `A`, so
/// the union bounds the reader by `A` and the *unbounded-reader* tier never
/// arms. Its re-emitting leg sits on a second Client whose id is **ambiguous**
/// (one of the two claimants even holds `A`, so this is not "the guess would
/// have been wrong" — it is "a guess is not evidence"), and claims a pid of its
/// own. The two legs share no key, so the bridge does not fire either.
///
/// Before F11-1 the self-claim bounded the output leg, both tiers stayed quiet,
/// and it re-emitted the call while eligible. Now the leg is unbounded, the
/// bounded-reader tier sweeps it, and `mpv` shows the sweep is still targeted.
#[test]
fn a_self_claimed_pid_cannot_spare_an_output_leg_the_bridge_cannot_reach() {
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 reader_client = graph.client(Some(80_000));
let reader = graph.node(
"forwarder-in",
MediaRole::StreamInput,
NodeProps {
client_id: Some(reader_client),
process_id: Some(PULSE_PID),
..NodeProps::default()
},
);
graph.link(hw, reader);
let ambiguous = graph.client(Some(80_000));
graph.client_with_id(ambiguous, Some(80_001));
let output = graph.node(
"forwarder-out",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(ambiguous),
process_id: Some(80_002),
..NodeProps::default()
},
);
graph.link(output, hw);
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
graph.link(bystander, hw);
// `mpv` staying eligible is what proves the reader is bounded: an
// unbounded tainted reader sweeps **every** output candidate, `mpv`
// included, and this row would then be testing the wrong tier.
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("forwarder-out", output, "unresolved-owner"),
],
);
}
#[test]
fn an_unknown_pipewire_pulse_pid_over_excludes_rather_than_leaks() {
// v3.4 §6.1.2's failure-mode paragraph: if pixelpass cannot identify
// the daemon PID, key 4 stops being suppressed, every module-created
// stream fuses into one owner, and the result is broad over-exclusion —
// annoying and safe. This test pins that direction; flipping it to
// "leave key 4 out when the PID is unknown" would be a leak.
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let a_in = graph.module_node("a-in", MediaRole::StreamInput, 1);
let a_out = graph.module_node("a-out", MediaRole::StreamOutput, 1);
graph.link(hw, a_in);
let mic = graph.device_node("mic", MediaRole::Source);
let b_in = graph.module_node("b-in", MediaRole::StreamInput, 2);
let b_out = graph.module_node("b-out", MediaRole::StreamOutput, 2);
graph.link(mic, b_in);
let decisions = run(
&graph,
&ExclusionCtx {
pipewire_pulse_pid: None,
..ctx()
},
);
assert_partition(
&decisions,
&[],
&[
("call", call, "peerspeak-owned"),
("a-out", a_out, "tainted-owner-bridge"),
("b-out", b_out, "tainted-owner-bridge"),
],
);
}
#[test]
fn coarse_keys_do_not_bridge_device_nodes() {
// ⚠️ A rule added during implementation, NOT present in design v3.4.
// Every ALSA device node is created by one WirePlumber process, so all
// of them share one `client.id` and one `application.process.id`.
// peerspeak's playback taints the default sink on every recompute; if
// coarse keys bridged devices, that taint would jump to the microphone
// source, and then every app holding a mic would lose its playback —
// the exact §6.1.1 catastrophe by another route.
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let mic = graph.device_node("mic", MediaRole::Source);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let ff_in = graph.app_node("firefox-mic", MediaRole::StreamInput, 11114);
let ff_out = graph.app_node("firefox-out", MediaRole::StreamOutput, 11114);
graph.link(mic, ff_in);
let decisions = run(&graph, &ctx());
assert_partition(
&decisions,
&[("ff-out", ff_out)],
&[("call", call, "peerspeak-owned")],
);
assert_tainted(&decisions, hw, "tainted-upstream");
assert_untainted(&decisions, mic);
assert_untainted(&decisions, ff_in);
}
#[test]
fn a_module_owned_device_still_bridges_on_its_strong_key() {
// The other side of that rule: exception 2 must not disarm the bridge
// for virtual sinks that genuinely belong to a module.
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
let virt = graph.module_node("virtual-sink", MediaRole::Sink, 900_001);
let leg = graph.module_node("virtual-out", MediaRole::StreamOutput, 900_001);
graph.link(call, virt);
graph.link(leg, hw);
let decisions = run(&graph, &ctx());
assert_partition(
&decisions,
&[],
&[
("call", call, "peerspeak-owned"),
("leg", leg, "tainted-owner-bridge"),
],
);
}
#[test]
fn firefox_three_cases() {
// v3.4 §6.1.1's table, as one graph with an exact partition.
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let mic = graph.device_node("mic", MediaRole::Source);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
// 1. music only
let music = graph.app_node("ff-music", MediaRole::StreamOutput, 1001);
graph.link(music, hw);
// 2. a Meet call: a mic input leg on an untainted source
let meet_in = graph.app_node("ff-meet-in", MediaRole::StreamInput, 1002);
let meet_out = graph.app_node("ff-meet-out", MediaRole::StreamOutput, 1002);
graph.link(mic, meet_in);
graph.link(meet_out, hw);
// 3. screen-sharing with desktop audio: an input leg on a tainted monitor
let share_in = graph.app_node("ff-share-in", MediaRole::StreamInput, 1003);
let share_out = graph.app_node("ff-share-out", MediaRole::StreamOutput, 1003);
graph.link(hw, share_in);
graph.link(share_out, hw);
assert_partition(
&run(&graph, &ctx()),
&[("music", music), ("meet-out", meet_out)],
&[
("call", call, "peerspeak-owned"),
("share-out", share_out, "tainted-owner-bridge"),
],
);
}
#[test]
fn an_unbounded_module_forwarder_fails_closed_and_sweeps_the_desktop() {
// v3.4 §12: "a module forwarder with neither link-group nor
// pulse.module.id ⇒ unresolved ⇒ excluded". Its process id is the
// daemon's (so key 4 is suppressed) and its legs carry different
// `client.id`s, so nothing can enumerate its siblings.
//
// ⚠️ Because the reading leg is itself *unbounded*, the whole desktop's
// output is swept — an ordinary app is NOT spared (Codex round 4: a
// real app can present no PID on its reader leg, so "unbounded ⇒ not an
// app" is unsound; over-exclude instead). The trigger is anomalous: a
// keyless reader actively consuming the call.
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let in_client = graph.client(Some(PULSE_PID));
let out_client = graph.client(Some(PULSE_PID));
let fwd_in = graph.node("fwd-in", MediaRole::StreamInput, app(in_client, PULSE_PID));
let fwd_out = graph.node(
"fwd-out",
MediaRole::StreamOutput,
app(out_client, PULSE_PID),
);
graph.link(hw, fwd_in);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
graph.link(firefox, hw);
assert_partition(
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("fwd-out", fwd_out, "unresolved-owner"),
("firefox", firefox, "unresolved-owner"),
],
);
}
#[test]
fn a_bounded_tainted_reader_leaves_ordinary_apps_alone() {
// The blast-radius guarantee survives for the *bounded* tier (round-1
// finding 4): a tainted reader with a real strong key excludes only the
// unbounded output legs that could share its identity, not real apps.
// Here an EasyEffects-shaped reader (link-group) reads the call, and its
// own re-emitting leg is excluded via the bridge — but firefox is not.
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let ee_in = graph.group_node("ee-in", MediaRole::StreamInput, "ee", 5000);
let ee_out = graph.group_node("ee-out", MediaRole::StreamOutput, "ee", 5000);
graph.link(hw, ee_in);
graph.link(ee_out, hw);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
graph.link(firefox, hw);
assert_partition(
&run(&graph, &ctx()),
&[("firefox", firefox)],
&[
("call", call, "peerspeak-owned"),
("ee-out", ee_out, "tainted-owner-bridge"),
],
);
}
#[test]
fn audio_duplex_over_taints_known_accepted() {
// v3.4 §6.1 edge-type-2 caveat, asserted so that fixing it later is a
// deliberate change and not a silent regression. A duplex device whose
// playback side is tainted has its capture side treated as tainted too,
// so an app recording from it loses its playback leg.
let mut graph = Graph::new();
let duplex = graph.device_node("duplex-card", MediaRole::Duplex);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, duplex);
let rec_in = graph.app_node("rec-in", MediaRole::StreamInput, 4444);
let rec_out = graph.app_node("rec-out", MediaRole::StreamOutput, 4444);
graph.link(duplex, rec_in);
assert_partition(
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("rec-out", rec_out, "tainted-owner-bridge"),
],
);
}
// ──────────────────────────────────────────────────────────────────────
// Unresolved ancestry — fail closed (v3.4 §6.1.4)
// ──────────────────────────────────────────────────────────────────────
#[test]
fn a_link_from_an_unknown_node_fails_closed() {
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let mystery_in = graph.app_node("mystery-in", MediaRole::StreamInput, 6000);
let mystery_out = graph.app_node("mystery-out", MediaRole::StreamOutput, 6000);
let ghost = graph.dangling_id();
graph.link_ids(ghost, mystery_in.id);
graph.link(mystery_out, hw);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
let decisions = run(&graph, &ctx());
assert_partition(
&decisions,
&[("firefox", firefox)],
&[("mystery-out", mystery_out, "tainted-owner-bridge")],
);
assert_tainted(&decisions, mystery_in, "unresolved-ancestry");
}
#[test]
fn an_ambiguous_recycled_global_id_fails_closed() {
// Two live nodes claiming one id means the observer missed a removal,
// so every edge touching that id is untrustworthy.
let mut graph = Graph::new();
let shared = graph.dangling_id();
let client = graph.client_of_app(7001);
let first = graph.node_with_id("first", MediaRole::StreamOutput, shared, app(client, 7001));
let second = graph.node_with_id("second", MediaRole::StreamOutput, shared, app(client, 7002));
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
assert_partition(
&run(&graph, &ctx()),
&[("firefox", firefox)],
&[
("first", first, "unresolved-ancestry"),
("second", second, "unresolved-ancestry"),
],
);
}
// ──────────────────────────────────────────────────────────────────────
// Uncertainty is not history — it never enters sticky state
// (round 9, from a live phase-5 audit run; see `Uncertainty` in mod.rs)
// ──────────────────────────────────────────────────────────────────────
#[test]
fn unresolved_ancestry_does_not_survive_being_resolved() {
// Measured live on a desktop: a link is observed while its output node is
// still unbound, the input side fails closed — correctly — and then that
// fail-closed mark became *sticky*, so a hardware sink stayed excluded for
// the process lifetime even after the node resolved and turned out to be
// an ordinary game. Phase 3r's bind-everything observer widens that window
// to every node, so this must clear.
let mut graph = Graph::new();
let ghost = graph.dangling_id();
let client = graph.client_of_app(6000);
let victim = graph.node("victim-in", MediaRole::StreamInput, app(client, 6000));
let sibling = graph.node("victim-out", MediaRole::StreamOutput, app(client, 6000));
graph.link_ids(ghost, victim.id);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
let c = ctx();
// While the ancestry is genuinely unresolved, the decision is unchanged:
// fail closed, both the victim and its sibling excluded.
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
assert_partition(
&first,
&[("firefox", firefox)],
&[("victim-out", sibling, "tainted-owner-bridge")],
);
assert_tainted(&first, victim, "unresolved-ancestry");
// The node behind that id turns up — nothing tainted, it was simply not
// observed yet. The uncertainty is gone, so nothing may remain of it.
let late_client = graph.client_of_app(7100);
let resolved = graph.node_with_id(
"was-unbound",
MediaRole::StreamOutput,
ghost,
app(late_client, 7100),
);
let (second, _) = evaluate(&graph.build(), &c, &sticky);
assert_partition(
&second,
&[
("firefox", firefox),
("victim-out", sibling),
("was-unbound", resolved),
],
&[],
);
}
#[test]
fn uncertainty_laundered_into_downstream_taint_is_not_sticky_either() {
// Retiring by reason *code* would not be enough: an unresolved node
// propagates `tainted-upstream`, which is indistinguishable from real
// contamination once recorded. The split has to be by provenance, so a
// node two hops from the uncertainty must clear too.
let mut graph = Graph::new();
let ghost = graph.dangling_id();
let forwarder_client = graph.client_of_app(6100);
let forwarder_in = graph.node(
"fwd-in",
MediaRole::StreamInput,
app(forwarder_client, 6100),
);
let forwarder_out = graph.node(
"fwd-out",
MediaRole::StreamOutput,
app(forwarder_client, 6100),
);
let downstream_client = graph.client_of_app(6200);
let downstream = graph.node("downstream", MediaRole::Sink, app(downstream_client, 6200));
let downstream_leg = graph.node(
"downstream-out",
MediaRole::StreamOutput,
app(downstream_client, 6200),
);
graph.link_ids(ghost, forwarder_in.id);
graph.link(forwarder_out, downstream);
let c = ctx();
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
assert_tainted(&first, forwarder_in, "unresolved-ancestry");
assert_tainted(&first, downstream, "tainted-upstream");
assert!(
first.candidates[&downstream_leg.serial].reason().is_some(),
"while the ancestry is unresolved the downstream owner is excluded too"
);
let late_client = graph.client_of_app(7200);
graph.node_with_id(
"was-unbound",
MediaRole::StreamOutput,
ghost,
app(late_client, 7200),
);
let (second, _) = evaluate(&graph.build(), &c, &sticky);
assert_eq!(
second.candidates[&downstream_leg.serial].reason(),
None,
"nothing derived from the uncertainty may outlive it"
);
assert_eq!(
second.candidates[&forwarder_out.serial].reason(),
None,
"including the unresolved node's own owner siblings"
);
}
#[test]
fn real_taint_is_still_sticky_when_its_topology_goes_away() {
// The other half of the same rule, stated positively: *evidence* is
// history and must survive. This is the guard on the change above — if
// provenance splitting ever leaks into the evidence path, peerspeak's own
// audio starts escaping.
let (graph, call, rec_in, rec_out, firefox) = sticky_scene();
let c = ctx();
let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
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"),
],
);
}
// ──────────────────────────────────────────────────────────────────────
// 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(),
&not_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(), &not_ready, &StickyState::default());
assert!(
!sticky.is_empty(),
"taint observed during a not-ready epoch is still taint"
);
let (ready, _) = evaluate(&graph.build_without(&[rec_in]), &ctx(), &sticky);
assert_partition(
&ready,
&[("firefox", firefox)],
&[
("call", call, "peerspeak-owned"),
("rec-out", rec_out, "tainted-owner-bridge"),
],
);
}
// ──────────────────────────────────────────────────────────────────────
// Regressions from Codex round 3 (second verification round)
// ──────────────────────────────────────────────────────────────────────
#[test]
fn an_inverse_asymmetric_forwarder_fails_closed() {
// Round 3 finding 1 — the mirror of round-1 finding 4, and a real leak.
// The reader is unbounded (client.id only, daemon PID suppressed) while
// its re-emitting leg carries an *unmatched* strong key, so the leg was
// "bounded" and stayed Eligible. When the reader itself cannot be
// bounded, its sibling could be any output, so a strong key that does
// not match it back proves nothing.
let mut graph = Graph::new();
let hw = graph.device_node("hw", MediaRole::Sink);
let call = graph.peerspeak_node("call", 7);
graph.link(call, hw);
let in_client = graph.client(Some(PULSE_PID));
let out_client = graph.client(Some(PULSE_PID));
let fwd_in = graph.node("fwd-in", MediaRole::StreamInput, app(in_client, PULSE_PID));
let fwd_out = graph.node(
"fwd-out",
MediaRole::StreamOutput,
NodeProps {
pulse_module_id: Some(77),
client_id: Some(out_client),
process_id: Some(PULSE_PID),
..NodeProps::default()
},
);
graph.link(hw, fwd_in);
let decisions = run(&graph, &ctx());
assert_eq!(
decisions.candidates[&fwd_out.serial]
.reason()
.map(Reason::code),
Some("unresolved-owner")
);
assert!(decisions.eligible().is_empty());
}
#[test]
fn an_unbounded_tainted_reader_excludes_every_output() {
// Round 4: conceded to Codex. An unbounded tainted reader could be a
// real app that simply exposed no PID on its reader leg, so no output
// property proves independence — exclude the whole candidate universe.
let mut graph = Graph::new();
let hw = graph.device_node("hw", MediaRole::Sink);
let call = graph.peerspeak_node("call", 7);
graph.link(call, hw);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
graph.link(firefox, hw);
// A bounded module leg — even a strong key does not spare it.
let daemon_leg = graph.module_node("daemon-leg", MediaRole::StreamOutput, 88);
graph.link(daemon_leg, hw);
// Without an unbounded reader, both are eligible.
assert_partition(
&run(&graph, &ctx()),
&[("firefox", firefox), ("daemon-leg", daemon_leg)],
&[("call", call, "peerspeak-owned")],
);
// Add a keyless reader with no PID at all — the exact shape the round-3
// narrowing would have mishandled.
let keyless_client = graph.client(Some(PULSE_PID));
let leak_in = graph.node(
"leak-in",
MediaRole::StreamInput,
NodeProps {
client_id: Some(keyless_client),
..NodeProps::default()
},
);
graph.link(hw, leak_in);
assert_partition(
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("firefox", firefox, "unresolved-owner"),
("daemon-leg", daemon_leg, "unresolved-owner"),
],
);
}
#[test]
fn a_real_app_with_no_pid_on_its_reader_leg_does_not_leak() {
// Codex round 4's exact counterexample to the narrowing I tried: one
// process, reader leg with no PID (unbounded), output leg with a real
// PID. The narrowing spared the output; the broad rule excludes it.
let mut graph = Graph::new();
let hw = graph.device_node("hw", MediaRole::Sink);
let call = graph.peerspeak_node("call", 7);
graph.link(call, hw);
let in_client = graph.client(Some(PULSE_PID));
let out_client = graph.client(Some(PULSE_PID));
let reader = graph.node(
"reader",
MediaRole::StreamInput,
NodeProps {
client_id: Some(in_client),
..NodeProps::default()
},
);
let leaky_out = graph.node("leaky-out", MediaRole::StreamOutput, app(out_client, 4321));
graph.link(hw, reader);
assert_partition(
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("leaky-out", leaky_out, "unresolved-owner"),
],
);
}
#[test]
fn a_strong_key_new_connection_of_a_still_tainted_owner_inherits_the_taint() {
// Round 3 finding 5: a mutation that kept only PID fingerprints survived
// the 49-test suite, because no fixture exercised a *strong-key*
// fingerprint reaching a new connection. Here the owner is tainted via
// its `pulse.module.id`, all its nodes vanish, its client stays live,
// and a second client opens a new leg carrying the same module id.
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let mod_in = graph.module_node("mod-in", MediaRole::StreamInput, 77);
let mod_out = graph.module_node("mod-out", MediaRole::StreamOutput, 77);
graph.link(hw, mod_in);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
let c = ctx();
let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
// The module's original client stays live; a new connection carries the
// same module id. The daemon PID is suppressed, so only the module-id
// fingerprint can catch this.
let new_client = graph.client(Some(PULSE_PID));
let late = graph.node(
"mod-out-late",
MediaRole::StreamOutput,
super::fixture::pulse_module(new_client, 77, PULSE_PID),
);
let (next, _) = evaluate(&graph.build_without(&[mod_in, mod_out]), &c, &sticky);
assert_partition(
&next,
&[("firefox", firefox)],
&[
("call", call, "peerspeak-owned"),
("late", late, "tainted-owner-bridge"),
],
);
}
#[test]
fn a_link_group_new_connection_of_a_still_tainted_owner_inherits_the_taint() {
// Round 4 finding 3: a mutation dropping LinkGroup fingerprints (keeping
// only module/PID) survived the 53-test suite, because the strong-key
// fingerprint test used `pulse.module.id`, not `node.link-group`. Here a
// link-group owner reads the call, its nodes vanish, its client stays
// live, and a new client opens a leg with the same link-group and a
// suppressed PID — only the link-group fingerprint can catch it.
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let g_in = graph.group_node("g-in", MediaRole::StreamInput, "filter-1", 6200);
let g_out = graph.group_node("g-out", MediaRole::StreamOutput, "filter-1", 6200);
graph.link(hw, g_in);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
let c = ctx();
let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
// New connection, same link-group, daemon PID (suppressed) so no usable
// PID remains — the module/PID fingerprints cannot reach it.
let new_client = graph.client(Some(PULSE_PID));
let late = graph.node(
"g-out-late",
MediaRole::StreamOutput,
super::fixture::link_group("filter-1", new_client, PULSE_PID),
);
let (next, _) = evaluate(&graph.build_without(&[g_in, g_out]), &c, &sticky);
assert_partition(
&next,
&[("firefox", firefox)],
&[
("call", call, "peerspeak-owned"),
("late", late, "tainted-owner-bridge"),
],
);
}
#[test]
fn a_local_root_receiver_bridges_without_an_inbound_link() {
// Round 5 finding 2: a mutation deleting the *role-based* receiver
// insertion survived all 55 tests, because every tested bridge source
// also had an inbound Link that put it in `receivers` anyway. A
// pixelpass capture sink is a taint root the moment it exists — before
// anything links into it — and its owner's re-emitting leg must bridge
// from it on the strength of its role alone.
let mut graph = Graph::new();
// A capture sink (PixelpassOwned by name), sharing module id 55 with a
// re-emitting output leg, and NO inbound link yet.
let sink = graph.module_node("pixelpass_capture_4242", MediaRole::Sink, 55);
let leg = graph.module_node("capture-reemit", MediaRole::StreamOutput, 55);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
let decisions = run(&graph, &ctx());
assert_partition(
&decisions,
&[("firefox", firefox)],
&[("leg", leg, "tainted-owner-bridge")],
);
assert_tainted(&decisions, sink, "pixelpass-owned");
}
#[test]
fn an_ambiguous_client_id_remembers_every_claimant_for_stickiness() {
// Round 6 finding 2: nothing pinned the ambiguous-Client branch, so a
// mutation remembering only the first claimant survived. Two live
// clients claim one global id; the tainted owner's node references it.
// If we remember only one and it is the one that later disappears, the
// still-live claimant that reopens an output escapes.
use super::snapshot::{ClientSnapshot, GlobalId};
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
// Two clients share one global id (the observer saw an id collision).
let shared_id = graph.dangling_id();
let client_a = graph.client_with_id(shared_id, Some(PULSE_PID));
let _client_b = graph.client_with_id(shared_id, Some(PULSE_PID));
assert_eq!(client_a, shared_id);
// The tainted reader references that (ambiguous) client id, no PID/keys.
let reader = graph.node(
"reader",
MediaRole::StreamInput,
NodeProps {
client_id: Some(shared_id),
..NodeProps::default()
},
);
let out = graph.node(
"out",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(shared_id),
..NodeProps::default()
},
);
graph.link(hw, reader);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
let c = ctx();
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
assert_eq!(
first.candidates[&out.serial].reason().map(Reason::code),
Some("tainted-owner-bridge")
);
// Both claimants must be remembered, or a mutation keeping only one
// could drop the surviving owner. At least both client serials appear.
let client_members: usize = sticky
.owners
.iter()
.flat_map(|o| o.members.iter())
.filter(|m| matches!(m, super::ObjectRef::Client(_)))
.count();
assert!(
client_members >= 2,
"both ambiguous-id clients should be remembered: {sticky:#?}"
);
let _ = (
ClientSnapshot {
serial: Serial(0),
id: GlobalId(0),
sec_pid: None,
},
firefox,
);
}