host/taint: owner key 4 falls back to the Client's pipewire.sec.pid

Native PipeWire clients put no application.process.id on their nodes —
only client.id. keys_of read node properties alone, so those nodes had no
key 4, were therefore unbounded, and propagate_unresolved_owner excluded
them the moment any tainted reader existed anywhere on the machine.

Measured: 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, that amounted to "native
PipeWire apps are never shareable". The tainted reader that armed it was
sunshine, which is itself bounded — so this is the bounded-reader arm,
not the keyless-reader case §6.1.1 narrates.

The pid is one hop away, on the node's Client, already in the snapshot.

RISK, 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 fuse all of them
into one owner. Exception 1 therefore applies to the fallback exactly as
it does to the node's own property, so the fallback strictly *adds*
correct bounding rather than trading it.

Ambiguous client ids yield no fallback pid: inventing an owner key is the
one direction that can reduce taint, so a coin toss is the wrong guess.

The client index is threaded through a new OwnerCtx rather than a sixth
positional Option<u32>, and evaluate() builds one and shares it, so the
components and the key index cannot disagree about who is bounded.

Round 10, R10-3. 6 new rows; 3 mutations verified — removing the
fallback, dropping the pulse-pid exception (11 rows die), and resolving
an ambiguous client id instead of dropping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 20:57:47 -04:00
co-authored by Claude Opus 5
parent bf5f2508b8
commit 295a575b15
5 changed files with 333 additions and 21 deletions
+207 -3
View File
@@ -15,7 +15,7 @@
use std::collections::BTreeSet;
use super::fixture::{Graph, NodeRef, PULSE_PID, app};
use super::owner::{OwnerKey, strongest_shared_key};
use super::owner::{OwnerCtx, OwnerKey, strongest_shared_key};
use super::snapshot::{MediaRole, NodeProps, PortDirection, Serial};
use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate};
@@ -717,8 +717,9 @@ fn owner_key_union_falls_through_a_present_but_unequal_key() {
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, Some(PULSE_PID)),
strongest_shared_key(a, b, &owner_ctx),
Some(OwnerKey::ProcessId)
);
}
@@ -729,11 +730,12 @@ fn the_strongest_shared_key_wins_when_several_match() {
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(),
Some(PULSE_PID)
&owner_ctx
),
Some(OwnerKey::LinkGroup)
);
@@ -770,6 +772,208 @@ fn the_pipewire_pulse_pid_does_not_fuse_unrelated_modules() {
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 node's own `application.process.id` wins when both are available. It is
/// a direct statement about the node; the Client's is a one-hop inference, and
/// they can legitimately differ (a Pulse-emulated node's pid is the app's while
/// its Client's `sec_pid` is the daemon's — the single most common shape here).
#[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")],
);
}
#[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