From 6ead1fe9f89e3153a7a066797e51f2b1e17fc05b Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 21 Jul 2026 16:24:15 -0400 Subject: [PATCH 1/7] host/taint: pure graph model + taint engine (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements design v3.4 §6.1–§6.1.3 behind a fixture test surface. No PipeWire types in any signature; nothing here links against libpipewire. Not wired into anything yet — phase 3's registry observer is what will feed it, so the module is `#![allow(dead_code)]` for now. evaluate(&GraphSnapshot, &ExclusionCtx, &StickyState) -> (Decisions, StickyState) - snapshot.rs: owned Node/Port/Link/Client model keyed on `Serial` (object.serial, 64-bit, identity) with `GlobalId` retained strictly as a snapshot-local lookup key. Two live objects claiming one id resolve as `Ambiguous`, which fails closed. - owner.rs: the owner bridge — the key union (link-group, pulse.module.id, client.id, application.process.id) with equality-not-first-present semantics, transitive union-find components, and both suppression rules. - mod.rs: monotone fixpoint over link edges, the conditional owner bridge (gated on the tainted member being one that *receives* audio) and the unbounded-owner backstop, then sticky merge. Stable `Reason` codes with an explicit priority so the reported reason never depends on traversal order. Three judgement calls that go beyond what v3.4 spells out, all flagged in the source: 1. Coarse keys (client.id, application.process.id) may not bridge device-role nodes. Every ALSA device is created by one WirePlumber process, so they share a client and a PID; peerspeak's playback taints the default sink on every recompute, and without this rule that taint reaches the microphone source and then every app holding a mic loses its playback — the §6.1.1 catastrophe by another route. 2. "Owner is bounded" is not "has a usable key": client.id alone does not bound an owner (the measured GStreamer split-client refutation), so the fail-closed backstop keys on strong keys or a usable PID. 3. Sticky entries record a reason per node rather than one per owner, so a forwarder's output leg keeps `tainted-owner-bridge` instead of inheriting its input leg's `tainted-upstream`. 32 fixture tests, each asserting an exact partition of the full candidate universe rather than spot-checking named nodes: v3.4 §12's matrix, the impl plan's degenerate-snapshot boundary, and the eligible half of every scenario so an exclude-everything build fails. Co-Authored-By: Claude Opus 4.8 --- src/host/mod.rs | 1 + src/host/taint/fixture.rs | 282 +++++++++++ src/host/taint/mod.rs | 745 +++++++++++++++++++++++++++++ src/host/taint/owner.rs | 272 +++++++++++ src/host/taint/snapshot.rs | 266 +++++++++++ src/host/taint/tests.rs | 926 +++++++++++++++++++++++++++++++++++++ 6 files changed, 2492 insertions(+) create mode 100644 src/host/taint/fixture.rs create mode 100644 src/host/taint/mod.rs create mode 100644 src/host/taint/owner.rs create mode 100644 src/host/taint/snapshot.rs create mode 100644 src/host/taint/tests.rs diff --git a/src/host/mod.rs b/src/host/mod.rs index bde0594..ea87de5 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -3,6 +3,7 @@ mod capture; mod pipeline; mod quality; mod serve; +pub mod taint; mod wayland; mod x11; diff --git a/src/host/taint/fixture.rs b/src/host/taint/fixture.rs new file mode 100644 index 0000000..5a5273d --- /dev/null +++ b/src/host/taint/fixture.rs @@ -0,0 +1,282 @@ +//! Synthetic graph builders for the taint-engine tests. +//! +//! Serials are handed out monotonically and never reused, exactly as +//! PipeWire does; global ids are handed out separately and **may be reused +//! on purpose**, which is what the recycling tests need. + +use std::collections::BTreeMap; + +use super::snapshot::{ + ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot, + PortDirection, PortSnapshot, Serial, +}; + +/// pipewire-pulse's PID, as measured on the target machine. +pub const PULSE_PID: u32 = 2541; +/// WirePlumber's PID — one process owning every device node on the box. +pub const SESSION_PID: u32 = 900; + +/// A node's identity in a fixture: what tests pass around. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct NodeRef { + pub serial: Serial, + pub id: GlobalId, +} + +#[derive(Default)] +pub struct Graph { + next_serial: u64, + next_id: u32, + nodes: Vec, + ports: Vec, + links: Vec, + clients: Vec, + /// One client connection per process / per module, which is what the + /// live graph looks like. Tests that need the *split*-client shape + /// (GStreamer opens one per stream) pass clients explicitly instead. + client_by_app: BTreeMap, + client_by_module: BTreeMap, + session_client: Option, +} + +impl Graph { + pub fn new() -> Self { + Self { + // Start past u32::MAX so every fixture also exercises the phase + // 0a widening: a serial that a u32 model would have truncated. + next_serial: u64::from(u32::MAX) + 1, + next_id: 1, + ..Self::default() + } + } + + fn serial(&mut self) -> Serial { + self.next_serial += 1; + Serial(self.next_serial) + } + + fn id(&mut self) -> GlobalId { + self.next_id += 1; + GlobalId(self.next_id) + } + + /// A client object. `sec_pid` is `pipewire.sec.pid` — pipewire-pulse's + /// PID for Pulse-emulated clients. + pub fn client(&mut self, sec_pid: Option) -> GlobalId { + let serial = self.serial(); + let id = self.id(); + self.clients.push(ClientSnapshot { + serial, + id, + sec_pid, + }); + id + } + + /// The client connection an ordinary process holds — one per PID, + /// created on demand. + pub fn client_of_app(&mut self, pid: u32) -> GlobalId { + if let Some(id) = self.client_by_app.get(&pid) { + return *id; + } + let id = self.client(Some(PULSE_PID)); + self.client_by_app.insert(pid, id); + id + } + + /// 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); + self.node(name, role, app(client, pid)) + } + + /// A leg of a pactl-loaded module: one client per module, and the + /// node's `application.process.id` is **pipewire-pulse's own**, because + /// pipewire-pulse genuinely is the client. + pub fn module_node(&mut self, name: &str, role: MediaRole, module: u64) -> NodeRef { + let client = match self.client_by_module.get(&module) { + Some(id) => *id, + None => { + let id = self.client(Some(PULSE_PID)); + self.client_by_module.insert(module, id); + id + } + }; + self.node(name, role, pulse_module(client, module, PULSE_PID)) + } + + /// A leg joined to its siblings by `node.link-group` — loopback, + /// filter-chain, echo-cancel. + pub fn group_node(&mut self, name: &str, role: MediaRole, group: &str, pid: u32) -> NodeRef { + let client = self.client_of_app(pid); + self.node(name, role, link_group(group, client, pid)) + } + + /// A device node as the session manager creates it: no strong key, and + /// WirePlumber's client and PID — shared with every other device. + pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef { + let session = match self.session_client { + Some(id) => id, + None => { + let id = self.client(None); + self.session_client = Some(id); + id + } + }; + self.node(name, role, device(session, SESSION_PID)) + } + + pub fn peerspeak_node(&mut self, name: &str, pid: u32) -> NodeRef { + let client = self.client_of_app(pid); + self.node(name, MediaRole::StreamOutput, peerspeak_owned(client, pid)) + } + + pub fn node(&mut self, name: &str, role: MediaRole, props: NodeProps) -> NodeRef { + let id = self.id(); + self.node_with_id(name, role, id, props) + } + + /// Force a global id — for reproducing id recycling after teardown. + pub fn node_with_id( + &mut self, + name: &str, + role: MediaRole, + id: GlobalId, + props: NodeProps, + ) -> NodeRef { + let serial = self.serial(); + self.nodes.push(NodeSnapshot { + serial, + id, + name: Some(name.to_string()), + role, + props, + }); + NodeRef { serial, id } + } + + pub fn port(&mut self, node: NodeRef, direction: PortDirection, exclusive: bool) { + let serial = self.serial(); + let id = self.id(); + self.ports.push(PortSnapshot { + serial, + id, + node: node.id, + direction, + exclusive, + monitor: false, + }); + } + + /// A signal edge: audio flows `from → to`. + pub fn link(&mut self, from: NodeRef, to: NodeRef) { + self.link_ids(from.id, to.id); + } + + /// A link naming raw ids, so a test can dangle an endpoint. + pub fn link_ids(&mut self, from: GlobalId, to: GlobalId) { + let serial = self.serial(); + let id = self.id(); + self.links.push(LinkSnapshot { + serial, + id, + output_node: from, + input_node: to, + output_port: None, + input_port: None, + }); + } + + /// An id that belongs to nothing — for unresolved-endpoint tests. + pub fn dangling_id(&mut self) -> GlobalId { + self.id() + } + + pub fn build(&self) -> GraphSnapshot { + self.build_without(&[]) + } + + /// A later snapshot in which some nodes have gone away, along with + /// their ports and every link touching them. Surviving objects keep + /// their serials, which is what makes sticky-taint sequences testable. + pub fn build_without(&self, dropped: &[NodeRef]) -> GraphSnapshot { + let gone_serials: Vec = dropped.iter().map(|n| n.serial).collect(); + let nodes: Vec = self + .nodes + .iter() + .filter(|n| !gone_serials.contains(&n.serial)) + .cloned() + .collect(); + // Filter by what was *dropped*, not by what is live: a link to an id + // that never had a node is a dangling endpoint, and dropping those + // here would quietly disarm every unresolved-ancestry test. + let gone_ids: Vec = dropped.iter().map(|n| n.id).collect(); + GraphSnapshot::new( + nodes, + self.ports + .iter() + .filter(|p| !gone_ids.contains(&p.node)) + .cloned() + .collect(), + self.links + .iter() + .filter(|l| !gone_ids.contains(&l.output_node) && !gone_ids.contains(&l.input_node)) + .cloned() + .collect(), + self.clients.clone(), + ) + } + + /// Drop clients too — full owner teardown. + pub fn drop_clients(&mut self, ids: &[GlobalId]) { + self.clients.retain(|c| !ids.contains(&c.id)); + } +} + +/// An ordinary application stream: real PID, one client connection. +pub fn app(client: GlobalId, pid: u32) -> NodeProps { + NodeProps { + client_id: Some(client), + process_id: Some(pid), + ..NodeProps::default() + } +} + +/// A pactl-module-created stream: the daemon is the client, so the node's +/// `application.process.id` is pipewire-pulse's own. +pub fn pulse_module(client: GlobalId, module: u64, pulse_pid: u32) -> NodeProps { + NodeProps { + pulse_module_id: Some(module), + client_id: Some(client), + process_id: Some(pulse_pid), + ..NodeProps::default() + } +} + +/// A PipeWire-module leg joined to its siblings by `node.link-group` +/// (loopback, filter-chain, echo-cancel). +pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps { + NodeProps { + link_group: Some(group.to_string()), + client_id: Some(client), + process_id: Some(pid), + ..NodeProps::default() + } +} + +/// A device node as the session manager creates it: no strong key, and the +/// session manager's own client and PID — shared with every other device. +pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps { + NodeProps { + client_id: Some(session_client), + process_id: Some(session_pid), + ..NodeProps::default() + } +} + +pub fn peerspeak_owned(client: GlobalId, pid: u32) -> NodeProps { + NodeProps { + peerspeak_owned: true, + ..app(client, pid) + } +} diff --git a/src/host/taint/mod.rs b/src/host/taint/mod.rs new file mode 100644 index 0000000..3719bdd --- /dev/null +++ b/src/host/taint/mod.rs @@ -0,0 +1,745 @@ +//! The taint engine — decides which `Stream/Output/Audio` nodes may be +//! fanned out into the screen-share capture without echoing peerspeak's own +//! audio back at the viewer. +//! +//! Implements design v3.4 §6.1–§6.1.3 (`peerspeak/docs/ +//! screenshare-audio-exclusion-plan.md`), phase 2 of the implementation +//! plan. **Pure**: no PipeWire types appear in any signature, nothing here +//! touches the daemon, and every test builds its own graph. +//! +//! ## The one-sentence predicate +//! +//! > A node is eligible only if **no** signal path reaches it from a +//! > peerspeak-owned node, the live AEC identity, or any pixelpass-owned +//! > object. **Unresolvable ancestry is not eligible.** +//! +//! That last sentence is the invariant the whole design rests on: every +//! other failure mode in here degrades into over-exclusion (one app's audio +//! silently missing from the share) rather than into echo. +//! +//! ## Why a graph walk and not a property check +//! +//! Exclusion does not propagate downstream by itself. Any node that +//! re-emits audio it received is a fresh, *untagged* `Stream/Output/Audio` +//! carrying the mix — including the one peerspeak playback stream that was +//! correctly excluded one hop earlier. EasyEffects, `module-loopback`, +//! combine-sinks, tunnel/RTP sinks and virtual-sink forwarders all have this +//! shape, and at least one such topology has been observed live on the +//! target machine. +//! +//! Taint therefore flows over **three** edge types: +//! +//! 1. **Link edges** — `link.output.node → link.input.node`. +//! 2. **Sink → monitor** — free at node granularity: the monitor connection +//! *is* a real Link whose output node is the sink node itself (measured). +//! A port-granular walk would need a synthetic edge; a node-granular one +//! does not. +//! 3. **Owner bridges** — the intra-process hop the graph cannot see. See +//! [`owner`]; this is the hard one. +//! +//! ## Stickiness +//! +//! Taint is **sticky per owner** for the duration of the share, because a +//! topological recompute forgets *buffered* audio: an app can read a tainted +//! monitor into a 5-second ring buffer, then have its input leg vanish, and +//! a purely topological engine would relink its output while it is still +//! emitting peerspeak's audio out of that buffer. No graph event marks the +//! moment a buffer drains. +//! +//! Stickiness is keyed on [`Serial`] — never on a node id, `client.id`, +//! module index or `link-group` string, **all of which recycle on this +//! stack**. An entry is cleared only once every member object has +//! disappeared; a key that reappears after full teardown is a new owner and +//! starts clean. + +// Phase 2 lands the engine behind its own test surface and nothing else: +// the registry observer that will feed it is phase 3, so in a non-test +// build every item here is legitimately unreachable for now. +#![allow(dead_code)] + +pub mod owner; +pub mod snapshot; + +#[cfg(test)] +mod fixture; +#[cfg(test)] +mod tests; + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use owner::{OwnerComponents, OwnerKey}; +use snapshot::{GraphSnapshot, IdLookup, MediaRole, NodeSnapshot, Serial}; + +/// The `node.name` prefix of a pixelpass capture sink. Any host's sink +/// counts, not just ours — fanning out a stream that is downstream of +/// *another* pixelpass host's capture sink builds a cycle (v3.4 §6.2). +pub const CAPTURE_SINK_PREFIX: &str = "pixelpass_capture_"; + +/// `node.link-group` prefix that marks *some* echo canceller. Hazard +/// detection only — it does **not** identify peerspeak's instance, which is +/// what `pulse.module.id` is for (v3.4 §5.2 correction 4). +pub const ECHO_CANCEL_GROUP_PREFIX: &str = "echo-cancel-"; + +/// Why a node is tainted or excluded. Stable machine-readable codes: this +/// value is the phase 5 audit output, the phase 6 status event, and the +/// eventual answer to "why isn't this app being shared?". +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum Reason { + /// Carries the `peerspeak.owned` tag (v3.4 §5.1). + PeerspeakOwned, + /// `pulse.module.id` equals the live AEC module index — exact equality + /// only. "Has any `pulse.module.id`" is explicitly rejected as a rule: + /// tunnel/RTP/loopback modules may be the only carrier of audio the + /// user legitimately wants shared (v3.4 §5.2 correction 2). + AecIdentity, + /// A pixelpass-owned object, ours or another host's capture sink. + PixelpassOwned, + /// An `echo-cancel-*` group that is **not** our validated identity. + /// Decision D3: warn and exclude rather than fan out. + ForeignEchoCancel, + /// Reached by a signal path from a tainted node (link or monitor edge). + TaintedUpstream, + /// Reached across an owner bridge; the key that did it, when the + /// tainted member shares one directly rather than transitively. + TaintedOwnerBridge { key: Option }, + /// A link endpoint, or a node's own id, could not be resolved in this + /// snapshot. Fail closed (v3.4 §6.1.4). + UnresolvedAncestry, + /// A tainted capture stream whose owner cannot be bounded by any usable + /// key, so its sibling output legs cannot be identified. Fail closed + /// (v3.4 §6.1.1, final paragraph). + UnresolvedOwner, + /// The observer has not reached a complete, coherent view of the graph + /// yet. No decision made from a partial graph is a decision. + GraphNotReady, + /// A `port.exclusive` port — fan-out will be refused (v3.4 §6.2). Local + /// to the node; does not propagate. + PortExclusive, + /// An encoded/passthrough stream — a second link would corrupt it. + /// Local to the node; does not propagate. + Passthrough, +} + +impl Reason { + pub fn code(self) -> &'static str { + match self { + Self::PeerspeakOwned => "peerspeak-owned", + Self::AecIdentity => "aec-identity", + Self::PixelpassOwned => "pixelpass-owned", + Self::ForeignEchoCancel => "foreign-echo-cancel", + Self::TaintedUpstream => "tainted-upstream", + Self::TaintedOwnerBridge { .. } => "tainted-owner-bridge", + Self::UnresolvedAncestry => "unresolved-ancestry", + Self::UnresolvedOwner => "unresolved-owner", + Self::GraphNotReady => "graph-not-ready", + Self::PortExclusive => "port-exclusive", + Self::Passthrough => "passthrough", + } + } + + /// Lower wins. A node can acquire taint several ways in one recompute + /// and the reported reason must not depend on traversal order, or the + /// audit output is unstable and the fixture tests are flaky. Explicit + /// priority, not BFS arrival order. + fn priority(self) -> u8 { + match self { + Self::PeerspeakOwned => 0, + Self::AecIdentity => 1, + Self::PixelpassOwned => 2, + Self::ForeignEchoCancel => 3, + Self::TaintedUpstream => 4, + Self::TaintedOwnerBridge { .. } => 5, + Self::UnresolvedAncestry => 6, + Self::UnresolvedOwner => 7, + // Non-propagating; never competes with the taint reasons above + // because it is only consulted for untainted candidates. + Self::GraphNotReady => 8, + Self::PortExclusive => 9, + Self::Passthrough => 10, + } + } + + /// Does this reason spread to downstream nodes and owner siblings? + fn propagates(self) -> bool { + self.priority() <= Self::UnresolvedOwner.priority() + } +} + +/// Everything the engine needs that is not in the graph itself. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ExclusionCtx { + /// The **validated** live AEC module index, or `None` for `--aec=off`. + /// The validation state machine (phase 4) owns the transitions; if it + /// is still `Validating` or has `Failed`, its caller must not fan out at + /// all rather than passing `None` here, which would merely mean "there + /// is no AEC". + pub aec_module_id: Option, + /// pipewire-pulse's own PID, derived by the observer (phase 3) from a + /// consistent `pipewire.sec.pid` across Pulse clients validated against + /// `/proc//comm`. `None` is safe but coarse — see [`owner`]. + pub pipewire_pulse_pid: Option, + /// Serials of objects pixelpass itself created this run. + pub pixelpass_owned: BTreeSet, + /// False until the readiness epoch has been reached (phase 3). Every + /// candidate is then ineligible: a decision from a partial graph is not + /// a decision. + pub graph_ready: bool, +} + +/// Object identity for sticky bookkeeping. Always a [`Serial`] — never a +/// recyclable id (v3.4 §6.1.3). +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub enum ObjectRef { + Node(Serial), + Client(Serial), +} + +/// One owner that has been tainted, and every object observed to constitute +/// it. Cleared only when **all** of them are gone. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StickyOwner { + /// Every object seen to be part of this owner, ever. Membership + /// accumulates: that is what makes "clear only once all member objects + /// have disappeared" true across churn. + pub members: BTreeSet, + /// The reason recorded for each node that was tainted in its own right. + /// Kept per node rather than collapsed to one owner-wide reason, or a + /// forwarder's output leg inherits its *input* leg's `tainted-upstream` + /// and the audit output stops naming the mechanism that actually + /// excluded it. + pub node_reasons: BTreeMap, +} + +impl StickyOwner { + /// The reason to apply to a member: its own recorded one, or — for a + /// leg that appeared later — the fact that it belongs to a tainted + /// owner, which is a bridge by definition. + fn reason_for(&self, serial: Serial) -> Reason { + self.node_reasons + .get(&serial) + .copied() + .unwrap_or(Reason::TaintedOwnerBridge { key: None }) + } +} + +/// Threaded explicitly through [`evaluate`] so stickiness is testable as a +/// sequence of snapshots rather than as hidden mutable state. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StickyState { + pub owners: Vec, +} + +impl StickyState { + pub fn is_empty(&self) -> bool { + self.owners.is_empty() + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Eligibility { + Eligible, + NotEligible { + reason: Reason, + /// The taint was carried over from a previous snapshot rather than + /// derived from the current topology. + sticky: bool, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NodeDecision { + pub serial: Serial, + pub name: Option, + pub eligibility: Eligibility, +} + +impl NodeDecision { + pub fn is_eligible(&self) -> bool { + matches!(self.eligibility, Eligibility::Eligible) + } + + pub fn reason(&self) -> Option { + match self.eligibility { + Eligibility::Eligible => None, + Eligibility::NotEligible { reason, .. } => Some(reason), + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct TaintEntry { + pub reason: Reason, + pub sticky: bool, +} + +/// The result of one recompute. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Decisions { + /// Every `Stream/Output/Audio` node in the snapshot — the complete + /// candidate universe, so callers can assert an exact partition rather + /// than spot-checking named nodes. + pub candidates: BTreeMap, + /// Taint over *all* node roles, for diagnostics and for the phase 5 + /// audit output. + pub taint: BTreeMap, +} + +impl Decisions { + /// Serials of eligible candidates, ascending. + pub fn eligible(&self) -> Vec { + self.candidates + .values() + .filter(|d| d.is_eligible()) + .map(|d| d.serial) + .collect() + } + + /// `(serial, reason code)` for excluded candidates, ascending. + pub fn excluded(&self) -> Vec<(Serial, &'static str)> { + self.candidates + .values() + .filter_map(|d| d.reason().map(|r| (d.serial, r.code()))) + .collect() + } +} + +/// Recompute eligibility for the whole graph. +/// +/// Full recompute per graph event is the v1 design (O(V+E) over a ~40-node +/// desktop graph); there is deliberately no incremental dirty-set. +pub fn evaluate( + snapshot: &GraphSnapshot, + ctx: &ExclusionCtx, + prior: &StickyState, +) -> (Decisions, StickyState) { + let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid); + + let mut taint: BTreeMap = BTreeMap::new(); + let mut sticky_serials: BTreeSet = BTreeSet::new(); + + seed_local_roots(snapshot, ctx, &mut taint); + seed_sticky( + snapshot, + prior, + &components, + &mut taint, + &mut sticky_serials, + ); + + // Monotone fixpoint: every step only adds taint, or lowers a node's + // reason priority, both of which are bounded. Link propagation and the + // owner bridge feed each other — a bridged output leg has downstream + // links, and a downstream monitor reader bridges to its own siblings — + // so neither can be run once. + let downstream = downstream_edges(snapshot, &mut taint); + loop { + let mut changed = false; + changed |= propagate_links(&downstream, &mut taint); + changed |= propagate_owner_bridge(snapshot, ctx, &components, &mut taint); + changed |= propagate_unresolved_owner(snapshot, ctx, &mut taint); + if !changed { + break; + } + } + + let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials); + let next_sticky = build_sticky(snapshot, &components, &taint, prior); + (decisions, next_sticky) +} + +/// Roots that are visible on the node itself. +fn seed_local_roots( + snapshot: &GraphSnapshot, + ctx: &ExclusionCtx, + taint: &mut BTreeMap, +) { + for node in snapshot.nodes() { + if let Some(reason) = local_root_reason(node, ctx) { + raise(taint, node.serial, reason); + } + // A node whose own global id is ambiguous cannot be the reliable + // endpoint of any link, so its ancestry is unresolvable. + if snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous) { + raise(taint, node.serial, Reason::UnresolvedAncestry); + } + } +} + +fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option { + if node.props.peerspeak_owned { + return Some(Reason::PeerspeakOwned); + } + if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id) + && module == aec + { + return Some(Reason::AecIdentity); + } + if ctx.pixelpass_owned.contains(&node.serial) + || node + .name + .as_deref() + .is_some_and(|name| name.starts_with(CAPTURE_SINK_PREFIX)) + { + return Some(Reason::PixelpassOwned); + } + if node + .props + .link_group + .as_deref() + .is_some_and(|group| group.starts_with(ECHO_CANCEL_GROUP_PREFIX)) + { + return Some(Reason::ForeignEchoCancel); + } + None +} + +/// Carry taint forward from previous snapshots (v3.4 §6.1.3). +fn seed_sticky( + snapshot: &GraphSnapshot, + prior: &StickyState, + components: &OwnerComponents, + taint: &mut BTreeMap, + sticky_serials: &mut BTreeSet, +) { + for entry in &prior.owners { + let live_nodes: Vec = entry + .members + .iter() + .filter_map(|member| match member { + ObjectRef::Node(serial) => snapshot.node(*serial).map(|_| *serial), + ObjectRef::Client(_) => None, + }) + .collect(); + // The owner is sticky, not the individual node: a leg that appears + // later in the same still-live owner inherits the taint. + for serial in live_nodes { + for member in components.members_with(serial) { + let reason = entry.reason_for(*member); + if raise(taint, *member, reason) || taint.get(member) == Some(&reason) { + sticky_serials.insert(*member); + } + } + } + } +} + +/// `output node → input nodes`, resolving snapshot-local ids. An endpoint +/// that does not resolve taints the *other* end as unresolved ancestry when +/// that other end is the input side — we cannot know what is feeding it. +fn downstream_edges( + snapshot: &GraphSnapshot, + taint: &mut BTreeMap, +) -> BTreeMap> { + let mut edges: BTreeMap> = BTreeMap::new(); + for link in snapshot.links() { + let from = snapshot.node_by_id(link.output_node); + let to = snapshot.node_by_id(link.input_node); + match (from, to) { + (Some(IdLookup::Unique(from)), Some(IdLookup::Unique(to))) => { + edges.entry(from).or_default().push(to); + } + (_, Some(IdLookup::Unique(to))) => { + // Something feeds this node and we cannot say what. + raise(taint, to, Reason::UnresolvedAncestry); + } + _ => {} + } + } + for targets in edges.values_mut() { + targets.sort_unstable(); + targets.dedup(); + } + edges +} + +fn propagate_links( + downstream: &BTreeMap>, + taint: &mut BTreeMap, +) -> bool { + let mut changed = false; + let mut queue: VecDeque = taint + .iter() + .filter(|(_, reason)| reason.propagates()) + .map(|(serial, _)| *serial) + .collect(); + while let Some(serial) = queue.pop_front() { + let Some(targets) = downstream.get(&serial) else { + continue; + }; + for target in targets { + if raise(taint, *target, Reason::TaintedUpstream) { + changed = true; + queue.push_back(*target); + } + } + } + changed +} + +/// The conditional owner bridge (v3.4 §6.1.1): taint crosses to an owner's +/// other legs **only** when the tainted member is one that actually +/// receives audio. The naive "this owner has both an input and an output +/// leg ⇒ exclude the output" rule would exclude every app using a +/// microphone, Firefox in a video call included. +fn propagate_owner_bridge( + snapshot: &GraphSnapshot, + ctx: &ExclusionCtx, + components: &OwnerComponents, + taint: &mut BTreeMap, +) -> bool { + let mut changed = false; + for members in components.components() { + let sources: Vec = members + .iter() + .copied() + .filter(|serial| { + taint.get(serial).is_some_and(|r| r.propagates()) + && snapshot + .node(*serial) + .is_some_and(|n| n.role.receives_audio()) + }) + .collect(); + if sources.is_empty() { + continue; + } + for target in members { + if sources.contains(target) { + continue; + } + let Some(target_node) = snapshot.node(*target) else { + continue; + }; + // Name the strongest key shared directly with any tainted + // member; `None` means the two are only transitively related. + let key = sources + .iter() + .filter_map(|source| snapshot.node(*source)) + .filter_map(|source| { + owner::strongest_shared_key(source, target_node, ctx.pipewire_pulse_pid) + }) + .min(); + changed |= raise(taint, *target, Reason::TaintedOwnerBridge { key }); + } + } + changed +} + +/// Fail-closed backstop for an owner we cannot bound (v3.4 §6.1.1, final +/// paragraph): a capture stream is reading tainted audio and nothing about +/// it lets us enumerate its sibling output legs, so we cannot know which +/// output leg is re-emitting what it read. Exclude the output legs that are +/// equally unbounded. +/// +/// Two deliberate restrictions keep the blast radius at "small", as the +/// design promises: +/// +/// - **Source must be a `Stream/Input/Audio`.** A tainted *device* sink is +/// the normal case, not an anomaly — peerspeak's own playback taints the +/// default sink on literally every recompute — and letting that trip this +/// rule would exclude the entire desktop. +/// - **Targets must themselves be unbounded.** Ordinary application streams +/// carry a real `application.process.id` and are bounded, so they are +/// never swept up; in practice only other daemon-owned keyless module +/// streams are. +fn propagate_unresolved_owner( + snapshot: &GraphSnapshot, + ctx: &ExclusionCtx, + taint: &mut BTreeMap, +) -> bool { + let unbounded_reader = snapshot.nodes().any(|node| { + node.role == MediaRole::StreamInput + && taint.get(&node.serial).is_some_and(|r| r.propagates()) + && !owner::owner_is_bounded(node, ctx.pipewire_pulse_pid) + }); + if !unbounded_reader { + return false; + } + let mut changed = false; + for node in snapshot.nodes() { + if node.role == MediaRole::StreamOutput + && !owner::owner_is_bounded(node, ctx.pipewire_pulse_pid) + { + changed |= raise(taint, node.serial, Reason::UnresolvedOwner); + } + } + changed +} + +fn build_decisions( + snapshot: &GraphSnapshot, + ctx: &ExclusionCtx, + taint: &BTreeMap, + sticky_serials: &BTreeSet, +) -> Decisions { + let mut candidates = BTreeMap::new(); + for node in snapshot.nodes().filter(|n| n.role.is_candidate()) { + let sticky = sticky_serials.contains(&node.serial); + let eligibility = if !ctx.graph_ready { + Eligibility::NotEligible { + reason: Reason::GraphNotReady, + sticky: false, + } + } else if let Some(reason) = taint.get(&node.serial) { + Eligibility::NotEligible { + reason: *reason, + sticky, + } + } else if let Some(reason) = local_exclusion(snapshot, node) { + Eligibility::NotEligible { + reason, + sticky: false, + } + } else { + Eligibility::Eligible + }; + candidates.insert( + node.serial, + NodeDecision { + serial: node.serial, + name: node.name.clone(), + eligibility, + }, + ); + } + Decisions { + candidates, + taint: taint + .iter() + .map(|(serial, reason)| { + ( + *serial, + TaintEntry { + reason: *reason, + sticky: sticky_serials.contains(serial), + }, + ) + }) + .collect(), + } +} + +/// Node-local reasons a link cannot be created even though the node is +/// clean. These do not propagate — an exclusive-port stream is unlinkable, +/// not hazardous. +fn local_exclusion(snapshot: &GraphSnapshot, node: &NodeSnapshot) -> Option { + if node.props.passthrough { + return Some(Reason::Passthrough); + } + if snapshot.ports_of(node.id).any(|port| port.exclusive) { + return Some(Reason::PortExclusive); + } + None +} + +/// Sticky bookkeeping for the next recompute: every tainted owner, with +/// every object observed to constitute it, merged with any prior entry that +/// still overlaps. Members accumulate — that is what makes "clear only once +/// all member objects have disappeared" true across churn. +fn build_sticky( + snapshot: &GraphSnapshot, + components: &OwnerComponents, + taint: &BTreeMap, + prior: &StickyState, +) -> StickyState { + let mut entries: Vec = Vec::new(); + + // Carry forward prior entries that still have at least one live member. + // An entry with none is gone for good: serials never recycle, so a + // vanished member can never come back. + for entry in &prior.owners { + if entry + .members + .iter() + .any(|member| is_live(snapshot, *member)) + { + entries.push(entry.clone()); + } + } + + for members in components.components() { + let node_reasons: BTreeMap = members + .iter() + .filter_map(|serial| { + taint + .get(serial) + .filter(|reason| reason.propagates()) + .map(|reason| (*serial, *reason)) + }) + .collect(); + if node_reasons.is_empty() { + continue; + } + let mut refs: BTreeSet = members.iter().map(|s| ObjectRef::Node(*s)).collect(); + refs.extend( + owner::client_serials_of(snapshot, members) + .into_iter() + .map(ObjectRef::Client), + ); + entries.push(StickyOwner { + members: refs, + node_reasons, + }); + } + + StickyState { + owners: merge_overlapping(entries), + } +} + +fn is_live(snapshot: &GraphSnapshot, member: ObjectRef) -> bool { + match member { + ObjectRef::Node(serial) => snapshot.node(serial).is_some(), + ObjectRef::Client(serial) => snapshot.clients().any(|c| c.serial == serial), + } +} + +/// Merge entries that share any member, keeping the strongest reason. +/// Owners fuse over time (a component that gains a leg belonging to a +/// previously separate sticky owner is one owner now); splitting them back +/// apart would drop taint, which is the unsafe direction. +fn merge_overlapping(mut entries: Vec) -> Vec { + let mut merged: Vec = Vec::new(); + while let Some(mut entry) = entries.pop() { + let mut absorbed = true; + while absorbed { + absorbed = false; + let mut rest = Vec::with_capacity(entries.len()); + for other in entries.drain(..) { + if entry.members.is_disjoint(&other.members) { + rest.push(other); + } else { + for (serial, reason) in other.node_reasons { + entry + .node_reasons + .entry(serial) + .and_modify(|existing| { + if reason.priority() < existing.priority() { + *existing = reason; + } + }) + .or_insert(reason); + } + entry.members.extend(other.members); + absorbed = true; + } + } + entries = rest; + } + merged.push(entry); + } + merged.sort_by(|a, b| a.members.iter().next().cmp(&b.members.iter().next())); + merged +} + +/// Record `reason` for `serial` if it is new or strictly stronger than what +/// is already recorded. Returns whether anything changed — the fixpoint's +/// termination argument rests on this being monotone. +fn raise(taint: &mut BTreeMap, serial: Serial, reason: Reason) -> bool { + match taint.get(&serial) { + Some(existing) if existing.priority() <= reason.priority() => false, + _ => { + taint.insert(serial, reason); + true + } + } +} diff --git a/src/host/taint/owner.rs b/src/host/taint/owner.rs new file mode 100644 index 0000000..0105432 --- /dev/null +++ b/src/host/taint/owner.rs @@ -0,0 +1,272 @@ +//! The owner bridge — grouping nodes that belong to the same *owner* even +//! though the graph shows no Link between them. +//! +//! This is the subtlest part of the design (v3.4 §6.1.2). Measured fact it +//! exists to handle: a `module-loopback` forwarder's input leg and output +//! leg have **no Link between them**, so walking Links alone from the +//! leaking output leg finds no inbound links at all — a dead end that reads +//! as "clean". The legs are related only by shared properties. +//! +//! ## The rule +//! +//! A union of keys, strongest first: +//! +//! | # | key | scope | +//! | --- | --- | --- | +//! | 1 | `node.link-group` | per module/filter instance | +//! | 2 | `pulse.module.id` | per pactl module | +//! | 3 | `client.id` | per **connection** | +//! | 4 | `application.process.id` | per process | +//! +//! ⚠️ **"Resolves" means the two legs carry the key AND the values are +//! EQUAL — not "the first key present".** A first-present implementation +//! reproduces the exact measured leak: for `gst-launch pulsesrc ! pulsesink` +//! both legs carry `client.id` (209 and 210) but the values *differ*, so +//! first-present stops at key 3, sees a mismatch, and concludes "different +//! owners". The legs are in fact one process (`application.process.id` +//! 20172 on both). So: try each key in order, and a key resolves only if +//! both legs carry it and the values are equal; otherwise fall through. +//! +//! ## Two exceptions, both guarding against mass over-exclusion +//! +//! 1. **Never bridge on key 4 when the value is pipewire-pulse's own PID** +//! (v3.4 §6.1.2). Module-created streams all carry the daemon's PID, so +//! bridging on it fuses every Pulse module into one owner and a single +//! tainted module input would exclude every module-created stream on the +//! box. Keys 1 and 2 already cover those cases precisely. +//! +//! 2. **Coarse keys (3 and 4) may not bridge device-role nodes.** ⚠️ This +//! rule is *not* in design v3.4 — it was found while implementing, and +//! it is the exact analogue of exception 1 for the session manager: +//! every ALSA `Audio/Sink` and `Audio/Source` on the box is created by +//! WirePlumber and therefore shares one `client.id` and one +//! `application.process.id`. Without this rule, the hardware sink +//! carrying peerspeak's playback (tainted by design, every single time) +//! would bridge to *every other device node including the microphone +//! source*, whose readers would then taint their owners' playback legs — +//! reproducing precisely the §6.1.1 catastrophe ("excludes any app using +//! a microphone") through a different door. Device nodes that genuinely +//! belong to a module still bridge, via keys 1 and 2. +//! +//! Grouping is **transitive** (union-find). That is the fail-closed +//! direction: bigger owner components mean more taint, never less. + +use std::collections::BTreeMap; + +use super::snapshot::{GlobalId, GraphSnapshot, NodeSnapshot, Serial}; + +/// 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)] +pub enum OwnerKey { + LinkGroup, + PulseModuleId, + ClientId, + ProcessId, +} + +impl OwnerKey { + /// Stable, machine-readable — this ends up in the phase 5 audit output + /// and the phase 6 status event. + pub fn code(self) -> &'static str { + match self { + Self::LinkGroup => "node.link-group", + Self::PulseModuleId => "pulse.module.id", + Self::ClientId => "client.id", + Self::ProcessId => "application.process.id", + } + } +} + +/// The value a node presents for a given key, if it presents one at all. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] +enum KeyValue { + Text(String), + Num(u64), +} + +/// Owner keys usable on this node, strongest first. +/// +/// 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)> { + let mut out = Vec::new(); + if let Some(group) = &node.props.link_group { + out.push((OwnerKey::LinkGroup, KeyValue::Text(group.clone()))); + } + if let Some(module) = node.props.pulse_module_id { + out.push((OwnerKey::PulseModuleId, KeyValue::Num(module))); + } + // Exception 2: coarse keys never bridge device-role nodes. + if node.role.is_device_role() { + return out; + } + 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 + // 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 { + out.push((OwnerKey::ProcessId, KeyValue::Num(u64::from(pid)))); + } + } + out +} + +/// Can this node's owner be positively bounded — i.e. can we enumerate its +/// sibling legs and be right? +/// +/// ⚠️ Not the same as "has any usable key", and the difference is a leak. +/// `client.id` alone does **not** bound an owner: that is the measured +/// GStreamer refutation, where one process presented two different +/// `client.id`s for its two legs. So an owner is bounded only by a strong +/// key (link-group / pulse.module.id) or by a *usable* process id — usable +/// meaning key 4 was not suppressed as pipewire-pulse's own PID. +/// +/// The case this exists for is v3.4 §12's "module forwarder with neither +/// `link-group` nor `pulse.module.id`": its process id is the daemon's and +/// therefore suppressed, its two legs may carry different `client.id`s, and +/// 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) + .iter() + .any(|(key, _)| *key != OwnerKey::ClientId) +} + +/// The strongest key two nodes share, or `None` if they share none. Used to +/// *name* the key in a bridge decision; membership itself is transitive and +/// comes from [`OwnerComponents`]. +pub fn strongest_shared_key( + a: &NodeSnapshot, + b: &NodeSnapshot, + pipewire_pulse_pid: Option, +) -> Option { + let a_keys = keys_of(a, pipewire_pulse_pid); + let b_keys = keys_of(b, pipewire_pulse_pid); + // `keys_of` yields strongest-first, so the first match is the strongest. + a_keys.iter().find_map(|(key, value)| { + b_keys + .iter() + .any(|(other_key, other_value)| other_key == key && other_value == value) + .then_some(*key) + }) +} + +/// Nodes partitioned into owner components. +#[derive(Clone, Debug, Default)] +pub struct OwnerComponents { + /// node serial → component index. + of_node: BTreeMap, + /// component index → member node serials, ascending. + members: Vec>, +} + +impl OwnerComponents { + pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option) -> Self { + let serials: Vec = snapshot.nodes().map(|n| n.serial).collect(); + let index: BTreeMap = + serials.iter().enumerate().map(|(i, s)| (*s, i)).collect(); + let mut uf = UnionFind::new(serials.len()); + + // Group by (key, value) and union within each group. Equivalent to + // the pairwise "some key resolves" rule, and O(n log n). + 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) { + buckets.entry((key, value)).or_default().push(slot); + } + } + for group in buckets.values() { + for pair in group.windows(2) { + uf.union(pair[0], pair[1]); + } + } + + // Compact roots into dense component indices, deterministically. + let mut root_to_component: BTreeMap = BTreeMap::new(); + let mut members: Vec> = Vec::new(); + let mut of_node = BTreeMap::new(); + for (slot, serial) in serials.iter().enumerate() { + let root = uf.find(slot); + let component = *root_to_component.entry(root).or_insert_with(|| { + members.push(Vec::new()); + members.len() - 1 + }); + members[component].push(*serial); + of_node.insert(*serial, component); + } + Self { of_node, members } + } + + pub fn component_of(&self, serial: Serial) -> Option { + self.of_node.get(&serial).copied() + } + + /// Member serials of the component containing `serial`, including it. + /// Empty if the node is not in this snapshot. + pub fn members_with(&self, serial: Serial) -> &[Serial] { + match self.component_of(serial) { + Some(component) => &self.members[component], + None => &[], + } + } + + pub fn components(&self) -> impl Iterator { + self.members.iter().map(Vec::as_slice) + } +} + +struct UnionFind { + parent: Vec, +} + +impl UnionFind { + fn new(len: usize) -> Self { + Self { + parent: (0..len).collect(), + } + } + + fn find(&mut self, mut node: usize) -> usize { + while self.parent[node] != node { + self.parent[node] = self.parent[self.parent[node]]; + node = self.parent[node]; + } + node + } + + fn union(&mut self, a: usize, b: usize) { + let (a, b) = (self.find(a), self.find(b)); + if a != b { + // Lowest root wins, so components are deterministic. + let (low, high) = if a < b { (a, b) } else { (b, a) }; + self.parent[high] = low; + } + } +} + +/// Client objects belonging to an owner component, so sticky taint can be +/// keyed on every object that constitutes the owner (v3.4 §6.1.3: clear the +/// entry only once **all** member objects are gone). +pub fn client_serials_of(snapshot: &GraphSnapshot, nodes: &[Serial]) -> Vec { + let mut out: Vec = nodes + .iter() + .filter_map(|serial| snapshot.node(*serial)) + .filter_map(|node| node.props.client_id) + .filter_map(|id: GlobalId| match snapshot.client_by_id(id) { + Some(super::snapshot::IdLookup::Unique(serial)) => Some(serial), + _ => None, + }) + .collect(); + out.sort_unstable(); + out.dedup(); + out +} diff --git a/src/host/taint/snapshot.rs b/src/host/taint/snapshot.rs new file mode 100644 index 0000000..ba4a3f4 --- /dev/null +++ b/src/host/taint/snapshot.rs @@ -0,0 +1,266 @@ +//! The plain, owned graph model the taint engine reasons over. +//! +//! **No PipeWire types appear in this file, by design** (impl plan §4, +//! phase 2). The registry observer (phase 3) translates live globals into +//! these structs; every test builds them by hand. Nothing here ever links +//! against libpipewire. +//! +//! Two id-ish things live in this model and confusing them is the bug the +//! whole file is shaped to prevent: +//! +//! - [`Serial`] — `object.serial`, 64-bit, monotonic, **never reused**. +//! This is *identity*. Sticky taint is keyed on it. +//! - [`GlobalId`] — the PipeWire global id, 32-bit and **recycled**. It is +//! a *lookup key within one snapshot* and nothing else: links name their +//! endpoints with it, nodes name their client with it. It must never +//! outlive the snapshot it was read from (design v3.4 §6.1.3). + +use std::collections::BTreeMap; + +/// `object.serial` — 64-bit, monotonic, never recycled. Identity. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)] +pub struct Serial(pub u64); + +/// A PipeWire global id — 32-bit and **recycled**. Snapshot-local lookup +/// key only; see the module docs. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)] +pub struct GlobalId(pub u32); + +/// What a node does with audio, parsed from `media.class`. +/// +/// Taint is computed at **node** granularity (v3.4 §6.1 edge type 2: the +/// monitor connection is already a real Link whose output node is the sink +/// itself, so a node-level walk crosses `app → sink → monitor-reader` for +/// free). Ports exist in the model for link creation in phase 6 and for the +/// `port.exclusive` predicate, not for taint. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)] +pub enum MediaRole { + /// `Stream/Output/Audio` — an application playing audio. The only + /// fan-out candidate. + StreamOutput, + /// `Stream/Input/Audio` — an application capturing audio. + StreamInput, + /// `Audio/Sink` — a real or virtual sink. + Sink, + /// `Audio/Source` — a real or virtual source. + Source, + /// `Audio/Duplex`. ⚠️ Node granularity smears taint across both roles + /// of these; accepted for v1 as fail-closed over-exclusion + /// (v3.4 §6.1, edge type 2 caveat). + Duplex, + /// Anything else, including video and unparseable/absent `media.class`. + Other, +} + +impl MediaRole { + pub fn parse(media_class: Option<&str>) -> Self { + match media_class { + Some("Stream/Output/Audio") => Self::StreamOutput, + Some("Stream/Input/Audio") => Self::StreamInput, + Some("Audio/Sink") => Self::Sink, + Some("Audio/Source") => Self::Source, + Some("Audio/Duplex") => Self::Duplex, + _ => Self::Other, + } + } + + /// Can this node *receive* audio? This is the gate on the owner bridge: + /// taint crosses the intra-process hop only when the owner is actually + /// reading tainted audio (v3.4 §6.1.1 — "this client has both an input + /// and an output leg ⇒ exclude the output" is the catastrophic rule + /// that excludes every app with a microphone). + /// + /// `Sink` counts: EasyEffects' `ee_sink` is an `Audio/Sink` that + /// receives the tainted mix, and its re-emitting leg is joined to it by + /// `node.link-group` with no Link between them. + pub fn receives_audio(self) -> bool { + matches!(self, Self::StreamInput | Self::Sink | Self::Duplex) + } + + /// Device-ish nodes — everything that is not a `Stream/*`. Coarse owner + /// keys are not allowed to bridge these; see [`super::owner`]. + pub fn is_device_role(self) -> bool { + matches!(self, Self::Sink | Self::Source | Self::Duplex) + } + + /// Only `Stream/Output/Audio` nodes are fan-out candidates (v3.4 §6.2). + pub fn is_candidate(self) -> bool { + matches!(self, Self::StreamOutput) + } +} + +/// The subset of node properties the engine actually reasons about. +/// +/// Deliberately a struct of parsed fields rather than a property bag: the +/// parsing (and its failure modes) belongs at the observer boundary, and a +/// bag invites `props.get("...")` typos that silently read `None` — which +/// on this feature means "not tainted". +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct NodeProps { + /// `peerspeak.owned` is present and truthy (v3.4 §5.1). A correctness + /// mechanism, explicitly *not* a security boundary. + pub peerspeak_owned: bool, + /// `pulse.module.id`, parsed as `u64` — never `u32`, per v3.4 §5.2's + /// parse-defensively note and the phase 0a truncation bug. + pub pulse_module_id: Option, + /// `node.link-group` — owner key 1, and the `echo-cancel-` hazard + /// prefix (v3.4 §5.4 / D3). + pub link_group: Option, + /// `client.id` — owner key 3. A **connection**, not an owner: GStreamer + /// opens one per stream (v3.4 §6.1.2, measured refutation). + pub client_id: Option, + /// `application.process.id` **on the node** — owner key 4. For + /// module-created streams this is pipewire-pulse's own PID, which is + /// why [`super::ExclusionCtx::pipewire_pulse_pid`] exists. + pub process_id: Option, + /// The stream negotiated an encoded/passthrough format; a second link + /// would refuse or corrupt it (v3.4 §6.2). + pub passthrough: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NodeSnapshot { + pub serial: Serial, + pub id: GlobalId, + /// `node.name`, for diagnostics and for `pixelpass_capture_*` ancestry + /// detection (v3.4 §6.2, cycle prevention). + pub name: Option, + pub role: MediaRole, + pub props: NodeProps, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PortDirection { + In, + Out, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PortSnapshot { + pub serial: Serial, + pub id: GlobalId, + /// Owning node, by snapshot-local id. + pub node: GlobalId, + pub direction: PortDirection, + /// `port.exclusive` — fan-out will be refused (v3.4 §6.2). + pub exclusive: bool, + /// `port.monitor`. Recorded for phase 6 link creation; taint does not + /// need it at node granularity. + pub monitor: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LinkSnapshot { + pub serial: Serial, + pub id: GlobalId, + /// `link.output.node` — the node audio flows **from**. + pub output_node: GlobalId, + /// `link.input.node` — the node audio flows **to**. + pub input_node: GlobalId, + pub output_port: Option, + pub input_port: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClientSnapshot { + pub serial: Serial, + pub id: GlobalId, + /// `pipewire.sec.pid` — for Pulse-emulated clients this is + /// **pipewire-pulse's** PID, identical across every unrelated app + /// (v3.4 §5.2 correction 5). Phase 3 derives the daemon PID from the + /// consistency of this value; the engine only consumes the result. + pub sec_pid: Option, +} + +/// How a snapshot-local id resolves. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum IdLookup { + Unique(Serial), + /// Two live objects in one snapshot claim the same global id — the + /// observer missed a removal, so the recycled id is ambiguous. Every + /// edge touching it is treated as unresolved, i.e. fail closed. + Ambiguous, +} + +/// One coherent observation of the graph. +/// +/// Built through [`GraphSnapshot::new`] so the id indexes and the ambiguity +/// detection cannot be skipped. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GraphSnapshot { + nodes: BTreeMap, + ports: BTreeMap, + links: BTreeMap, + clients: BTreeMap, + node_ids: BTreeMap, + client_ids: BTreeMap, +} + +impl GraphSnapshot { + pub fn new( + nodes: Vec, + ports: Vec, + links: Vec, + clients: Vec, + ) -> Self { + let node_ids = index_ids(nodes.iter().map(|n| (n.id, n.serial))); + let client_ids = index_ids(clients.iter().map(|c| (c.id, c.serial))); + Self { + nodes: nodes.into_iter().map(|n| (n.serial, n)).collect(), + ports: ports.into_iter().map(|p| (p.serial, p)).collect(), + links: links.into_iter().map(|l| (l.serial, l)).collect(), + clients: clients.into_iter().map(|c| (c.serial, c)).collect(), + node_ids, + client_ids, + } + } + + pub fn nodes(&self) -> impl Iterator { + self.nodes.values() + } + + pub fn node(&self, serial: Serial) -> Option<&NodeSnapshot> { + self.nodes.get(&serial) + } + + pub fn links(&self) -> impl Iterator { + self.links.values() + } + + pub fn ports(&self) -> impl Iterator { + self.ports.values() + } + + pub fn clients(&self) -> impl Iterator { + self.clients.values() + } + + /// Resolve a snapshot-local node id. `None` means "no such node in this + /// snapshot", which for a link endpoint means unresolved ancestry. + pub fn node_by_id(&self, id: GlobalId) -> Option { + self.node_ids.get(&id).copied() + } + + pub fn client_by_id(&self, id: GlobalId) -> Option { + self.client_ids.get(&id).copied() + } + + /// Ports belonging to a node, by the node's snapshot-local id. + pub fn ports_of(&self, node: GlobalId) -> impl Iterator { + self.ports.values().filter(move |p| p.node == node) + } +} + +fn index_ids(entries: impl Iterator) -> BTreeMap { + let mut out: BTreeMap = BTreeMap::new(); + for (id, serial) in entries { + out.entry(id) + .and_modify(|slot| { + if *slot != IdLookup::Unique(serial) { + *slot = IdLookup::Ambiguous; + } + }) + .or_insert(IdLookup::Unique(serial)); + } + out +} diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs new file mode 100644 index 0000000..1e71d61 --- /dev/null +++ b/src/host/taint/tests.rs @@ -0,0 +1,926 @@ +//! The taint-engine fixture matrix — design v3.4 §12 plus the impl plan's +//! degenerate-snapshot addition. +//! +//! **Every test asserts an exact partition of the whole candidate universe**, +//! never a spot check on one named node. Checking only the node a test cares +//! about constrains nothing about the others, and an exclude-everything +//! implementation would pass. So each test names *every* `Stream/Output/Audio` +//! node in its graph as either eligible or excluded-with-a-reason-code. +//! +//! Fixture realism that is load-bearing: an ordinary app holds **one client +//! connection**, each pactl module holds its own, and every device node +//! shares the session manager's. Getting that wrong makes the engine look +//! broken (or, worse, makes a leak look fine). + +use std::collections::BTreeSet; + +use super::fixture::{Graph, NodeRef, PULSE_PID, app}; +use super::owner::{OwnerKey, strongest_shared_key}; +use super::snapshot::{MediaRole, NodeProps, PortDirection, Serial}; +use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate}; + +fn ctx() -> ExclusionCtx { + ExclusionCtx { + aec_module_id: None, + pipewire_pulse_pid: Some(PULSE_PID), + pixelpass_owned: BTreeSet::new(), + graph_ready: true, + } +} + +fn run(graph: &Graph, ctx: &ExclusionCtx) -> Decisions { + evaluate(&graph.build(), ctx, &StickyState::default()).0 +} + +/// Assert the complete candidate partition. `excluded` names reason codes. +#[track_caller] +fn assert_partition( + decisions: &Decisions, + eligible: &[(&str, NodeRef)], + excluded: &[(&str, NodeRef, &str)], +) { + let mut expected_eligible: Vec = 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!(""), |(name, _)| name.into()) + }; + let actual: Vec = decisions + .candidates + .values() + .map(|d| match d.reason() { + None => format!("{} = eligible", label(d.serial)), + Some(reason) => format!("{} = {}", label(d.serial), reason.code()), + }) + .collect(); + + assert_eq!( + (decisions.eligible(), decisions.excluded()), + (expected_eligible, expected_excluded), + "actual decisions: {actual:#?}" + ); +} + +#[track_caller] +fn assert_tainted(decisions: &Decisions, node: NodeRef, reason: &str) { + let entry = decisions + .taint + .get(&node.serial) + .unwrap_or_else(|| panic!("expected {:?} to be tainted", node.serial)); + assert_eq!(entry.reason.code(), reason); +} + +#[track_caller] +fn assert_untainted(decisions: &Decisions, node: NodeRef) { + assert_eq!( + decisions.taint.get(&node.serial).map(|e| e.reason.code()), + None, + "expected {:?} to be untainted", + node.serial + ); +} + +// ────────────────────────────────────────────────────────────────────── +// Boundary / degenerate cases — the fail-closed default +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn empty_snapshot_yields_nothing_eligible() { + let decisions = run(&Graph::new(), &ctx()); + assert!(decisions.candidates.is_empty()); + assert!(decisions.eligible().is_empty()); +} + +#[test] +fn a_graph_with_only_untainted_streams_still_partitions() { + // The other half of the degenerate case: "nothing eligible" must not be + // reached by excluding everything unconditionally. + let mut graph = Graph::new(); + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + assert_partition(&run(&graph, &ctx()), &[("firefox", firefox)], &[]); +} + +#[test] +fn graph_not_ready_excludes_every_candidate() { + let mut graph = Graph::new(); + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + let decisions = run( + &graph, + &ExclusionCtx { + graph_ready: false, + ..ctx() + }, + ); + assert_partition(&decisions, &[], &[("firefox", firefox, "graph-not-ready")]); +} + +#[test] +fn decisions_do_not_depend_on_insertion_order() { + // The reported reason must come from an explicit priority, not from + // traversal order, or the audit output is unstable between recomputes. + let build = |reverse: bool| { + let mut graph = Graph::new(); + let sink = graph.device_node("sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + if reverse { + graph.link(firefox, sink); + graph.link(call, sink); + } else { + graph.link(call, sink); + graph.link(firefox, sink); + } + run(&graph, &ctx()).excluded() + }; + assert_eq!(build(false), build(true)); +} + +// ────────────────────────────────────────────────────────────────────── +// Node-local roots (v3.4 §5.1, §5.2, §6.2, §6.6) +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn peerspeak_tagged_nodes_are_excluded_and_plain_apps_are_not() { + let mut graph = Graph::new(); + let sink = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak-call", 7); + let mpv = graph.peerspeak_node("peerspeak-mpv", 8); + let notify = graph.peerspeak_node("peerspeak-notify", 9); + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + for node in [call, mpv, notify, firefox] { + graph.link(node, sink); + } + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[("firefox", firefox)], + &[ + ("call", call, "peerspeak-owned"), + ("mpv", mpv, "peerspeak-owned"), + ("notify", notify, "peerspeak-owned"), + ], + ); + // The sink carried peerspeak audio, so it is tainted — that is what + // makes every monitor reader downstream of it tainted too. + assert_tainted(&decisions, sink, "tainted-upstream"); +} + +#[test] +fn aec_identity_is_exact_equality_and_other_modules_stay_eligible() { + let mut graph = Graph::new(); + let ours = graph.module_node("echo-cancel-playback", MediaRole::StreamOutput, 536_870_919); + // A tunnel/RTP module: it has a `pulse.module.id`, just not ours. + // "Has any pulse.module.id" is explicitly rejected as an exclusion rule + // — such a module may be the only carrier of audio the user wants + // shared (v3.4 §5.2 correction 2). + let tunnel = graph.module_node("tunnel-out", MediaRole::StreamOutput, 536_870_921); + let plain = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + + let decisions = run( + &graph, + &ExclusionCtx { + aec_module_id: Some(536_870_919), + ..ctx() + }, + ); + assert_partition( + &decisions, + &[("tunnel", tunnel), ("plain", plain)], + &[("ours", ours, "aec-identity")], + ); +} + +#[test] +fn aec_module_id_is_compared_beyond_u32() { + // Guards the phase 0a widening at the engine boundary too: these two + // differ only above bit 32. + let big = u64::from(u32::MAX) + 7; + let mut graph = Graph::new(); + let ours = graph.module_node("aec", MediaRole::StreamOutput, big); + let other = graph.module_node("other", MediaRole::StreamOutput, big ^ 0x1_0000_0000); + let decisions = run( + &graph, + &ExclusionCtx { + aec_module_id: Some(big), + ..ctx() + }, + ); + assert_partition( + &decisions, + &[("other", other)], + &[("ours", ours, "aec-identity")], + ); +} + +#[test] +fn foreign_echo_cancel_group_is_excluded_not_shared() { + // Decision D3: warn and exclude. The group prefix is hazard detection, + // never ownership — it cannot tell peerspeak's AEC from anyone else's. + let mut graph = Graph::new(); + let foreign = graph.group_node( + "echo-cancel-playback", + MediaRole::StreamOutput, + "echo-cancel-9999-13", + 4321, + ); + let plain = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + assert_partition( + &run(&graph, &ctx()), + &[("plain", plain)], + &[("foreign", foreign, "foreign-echo-cancel")], + ); +} + +#[test] +fn pixelpass_capture_sink_and_its_downstream_are_excluded() { + // v3.4 §6.2 cycle prevention, and impl-plan §5.1 row 7: observing the + // capture sink is not enough — a *downstream* candidate must be named, + // or recognising `pixelpass_capture_*` as a mere sink name would pass + // without any transitive propagation. + let mut graph = Graph::new(); + let other_host_sink = graph.module_node("pixelpass_capture_31337", MediaRole::Sink, 42); + let fwd_in = graph.module_node("fwd-in", MediaRole::StreamInput, 77); + let fwd_out = graph.module_node("fwd-out", MediaRole::StreamOutput, 77); + graph.link(other_host_sink, fwd_in); + let plain = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[("plain", plain)], + &[("fwd-out", fwd_out, "tainted-owner-bridge")], + ); + assert_tainted(&decisions, other_host_sink, "pixelpass-owned"); + assert_tainted(&decisions, fwd_in, "tainted-upstream"); +} + +#[test] +fn pixelpass_owned_serials_are_roots_even_without_a_capture_sink_name() { + let mut graph = Graph::new(); + let ours = graph.device_node("some-sink", MediaRole::Sink); + let reader = graph.app_node("reader-in", MediaRole::StreamInput, 5150); + let leg = graph.app_node("reader-out", MediaRole::StreamOutput, 5150); + graph.link(ours, reader); + let decisions = run( + &graph, + &ExclusionCtx { + pixelpass_owned: BTreeSet::from([ours.serial]), + ..ctx() + }, + ); + assert_partition(&decisions, &[], &[("leg", leg, "tainted-owner-bridge")]); + assert_tainted(&decisions, ours, "pixelpass-owned"); +} + +#[test] +fn port_exclusive_and_passthrough_are_local_exclusions() { + let mut graph = Graph::new(); + let exclusive = graph.app_node("exclusive", MediaRole::StreamOutput, 100); + graph.port(exclusive, PortDirection::Out, true); + let client = graph.client_of_app(101); + let passthrough = graph.node( + "passthrough", + MediaRole::StreamOutput, + NodeProps { + passthrough: true, + ..app(client, 101) + }, + ); + let ok = graph.app_node("ok", MediaRole::StreamOutput, 102); + graph.port(ok, PortDirection::Out, false); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[("ok", ok)], + &[ + ("exclusive", exclusive, "port-exclusive"), + ("passthrough", passthrough, "passthrough"), + ], + ); + // Neither is hazardous — an unlinkable stream must not taint anything. + assert_untainted(&decisions, exclusive); + assert_untainted(&decisions, passthrough); +} + +#[test] +fn a_node_with_no_props_at_all_is_eligible_and_non_streams_are_not_candidates() { + let mut graph = Graph::new(); + let bare = graph.node("bare", MediaRole::StreamOutput, NodeProps::default()); + graph.node("video", MediaRole::Other, NodeProps::default()); + graph.node("mic", MediaRole::Source, NodeProps::default()); + assert_partition(&run(&graph, &ctx()), &[("bare", bare)], &[]); +} + +// ────────────────────────────────────────────────────────────────────── +// Edge type 2 — sink → monitor, free at node granularity +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn taint_crosses_app_then_sink_then_monitor_reader() { + let mut graph = Graph::new(); + let sink = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, sink); + // A recorder reading the sink's monitor. The monitor connection IS a + // real Link whose output node is the sink itself, so no synthetic edge + // is needed at node granularity. + let rec_in = graph.app_node("recorder-in", MediaRole::StreamInput, 555); + graph.link(sink, rec_in); + let rec_out = graph.app_node("recorder-out", MediaRole::StreamOutput, 555); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[], + &[ + ("call", call, "peerspeak-owned"), + ("rec-out", rec_out, "tainted-owner-bridge"), + ], + ); + assert_tainted(&decisions, rec_in, "tainted-upstream"); +} + +#[test] +fn playing_into_a_tainted_sink_does_not_taint_the_player() { + // Taint flows downstream only. Every app on the box plays into the same + // sink peerspeak does; if that tainted them, nothing would ever be + // shareable and the feature would ship as silence. + let mut graph = Graph::new(); + let sink = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + graph.link(call, sink); + graph.link(firefox, sink); + + assert_partition( + &run(&graph, &ctx()), + &[("firefox", firefox)], + &[("call", call, "peerspeak-owned")], + ); +} + +// ────────────────────────────────────────────────────────────────────── +// Edge type 3 — the owner bridge (v3.4 §6.1.1, §6.1.2) +// ────────────────────────────────────────────────────────────────────── + +/// The measured `module-null-sink` + `module-loopback` shape. Walking Links +/// alone from the leaking output leg finds *no inbound links at all* — a +/// dead end that reads as "clean". +#[test] +fn loopback_forwarder_output_leg_is_excluded_via_the_owner_bridge() { + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let null_sink = graph.module_node("fabletest_sink", MediaRole::Sink, 536_870_917); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, null_sink); + + // The tainted forwarder: legs share `pulse.module.id`, no Link between. + let lb_in = graph.module_node("input.loopback", MediaRole::StreamInput, 536_870_918); + let lb_out = graph.module_node("output.loopback", MediaRole::StreamOutput, 536_870_918); + graph.link(null_sink, lb_in); + graph.link(lb_out, hw); + + // The same shape with a clean input — impl-plan §5.1 row 1's eligible + // half. Without it the test would also pass on "exclude all forwarders". + let clean_sink = graph.device_node("clean-sink", MediaRole::Sink); + let clean_in = graph.module_node("clean-in", MediaRole::StreamInput, 536_870_920); + let clean_out = graph.module_node("clean-out", MediaRole::StreamOutput, 536_870_920); + graph.link(clean_sink, clean_in); + graph.link(clean_out, hw); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[("clean-out", clean_out)], + &[ + ("call", call, "peerspeak-owned"), + ("lb-out", lb_out, "tainted-owner-bridge"), + ], + ); + // ...and it was the bridge, naming the key — not a Link walk. + assert_eq!( + decisions.taint[&lb_out.serial].reason, + Reason::TaintedOwnerBridge { + key: Some(OwnerKey::PulseModuleId) + } + ); +} + +#[test] +fn easyeffects_shape_bridges_from_an_audio_sink_to_its_re_emitting_leg() { + // The canonical installed case. The tainted member here is an + // `Audio/Sink`, not a `Stream/Input/Audio` — if the bridge only fired + // from stream inputs, EasyEffects would forward the whole call into the + // share at full level. + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let ee_sink = graph.group_node("ee_sink", MediaRole::Sink, "ee", 4321); + let ee_out = graph.group_node("easyeffects-out", MediaRole::StreamOutput, "ee", 4321); + graph.link(ee_out, hw); + let call = graph.peerspeak_node("peerspeak", 7); + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + graph.link(call, ee_sink); + graph.link(firefox, ee_sink); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[("firefox", firefox)], + &[ + ("call", call, "peerspeak-owned"), + ("ee-out", ee_out, "tainted-owner-bridge"), + ], + ); + assert_eq!( + decisions.taint[&ee_out.serial].reason, + Reason::TaintedOwnerBridge { + key: Some(OwnerKey::LinkGroup) + } + ); +} + +#[test] +fn gstreamer_split_clients_bridge_on_process_id() { + // The C2 refutation, measured: one `gst-launch pulsesrc ! pulsesink` + // process produced TWO client objects (209 and 210) for its two legs. + let mut graph = Graph::new(); + let tainted_sink = graph.device_node("null-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, tainted_sink); + + let client_in = graph.client(Some(PULSE_PID)); + let client_out = graph.client(Some(PULSE_PID)); + let gst_in = graph.node("gst-in", MediaRole::StreamInput, app(client_in, 20172)); + let gst_out = graph.node("gst-out", MediaRole::StreamOutput, app(client_out, 20172)); + graph.link(tainted_sink, gst_in); + + // Control: the same split-client shape reading an *untainted* source. + let mic = graph.device_node("mic", MediaRole::Source); + let ok_client_in = graph.client(Some(PULSE_PID)); + let ok_client_out = graph.client(Some(PULSE_PID)); + let ok_in = graph.node("ok-in", MediaRole::StreamInput, app(ok_client_in, 30000)); + let ok_out = graph.node("ok-out", MediaRole::StreamOutput, app(ok_client_out, 30000)); + graph.link(mic, ok_in); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[("ok-out", ok_out)], + &[ + ("call", call, "peerspeak-owned"), + ("gst-out", gst_out, "tainted-owner-bridge"), + ], + ); + assert_eq!( + decisions.taint[&gst_out.serial].reason, + Reason::TaintedOwnerBridge { + key: Some(OwnerKey::ProcessId) + } + ); +} + +#[test] +fn owner_key_union_falls_through_a_present_but_unequal_key() { + // The §6.1.2 wording trap, asserted on the key resolver itself: a + // first-present implementation stops at `client.id`, sees 209 != 210, + // concludes "different owners", and the leak survives. + let mut graph = Graph::new(); + let a_client = graph.client(Some(PULSE_PID)); + let b_client = graph.client(Some(PULSE_PID)); + let a = graph.node("a", MediaRole::StreamInput, app(a_client, 20172)); + let b = graph.node("b", MediaRole::StreamOutput, app(b_client, 20172)); + let snapshot = graph.build(); + let (a, b) = ( + snapshot.node(a.serial).unwrap(), + snapshot.node(b.serial).unwrap(), + ); + assert_ne!(a.props.client_id, b.props.client_id); + assert_eq!( + strongest_shared_key(a, b, Some(PULSE_PID)), + Some(OwnerKey::ProcessId) + ); +} + +#[test] +fn the_strongest_shared_key_wins_when_several_match() { + let mut graph = Graph::new(); + let a = graph.group_node("a", MediaRole::StreamInput, "g", 500); + let b = graph.group_node("b", MediaRole::StreamOutput, "g", 500); + let snapshot = graph.build(); + assert_eq!( + strongest_shared_key( + snapshot.node(a.serial).unwrap(), + snapshot.node(b.serial).unwrap(), + Some(PULSE_PID) + ), + Some(OwnerKey::LinkGroup) + ); +} + +#[test] +fn the_pipewire_pulse_pid_does_not_fuse_unrelated_modules() { + // impl-plan §5.1 row 3: two Pulse modules, one tainted input. The other + // module's output MUST stay eligible — that is the only thing that + // makes wrong pipewire-pulse-PID fusion observable at all. + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, hw); + + let a_in = graph.module_node("a-in", MediaRole::StreamInput, 1); + let a_out = graph.module_node("a-out", MediaRole::StreamOutput, 1); + graph.link(hw, a_in); + + let mic = graph.device_node("mic", MediaRole::Source); + let b_in = graph.module_node("b-in", MediaRole::StreamInput, 2); + let b_out = graph.module_node("b-out", MediaRole::StreamOutput, 2); + graph.link(mic, b_in); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[("b-out", b_out)], + &[ + ("call", call, "peerspeak-owned"), + ("a-out", a_out, "tainted-owner-bridge"), + ], + ); + assert_untainted(&decisions, b_in); +} + +#[test] +fn an_unknown_pipewire_pulse_pid_over_excludes_rather_than_leaks() { + // v3.4 §6.1.2's failure-mode paragraph: if pixelpass cannot identify + // the daemon PID, key 4 stops being suppressed, every module-created + // stream fuses into one owner, and the result is broad over-exclusion — + // annoying and safe. This test pins that direction; flipping it to + // "leave key 4 out when the PID is unknown" would be a leak. + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, hw); + let a_in = graph.module_node("a-in", MediaRole::StreamInput, 1); + let a_out = graph.module_node("a-out", MediaRole::StreamOutput, 1); + graph.link(hw, a_in); + let mic = graph.device_node("mic", MediaRole::Source); + let b_in = graph.module_node("b-in", MediaRole::StreamInput, 2); + let b_out = graph.module_node("b-out", MediaRole::StreamOutput, 2); + graph.link(mic, b_in); + + let decisions = run( + &graph, + &ExclusionCtx { + pipewire_pulse_pid: None, + ..ctx() + }, + ); + assert_partition( + &decisions, + &[], + &[ + ("call", call, "peerspeak-owned"), + ("a-out", a_out, "tainted-owner-bridge"), + ("b-out", b_out, "tainted-owner-bridge"), + ], + ); +} + +#[test] +fn coarse_keys_do_not_bridge_device_nodes() { + // ⚠️ A rule added during implementation, NOT present in design v3.4. + // Every ALSA device node is created by one WirePlumber process, so all + // of them share one `client.id` and one `application.process.id`. + // peerspeak's playback taints the default sink on every recompute; if + // coarse keys bridged devices, that taint would jump to the microphone + // source, and then every app holding a mic would lose its playback — + // the exact §6.1.1 catastrophe by another route. + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let mic = graph.device_node("mic", MediaRole::Source); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, hw); + let ff_in = graph.app_node("firefox-mic", MediaRole::StreamInput, 11114); + let ff_out = graph.app_node("firefox-out", MediaRole::StreamOutput, 11114); + graph.link(mic, ff_in); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[("ff-out", ff_out)], + &[("call", call, "peerspeak-owned")], + ); + assert_tainted(&decisions, hw, "tainted-upstream"); + assert_untainted(&decisions, mic); + assert_untainted(&decisions, ff_in); +} + +#[test] +fn a_module_owned_device_still_bridges_on_its_strong_key() { + // The other side of that rule: exception 2 must not disarm the bridge + // for virtual sinks that genuinely belong to a module. + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + let virt = graph.module_node("virtual-sink", MediaRole::Sink, 900_001); + let leg = graph.module_node("virtual-out", MediaRole::StreamOutput, 900_001); + graph.link(call, virt); + graph.link(leg, hw); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[], + &[ + ("call", call, "peerspeak-owned"), + ("leg", leg, "tainted-owner-bridge"), + ], + ); +} + +#[test] +fn firefox_three_cases() { + // v3.4 §6.1.1's table, as one graph with an exact partition. + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let mic = graph.device_node("mic", MediaRole::Source); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, hw); + + // 1. music only + let music = graph.app_node("ff-music", MediaRole::StreamOutput, 1001); + graph.link(music, hw); + // 2. a Meet call: a mic input leg on an untainted source + let meet_in = graph.app_node("ff-meet-in", MediaRole::StreamInput, 1002); + let meet_out = graph.app_node("ff-meet-out", MediaRole::StreamOutput, 1002); + graph.link(mic, meet_in); + graph.link(meet_out, hw); + // 3. screen-sharing with desktop audio: an input leg on a tainted monitor + let share_in = graph.app_node("ff-share-in", MediaRole::StreamInput, 1003); + let share_out = graph.app_node("ff-share-out", MediaRole::StreamOutput, 1003); + graph.link(hw, share_in); + graph.link(share_out, hw); + + assert_partition( + &run(&graph, &ctx()), + &[("music", music), ("meet-out", meet_out)], + &[ + ("call", call, "peerspeak-owned"), + ("share-out", share_out, "tainted-owner-bridge"), + ], + ); +} + +#[test] +fn an_unbounded_module_forwarder_fails_closed() { + // v3.4 §12: "a module forwarder with neither link-group nor + // pulse.module.id ⇒ unresolved ⇒ excluded". Its process id is the + // daemon's (so key 4 is suppressed) and its legs carry different + // `client.id`s, so nothing can enumerate its siblings. + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, hw); + + let in_client = graph.client(Some(PULSE_PID)); + let out_client = graph.client(Some(PULSE_PID)); + let fwd_in = graph.node("fwd-in", MediaRole::StreamInput, app(in_client, PULSE_PID)); + let fwd_out = graph.node( + "fwd-out", + MediaRole::StreamOutput, + app(out_client, PULSE_PID), + ); + graph.link(hw, fwd_in); + + // The blast radius must stay small: an ordinary app is bounded by its + // own PID and must not be swept up. + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + graph.link(firefox, hw); + + assert_partition( + &run(&graph, &ctx()), + &[("firefox", firefox)], + &[ + ("call", call, "peerspeak-owned"), + ("fwd-out", fwd_out, "unresolved-owner"), + ], + ); +} + +#[test] +fn audio_duplex_over_taints_known_accepted() { + // v3.4 §6.1 edge-type-2 caveat, asserted so that fixing it later is a + // deliberate change and not a silent regression. A duplex device whose + // playback side is tainted has its capture side treated as tainted too, + // so an app recording from it loses its playback leg. + let mut graph = Graph::new(); + let duplex = graph.device_node("duplex-card", MediaRole::Duplex); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, duplex); + let rec_in = graph.app_node("rec-in", MediaRole::StreamInput, 4444); + let rec_out = graph.app_node("rec-out", MediaRole::StreamOutput, 4444); + graph.link(duplex, rec_in); + + assert_partition( + &run(&graph, &ctx()), + &[], + &[ + ("call", call, "peerspeak-owned"), + ("rec-out", rec_out, "tainted-owner-bridge"), + ], + ); +} + +// ────────────────────────────────────────────────────────────────────── +// Unresolved ancestry — fail closed (v3.4 §6.1.4) +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn a_link_from_an_unknown_node_fails_closed() { + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let mystery_in = graph.app_node("mystery-in", MediaRole::StreamInput, 6000); + let mystery_out = graph.app_node("mystery-out", MediaRole::StreamOutput, 6000); + let ghost = graph.dangling_id(); + graph.link_ids(ghost, mystery_in.id); + graph.link(mystery_out, hw); + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[("firefox", firefox)], + &[("mystery-out", mystery_out, "tainted-owner-bridge")], + ); + assert_tainted(&decisions, mystery_in, "unresolved-ancestry"); +} + +#[test] +fn an_ambiguous_recycled_global_id_fails_closed() { + // Two live nodes claiming one id means the observer missed a removal, + // so every edge touching that id is untrustworthy. + let mut graph = Graph::new(); + let shared = graph.dangling_id(); + let client = graph.client_of_app(7001); + let first = graph.node_with_id("first", MediaRole::StreamOutput, shared, app(client, 7001)); + let second = graph.node_with_id("second", MediaRole::StreamOutput, shared, app(client, 7002)); + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + + assert_partition( + &run(&graph, &ctx()), + &[("firefox", firefox)], + &[ + ("first", first, "unresolved-ancestry"), + ("second", second, "unresolved-ancestry"), + ], + ); +} + +// ────────────────────────────────────────────────────────────────────── +// Stickiness and lifetime-awareness (v3.4 §6.1.3) +// ────────────────────────────────────────────────────────────────────── + +/// The scene the sticky tests share: one owner with a tainted input leg and +/// an output leg, plus an untouched bystander. +fn sticky_scene() -> (Graph, NodeRef, NodeRef, NodeRef, NodeRef) { + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, hw); + let rec_in = graph.app_node("rec-in", MediaRole::StreamInput, 8080); + let rec_out = graph.app_node("rec-out", MediaRole::StreamOutput, 8080); + graph.link(hw, rec_in); + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + (graph, call, rec_in, rec_out, firefox) +} + +#[test] +fn sticky_taint_survives_the_tainted_input_leg_disappearing() { + // The C3 buffered-audio defect: the recorder stops capturing, but its + // 5-second ring buffer is still full of peerspeak's audio and no graph + // event marks the moment it drains. + let (graph, call, rec_in, rec_out, firefox) = sticky_scene(); + let c = ctx(); + let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); + assert_partition( + &first, + &[("firefox", firefox)], + &[ + ("call", call, "peerspeak-owned"), + ("rec-out", rec_out, "tainted-owner-bridge"), + ], + ); + + let (second, _) = evaluate(&graph.build_without(&[rec_in]), &c, &sticky); + assert_partition( + &second, + &[("firefox", firefox)], + &[ + ("call", call, "peerspeak-owned"), + ("rec-out", rec_out, "tainted-owner-bridge"), + ], + ); + assert!( + matches!( + second.candidates[&rec_out.serial].eligibility, + Eligibility::NotEligible { sticky: true, .. } + ), + "the second exclusion must be recorded as sticky, not re-derived" + ); +} + +#[test] +fn a_new_leg_of_a_still_tainted_owner_inherits_the_taint() { + // Stickiness is per *owner*, not per node: the recorder opening a + // second output stream after its input leg closed must not escape. + let (mut graph, _, rec_in, _, _) = sticky_scene(); + let c = ctx(); + let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); + let late = graph.app_node("rec-out-2", MediaRole::StreamOutput, 8080); + let (second, _) = evaluate(&graph.build_without(&[rec_in]), &c, &sticky); + assert_eq!( + second.candidates[&late.serial].reason().map(Reason::code), + Some("tainted-owner-bridge") + ); +} + +#[test] +fn sticky_taint_clears_once_every_owner_member_is_gone() { + // Otherwise an app is unshareable forever, which is the over-exclusion + // half of the trade and just as much a bug. + let (mut graph, call, rec_in, rec_out, firefox) = sticky_scene(); + let c = ctx(); + let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); + + // Full teardown: both legs and the client object. + let recorder_client = graph.client_of_app(8080); + graph.drop_clients(&[recorder_client]); + let torn_down = graph.build_without(&[rec_in, rec_out]); + let (_, sticky) = evaluate(&torn_down, &c, &sticky); + assert!( + sticky + .owners + .iter() + .all(|owner| !owner.members.contains(&ObjectRef::Node(rec_out.serial))), + "the recorder's sticky entry should have been dropped: {sticky:#?}" + ); + + // A brand-new recorder with the same PID starts clean. + let fresh = graph.app_node("rec-out-fresh", MediaRole::StreamOutput, 8080); + let (third, _) = evaluate(&graph.build_without(&[rec_in, rec_out]), &c, &sticky); + assert_partition( + &third, + &[("firefox", firefox), ("fresh", fresh)], + &[("call", call, "peerspeak-owned")], + ); +} + +#[test] +fn recycled_ids_module_indices_and_link_groups_do_not_inherit_taint() { + // Every one of these recycles on this stack — measured for module + // indices, node ids and link-group strings. Only `object.serial` does + // not, which is exactly why stickiness is keyed on it. + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, hw); + let old_in = graph.group_node("old-in", MediaRole::StreamInput, "loopback-2541-13", 6100); + let old_out = graph.group_node("old-out", MediaRole::StreamOutput, "loopback-2541-13", 6100); + graph.link(hw, old_in); + + let c = ctx(); + let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); + assert_eq!( + first.candidates[&old_out.serial].reason().map(Reason::code), + Some("tainted-owner-bridge") + ); + + // Teardown, then a brand-new module reusing the global id, the PID and + // the link-group string verbatim — everything except the serial. + let old_client = graph.client_of_app(6100); + graph.drop_clients(&[old_client]); + let reborn_client = graph.client(Some(PULSE_PID)); + let reborn = graph.node_with_id( + "reborn-out", + MediaRole::StreamOutput, + old_out.id, + super::fixture::link_group("loopback-2541-13", reborn_client, 6100), + ); + let (second, _) = evaluate(&graph.build_without(&[old_in, old_out]), &c, &sticky); + assert_partition( + &second, + &[("reborn", reborn)], + &[("call", call, "peerspeak-owned")], + ); +} From a46c4cd20ced60cbc0b7ab81691e04d70a00fec6 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 21 Jul 2026 16:47:38 -0400 Subject: [PATCH 2/7] host/taint: close five leaks found in Codex round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five were reachable, all five now have a regression test, and each test was verified by injecting the mutation that reverts its fix. 1. Sticky taint ignored surviving Client members. An app can close every stream while keeping its PipeWire connection open and then open a new one — Firefox does this constantly — and the new leg came back Eligible while the owner's buffers still held the call. Sticky seeding now resolves live Client serials to their current nodes. 2. "Receives audio" was inferred from `media.class` alone, so a node with an absent or unexpected class sitting on a real inbound link could not start an owner bridge and its sibling re-emitted the call. A node is now a receiver if it appears as a resolved `link.input.node` OR has a receiving role. 3. The device-node coarse-key exception was keyed on `media.class` being `Audio/Sink|Source|Duplex`, which also stripped the only correlation a *native virtual sink* has (own client, no link-group, no module id). Now keyed on `device.id`, measured on the live graph as the exact discriminator: the 5 ALSA nodes carry device.id 43/45/46 and share `client.id` 42 (`WirePlumber [export]`); the 3 `support.null-audio-sink` nodes carry no device.id and hold their own clients. 4. The unbounded-owner backstop required the tainted *reader* to be unbounded. Properties can be asymmetric — a reader with a link-group whose re-emitting leg has none is bounded while its sibling is not findable — so that condition is dropped; targets stay restricted to unbounded output legs, which keeps the blast radius small. 5. A not-ready snapshot could retire sticky owners, erasing taint history on the strength of a graph already declared untrustworthy. `evaluate` now returns the prior state unchanged while `!graph_ready`. Test-quality findings, also fixed: - a single pass of each rule survived all 32 tests (every fixture needed at most one owner hop) → two-chained-forwarder test with a clean control, plus a 60-layer chain to catch an accidental blow-up - first-write-wins `raise()` survived → a node reached by bridge on one pass and by a direct link on the next must report the stronger reason - `drop_clients` left the fixture's client caches stale, so "a fresh client after teardown" was really a dangling id; the recycling row now reuses node id, client id AND `pulse.module.id` verbatim Also corrected the cost claim: this is O((V+E)·D) for owner-bridge depth D, not O(V+E) as v3.4 §6.4 states. Owner keys are now computed once per snapshot instead of per candidate pair. 42 tests. Co-Authored-By: Claude Opus 4.8 --- src/host/taint/fixture.rs | 68 +++++-- src/host/taint/mod.rs | 177 +++++++++++++------ src/host/taint/owner.rs | 87 +++++++-- src/host/taint/snapshot.rs | 10 ++ src/host/taint/tests.rs | 351 +++++++++++++++++++++++++++++++++++++ 5 files changed, 616 insertions(+), 77 deletions(-) diff --git a/src/host/taint/fixture.rs b/src/host/taint/fixture.rs index 5a5273d..b927975 100644 --- a/src/host/taint/fixture.rs +++ b/src/host/taint/fixture.rs @@ -90,18 +90,25 @@ impl Graph { self.node(name, role, app(client, pid)) } - /// A leg of a pactl-loaded module: one client per module, and the - /// node's `application.process.id` is **pipewire-pulse's own**, because - /// pipewire-pulse genuinely is the client. - pub fn module_node(&mut self, name: &str, role: MediaRole, module: u64) -> NodeRef { - let client = match self.client_by_module.get(&module) { + /// The client a pactl module holds. Measured: each module gets its own + /// (`sink-sunshine-*` were clients 83/86/92), which is why one tainted + /// module does not fuse with the next. + pub fn client_of_module(&mut self, module: u64) -> GlobalId { + match self.client_by_module.get(&module) { Some(id) => *id, None => { let id = self.client(Some(PULSE_PID)); self.client_by_module.insert(module, id); id } - }; + } + } + + /// A leg of a pactl-loaded module: one client per module, and the + /// node's `application.process.id` is **pipewire-pulse's own**, because + /// pipewire-pulse genuinely is the client. + pub fn module_node(&mut self, name: &str, role: MediaRole, module: u64) -> NodeRef { + let client = self.client_of_module(module); self.node(name, role, pulse_module(client, module, PULSE_PID)) } @@ -112,8 +119,9 @@ impl Graph { self.node(name, role, link_group(group, client, pid)) } - /// A device node as the session manager creates it: no strong key, and - /// WirePlumber's client and PID — shared with every other device. + /// A device node as the session manager creates it: no strong key, + /// WirePlumber's client and PID — shared with every other device — and + /// a `device.id`, which is what marks it as session-manager-exported. pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef { let session = match self.session_client { Some(id) => id, @@ -123,7 +131,17 @@ impl Graph { id } }; - self.node(name, role, device(session, SESSION_PID)) + let device_id = self.id(); + self.node(name, role, device(session, SESSION_PID, device_id)) + } + + /// A **virtual** sink an application created natively: an `Audio/Sink` + /// with no `device.id` and no strong key, sharing one client with the + /// stream that re-emits what it receives. Coarse keys must still bridge + /// these two, or the whole call leaks through the re-emitting leg. + pub fn native_virtual_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef { + let client = self.client_of_app(pid); + self.node(name, role, app(client, pid)) } pub fn peerspeak_node(&mut self, name: &str, pid: u32) -> NodeRef { @@ -228,8 +246,31 @@ impl Graph { } /// Drop clients too — full owner teardown. + /// + /// Invalidates the per-app/per-module caches as well: leaving them + /// stale made a later `client_of_app` hand back the *removed* client's + /// id, so a test that meant "a brand-new client after teardown" was + /// really building a node pointing at a client object that no longer + /// existed (Codex round 1, finding 8). pub fn drop_clients(&mut self, ids: &[GlobalId]) { self.clients.retain(|c| !ids.contains(&c.id)); + self.client_by_app.retain(|_, id| !ids.contains(id)); + self.client_by_module.retain(|_, id| !ids.contains(id)); + if self.session_client.is_some_and(|id| ids.contains(&id)) { + self.session_client = None; + } + } + + /// A client that reuses a global id a dead client had — the recycling + /// case, with a fresh serial. + pub fn client_with_id(&mut self, id: GlobalId, sec_pid: Option) -> GlobalId { + let serial = self.serial(); + self.clients.push(ClientSnapshot { + serial, + id, + sec_pid, + }); + id } } @@ -266,10 +307,17 @@ pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps { /// A device node as the session manager creates it: no strong key, and the /// session manager's own client and PID — shared with every other device. -pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps { +/// +/// Measured 2026-07-21: real ALSA device nodes carry the shared +/// `client.id` but **no** `application.process.id` at all. Giving them one +/// here is deliberately *more* pessimistic than reality — it hands the +/// engine a second coarse key it could fuse devices on, so a test that +/// passes here also passes against the real props. +pub fn device(session_client: GlobalId, session_pid: u32, device_id: GlobalId) -> NodeProps { NodeProps { client_id: Some(session_client), process_id: Some(session_pid), + device_id: Some(device_id), ..NodeProps::default() } } diff --git a/src/host/taint/mod.rs b/src/host/taint/mod.rs index 3719bdd..0ede728 100644 --- a/src/host/taint/mod.rs +++ b/src/host/taint/mod.rs @@ -305,14 +305,25 @@ impl Decisions { /// Recompute eligibility for the whole graph. /// -/// Full recompute per graph event is the v1 design (O(V+E) over a ~40-node -/// desktop graph); there is deliberately no incremental dirty-set. +/// Full recompute per graph event is the v1 design; there is deliberately +/// no incremental dirty-set. +/// +/// ⚠️ **Cost is not O(V+E), despite what v3.4 §6.4 says.** Each fixpoint +/// pass re-runs a full link BFS *and* a full owner scan, and one pass is +/// consumed per layer of owner-bridge hops, so it is O((V+E)·D) for a +/// bridge depth D. D is 1 for every topology observed so far and 2 for a +/// forwarder feeding a forwarder; a 60-layer chain is covered by a test +/// purely to catch an accidental blow-up. Phase 5 records the real +/// recompute-duration distribution and maximum, which is what the "full +/// recompute is fine for v1" claim should actually rest on — a measured +/// headroom, not a node count. pub fn evaluate( snapshot: &GraphSnapshot, 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); let mut taint: BTreeMap = BTreeMap::new(); let mut sticky_serials: BTreeSet = BTreeSet::new(); @@ -331,19 +342,29 @@ pub fn evaluate( // owner bridge feed each other — a bridged output leg has downstream // links, and a downstream monitor reader bridges to its own siblings — // so neither can be run once. - let downstream = downstream_edges(snapshot, &mut taint); + let edges = downstream_edges(snapshot, &mut taint); loop { let mut changed = false; - changed |= propagate_links(&downstream, &mut taint); - changed |= propagate_owner_bridge(snapshot, ctx, &components, &mut taint); - changed |= propagate_unresolved_owner(snapshot, ctx, &mut taint); + changed |= propagate_links(&edges.edges, &mut taint); + changed |= propagate_owner_bridge(&keys, &components, &edges, &mut taint); + changed |= propagate_unresolved_owner(snapshot, &keys, &edges, &mut taint); if !changed { break; } } let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials); - let next_sticky = build_sticky(snapshot, &components, &taint, prior); + let next_sticky = if ctx.graph_ready { + build_sticky(snapshot, &components, &taint, prior) + } else { + // ⚠️ Never retire a sticky owner on the strength of a snapshot we + // have already declared untrustworthy (Codex round 1). An object + // missing from a partial graph has not been observed to disappear, + // and `build_sticky` drops entries whose members are all absent — + // so one not-ready recompute could erase the taint history and the + // next ready one would hand back a clean bill of health. + prior.clone() + }; (decisions, next_sticky) } @@ -402,14 +423,25 @@ fn seed_sticky( sticky_serials: &mut BTreeSet, ) { for entry in &prior.owners { - let live_nodes: Vec = entry - .members - .iter() - .filter_map(|member| match member { - ObjectRef::Node(serial) => snapshot.node(*serial).map(|_| *serial), - ObjectRef::Client(_) => None, - }) - .collect(); + let mut live_nodes: Vec = Vec::new(); + for member in &entry.members { + match member { + ObjectRef::Node(serial) => { + if snapshot.node(*serial).is_some() { + live_nodes.push(*serial); + } + } + // ⚠️ A surviving **Client** re-seeds too. An app can close + // every stream it had while keeping its PipeWire connection + // open, then open a fresh one — Firefox does exactly this. + // Seeding only from live nodes let that new leg come back + // Eligible while the owner was still, by v3.4 §6.1.3's own + // rule, tainted (its buffers outlive its streams). + ObjectRef::Client(serial) => { + live_nodes.extend(nodes_of_client(snapshot, *serial)); + } + } + } // The owner is sticky, not the individual node: a leg that appears // later in the same still-live owner inherits the taint. for serial in live_nodes { @@ -423,24 +455,45 @@ fn seed_sticky( } } +/// Nodes currently attached to a client, by the client's **serial**. The +/// client's snapshot-local id is resolved fresh each time, so a recycled id +/// can never resurrect a dead owner. +fn nodes_of_client(snapshot: &GraphSnapshot, client: Serial) -> Vec { + let Some(id) = snapshot + .clients() + .find(|c| c.serial == client) + .map(|c| c.id) + else { + return Vec::new(); + }; + // If that id is ambiguous, two clients claim it and we cannot say which + // nodes belong to the sticky one — so claim them all, which is the + // fail-closed direction. + snapshot + .nodes() + .filter(|node| node.props.client_id == Some(id)) + .map(|node| node.serial) + .collect() +} + /// `output node → input nodes`, resolving snapshot-local ids. An endpoint /// that does not resolve taints the *other* end as unresolved ancestry when /// that other end is the input side — we cannot know what is feeding it. -fn downstream_edges( - snapshot: &GraphSnapshot, - taint: &mut BTreeMap, -) -> BTreeMap> { +fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap) -> Edges { let mut edges: BTreeMap> = BTreeMap::new(); + let mut receivers: BTreeSet = BTreeSet::new(); for link in snapshot.links() { let from = snapshot.node_by_id(link.output_node); let to = snapshot.node_by_id(link.input_node); match (from, to) { (Some(IdLookup::Unique(from)), Some(IdLookup::Unique(to))) => { edges.entry(from).or_default().push(to); + receivers.insert(to); } (_, Some(IdLookup::Unique(to))) => { // Something feeds this node and we cannot say what. raise(taint, to, Reason::UnresolvedAncestry); + receivers.insert(to); } _ => {} } @@ -449,7 +502,29 @@ fn downstream_edges( targets.sort_unstable(); targets.dedup(); } - edges + // A node that receives audio by *role* counts even with no inbound link + // yet: a pixelpass capture sink is a taint root the moment it exists, + // and its owner's re-emitting leg must be bridged from it immediately. + receivers.extend( + snapshot + .nodes() + .filter(|node| node.role.receives_audio()) + .map(|node| node.serial), + ); + Edges { edges, receivers } +} + +/// Resolved signal edges plus the set of nodes that can receive audio. +struct Edges { + edges: BTreeMap>, + /// ⚠️ Membership is "appears as a resolved `link.input.node`" **or** + /// "has a receiving role" — deliberately not role alone. Codex round 1: + /// a node whose `media.class` is absent or unexpected (`Other`), or an + /// `Audio/Source` that is really a filter output, can sit on an inbound + /// link carrying tainted audio; inferring "receives audio" from the role + /// alone left such a node unable to start an owner bridge, and its + /// sibling output leg stayed Eligible while re-emitting the call. + receivers: BTreeSet, } fn propagate_links( @@ -482,21 +557,19 @@ fn propagate_links( /// leg ⇒ exclude the output" rule would exclude every app using a /// microphone, Firefox in a video call included. fn propagate_owner_bridge( - snapshot: &GraphSnapshot, - ctx: &ExclusionCtx, + keys: &owner::OwnerKeyIndex, components: &OwnerComponents, + edges: &Edges, taint: &mut BTreeMap, ) -> bool { let mut changed = false; for members in components.components() { - let sources: Vec = members + let sources: BTreeSet = members .iter() .copied() .filter(|serial| { taint.get(serial).is_some_and(|r| r.propagates()) - && snapshot - .node(*serial) - .is_some_and(|n| n.role.receives_audio()) + && edges.receivers.contains(serial) }) .collect(); if sources.is_empty() { @@ -506,17 +579,11 @@ fn propagate_owner_bridge( if sources.contains(target) { continue; } - let Some(target_node) = snapshot.node(*target) else { - continue; - }; // Name the strongest key shared directly with any tainted // member; `None` means the two are only transitively related. let key = sources .iter() - .filter_map(|source| snapshot.node(*source)) - .filter_map(|source| { - owner::strongest_shared_key(source, target_node, ctx.pipewire_pulse_pid) - }) + .filter_map(|source| keys.strongest_shared(*source, *target)) .min(); changed |= raise(taint, *target, Reason::TaintedOwnerBridge { key }); } @@ -525,40 +592,44 @@ fn propagate_owner_bridge( } /// Fail-closed backstop for an owner we cannot bound (v3.4 §6.1.1, final -/// paragraph): a capture stream is reading tainted audio and nothing about -/// it lets us enumerate its sibling output legs, so we cannot know which -/// output leg is re-emitting what it read. Exclude the output legs that are -/// equally unbounded. +/// paragraph): something read tainted audio and nothing about the output +/// legs on this box lets us enumerate which of them are its siblings, so we +/// cannot know which one is re-emitting what it read. Exclude the output +/// legs that are equally unbounded. /// -/// Two deliberate restrictions keep the blast radius at "small", as the -/// design promises: +/// Two restrictions keep the blast radius at "small", as the design +/// promises — and one earlier restriction was **removed** as unsafe: /// -/// - **Source must be a `Stream/Input/Audio`.** A tainted *device* sink is -/// the normal case, not an anomaly — peerspeak's own playback taints the -/// default sink on literally every recompute — and letting that trip this -/// rule would exclude the entire desktop. +/// - **Source must not be a real device node.** A tainted hardware sink is +/// the normal case, not an anomaly: peerspeak's own playback taints the +/// default sink on literally every recompute, and letting that trip this +/// rule would exclude the whole desktop. /// - **Targets must themselves be unbounded.** Ordinary application streams /// carry a real `application.process.id` and are bounded, so they are -/// never swept up; in practice only other daemon-owned keyless module -/// streams are. +/// never swept up; in practice only daemon-owned keyless module streams +/// are. +/// - ⚠️ **Removed (Codex round 1): the source no longer has to be unbounded +/// itself.** Properties can be asymmetric — a reader carrying a +/// `node.link-group` whose re-emitting leg carries none is *bounded* while +/// its sibling is not findable — and requiring an unbounded source let +/// exactly that shape through as Eligible. fn propagate_unresolved_owner( snapshot: &GraphSnapshot, - ctx: &ExclusionCtx, + keys: &owner::OwnerKeyIndex, + edges: &Edges, taint: &mut BTreeMap, ) -> bool { - let unbounded_reader = snapshot.nodes().any(|node| { - node.role == MediaRole::StreamInput + let tainted_reader = snapshot.nodes().any(|node| { + node.props.device_id.is_none() + && edges.receivers.contains(&node.serial) && taint.get(&node.serial).is_some_and(|r| r.propagates()) - && !owner::owner_is_bounded(node, ctx.pipewire_pulse_pid) }); - if !unbounded_reader { + if !tainted_reader { return false; } let mut changed = false; for node in snapshot.nodes() { - if node.role == MediaRole::StreamOutput - && !owner::owner_is_bounded(node, ctx.pipewire_pulse_pid) - { + if node.role == MediaRole::StreamOutput && !keys.is_bounded(node.serial) { changed |= raise(taint, node.serial, Reason::UnresolvedOwner); } } diff --git a/src/host/taint/owner.rs b/src/host/taint/owner.rs index 0105432..37fabe4 100644 --- a/src/host/taint/owner.rs +++ b/src/host/taint/owner.rs @@ -35,18 +35,34 @@ //! tainted module input would exclude every module-created stream on the //! box. Keys 1 and 2 already cover those cases precisely. //! -//! 2. **Coarse keys (3 and 4) may not bridge device-role nodes.** ⚠️ This -//! rule is *not* in design v3.4 — it was found while implementing, and -//! it is the exact analogue of exception 1 for the session manager: -//! every ALSA `Audio/Sink` and `Audio/Source` on the box is created by -//! WirePlumber and therefore shares one `client.id` and one -//! `application.process.id`. Without this rule, the hardware sink -//! carrying peerspeak's playback (tainted by design, every single time) -//! would bridge to *every other device node including the microphone -//! source*, whose readers would then taint their owners' playback legs — -//! reproducing precisely the §6.1.1 catastrophe ("excludes any app using -//! a microphone") through a different door. Device nodes that genuinely -//! belong to a module still bridge, via keys 1 and 2. +//! 2. **Coarse keys (3 and 4) may not bridge nodes exported from a real +//! `Device`** — i.e. nodes carrying `device.id`. ⚠️ This rule is *not* +//! in design v3.4; it was found while implementing, and it is the exact +//! analogue of exception 1 for the session manager. +//! ✅ **MEASURED on the live graph 2026-07-21:** +//! +//! | node | `client.id` | `device.id` | `factory.name` | +//! | --- | --- | --- | --- | +//! | 5 × `alsa_{output,input}.*` | **42** (`WirePlumber [export]`) | 43/45/46 | `api.alsa.pcm.{sink,source}` | +//! | 3 × `sink-sunshine-*` | 83 / 86 / 92 (each its own) | **absent** | `support.null-audio-sink` | +//! +//! So one shared coarse key genuinely does relate every hardware device +//! on the box, and `device.id` cleanly separates that set from virtual +//! sinks. Without the rule, the hardware sink carrying peerspeak's +//! playback (tainted by design, every single recompute) would bridge to +//! *every other device node including the microphone source*, whose +//! readers would then taint their owners' playback legs — reproducing +//! precisely the §6.1.1 catastrophe ("excludes any app using a +//! microphone") through a different door. +//! +//! ⚠️ **Keyed on `device.id`, NOT on `media.class` being `Audio/Sink`.** +//! The first cut suppressed coarse keys for every device-*role* node, +//! and Codex refuted it: a **native virtual sink** — an app that creates +//! an `Audio/Sink` plus a re-emitting stream on one client, with no +//! `link-group` and no `pulse.module.id` — would then have had its only +//! correlation stripped, and it would have leaked the whole call. Such a +//! sink has no `device.id`, so it now bridges on `client.id` as it +//! should. //! //! Grouping is **transitive** (union-find). That is the fail-closed //! direction: bigger owner components mean more taint, never less. @@ -98,8 +114,9 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option) -> Vec<(OwnerKe if let Some(module) = node.props.pulse_module_id { out.push((OwnerKey::PulseModuleId, KeyValue::Num(module))); } - // Exception 2: coarse keys never bridge device-role nodes. - if node.role.is_device_role() { + // Exception 2: coarse keys never bridge nodes exported from a real + // Device — they all share the session manager's client. + if node.props.device_id.is_some() { return out; } if let Some(client) = node.props.client_id { @@ -140,6 +157,48 @@ pub fn owner_is_bounded(node: &NodeSnapshot, pipewire_pulse_pid: Option) -> .any(|(key, _)| *key != OwnerKey::ClientId) } +/// Owner keys computed once per snapshot. +/// +/// `keys_of` allocates a `Vec` and clones the `link-group` string, and the +/// bridge asks for keys once per (tainted member × component member) pair — +/// so recomputing was the hot spot in an otherwise linear pass. +#[derive(Debug, Default)] +pub struct OwnerKeyIndex { + keys: BTreeMap>, +} + +impl OwnerKeyIndex { + pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option) -> Self { + Self { + keys: snapshot + .nodes() + .map(|node| (node.serial, keys_of(node, pipewire_pulse_pid))) + .collect(), + } + } + + /// The strongest key these two nodes share directly, if any. + pub fn strongest_shared(&self, a: Serial, b: Serial) -> Option { + let (Some(a_keys), Some(b_keys)) = (self.keys.get(&a), self.keys.get(&b)) else { + return None; + }; + // Stored strongest-first, so the first match is the strongest. + a_keys.iter().find_map(|(key, value)| { + b_keys + .iter() + .any(|(other_key, other_value)| other_key == key && other_value == value) + .then_some(*key) + }) + } + + /// See [`owner_is_bounded`]. + pub fn is_bounded(&self, serial: Serial) -> bool { + self.keys + .get(&serial) + .is_some_and(|keys| keys.iter().any(|(key, _)| *key != OwnerKey::ClientId)) + } +} + /// The strongest key two nodes share, or `None` if they share none. Used to /// *name* the key in a bridge decision; membership itself is transitive and /// comes from [`OwnerComponents`]. diff --git a/src/host/taint/snapshot.rs b/src/host/taint/snapshot.rs index ba4a3f4..6ddcba6 100644 --- a/src/host/taint/snapshot.rs +++ b/src/host/taint/snapshot.rs @@ -116,6 +116,16 @@ pub struct NodeProps { /// The stream negotiated an encoded/passthrough format; a second link /// would refuse or corrupt it (v3.4 §6.2). pub passthrough: bool, + /// `device.id` — set only on nodes exported from a real `Device` + /// object by the session manager. + /// + /// ✅ **Measured 2026-07-21**: this is the discriminator between "a + /// hardware device node, one of a set that all share WirePlumber's + /// `client.id`" and "a virtual sink with its own client". The five + /// ALSA nodes carry `device.id` 43/45/46 with `device.api=alsa`; the + /// three `support.null-audio-sink` nodes carry none. See + /// [`super::owner`] exception 2, which turns on exactly this. + pub device_id: Option, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs index 1e71d61..1105676 100644 --- a/src/host/taint/tests.rs +++ b/src/host/taint/tests.rs @@ -924,3 +924,354 @@ fn recycled_ids_module_indices_and_link_groups_do_not_inherit_taint() { &[("call", call, "peerspeak-owned")], ); } + +// ────────────────────────────────────────────────────────────────────── +// Regressions from Codex round 1 — each of these was a leak or a lost +// taint history, and each mutation-tests the fix that closed it. +// ────────────────────────────────────────────────────────────────────── + +#[test] +fn sticky_taint_survives_when_only_the_client_object_remains() { + // Finding 1. An app can close every stream it holds while keeping its + // PipeWire connection open, then open a fresh one — Firefox does this + // constantly. Seeding sticky taint only from live *nodes* let the new + // leg come back Eligible while the owner's buffers were still full of + // the call. + let (mut graph, call, rec_in, rec_out, firefox) = sticky_scene(); + let c = ctx(); + let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); + + // Both legs vanish; the client object does not. + let late = graph.app_node("rec-out-late", MediaRole::StreamOutput, 8080); + let (next, _) = evaluate(&graph.build_without(&[rec_in, rec_out]), &c, &sticky); + assert_partition( + &next, + &[("firefox", firefox)], + &[ + ("call", call, "peerspeak-owned"), + ("late", late, "tainted-owner-bridge"), + ], + ); +} + +#[test] +fn an_inbound_linked_node_of_unknown_role_still_bridges() { + // Finding 2. "Receives audio" cannot be inferred from `media.class` + // alone: a node with an absent or unexpected class sits on a real + // inbound link carrying the call, and if it cannot start an owner + // bridge its sibling output leg re-emits the call as Eligible. + let mut graph = Graph::new(); + let sink = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, sink); + + let client = graph.client_of_app(8080); + let unknown_in = graph.node("unknown-in", MediaRole::Other, app(client, 8080)); + let leaked_out = graph.node("leaked-out", MediaRole::StreamOutput, app(client, 8080)); + graph.link(sink, unknown_in); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[], + &[ + ("call", call, "peerspeak-owned"), + ("leaked-out", leaked_out, "tainted-owner-bridge"), + ], + ); + assert_tainted(&decisions, unknown_in, "tainted-upstream"); +} + +#[test] +fn a_candidate_that_is_itself_a_receiver_still_bridges_to_its_siblings() { + // The other half of finding 2: a `Stream/Output/Audio` on an inbound + // link is excluded by the link walk, but it must also be able to carry + // taint across the owner bridge to its siblings. + let mut graph = Graph::new(); + let sink = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, sink); + + let odd_in = graph.app_node("odd-in", MediaRole::StreamOutput, 9100); + let sibling = graph.app_node("sibling-out", MediaRole::StreamOutput, 9100); + graph.link(sink, odd_in); + + assert_partition( + &run(&graph, &ctx()), + &[], + &[ + ("call", call, "peerspeak-owned"), + ("odd-in", odd_in, "tainted-upstream"), + ("sibling", sibling, "tainted-owner-bridge"), + ], + ); +} + +#[test] +fn a_native_virtual_sink_bridges_on_its_client() { + // Finding 3. The device exception is keyed on `device.id`, not on the + // node's role: an app that creates its own `Audio/Sink` plus a + // re-emitting stream on one client has no `device.id`, no link-group + // and no `pulse.module.id`, so `client.id` is the *only* thing relating + // the two. Suppressing coarse keys by role stripped it and leaked the + // whole call. + let mut graph = Graph::new(); + let hw = graph.device_node("hw", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + let virtual_sink = graph.native_virtual_node("native-virtual-sink", MediaRole::Sink, 4321); + let reemitter = graph.native_virtual_node("native-output", MediaRole::StreamOutput, 4321); + graph.link(call, virtual_sink); + graph.link(reemitter, hw); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[], + &[ + ("call", call, "peerspeak-owned"), + ("reemitter", reemitter, "tainted-owner-bridge"), + ], + ); + assert_eq!( + decisions.taint[&reemitter.serial].reason, + Reason::TaintedOwnerBridge { + key: Some(OwnerKey::ClientId) + } + ); +} + +#[test] +fn an_asymmetric_forwarder_fails_closed() { + // Finding 4. The reader carries a `node.link-group` and is therefore + // "bounded", but its re-emitting leg carries none, so the strong key + // finds no sibling. Requiring the *source* to be unbounded let this + // through as Eligible. + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, hw); + + let in_client = graph.client(Some(PULSE_PID)); + let out_client = graph.client(Some(PULSE_PID)); + let fwd_in = graph.node( + "fwd-in", + MediaRole::StreamInput, + NodeProps { + link_group: Some("asymmetric-forwarder".into()), + client_id: Some(in_client), + process_id: Some(PULSE_PID), + ..NodeProps::default() + }, + ); + let fwd_out = graph.node( + "fwd-out", + MediaRole::StreamOutput, + app(out_client, PULSE_PID), + ); + graph.link(hw, fwd_in); + + // Blast radius control: a real app is bounded by its own PID. + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + graph.link(firefox, hw); + + assert_partition( + &run(&graph, &ctx()), + &[("firefox", firefox)], + &[ + ("call", call, "peerspeak-owned"), + ("fwd-out", fwd_out, "unresolved-owner"), + ], + ); +} + +#[test] +fn a_not_ready_snapshot_never_retires_sticky_owners() { + // Finding 5. `graph_ready == false` says "this view of the graph is not + // trustworthy". Retiring sticky owners because their members are absent + // from an untrustworthy snapshot erases the taint history, and the next + // ready recompute hands back a clean bill of health for an owner that + // never went away. + let (graph, call, rec_in, rec_out, firefox) = sticky_scene(); + let c = ctx(); + let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); + + let not_ready = ExclusionCtx { + graph_ready: false, + ..ctx() + }; + let (_, carried) = evaluate( + &super::snapshot::GraphSnapshot::default(), + ¬_ready, + &sticky, + ); + assert_eq!( + carried, sticky, + "a not-ready recompute must not edit history" + ); + + let (after, _) = evaluate(&graph.build_without(&[rec_in]), &c, &carried); + assert_partition( + &after, + &[("firefox", firefox)], + &[ + ("call", call, "peerspeak-owned"), + ("rec-out", rec_out, "tainted-owner-bridge"), + ], + ); +} + +#[test] +fn taint_crosses_two_chained_forwarders() { + // Finding 6. Every earlier fixture needed at most one owner hop, so + // replacing the fixpoint with a single pass of each rule survived the + // whole suite. Two chained forwarders need two. + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + let sink1 = graph.module_node("sink1", MediaRole::Sink, 100); + graph.link(call, sink1); + + let fwd1_in = graph.module_node("fwd1-in", MediaRole::StreamInput, 101); + let fwd1_out = graph.module_node("fwd1-out", MediaRole::StreamOutput, 101); + graph.link(sink1, fwd1_in); + + let sink2 = graph.module_node("sink2", MediaRole::Sink, 102); + graph.link(fwd1_out, sink2); + let fwd2_in = graph.module_node("fwd2-in", MediaRole::StreamInput, 103); + let fwd2_out = graph.module_node("fwd2-out", MediaRole::StreamOutput, 103); + graph.link(sink2, fwd2_in); + graph.link(fwd2_out, hw); + + // Negative control: an unrelated clean forwarder chain of the same shape. + let clean_sink = graph.device_node("clean-sink", MediaRole::Sink); + let clean_in = graph.module_node("clean-in", MediaRole::StreamInput, 200); + let clean_out = graph.module_node("clean-out", MediaRole::StreamOutput, 200); + graph.link(clean_sink, clean_in); + graph.link(clean_out, hw); + + assert_partition( + &run(&graph, &ctx()), + &[("clean-out", clean_out)], + &[ + ("call", call, "peerspeak-owned"), + ("fwd1-out", fwd1_out, "tainted-owner-bridge"), + ("fwd2-out", fwd2_out, "tainted-owner-bridge"), + ], + ); +} + +#[test] +fn a_stronger_reason_replaces_a_weaker_one_regardless_of_arrival_order() { + // Finding 7. The old determinism test reversed two links that never + // competed, so a first-write-wins `raise()` survived it. Here a node is + // reached by the owner bridge on the first fixpoint pass and by a + // direct tainted link on the second; the direct link is the stronger + // reason and must win. + let build = |reverse: bool| { + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, hw); + let rec_in = graph.app_node("rec-in", MediaRole::StreamInput, 5000); + let mid = graph.app_node("mid-out", MediaRole::StreamOutput, 5000); + let owned_sink = graph.native_virtual_node("owned-sink", MediaRole::Sink, 5000); + if reverse { + graph.link(mid, owned_sink); + graph.link(hw, rec_in); + } else { + graph.link(hw, rec_in); + graph.link(mid, owned_sink); + } + let decisions = run(&graph, &ctx()); + ( + decisions.taint[&owned_sink.serial].reason.code(), + decisions.excluded(), + ) + }; + let forward = build(false); + assert_eq!( + forward.0, "tainted-upstream", + "bridged first, then reached directly — the direct link is stronger" + ); + assert_eq!(forward, build(true), "and it must not depend on link order"); +} + +#[test] +fn a_recycled_client_id_and_module_index_do_not_inherit_taint() { + // Finding 8. The original recycling test never reused a + // `pulse.module.id`, and its "fresh" client silently reused the dead + // client's global id because the fixture cache was stale — so the node + // pointed at a client object that no longer existed. + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + graph.link(call, hw); + let old_in = graph.module_node("old-in", MediaRole::StreamInput, 536_870_919); + let old_out = graph.module_node("old-out", MediaRole::StreamOutput, 536_870_919); + graph.link(hw, old_in); + + let c = ctx(); + let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default()); + assert_eq!( + first.candidates[&old_out.serial].reason().map(Reason::code), + Some("tainted-owner-bridge") + ); + + // Full teardown, then a module that reuses the node id, the client id + // and the module index verbatim. Only the serials are new — which is + // the entire reason stickiness is keyed on them. + let old_client = graph.client_of_module(536_870_919); + graph.drop_clients(&[old_client]); + let reborn_client = graph.client_with_id(old_client, Some(PULSE_PID)); + let reborn = graph.node_with_id( + "reborn-out", + MediaRole::StreamOutput, + old_out.id, + super::fixture::pulse_module(reborn_client, 536_870_919, PULSE_PID), + ); + let (second, _) = evaluate(&graph.build_without(&[old_in, old_out]), &c, &sticky); + assert_partition( + &second, + &[("reborn", reborn)], + &[("call", call, "peerspeak-owned")], + ); +} + +#[test] +fn a_deep_forwarder_chain_converges() { + // Finding 9. The fixpoint is monotone so it terminates, but each pass + // re-runs a full link BFS and a full owner scan, so a chain of owner + // hops costs one pass per layer. This is the shape that would expose an + // accidental blow-up; phase 5 measures the real distribution. + const LAYERS: u64 = 60; + let mut graph = Graph::new(); + let hw = graph.device_node("hw-sink", MediaRole::Sink); + let call = graph.peerspeak_node("peerspeak", 7); + let mut upstream = graph.module_node("sink-0", MediaRole::Sink, 1); + graph.link(call, upstream); + let mut last_out = None; + for layer in 1..=LAYERS { + let module = 1000 + layer; + let leg_in = graph.module_node(&format!("fwd-{layer}-in"), MediaRole::StreamInput, module); + let leg_out = + graph.module_node(&format!("fwd-{layer}-out"), MediaRole::StreamOutput, module); + graph.link(upstream, leg_in); + let next = graph.module_node(&format!("sink-{layer}"), MediaRole::Sink, 2000 + layer); + graph.link(leg_out, next); + upstream = next; + last_out = Some(leg_out); + } + graph.link(last_out.expect("at least one layer"), hw); + + let decisions = run(&graph, &ctx()); + assert_eq!( + decisions.eligible(), + Vec::new(), + "every leg of the chain carries the call" + ); + assert_eq!( + decisions.candidates.len(), + LAYERS as usize + 1, + "the whole chain plus peerspeak's own playback" + ); +} From 31084edcfacb81932b92db9ad8f82dfd56bcdd16 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 21 Jul 2026 17:12:21 -0400 Subject: [PATCH 3/7] host/taint: close the partial fixes found in Codex round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verification round earned its place: five of the six round-1 fixes were partial, and two of the gaps were worse than the bugs they replaced. 1. ⚠️ The round-1 sticky fix smuggled the suppressed key back in. `client_serials_of` recorded the shared `WirePlumber [export]` client as a member of a tainted hardware sink's owner, so the *second* recompute expanded that client to every sound card on the box, tainted the microphone, and excluded every app holding one — the §6.1.1 catastrophe arriving one epoch late instead of never. `client.id` may now only be recorded, or expanded, for nodes where it is a usable owner key. The regression test evaluates an unchanged snapshot three times: a correct engine's answer must not drift when nothing has. 2. Sticky followed a surviving *connection*, not a surviving *owner*. A process can leave one client idle and open a second — GStreamer opens one per stream as a matter of course — and the new leg escaped. `StickyOwner` now carries owner **fingerprints** (strong keys and a usable PID, never `client.id`), applied only while some serial member is still live, so a recyclable key cannot resurrect a dead owner. 3. An **ambiguous** link input endpoint tainted every claimant but made none of them a receiver, so their sibling output legs stayed eligible. Taint without receiver status cannot start an owner bridge. 4. `device.id` is a raw observation, not the classification the coarse-key exception needs — PipeWire defines it only as "the Device this node belongs to", so a forwarding node carrying one would have lost both its owner keys and its ability to trip the backstop. Replaced by `session_device`, a phase-3 obligation (`device.id` AND `device.api`) documented to fail closed when it cannot classify. 5. Readiness now gates sticky **retirement only**. Round 1 stopped a not-ready epoch erasing history; it also stopped it recording any, so a reader could consume and buffer the call during that epoch, vanish before readiness, and leave its output eligible. 6. Added the unresolved-output-plus-unknown-role fixture: deleting one `receivers.insert` survived all 42 previous tests. Mutation-verified: 7/7 reverts killed by their intended test. Two attempts did not land first time and both were my error, not the engine's — the client-key guard is applied at two sites so removing one is not a revert (removing the pair is, and that is killed), and the fingerprint-lifetime test put the recycled node in a snapshot *after* the entry had already been retired, so the guard was never consulted. Rewritten to place it in the same snapshot that first sees the owner gone. Cost comment corrected again, to O(D·(V+E+Σ|sources|·|targets|)). 49 tests. Co-Authored-By: Claude Opus 4.8 --- src/host/taint/fixture.rs | 16 ++- src/host/taint/mod.rs | 134 +++++++++++++++++------ src/host/taint/owner.rs | 75 +++++++++++-- src/host/taint/snapshot.rs | 42 ++++++-- src/host/taint/tests.rs | 215 +++++++++++++++++++++++++++++++++++++ 5 files changed, 426 insertions(+), 56 deletions(-) diff --git a/src/host/taint/fixture.rs b/src/host/taint/fixture.rs index b927975..ec3f2bd 100644 --- a/src/host/taint/fixture.rs +++ b/src/host/taint/fixture.rs @@ -131,8 +131,16 @@ impl Graph { id } }; - let device_id = self.id(); - self.node(name, role, device(session, SESSION_PID, device_id)) + self.node(name, role, device(session, SESSION_PID)) + } + + /// A node that *belongs to* a Device but is not a passive device node — + /// a filter associated with a card. Phase 3 must not classify this as a + /// session device, or it loses both its coarse owner keys and its + /// ability to trip the fail-closed backstop. + pub fn device_associated_filter(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef { + let client = self.client_of_app(pid); + self.node(name, role, app(client, pid)) } /// A **virtual** sink an application created natively: an `Audio/Sink` @@ -313,11 +321,11 @@ pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps { /// here is deliberately *more* pessimistic than reality — it hands the /// engine a second coarse key it could fuse devices on, so a test that /// passes here also passes against the real props. -pub fn device(session_client: GlobalId, session_pid: u32, device_id: GlobalId) -> NodeProps { +pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps { NodeProps { client_id: Some(session_client), process_id: Some(session_pid), - device_id: Some(device_id), + session_device: true, ..NodeProps::default() } } diff --git a/src/host/taint/mod.rs b/src/host/taint/mod.rs index 0ede728..5789f92 100644 --- a/src/host/taint/mod.rs +++ b/src/host/taint/mod.rs @@ -202,6 +202,17 @@ pub struct StickyOwner { /// accumulates: that is what makes "clear only once all member objects /// have disappeared" true across churn. pub members: BTreeSet, + /// Owner keys remembered across connections — strong keys and a usable + /// process id, never `client.id`. Applied only while some serial member + /// above is still live, which is what keeps a recyclable key from + /// resurrecting a dead owner. + /// + /// Needed because a live Client is not the same thing as a live owner: + /// a process can leave one connection idle and open a second, and + /// GStreamer opens one connection per stream as a matter of course, so + /// following connections alone lets the next leg escape (Codex round 2, + /// finding 2). + pub fingerprints: BTreeSet, /// The reason recorded for each node that was tainted in its own right. /// Kept per node rather than collapsed to one owner-wide reason, or a /// forwarder's output leg inherits its *input* leg's `tainted-upstream` @@ -309,14 +320,16 @@ impl Decisions { /// no incremental dirty-set. /// /// ⚠️ **Cost is not O(V+E), despite what v3.4 §6.4 says.** Each fixpoint -/// pass re-runs a full link BFS *and* a full owner scan, and one pass is -/// consumed per layer of owner-bridge hops, so it is O((V+E)·D) for a -/// bridge depth D. D is 1 for every topology observed so far and 2 for a -/// forwarder feeding a forwarder; a 60-layer chain is covered by a test -/// purely to catch an accidental blow-up. Phase 5 records the real -/// recompute-duration distribution and maximum, which is what the "full -/// recompute is fine for v1" claim should actually rest on — a measured -/// headroom, not a node count. +/// pass re-runs a full link BFS *and* a full owner scan, and the bridge +/// scans every tainted source in a component for each target, so the bound +/// is `O(D · (V + E + Σ_C |sources_C|·|targets_C|))` — worst case +/// `O(D · (V² + E))` — for an owner-bridge depth D. D is 1 for every +/// topology observed so far and 2 for a forwarder feeding a forwarder, and +/// components on a real desktop are two or three nodes; the quadratic term +/// needs one owner with many legs. A 60-layer chain test guards the depth +/// dimension only. Phase 5 records the real recompute-duration +/// distribution and maximum, which is what "full recompute is fine for v1" +/// should rest on — measured headroom, not a node count. pub fn evaluate( snapshot: &GraphSnapshot, ctx: &ExclusionCtx, @@ -331,6 +344,7 @@ pub fn evaluate( seed_local_roots(snapshot, ctx, &mut taint); seed_sticky( snapshot, + &keys, prior, &components, &mut taint, @@ -354,17 +368,15 @@ pub fn evaluate( } let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials); - let next_sticky = if ctx.graph_ready { - build_sticky(snapshot, &components, &taint, prior) - } else { - // ⚠️ Never retire a sticky owner on the strength of a snapshot we - // have already declared untrustworthy (Codex round 1). An object - // missing from a partial graph has not been observed to disappear, - // and `build_sticky` drops entries whose members are all absent — - // so one not-ready recompute could erase the taint history and the - // next ready one would hand back a clean bill of health. - prior.clone() - }; + // ⚠️ Readiness gates **retirement only**, never addition (Codex rounds + // 1 and 2, which caught the two halves of this in turn). An object + // missing from an untrustworthy snapshot has not been observed to + // disappear, so retiring on that basis erases history and the next + // ready recompute hands back a clean bill of health. But taint + // *observed* during a not-ready epoch is real — a reader can consume + // and buffer the call and then vanish before readiness — so discarding + // additions was the same defect pointing the other way. + let next_sticky = build_sticky(snapshot, &keys, &components, &taint, prior, ctx.graph_ready); (decisions, next_sticky) } @@ -415,8 +427,13 @@ fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option } /// Carry taint forward from previous snapshots (v3.4 §6.1.3). +/// +/// An owner is re-seeded from three kinds of evidence, all lifetime-scoped +/// to a still-live member: its own surviving nodes, nodes on a surviving +/// **Client**, and nodes presenting a remembered owner **fingerprint**. fn seed_sticky( snapshot: &GraphSnapshot, + keys: &owner::OwnerKeyIndex, prior: &StickyState, components: &OwnerComponents, taint: &mut BTreeMap, @@ -431,17 +448,29 @@ fn seed_sticky( live_nodes.push(*serial); } } - // ⚠️ A surviving **Client** re-seeds too. An app can close + // A surviving **Client** re-seeds too. An app can close // every stream it had while keeping its PipeWire connection // open, then open a fresh one — Firefox does exactly this. - // Seeding only from live nodes let that new leg come back - // Eligible while the owner was still, by v3.4 §6.1.3's own - // rule, tainted (its buffers outlive its streams). ObjectRef::Client(serial) => { - live_nodes.extend(nodes_of_client(snapshot, *serial)); + live_nodes.extend(nodes_of_client(snapshot, keys, *serial)); } } } + if live_nodes.is_empty() && !entry.members.iter().any(|m| is_live(snapshot, *m)) { + // Nothing of this owner remains; its fingerprints are just + // recyclable strings now and must not be applied to anyone. + continue; + } + // Fingerprints reach a *new connection* of the same still-live + // process, which neither of the two paths above can see. + for fingerprint in &entry.fingerprints { + live_nodes.extend( + snapshot + .nodes() + .filter(|node| keys.has_fingerprint(node.serial, fingerprint)) + .map(|node| node.serial), + ); + } // The owner is sticky, not the individual node: a leg that appears // later in the same still-live owner inherits the taint. for serial in live_nodes { @@ -458,7 +487,21 @@ fn seed_sticky( /// Nodes currently attached to a client, by the client's **serial**. The /// client's snapshot-local id is resolved fresh each time, so a recycled id /// can never resurrect a dead owner. -fn nodes_of_client(snapshot: &GraphSnapshot, client: Serial) -> Vec { +/// +/// Nodes for which `client.id` is not a usable owner key — session-manager +/// device nodes — are excluded, or the shared `WirePlumber [export]` Client +/// would drag every sound card on the box into one sticky owner. +/// +/// The same gate is applied when *recording* clients into a sticky entry +/// (`owner::client_serials_of`). Either one alone closes the leak; both are +/// kept because they answer different questions ("may this client be +/// remembered?" and "may this client speak for that node?"), and the +/// regression test kills the removal of the pair. +fn nodes_of_client( + snapshot: &GraphSnapshot, + keys: &owner::OwnerKeyIndex, + client: Serial, +) -> Vec { let Some(id) = snapshot .clients() .find(|c| c.serial == client) @@ -466,12 +509,10 @@ fn nodes_of_client(snapshot: &GraphSnapshot, client: Serial) -> Vec { else { return Vec::new(); }; - // If that id is ambiguous, two clients claim it and we cannot say which - // nodes belong to the sticky one — so claim them all, which is the - // fail-closed direction. snapshot .nodes() .filter(|node| node.props.client_id == Some(id)) + .filter(|node| keys.uses_client_key(node.serial)) .map(|node| node.serial) .collect() } @@ -495,6 +536,19 @@ fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap { + // Several nodes claim the input id and we cannot say which + // one this link feeds, so every claimant is a receiver. + // They are already tainted as unresolved by their own + // ambiguous id — but taint without receiver status cannot + // start an owner bridge, so their sibling output legs stayed + // Eligible (Codex round 2, finding 3). + receivers.extend( + snapshot + .nodes_with_id(link.input_node) + .map(|node| node.serial), + ); + } _ => {} } } @@ -620,7 +674,7 @@ fn propagate_unresolved_owner( taint: &mut BTreeMap, ) -> bool { let tainted_reader = snapshot.nodes().any(|node| { - node.props.device_id.is_none() + !node.props.session_device && edges.receivers.contains(&node.serial) && taint.get(&node.serial).is_some_and(|r| r.propagates()) }); @@ -708,20 +762,24 @@ fn local_exclusion(snapshot: &GraphSnapshot, node: &NodeSnapshot) -> Option, prior: &StickyState, + retire_absent: bool, ) -> StickyState { let mut entries: Vec = Vec::new(); // Carry forward prior entries that still have at least one live member. // An entry with none is gone for good: serials never recycle, so a - // vanished member can never come back. + // vanished member can never come back — but only a *trustworthy* + // snapshot is allowed to conclude that a member is absent. for entry in &prior.owners { - if entry - .members - .iter() - .any(|member| is_live(snapshot, *member)) + if !retire_absent + || entry + .members + .iter() + .any(|member| is_live(snapshot, *member)) { entries.push(entry.clone()); } @@ -742,12 +800,17 @@ fn build_sticky( } let mut refs: BTreeSet = members.iter().map(|s| ObjectRef::Node(*s)).collect(); refs.extend( - owner::client_serials_of(snapshot, members) + owner::client_serials_of(snapshot, keys, members) .into_iter() .map(ObjectRef::Client), ); + let fingerprints = members + .iter() + .flat_map(|serial| keys.fingerprints(*serial)) + .collect(); entries.push(StickyOwner { members: refs, + fingerprints, node_reasons, }); } @@ -791,6 +854,7 @@ fn merge_overlapping(mut entries: Vec) -> Vec { .or_insert(reason); } entry.members.extend(other.members); + entry.fingerprints.extend(other.fingerprints); absorbed = true; } } diff --git a/src/host/taint/owner.rs b/src/host/taint/owner.rs index 37fabe4..94de20e 100644 --- a/src/host/taint/owner.rs +++ b/src/host/taint/owner.rs @@ -114,9 +114,9 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option) -> Vec<(OwnerKe if let Some(module) = node.props.pulse_module_id { out.push((OwnerKey::PulseModuleId, KeyValue::Num(module))); } - // Exception 2: coarse keys never bridge nodes exported from a real - // Device — they all share the session manager's client. - if node.props.device_id.is_some() { + // Exception 2: coarse keys never bridge passive session-manager device + // nodes — they all share the session manager's client. + if node.props.session_device { return out; } if let Some(client) = node.props.client_id { @@ -191,6 +191,53 @@ impl OwnerKeyIndex { }) } + /// Is `client.id` a usable owner key for this node? + /// + /// ⚠️ Load-bearing for sticky state. A device node's `client.id` is + /// suppressed by exception 2, so recording the session manager's Client + /// as a *member* of a tainted device's sticky owner would smuggle the + /// suppressed key back in: the next recompute would expand that Client + /// to every hardware node on the box — the microphone included — and + /// the §6.1.1 catastrophe would arrive one epoch late instead of never. + /// (Codex round 2, finding 1.) + pub fn uses_client_key(&self, serial: Serial) -> bool { + self.keys + .get(&serial) + .is_some_and(|keys| keys.iter().any(|(key, _)| *key == OwnerKey::ClientId)) + } + + /// The owner keys that are safe to remember *across* connections, for + /// sticky taint: the strong keys plus a usable process id. + /// + /// `client.id` is deliberately excluded — it identifies a *connection*, + /// and the whole point of a fingerprint is to survive one process + /// closing a connection and opening another. A live Client member is + /// what covers the same-connection case, precisely. + /// + /// These are recyclable strings and numbers, so they are only ever + /// applied while some **serial** member of the owner is still live + /// (v3.4 §6.1.3): while the process is alive, its PID cannot have been + /// handed to anyone else. + pub fn fingerprints(&self, serial: Serial) -> Vec { + self.keys + .get(&serial) + .map(|keys| { + keys.iter() + .filter(|(key, _)| *key != OwnerKey::ClientId) + .map(|(key, value)| Fingerprint(*key, value.clone())) + .collect() + }) + .unwrap_or_default() + } + + /// Does this node currently present `fingerprint`? + pub fn has_fingerprint(&self, serial: Serial, fingerprint: &Fingerprint) -> bool { + self.keys.get(&serial).is_some_and(|keys| { + keys.iter() + .any(|(key, value)| *key == fingerprint.0 && *value == fingerprint.1) + }) + } + /// See [`owner_is_bounded`]. pub fn is_bounded(&self, serial: Serial) -> bool { self.keys @@ -218,6 +265,10 @@ pub fn strongest_shared_key( }) } +/// A remembered owner key — see [`OwnerKeyIndex::fingerprints`]. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub struct Fingerprint(OwnerKey, KeyValue); + /// Nodes partitioned into owner components. #[derive(Clone, Debug, Default)] pub struct OwnerComponents { @@ -315,15 +366,23 @@ impl UnionFind { /// Client objects belonging to an owner component, so sticky taint can be /// keyed on every object that constitutes the owner (v3.4 §6.1.3: clear the /// entry only once **all** member objects are gone). -pub fn client_serials_of(snapshot: &GraphSnapshot, nodes: &[Serial]) -> Vec { +pub fn client_serials_of( + snapshot: &GraphSnapshot, + keys: &OwnerKeyIndex, + nodes: &[Serial], +) -> Vec { let mut out: Vec = nodes .iter() + // Only nodes for which `client.id` is a *usable* owner key. See + // `uses_client_key`: recording a device node's shared session-manager + // Client here would defeat exception 2 on the next recompute. + .filter(|serial| keys.uses_client_key(**serial)) .filter_map(|serial| snapshot.node(*serial)) .filter_map(|node| node.props.client_id) - .filter_map(|id: GlobalId| match snapshot.client_by_id(id) { - Some(super::snapshot::IdLookup::Unique(serial)) => Some(serial), - _ => None, - }) + // An ambiguous client id means two Clients claim it and we cannot + // say which one is ours, so remember both: an entry that recorded + // neither could be retired while its owner was still live. + .flat_map(|id: GlobalId| snapshot.clients_with_id(id).map(|client| client.serial)) .collect(); out.sort_unstable(); out.dedup(); diff --git a/src/host/taint/snapshot.rs b/src/host/taint/snapshot.rs index 6ddcba6..716f567 100644 --- a/src/host/taint/snapshot.rs +++ b/src/host/taint/snapshot.rs @@ -116,16 +116,28 @@ pub struct NodeProps { /// The stream negotiated an encoded/passthrough format; a second link /// would refuse or corrupt it (v3.4 §6.2). pub passthrough: bool, - /// `device.id` — set only on nodes exported from a real `Device` - /// object by the session manager. + /// This node is a **passive device node exported by the session + /// manager** — a real sound card's sink or source, not something that + /// forwards audio. /// - /// ✅ **Measured 2026-07-21**: this is the discriminator between "a - /// hardware device node, one of a set that all share WirePlumber's - /// `client.id`" and "a virtual sink with its own client". The five - /// ALSA nodes carry `device.id` 43/45/46 with `device.api=alsa`; the - /// three `support.null-audio-sink` nodes carry none. See - /// [`super::owner`] exception 2, which turns on exactly this. - pub device_id: Option, + /// ⚠️ **A classification the observer owes, not a raw property.** The + /// first cut set this from bare `device.id`, and Codex refuted it: + /// PipeWire defines `device.id` only as "the Device this Node belongs + /// to", which does not promise the node is passive. A forwarding node + /// that happens to carry one would silently lose its coarse owner keys + /// *and* its ability to trip the fail-closed backstop — two protections + /// at once. **Phase 3 must require `device.id` AND `device.api`** + /// (measured: `device.api=alsa` on all five real ALSA nodes, absent on + /// every `support.null-audio-sink`), and should treat anything it + /// cannot positively classify as *not* a device — that is the + /// fail-closed direction here, because the flag only ever *removes* + /// taint mechanisms. + /// + /// What it is for: every device node on the box shares the session + /// manager's `client.id` (measured: 42, `WirePlumber [export]`), so + /// coarse owner keys must not bridge them. See [`super::owner`] + /// exception 2. + pub session_device: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -255,6 +267,18 @@ impl GraphSnapshot { self.client_ids.get(&id).copied() } + /// Every node claiming a global id. More than one means the id is + /// [`IdLookup::Ambiguous`] and each claimant must be treated as a + /// possible endpoint of any link naming it. + pub fn nodes_with_id(&self, id: GlobalId) -> impl Iterator { + self.nodes.values().filter(move |node| node.id == id) + } + + /// Every client claiming a global id — same fail-closed reasoning. + pub fn clients_with_id(&self, id: GlobalId) -> impl Iterator { + self.clients.values().filter(move |client| client.id == id) + } + /// Ports belonging to a node, by the node's snapshot-local id. pub fn ports_of(&self, node: GlobalId) -> impl Iterator { self.ports.values().filter(move |p| p.node == node) diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs index 1105676..e2d215b 100644 --- a/src/host/taint/tests.rs +++ b/src/host/taint/tests.rs @@ -1275,3 +1275,218 @@ fn a_deep_forwarder_chain_converges() { "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(), ¬_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"), + ], + ); +} From f35bab0379dd7688a40dd6358a2153a90fd91c37 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 21 Jul 2026 17:40:24 -0400 Subject: [PATCH 4/7] host/taint: close the inverse asymmetric leak; strengthen contracts (Codex round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 was the second verification round. One real leak, one accepted narrowing of Codex's own suggested fix, two contract strengthenings, and a test-gap fix. F1 (P1, real leak, fixed): the inverse of round-1 finding 4. A tainted reader that is itself *unbounded* (client.id only, daemon PID suppressed) whose re-emitting leg carried an *unmatched* strong key left that leg "bounded" and Eligible. An unbounded reader cannot be positively related to any output, so a strong key that does not match it back proves nothing. Two-tier backstop. A bounded tainted reader excludes only unbounded outputs (a differently-keyed output is provably a different owner). An unbounded tainted reader also excludes daemon-owned outputs — but NOT ordinary apps. ⚠️ Deliberately narrower than Codex's suggested "exclude every output". An unbounded reader is necessarily daemon-owned (a real app has its own PID, which is a usable key, so it would be bounded), so its sibling is another daemon leg, never an app. Sweeping in real apps would lose the round-1 "blast radius stays small" guarantee for no safety gain. When the daemon PID is unknown the app/leg distinction collapses and the rule degrades to Codex's exclude-all. Both directions are pinned by tests, and the over-aggressive variant fails the spares-real-apps test. F2 (contract, strengthened): `session_device` is documented as a positive high-confidence phase-3 classification, not `device.id`+`device.api` (measured insufficient — a card filter can carry both; node.physical is null on the real ALSA nodes so it is not a discriminator). Fail closed: unknown ⇒ false. Documented why a mis-classified filter still does not leak in practice — its legs share a link-group (strong-key bridge) and an unbounded reading leg trips the two-tier backstop. F5 (P2, test gap): a mutation keeping only PID fingerprints survived all 49 tests. Added a strong-key (pulse.module.id) new-connection fixture. Mutation-verified 3/3 including the over-aggressive counter-mutation. Still OWED to round 3, carried to round 4 for adjudication: finding 3 (a not-ready epoch can persist provisional owner *fusion* as sticky over-exclusion). It is over-exclusion, never an echo leak, and closing it needs a readiness/provenance model decision rather than a local patch — see the round-4 handoff. 53 tests. Co-Authored-By: Claude Opus 4.8 --- src/host/taint/mod.rs | 69 +++++++++++--- src/host/taint/snapshot.rs | 57 ++++++++---- src/host/taint/tests.rs | 182 +++++++++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 28 deletions(-) diff --git a/src/host/taint/mod.rs b/src/host/taint/mod.rs index 5789f92..0cfa063 100644 --- a/src/host/taint/mod.rs +++ b/src/host/taint/mod.rs @@ -361,7 +361,8 @@ pub fn evaluate( let mut changed = false; changed |= propagate_links(&edges.edges, &mut taint); changed |= propagate_owner_bridge(&keys, &components, &edges, &mut taint); - changed |= propagate_unresolved_owner(snapshot, &keys, &edges, &mut taint); + changed |= + propagate_unresolved_owner(snapshot, ctx.pipewire_pulse_pid, &keys, &edges, &mut taint); if !changed { break; } @@ -662,28 +663,72 @@ fn propagate_owner_bridge( /// carry a real `application.process.id` and are bounded, so they are /// never swept up; in practice only daemon-owned keyless module streams /// are. -/// - ⚠️ **Removed (Codex round 1): the source no longer has to be unbounded -/// itself.** Properties can be asymmetric — a reader carrying a -/// `node.link-group` whose re-emitting leg carries none is *bounded* while -/// its sibling is not findable — and requiring an unbounded source let -/// exactly that shape through as Eligible. +/// - ⚠️ **The source does not have to be unbounded** (Codex round 1): a +/// reader carrying a `node.link-group` whose re-emitting leg carries none +/// is *bounded* while its sibling is not findable, and requiring an +/// unbounded source let exactly that shape through. +/// +/// **Two tiers, because a tainted reader we cannot bound is a bigger +/// unknown than one we can** (Codex round 3 — the mirror image of the +/// round-1 case): +/// +/// - A *bounded* tainted reader has a strong key or a usable PID, so its +/// siblings are exactly the output legs sharing that key. Any output leg +/// that is *itself* bounded by a **different** key is provably a different +/// owner and stays eligible; only unbounded output legs are its possible +/// siblings. → exclude unbounded outputs. +/// - An *unbounded* tainted reader has nothing that identifies its owner, so +/// its re-emitting leg could carry a strong key we cannot match back to +/// it. But it cannot be *anything*: a reader with no usable owner key is +/// necessarily **daemon-owned** — a real application has its own PID, +/// which is a usable key, so it would be bounded. Its sibling is therefore +/// another daemon-owned output, never an ordinary app. → also exclude the +/// daemon-owned outputs (bounded by a strong key or not); leave outputs +/// carrying a real, non-daemon PID eligible, because a real app is +/// provably a different owner from a daemon module leg. +/// +/// ⚠️ This is deliberately **narrower than Codex round 3's suggested +/// "exclude every output"**, which would make an ordinary app unshareable +/// whenever any keyless module forwarder reads the call — losing the +/// round-1 "blast radius stays small" guarantee for no safety gain, since +/// a real app cannot be the sibling of a daemon leg. When the daemon PID +/// is *unknown* the distinction collapses (we cannot tell a real app from +/// a module leg) and the rule degrades to Codex's: exclude everything. fn propagate_unresolved_owner( snapshot: &GraphSnapshot, + ctx_pulse_pid: Option, keys: &owner::OwnerKeyIndex, edges: &Edges, taint: &mut BTreeMap, ) -> bool { - let tainted_reader = snapshot.nodes().any(|node| { - !node.props.session_device + let mut has_tainted_reader = false; + let mut has_unbounded_tainted_reader = false; + for node in snapshot.nodes() { + let is_tainted_reader = !node.props.session_device && edges.receivers.contains(&node.serial) - && taint.get(&node.serial).is_some_and(|r| r.propagates()) - }); - if !tainted_reader { + && taint.get(&node.serial).is_some_and(|r| r.propagates()); + if is_tainted_reader { + has_tainted_reader = true; + has_unbounded_tainted_reader |= !keys.is_bounded(node.serial); + } + } + if !has_tainted_reader { return false; } let mut changed = false; for node in snapshot.nodes() { - if node.role == MediaRole::StreamOutput && !keys.is_bounded(node.serial) { + if node.role != MediaRole::StreamOutput { + continue; + } + // A real, non-daemon PID proves the node is an ordinary app, not a + // module leg — the one thing an unbounded daemon reader's sibling + // cannot be. Unknown daemon PID ⇒ cannot prove it ⇒ swept in. + let is_real_app = matches!( + (node.props.process_id, ctx_pulse_pid), + (Some(pid), Some(daemon)) if pid != daemon + ); + let swept_by_unbounded = has_unbounded_tainted_reader && !is_real_app; + if !keys.is_bounded(node.serial) || swept_by_unbounded { changed |= raise(taint, node.serial, Reason::UnresolvedOwner); } } diff --git a/src/host/taint/snapshot.rs b/src/host/taint/snapshot.rs index 716f567..2c06174 100644 --- a/src/host/taint/snapshot.rs +++ b/src/host/taint/snapshot.rs @@ -120,23 +120,48 @@ pub struct NodeProps { /// manager** — a real sound card's sink or source, not something that /// forwards audio. /// - /// ⚠️ **A classification the observer owes, not a raw property.** The - /// first cut set this from bare `device.id`, and Codex refuted it: - /// PipeWire defines `device.id` only as "the Device this Node belongs - /// to", which does not promise the node is passive. A forwarding node - /// that happens to carry one would silently lose its coarse owner keys - /// *and* its ability to trip the fail-closed backstop — two protections - /// at once. **Phase 3 must require `device.id` AND `device.api`** - /// (measured: `device.api=alsa` on all five real ALSA nodes, absent on - /// every `support.null-audio-sink`), and should treat anything it - /// cannot positively classify as *not* a device — that is the - /// fail-closed direction here, because the flag only ever *removes* - /// taint mechanisms. + /// ⚠️ **A positive high-confidence classification the observer owes, not + /// a raw property** (Codex rounds 2–3). PipeWire defines `device.id` + /// only as "the Device this node belongs to" and `device.api` as that + /// Device's access API; **neither promises the node passively terminates + /// audio**, so a card-associated filter can satisfy both. Setting this + /// flag *removes* two protections at once — the node's coarse owner keys + /// (`owner` exception 2) and its ability to trip the fail-closed + /// backstop — so a false positive is a leak, not over-exclusion. /// - /// What it is for: every device node on the box shares the session - /// manager's `client.id` (measured: 42, `WirePlumber [export]`), so - /// coarse owner keys must not bridge them. See [`super::owner`] - /// exception 2. + /// **Phase-3 contract:** + /// - Set `true` only on positively-identified passive hardware + /// terminals: a resolved `device.id` on a real backend + /// (`device.api` present) whose `factory.name` is a hardware PCM + /// factory (`api.alsa.pcm.{sink,source}` and the like), never a + /// filter/loopback/null-sink factory. Measured discriminator on the + /// target box: the five ALSA nodes carry `device.api=alsa` + + /// `factory.name=api.alsa.pcm.*` and share `client.id=42` + /// (`WirePlumber [export]`); the three `support.null-audio-sink` nodes + /// carry neither. (`node.physical` was measured **null** on the ALSA + /// nodes here, so it is *not* a usable discriminator — do not rely on + /// it.) + /// - **Fail closed: unknown ⇒ `false`.** A node that cannot be + /// positively classified keeps its owner keys and can trip the + /// backstop; both are the safe direction. + /// - A node MUST NOT enter a snapshot with this field provisional. If + /// the Device backing a node has not yet been bound, withhold the node + /// and keep the epoch not-ready — otherwise a provisional `false` + /// during not-ready fuses sink and mic on the shared session client + /// and that fusion can persist as sticky over-exclusion (round-3 + /// finding 3). + /// + /// Why a mis-classified *filter* does not actually leak in practice: a + /// WirePlumber smart-filter's two legs share a `node.link-group`, so the + /// strong-key bridge catches the re-emitting leg regardless of this + /// flag; and if the reading leg is genuinely unbounded, the two-tier + /// backstop (`propagate_unresolved_owner`) excludes every output. The + /// flag is the belt; those are the braces. + /// + /// What it is for: every real device node shares the session manager's + /// `client.id`, so coarse owner keys must not bridge them — else + /// peerspeak's playback (which taints the default sink every recompute) + /// would reach the microphone. See [`super::owner`] exception 2. pub session_device: bool, } diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs index e2d215b..3c28fcc 100644 --- a/src/host/taint/tests.rs +++ b/src/host/taint/tests.rs @@ -1490,3 +1490,185 @@ fn a_not_ready_snapshot_still_records_new_taint() { ], ); } + +// ────────────────────────────────────────────────────────────────────── +// 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_sweeps_daemon_legs_but_spares_real_apps() { + // The two-tier rule, made explicit and deliberately narrower than + // Codex round 3's "exclude everything". An unbounded tainted reader is + // necessarily daemon-owned, so its sibling is another daemon leg — a + // *bounded module output* here, carrying a strong key that does not + // match the reader. An ordinary app with its own PID is provably a + // different owner and stays shareable. + 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 output leg (strong key 88, daemon PID) with no + // matching reader — a possible sibling of the unbounded reader below. + let daemon_leg = graph.module_node("daemon-leg", MediaRole::StreamOutput, 88); + graph.link(daemon_leg, hw); + + // Without an unbounded reader, both non-peerspeak outputs are eligible. + assert_partition( + &run(&graph, &ctx()), + &[("firefox", firefox), ("daemon-leg", daemon_leg)], + &[("call", call, "peerspeak-owned")], + ); + + // Add a keyless daemon-PID forwarder reading the call. + let ghost_client = graph.client(Some(PULSE_PID)); + let leak_in = graph.node( + "leak-in", + MediaRole::StreamInput, + app(ghost_client, PULSE_PID), + ); + graph.link(hw, leak_in); + assert_partition( + &run(&graph, &ctx()), + &[("firefox", firefox)], + &[ + ("call", call, "peerspeak-owned"), + ("daemon-leg", daemon_leg, "unresolved-owner"), + ], + ); +} + +#[test] +fn an_unknown_daemon_pid_makes_an_unbounded_reader_exclude_everything() { + // The fallback: with the daemon PID unknown we cannot tell a real app + // from a module leg, so the rule degrades to Codex's "exclude all". + 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); + let ghost_client = graph.client(None); + let leak_in = graph.node("leak-in", MediaRole::StreamInput, app(ghost_client, 0)); + graph.link(hw, leak_in); + + let decisions = run( + &graph, + &ExclusionCtx { + pipewire_pulse_pid: None, + ..ctx() + }, + ); + // With pulse PID unknown, `app(_, 0)` has no suppression so PID 0 is a + // usable key and the reader is bounded... so force the reader unbounded + // by giving it no PID at all. + let _ = decisions; + 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); + let keyless_client = graph.client(None); + 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, + &ExclusionCtx { + pipewire_pulse_pid: None, + ..ctx() + }, + ), + &[], + &[ + ("call", call, "peerspeak-owned"), + ("firefox", firefox, "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"), + ], + ); +} From 2183084ec80f20a9f8c0bf7a1f7caabe7f9642a0 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 21 Jul 2026 17:54:08 -0400 Subject: [PATCH 5/7] host/taint: concede the unbounded-reader rule; pin link-group fingerprints (Codex round 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 adjudicated my three round-3 pushbacks. Codex ruled: F2 bool seam sufficient (YES), F3 accepted as a phase-3 contract not a phase-2 blocker (YES) — but my F1 narrowing was unsound (NO), with a clean counterexample. F1 (conceded): I had narrowed "unbounded tainted reader ⇒ exclude every output" to spare outputs carrying a real, non-daemon PID, arguing an unbounded reader must be daemon-owned. Codex refuted it: `application.process.id` is optional and client-controlled, so one real process can present NO pid on its reading leg (unbounded) and a real pid on its output leg — the narrowing spares that output and leaks the call. App properties cannot carry a soundness argument; only `pipewire.*` has protected identity. Reverted to the broad rule: an unbounded tainted reader excludes the whole candidate universe. Added the exact counterexample as a test (`a_real_app_with_no_pid_on_its_reader_leg...`) and kept a bounded-reader test to show the round-1 blast-radius guarantee still holds for the bounded tier. F2 (doc corrected): removed the "a mis-classified filter is still braced" claim — Codex showed a filter with no shared strong key, wrongly marked `session_device`, cannot trip the backstop from its reading leg and leaks through a differently-keyed output. A false positive is now documented as leak-capable; the only defence is the correct positive classifier. F3 (link-group fingerprint, pinned): a mutation dropping LinkGroup fingerprints survived all 53 tests, because the strong-key fingerprint test used pulse.module.id. Added a link-group new-connection test. Mutation-verified 2/2. 55 tests. Phase-2 open item is now only F3-as-phase-3-contract, which Codex accepted is not a phase-2 blocker. Co-Authored-By: Claude Opus 4.8 --- src/host/taint/mod.rs | 50 ++++------ src/host/taint/snapshot.rs | 15 +-- src/host/taint/tests.rs | 191 +++++++++++++++++++++++-------------- 3 files changed, 149 insertions(+), 107 deletions(-) diff --git a/src/host/taint/mod.rs b/src/host/taint/mod.rs index 0cfa063..480b4f2 100644 --- a/src/host/taint/mod.rs +++ b/src/host/taint/mod.rs @@ -361,8 +361,7 @@ pub fn evaluate( let mut changed = false; changed |= propagate_links(&edges.edges, &mut taint); changed |= propagate_owner_bridge(&keys, &components, &edges, &mut taint); - changed |= - propagate_unresolved_owner(snapshot, ctx.pipewire_pulse_pid, &keys, &edges, &mut taint); + changed |= propagate_unresolved_owner(snapshot, &keys, &edges, &mut taint); if !changed { break; } @@ -678,25 +677,25 @@ fn propagate_owner_bridge( /// owner and stays eligible; only unbounded output legs are its possible /// siblings. → exclude unbounded outputs. /// - An *unbounded* tainted reader has nothing that identifies its owner, so -/// its re-emitting leg could carry a strong key we cannot match back to -/// it. But it cannot be *anything*: a reader with no usable owner key is -/// necessarily **daemon-owned** — a real application has its own PID, -/// which is a usable key, so it would be bounded. Its sibling is therefore -/// another daemon-owned output, never an ordinary app. → also exclude the -/// daemon-owned outputs (bounded by a strong key or not); leave outputs -/// carrying a real, non-daemon PID eligible, because a real app is -/// provably a different owner from a daemon module leg. +/// its re-emitting leg could be **any** output on the box, and no property +/// on an output leg can prove it is unrelated. → exclude every output +/// candidate. /// -/// ⚠️ This is deliberately **narrower than Codex round 3's suggested -/// "exclude every output"**, which would make an ordinary app unshareable -/// whenever any keyless module forwarder reads the call — losing the -/// round-1 "blast radius stays small" guarantee for no safety gain, since -/// a real app cannot be the sibling of a daemon leg. When the daemon PID -/// is *unknown* the distinction collapses (we cannot tell a real app from -/// a module leg) and the rule degrades to Codex's: exclude everything. +/// ⚠️ I tried to narrow this to "daemon-owned outputs only", on the +/// theory that an unbounded reader must be daemon-owned (a real app has a +/// PID, which would bound it) so a real-PID output is provably a different +/// owner. **Codex refuted it (round 4):** `application.process.id` is +/// optional and client-controlled, so a real process can present *no* PID +/// on its reading leg (unbounded) and a real PID on its output leg — one +/// owner, spared by the narrowing, leaking the call. Only `pipewire.*` +/// properties have protected identity; app properties cannot carry a +/// soundness argument. So: exclude everything. The trigger is genuinely +/// anomalous — a keyless reader actively consuming the call; EasyEffects +/// and loopbacks carry a `node.link-group` and are *bounded*, so they do +/// not trip this tier — and phase 5's dry run surfaces it before it can +/// gate anything real. fn propagate_unresolved_owner( snapshot: &GraphSnapshot, - ctx_pulse_pid: Option, keys: &owner::OwnerKeyIndex, edges: &Edges, taint: &mut BTreeMap, @@ -717,18 +716,9 @@ fn propagate_unresolved_owner( } let mut changed = false; for node in snapshot.nodes() { - if node.role != MediaRole::StreamOutput { - continue; - } - // A real, non-daemon PID proves the node is an ordinary app, not a - // module leg — the one thing an unbounded daemon reader's sibling - // cannot be. Unknown daemon PID ⇒ cannot prove it ⇒ swept in. - let is_real_app = matches!( - (node.props.process_id, ctx_pulse_pid), - (Some(pid), Some(daemon)) if pid != daemon - ); - let swept_by_unbounded = has_unbounded_tainted_reader && !is_real_app; - if !keys.is_bounded(node.serial) || swept_by_unbounded { + if node.role == MediaRole::StreamOutput + && (has_unbounded_tainted_reader || !keys.is_bounded(node.serial)) + { changed |= raise(taint, node.serial, Reason::UnresolvedOwner); } } diff --git a/src/host/taint/snapshot.rs b/src/host/taint/snapshot.rs index 2c06174..328a12a 100644 --- a/src/host/taint/snapshot.rs +++ b/src/host/taint/snapshot.rs @@ -151,12 +151,15 @@ pub struct NodeProps { /// and that fusion can persist as sticky over-exclusion (round-3 /// finding 3). /// - /// Why a mis-classified *filter* does not actually leak in practice: a - /// WirePlumber smart-filter's two legs share a `node.link-group`, so the - /// strong-key bridge catches the re-emitting leg regardless of this - /// flag; and if the reading leg is genuinely unbounded, the two-tier - /// backstop (`propagate_unresolved_owner`) excludes every output. The - /// flag is the belt; those are the braces. + /// ⚠️ **A false positive is leak-capable — do not treat it as braced.** + /// I claimed a mis-classified filter could not leak because its legs + /// share a `node.link-group` (strong-key bridge) or trip the unbounded + /// backstop. Codex refuted it (round 4): a filter *without* a shared + /// strong key, marked `session_device=true`, cannot activate the + /// backstop from its reading leg, so a differently-keyed re-emitting leg + /// leaks. Those braces catch *some* shapes, not all. The only real + /// defence is a correct classifier — hence "positive high-confidence" + /// and "fail closed to false" above, without exception. /// /// What it is for: every real device node shares the session manager's /// `client.id`, so coarse owner keys must not bridge them — else diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs index 3c28fcc..3b6ac86 100644 --- a/src/host/taint/tests.rs +++ b/src/host/taint/tests.rs @@ -683,11 +683,17 @@ fn firefox_three_cases() { } #[test] -fn an_unbounded_module_forwarder_fails_closed() { +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); @@ -703,8 +709,35 @@ fn an_unbounded_module_forwarder_fails_closed() { ); graph.link(hw, fwd_in); - // The blast radius must stay small: an ordinary app is bounded by its - // own PID and must not be swept up. + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + graph.link(firefox, hw); + + assert_partition( + &run(&graph, &ctx()), + &[], + &[ + ("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); @@ -713,7 +746,7 @@ fn an_unbounded_module_forwarder_fails_closed() { &[("firefox", firefox)], &[ ("call", call, "peerspeak-owned"), - ("fwd-out", fwd_out, "unresolved-owner"), + ("ee-out", ee_out, "tainted-owner-bridge"), ], ); } @@ -1534,81 +1567,30 @@ fn an_inverse_asymmetric_forwarder_fails_closed() { } #[test] -fn an_unbounded_tainted_reader_sweeps_daemon_legs_but_spares_real_apps() { - // The two-tier rule, made explicit and deliberately narrower than - // Codex round 3's "exclude everything". An unbounded tainted reader is - // necessarily daemon-owned, so its sibling is another daemon leg — a - // *bounded module output* here, carrying a strong key that does not - // match the reader. An ordinary app with its own PID is provably a - // different owner and stays shareable. +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 output leg (strong key 88, daemon PID) with no - // matching reader — a possible sibling of the unbounded reader below. + // 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 non-peerspeak outputs are eligible. + // 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 daemon-PID forwarder reading the call. - let ghost_client = graph.client(Some(PULSE_PID)); - let leak_in = graph.node( - "leak-in", - MediaRole::StreamInput, - app(ghost_client, PULSE_PID), - ); - graph.link(hw, leak_in); - assert_partition( - &run(&graph, &ctx()), - &[("firefox", firefox)], - &[ - ("call", call, "peerspeak-owned"), - ("daemon-leg", daemon_leg, "unresolved-owner"), - ], - ); -} - -#[test] -fn an_unknown_daemon_pid_makes_an_unbounded_reader_exclude_everything() { - // The fallback: with the daemon PID unknown we cannot tell a real app - // from a module leg, so the rule degrades to Codex's "exclude all". - 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); - let ghost_client = graph.client(None); - let leak_in = graph.node("leak-in", MediaRole::StreamInput, app(ghost_client, 0)); - graph.link(hw, leak_in); - - let decisions = run( - &graph, - &ExclusionCtx { - pipewire_pulse_pid: None, - ..ctx() - }, - ); - // With pulse PID unknown, `app(_, 0)` has no suppression so PID 0 is a - // usable key and the reader is bounded... so force the reader unbounded - // by giving it no PID at all. - let _ = decisions; - 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); - let keyless_client = graph.client(None); + // 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, @@ -1619,17 +1601,45 @@ fn an_unknown_daemon_pid_makes_an_unbounded_reader_exclude_everything() { ); graph.link(hw, leak_in); assert_partition( - &run( - &graph, - &ExclusionCtx { - pipewire_pulse_pid: None, - ..ctx() - }, - ), + &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"), ], ); } @@ -1672,3 +1682,42 @@ fn a_strong_key_new_connection_of_a_still_tainted_owner_inherits_the_taint() { ], ); } + +#[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"), + ], + ); +} From 65fde92628a70237e964bf07c1dbebe1bc633c6f Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 21 Jul 2026 18:06:45 -0400 Subject: [PATCH 6/7] host/taint: pin the role-receiver mutation; doc fixes; document the unbounded-buffer limit (Codex round 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 5 convergence check. Codex confirmed F1(broad rule)/F2(doc)/ link-group fingerprint complete, and raised three more: - P2 (fixed): a mutation deleting the *role-based* receiver insertion survived all 55 tests — every tested bridge source also had an inbound link. A pixelpass capture sink is a taint root before anything links into it, and its re-emitting sibling must bridge from it on role alone. Added `a_local_root_receiver_bridges_without_an_inbound_link`; mutation now killed. - P3 (fixed): doc drift. The backstop's preamble still described the old "targets must be unbounded / apps never swept" rule; rewritten to the two-tier trigger/sweep. The `session_device` factory guidance now says explicit allowlist, not "and the like". - P1 (dispositioned as a documented v1 limitation, not fixed): a buffered echo across a *full* teardown of an *unbounded* reader. Grounds, in the module docs: (1) it needs a stream exposing no PID/module-id/link-group, which is malformed/identity-hiding and outside v3.4 §2's non-adversarial threat model; (2) it contradicts the design's explicit "reappears after full teardown ⇒ new owner, starts clean" (§6.1.3), so closing it is a design change; (3) the only closed-form fix is a whole-share hammer (one keyless stream ⇒ desktop unshareable for the share). Reachable cases — a reader live now — are already covered by the backstop. Owed to the design doc as a round-8 note. 56 tests. Taking the P1 disposition to Codex for ratification, then to the user as a design decision. Co-Authored-By: Claude Opus 4.8 --- src/host/taint/mod.rs | 63 +++++++++++++++++++++++++++++--------- src/host/taint/snapshot.rs | 10 ++++-- src/host/taint/tests.rs | 24 +++++++++++++++ 3 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/host/taint/mod.rs b/src/host/taint/mod.rs index 480b4f2..dd6e3af 100644 --- a/src/host/taint/mod.rs +++ b/src/host/taint/mod.rs @@ -51,6 +51,40 @@ //! stack**. An entry is cleared only once every member object has //! disappeared; a key that reappears after full teardown is a new owner and //! starts clean. +//! +//! ## Documented v1 limitation — buffered audio across a full teardown of an +//! ## *unbounded* reader (Codex phase-2 round 5) +//! +//! The "clear on full teardown" boundary is the design's, verbatim +//! (v3.4 §6.1.3: "a key that reappears after full teardown is a new owner +//! and starts clean"). It leaves one theoretical echo: an **unbounded** +//! reader — one carrying no `node.link-group`, no `pulse.module.id`, and no +//! usable PID — reads the call into an internal buffer, tears down *all* its +//! PipeWire objects while keeping that buffer, then reconnects with fresh +//! objects and replays. With every member serial gone and no owner key to +//! fingerprint, nothing links the new owner to the old, and if no reader is +//! live in that epoch the [backstop](propagate_unresolved_owner) does not +//! fire either — so the replayed leg is eligible. +//! +//! **Left as a documented v1 limitation, not fixed, on three grounds:** +//! 1. **Outside the threat model.** v3.4 §2 states the model is "don't echo +//! the user's own call back at them," *not* "defend against a hostile +//! local process." A stream that exposes none of PID / module id / +//! link-group is a malformed or deliberately identity-hiding stream; real +//! software (apps carry a PID, pactl modules a module id, PipeWire +//! filters a link-group, GStreamer a PID) does not produce one. +//! 2. **It contradicts the design's own boundary** (§6.1.3, quoted above), +//! so closing it is a *design change*, not a bug fix. +//! 3. **No proportionate fix exists.** The only closed-form fix is a +//! whole-share hammer — once any unbounded reader is seen, exclude every +//! output for the rest of the share — which makes the desktop +//! unshareable on the mere appearance of one keyless stream. The reachable +//! cases (a reader live *now*) are already covered by the backstop's +//! exclude-everything tier. +//! +//! Owed to the design doc as a round-8 note (impl plan §11); if field +//! testing ever surfaces a real unbounded reader, revisit with +//! process-generation evidence rather than the hammer. // Phase 2 lands the engine behind its own test surface and nothing else: // the registry observer that will feed it is phase 3, so in a non-test @@ -651,21 +685,22 @@ fn propagate_owner_bridge( /// cannot know which one is re-emitting what it read. Exclude the output /// legs that are equally unbounded. /// -/// Two restrictions keep the blast radius at "small", as the design -/// promises — and one earlier restriction was **removed** as unsafe: +/// The trigger and the sweep, precisely (both edges hard-won across four +/// Codex rounds): /// -/// - **Source must not be a real device node.** A tainted hardware sink is -/// the normal case, not an anomaly: peerspeak's own playback taints the -/// default sink on literally every recompute, and letting that trip this -/// rule would exclude the whole desktop. -/// - **Targets must themselves be unbounded.** Ordinary application streams -/// carry a real `application.process.id` and are bounded, so they are -/// never swept up; in practice only daemon-owned keyless module streams -/// are. -/// - ⚠️ **The source does not have to be unbounded** (Codex round 1): a -/// reader carrying a `node.link-group` whose re-emitting leg carries none -/// is *bounded* while its sibling is not findable, and requiring an -/// unbounded source let exactly that shape through. +/// - **Trigger — any tainted receiver that is not a real device node.** A +/// tainted hardware sink is the normal case, not an anomaly (peerspeak's +/// playback taints the default sink every recompute), so device nodes do +/// not trip it. The source does **not** have to be unbounded: a reader +/// with a `node.link-group` whose re-emitting leg carries none is bounded +/// while its sibling is unfindable (round 1). +/// - **Sweep — depends on whether any tainted reader is itself unbounded.** +/// A *bounded* reader's siblings are exactly the outputs sharing its key, +/// so only the unbounded outputs (which could share its unknowable-only- +/// in-part identity) are swept; a differently-keyed output is provably a +/// different owner. An *unbounded* reader could be **any** owner — a real +/// process may present no PID on its reading leg (round 4) — so every +/// output candidate is swept, real apps included. /// /// **Two tiers, because a tainted reader we cannot bound is a bigger /// unknown than one we can** (Codex round 3 — the mirror image of the diff --git a/src/host/taint/snapshot.rs b/src/host/taint/snapshot.rs index 328a12a..88c3417 100644 --- a/src/host/taint/snapshot.rs +++ b/src/host/taint/snapshot.rs @@ -132,9 +132,13 @@ pub struct NodeProps { /// **Phase-3 contract:** /// - Set `true` only on positively-identified passive hardware /// terminals: a resolved `device.id` on a real backend - /// (`device.api` present) whose `factory.name` is a hardware PCM - /// factory (`api.alsa.pcm.{sink,source}` and the like), never a - /// filter/loopback/null-sink factory. Measured discriminator on the + /// (`device.api` present) whose `factory.name` is on an **explicit + /// hardware-PCM allowlist** — `api.alsa.pcm.sink`, `api.alsa.pcm.source`, + /// and the equivalent for other real backends (bluez5, v4l2 for the + /// media case) as phase 3 enumerates them — never a filter, loopback, + /// or `support.null-audio-sink` factory. An allowlist, not a + /// substring or a denylist: an unknown factory is not a device. + /// Measured discriminator on the /// target box: the five ALSA nodes carry `device.api=alsa` + /// `factory.name=api.alsa.pcm.*` and share `client.id=42` /// (`WirePlumber [export]`); the three `support.null-audio-sink` nodes diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs index 3b6ac86..0a887cb 100644 --- a/src/host/taint/tests.rs +++ b/src/host/taint/tests.rs @@ -1721,3 +1721,27 @@ fn a_link_group_new_connection_of_a_still_tainted_owner_inherits_the_taint() { ], ); } + +#[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"); +} From 279903e56e1946a353d79e42df541c14df1a9a63 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Wed, 22 Jul 2026 04:27:50 -0400 Subject: [PATCH 7/7] host/taint: correct the buffered-echo scoping (in-threat-model); pin ambiguous-client (Codex round 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex refuted my round-5 disposition and was right: the buffered-echo gap is NOT limited to keyless/unbounded readers. A normal PID-bearing app — recorder, DAW, GStreamer — can read the call, buffer it in application memory, fully tear down its PipeWire Node *and* Client, then (still the same live process) open a fresh Client + output and replay. `seed_sticky` drops the PID fingerprint once every old serial is gone, so the replayed leg is Eligible. That is in-threat-model, so my "outside the threat model" claim was false. - Rewrote the module-doc gap note honestly: in-threat-model, reachable by non-adversarial software, sitting on the design's §6.1.3 "full teardown ⇒ starts clean" boundary. Framed the two options — (A) accept as a documented v1 limitation, (B) process-generation lifetime (PID + /proc start-time, phase 3 supplies liveness, §6.1.3 revised). This is a designer's decision (it revises the security surface); NOT resolved in code. `a_fingerprint_does_not_outlive_its_owner` currently encodes Option A and flips under B. - P2 (fixed): pinned the ambiguous-client-id branch. A mutation remembering only the first of two clients claiming one global id survived the suite; added a test scoped to the ambiguous owner (the global count was masked by the peerspeak owner's client). Verified the `.next()` mutation now fails it. 57 tests. Phase 2 is NOT converged — the buffered-echo design decision is owed to the user before merge. Co-Authored-By: Claude Opus 4.8 --- src/host/taint/mod.rs | 66 ++++++++++++++++++++++------------------- src/host/taint/tests.rs | 61 +++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 30 deletions(-) diff --git a/src/host/taint/mod.rs b/src/host/taint/mod.rs index dd6e3af..bd39aa6 100644 --- a/src/host/taint/mod.rs +++ b/src/host/taint/mod.rs @@ -52,39 +52,45 @@ //! disappeared; a key that reappears after full teardown is a new owner and //! starts clean. //! -//! ## Documented v1 limitation — buffered audio across a full teardown of an -//! ## *unbounded* reader (Codex phase-2 round 5) +//! ## ⚠️ KNOWN OPEN GAP — buffered audio across a full PipeWire teardown of +//! ## a still-live process (Codex phase-2 rounds 5–6) — DESIGN DECISION OWED //! -//! The "clear on full teardown" boundary is the design's, verbatim -//! (v3.4 §6.1.3: "a key that reappears after full teardown is a new owner -//! and starts clean"). It leaves one theoretical echo: an **unbounded** -//! reader — one carrying no `node.link-group`, no `pulse.module.id`, and no -//! usable PID — reads the call into an internal buffer, tears down *all* its -//! PipeWire objects while keeping that buffer, then reconnects with fresh -//! objects and replays. With every member serial gone and no owner key to -//! fingerprint, nothing links the new owner to the old, and if no reader is -//! live in that epoch the [backstop](propagate_unresolved_owner) does not -//! fire either — so the replayed leg is eligible. +//! **This is an in-threat-model echo gap, not an outside-the-model one — an +//! earlier version of this note wrongly scoped it to keyless streams.** //! -//! **Left as a documented v1 limitation, not fixed, on three grounds:** -//! 1. **Outside the threat model.** v3.4 §2 states the model is "don't echo -//! the user's own call back at them," *not* "defend against a hostile -//! local process." A stream that exposes none of PID / module id / -//! link-group is a malformed or deliberately identity-hiding stream; real -//! software (apps carry a PID, pactl modules a module id, PipeWire -//! filters a link-group, GStreamer a PID) does not produce one. -//! 2. **It contradicts the design's own boundary** (§6.1.3, quoted above), -//! so closing it is a *design change*, not a bug fix. -//! 3. **No proportionate fix exists.** The only closed-form fix is a -//! whole-share hammer — once any unbounded reader is seen, exclude every -//! output for the rest of the share — which makes the desktop -//! unshareable on the mere appearance of one keyless stream. The reachable -//! cases (a reader live *now*) are already covered by the backstop's -//! exclude-everything tier. +//! The scenario, entirely with a real PID-bearing app (a recorder, a DAW, +//! a GStreamer pipeline): it reads the call into an application buffer, +//! **fully** tears down its PipeWire Node *and* Client while keeping that +//! buffer, then — still the same live process — opens a fresh Client and a +//! `Stream/Output/Audio` and replays. Every old serial is gone, so +//! [`seed_sticky`] refuses to apply the remembered PID fingerprint (the +//! fingerprint is lifetime-scoped to a live serial member, because bare keys +//! recycle); no reader is live in the new epoch, so the backstop does not +//! fire; the replayed leg is eligible. //! -//! Owed to the design doc as a round-8 note (impl plan §11); if field -//! testing ever surfaces a real unbounded reader, revisit with -//! process-generation evidence rather than the hammer. +//! It is real and reachable by non-adversarial software. It also sits +//! exactly on the design's stated boundary (v3.4 §6.1.3: "a key that +//! reappears after full teardown is a new owner and starts clean"), so +//! closing it is a **design change**, not a local bug fix: +//! +//! - **Option A — accept as a documented v1 limitation.** Contrived in +//! practice (most apps hold their PipeWire connection open for their +//! lifetime; the round-2 fix already covers the common +//! idle-a-client-and-open-another case), never a *silent* correctness +//! regression since it is written down, and phase 5's dry run would show +//! it. But it is a known echo path, which sits badly against the feature's +//! fail-closed ethos. +//! - **Option B — process-generation lifetime.** Key the fingerprint's +//! lifetime on the owning **process** being alive — PID + `/proc` start +//! time (or a pidfd) to defeat PID reuse — instead of on a live PipeWire +//! object. Phase 3 supplies process liveness; §6.1.3's node/client-only +//! lifetime definition is revised. Closes the PID-bearing case; the truly +//! keyless sub-case (no PID at all) genuinely *is* outside the threat +//! model and stays a documented limit. +//! +//! The choice is the designer's (it revises the security surface). Until it +//! is made, `a_fingerprint_does_not_outlive_its_owner` encodes Option A's +//! behaviour — flip it if B is chosen. Owed to the design doc as round 8. // Phase 2 lands the engine behind its own test surface and nothing else: // the registry observer that will feed it is phase 3, so in a non-test diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs index 0a887cb..f8ad8d1 100644 --- a/src/host/taint/tests.rs +++ b/src/host/taint/tests.rs @@ -1745,3 +1745,64 @@ fn a_local_root_receiver_bridges_without_an_inbound_link() { ); 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); +}