diff --git a/src/host/audit/tests.rs b/src/host/audit/tests.rs index f62a0de..80fc890 100644 --- a/src/host/audit/tests.rs +++ b/src/host/audit/tests.rs @@ -150,7 +150,10 @@ fn an_ignored_ownership_tag_is_reported_without_excluding_anything() { let row = &body.ignored_ownership_tags[0]; assert_eq!(row.serial, impostor.serial.0); assert_eq!(row.role, "stream-input"); - assert_eq!(row.name.as_deref(), Some(owned_name("rogue", 4_242).as_str())); + assert_eq!( + row.name.as_deref(), + Some(owned_name("rogue", 4_242).as_str()) + ); } /// The common path stays quiet: a correctly tagged peerspeak producer is diff --git a/src/host/taint/fixture.rs b/src/host/taint/fixture.rs index 7d146aa..d0c2647 100644 --- a/src/host/taint/fixture.rs +++ b/src/host/taint/fixture.rs @@ -36,6 +36,10 @@ pub struct Graph { /// (GStreamer opens one per stream) pass clients explicitly instead. client_by_app: BTreeMap, client_by_module: BTreeMap, + /// Native (non-Pulse-emulated) clients, whose `pipewire.sec.pid` is the + /// app's **own** pid rather than pipewire-pulse's. See + /// [`Graph::native_client_node`]. + native_client_by_app: BTreeMap, session_client: Option, } @@ -84,6 +88,35 @@ impl Graph { id } + /// A **native PipeWire** client's stream: `client.id` on the node, **no + /// `application.process.id`**, and the app's real pid only on the Client + /// as `pipewire.sec.pid`. + /// + /// ⚠️ This is what an ordinary app actually looks like when it does not go + /// through pipewire-pulse — measured for mpv on its default ao and for + /// peerspeak's own playback stream. [`Graph::app_node`] models the + /// Pulse-emulated shape, where the pid is on the node and the Client's + /// `sec_pid` is the *daemon's*; both shapes are live on this host, and + /// only this one exercises key 4's Client fallback (round 10, R10-3). + pub fn native_client_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef { + let client = match self.native_client_by_app.get(&pid) { + Some(id) => *id, + None => { + let id = self.client(Some(pid)); + self.native_client_by_app.insert(pid, id); + id + } + }; + self.node( + name, + role, + NodeProps { + client_id: Some(client), + ..NodeProps::default() + }, + ) + } + /// An ordinary application stream: its own client, its own PID. pub fn app_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef { let client = self.client_of_app(pid); diff --git a/src/host/taint/mod.rs b/src/host/taint/mod.rs index 488d267..76a8b45 100644 --- a/src/host/taint/mod.rs +++ b/src/host/taint/mod.rs @@ -410,8 +410,13 @@ pub fn evaluate( ctx: &ExclusionCtx, prior: &StickyState, ) -> (Decisions, StickyState) { - let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid); - let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid); + // Built once and shared: it carries the Client → `pipewire.sec.pid` index + // that key 4 falls back to (round 10, R10-3), so the components and the + // key index must be derived from the *same* one or they would disagree + // about which nodes are bounded. + let owner_ctx = owner::OwnerCtx::new(snapshot, ctx.pipewire_pulse_pid); + let components = OwnerComponents::build(snapshot, &owner_ctx); + let keys = owner::OwnerKeyIndex::build(snapshot, &owner_ctx); // Pass 1 — the fail-closed view. Every decision is made from this one, so // "we could not see" counts as taint. diff --git a/src/host/taint/owner.rs b/src/host/taint/owner.rs index 94de20e..d0cbd7b 100644 --- a/src/host/taint/owner.rs +++ b/src/host/taint/owner.rs @@ -67,10 +67,65 @@ //! Grouping is **transitive** (union-find). That is the fail-closed //! direction: bigger owner components mean more taint, never less. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use super::snapshot::{GlobalId, GraphSnapshot, NodeSnapshot, Serial}; +/// Everything owner-key derivation needs from outside a single node. +/// +/// Introduced by round 10 (R10-3). Before it, `keys_of` read only node +/// properties, and key 4 was therefore available **only** to nodes carrying +/// `application.process.id` — which native PipeWire clients do not. mpv on its +/// default ao, and peerspeak's own playback stream, expose nothing but +/// `client.id`, so both were *unbounded*, and the moment any tainted reader +/// existed anywhere, `propagate_unresolved_owner` excluded every one of them. +/// Measured: an untagged mpv went from eligible (alone) to `unresolved-owner` +/// the instant peerspeak played audio. That is "native-PipeWire apps are never +/// shareable", which is not a feature. +/// +/// The missing pid is not missing at all — it is one hop away, on the node's +/// **Client**, as `pipewire.sec.pid`, and already in the snapshot. +pub struct OwnerCtx { + pub pipewire_pulse_pid: Option, + /// `client.id` → that Client's `pipewire.sec.pid`. + /// + /// Clients whose global id is **ambiguous** (two live objects claiming it, + /// i.e. the observer missed a removal) are deliberately absent: resolving + /// an ambiguous id to a pid would attribute a node to whichever Client won + /// a coin toss, and inventing an owner key is the one direction that can + /// *reduce* taint. Absent ⇒ unbounded ⇒ fails closed, as before. + client_pids: BTreeMap, +} + +impl OwnerCtx { + pub fn new(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option) -> Self { + let mut client_pids: BTreeMap = BTreeMap::new(); + let mut ambiguous: BTreeSet = BTreeSet::new(); + for client in snapshot.clients() { + if client_pids.contains_key(&client.id) || ambiguous.contains(&client.id) { + // Two Clients claiming one id: drop it entirely rather than + // pick. See the field docs. + client_pids.remove(&client.id); + ambiguous.insert(client.id); + continue; + } + if let Some(pid) = client.sec_pid { + client_pids.insert(client.id, pid); + } + } + Self { + pipewire_pulse_pid, + client_pids, + } + } + + /// The `pipewire.sec.pid` of this node's Client, if it has one and that + /// Client's id is unambiguous. + fn client_pid(&self, node: &NodeSnapshot) -> Option { + self.client_pids.get(&node.props.client_id?).copied() + } +} + /// Which key bridged two legs. Ordered strongest first; the `Ord` derive is /// load-bearing for "report the strongest shared key". #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)] @@ -106,7 +161,7 @@ enum KeyValue { /// A key that is present but unusable (the pipewire-pulse PID; a coarse key /// on a device node) is **absent** here — that is the whole mechanism of the /// two exceptions. -fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option) -> Vec<(OwnerKey, KeyValue)> { +fn keys_of(node: &NodeSnapshot, ctx: &OwnerCtx) -> Vec<(OwnerKey, KeyValue)> { let mut out = Vec::new(); if let Some(group) = &node.props.link_group { out.push((OwnerKey::LinkGroup, KeyValue::Text(group.clone()))); @@ -122,13 +177,25 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option) -> Vec<(OwnerKe if let Some(client) = node.props.client_id { out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0)))); } - if let Some(pid) = node.props.process_id { - // Exception 1. Note the fail-closed asymmetry when the daemon PID is - // unknown (`None`): the exception does *not* fire, key 4 applies to + // Key 4, from the node if it has one, otherwise from its Client + // (round 10, R10-3). The node's own property wins when both exist: it is + // the direct statement, and the Client's is a one-hop inference. + // + // ⚠️ **Exception 1 must apply to the fallback too, and this is the whole + // risk of the fallback.** Measured on this host: 15 unrelated Clients + // share `sec_pid` 2528, which is pipewire-pulse's own — every + // Pulse-emulated app has one. Adding key 4 unguarded would fuse all + // fifteen into a single owner and bridge taint between completely + // unrelated applications, which is enormous over-exclusion. Guarded, the + // fallback strictly *adds* correct bounding: it fires only for native + // clients, which are exactly the ones that carry a real per-app pid here. + if let Some(pid) = node.props.process_id.or_else(|| ctx.client_pid(node)) { + // Note the fail-closed asymmetry when the daemon PID is unknown + // (`None`): the exception does *not* fire, key 4 applies to // everything, and Pulse modules fuse into one owner. That is broad // over-exclusion — annoying and safe — which is the direction v3.4 // §6.1.2's failure-mode paragraph asks for. - if Some(pid) != pipewire_pulse_pid { + if Some(pid) != ctx.pipewire_pulse_pid { out.push((OwnerKey::ProcessId, KeyValue::Num(u64::from(pid)))); } } @@ -151,8 +218,8 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option) -> Vec<(OwnerKe /// nothing else relates them. Its sibling output leg cannot be found, so /// the engine must fail closed rather than declare it clean /// (v3.4 §6.1.1, final paragraph). -pub fn owner_is_bounded(node: &NodeSnapshot, pipewire_pulse_pid: Option) -> bool { - keys_of(node, pipewire_pulse_pid) +pub fn owner_is_bounded(node: &NodeSnapshot, ctx: &OwnerCtx) -> bool { + keys_of(node, ctx) .iter() .any(|(key, _)| *key != OwnerKey::ClientId) } @@ -168,11 +235,11 @@ pub struct OwnerKeyIndex { } impl OwnerKeyIndex { - pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option) -> Self { + pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self { Self { keys: snapshot .nodes() - .map(|node| (node.serial, keys_of(node, pipewire_pulse_pid))) + .map(|node| (node.serial, keys_of(node, ctx))) .collect(), } } @@ -252,10 +319,10 @@ impl OwnerKeyIndex { pub fn strongest_shared_key( a: &NodeSnapshot, b: &NodeSnapshot, - pipewire_pulse_pid: Option, + ctx: &OwnerCtx, ) -> Option { - let a_keys = keys_of(a, pipewire_pulse_pid); - let b_keys = keys_of(b, pipewire_pulse_pid); + let a_keys = keys_of(a, ctx); + let b_keys = keys_of(b, ctx); // `keys_of` yields strongest-first, so the first match is the strongest. a_keys.iter().find_map(|(key, value)| { b_keys @@ -279,7 +346,7 @@ pub struct OwnerComponents { } impl OwnerComponents { - pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option) -> Self { + pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self { let serials: Vec = snapshot.nodes().map(|n| n.serial).collect(); let index: BTreeMap = serials.iter().enumerate().map(|(i, s)| (*s, i)).collect(); @@ -290,7 +357,7 @@ impl OwnerComponents { let mut buckets: BTreeMap<(OwnerKey, KeyValue), Vec> = BTreeMap::new(); for node in snapshot.nodes() { let slot = index[&node.serial]; - for (key, value) in keys_of(node, pipewire_pulse_pid) { + for (key, value) in keys_of(node, ctx) { buckets.entry((key, value)).or_default().push(slot); } } diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs index ee9c285..c8e57f5 100644 --- a/src/host/taint/tests.rs +++ b/src/host/taint/tests.rs @@ -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