//! 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. **Hardware-device bridges** — a passive sink can feed a passive source //! on the same physical Device through a mixer/loopback path that PipeWire //! does not expose as a Link. The observer positively classifies both //! terminals and retains their shared `device.id`; the walk conservatively //! adds `sink → source` for that one Device. //! 4. **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. //! //! ## ⚠️ KNOWN OPEN GAP — buffered audio across a full PipeWire teardown of //! ## a still-live process (Codex phase-2 rounds 5–6) — DESIGN DECISION OWED //! //! **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.** //! //! 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. //! //! 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 // build every item here is legitimately unreachable for now. #![allow(dead_code)] pub mod owner; pub mod snapshot; // `pub` so the phase-5 audit's pure tests can drive the auditor with the same // graph builder the taint fixtures use — one fixture vocabulary, so an audit // test and a taint test describing the same topology cannot drift apart. #[cfg(test)] pub 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-"; /// Ownership carrier 1: the node property peerspeak sets on everything it /// plays (v3.5 §5.1). Read at the observer boundary, which is the only place /// that touches raw property names — see [`super::observer`]. /// /// ⚠️ **Cross-repo wire contract.** peerspeak emits this; it does not depend /// on this crate, nor this crate on it. The values are pinned in /// `tests/fixtures/ownership-tag-contract.txt`, committed byte-identical in /// both repos, and asserted by [`tests::ownership_carriers_match_the_cross_repo_fixture`]. /// The producer's matching constants live in peerspeak /// `src/audio/ownership.rs`. Changing either is a both-repos-same-session /// change that invalidates the phase 5 matrix. pub const PEERSPEAK_OWNED_PROP: &str = "peerspeak.owned"; /// The value peerspeak emits for [`PEERSPEAK_OWNED_PROP`], and the **only** /// value this consumer reads as owned. /// /// ⚠️ This doc used to say the opposite — that any truthy value counted, on /// the theory that treating an unexpected value as "owned" is the fail-closed /// direction. R10-4 removed that leniency and the round-10 review caught the /// prose surviving it here and in the shared fixture. The theory is wrong: /// leniency buys false-positive *exclusion*, not safety, and it let any /// process suppress a rival application's audio from the share with a /// property it did not have to spell right. Fail-closed on this feature is /// about **ancestry** — an unresolvable graph is not eligible — not about /// parsing. The matching lives in the observer's `peerspeak_owned`, which is /// deliberately *not* the lenient `truthy` used for PipeWire's own booleans. pub const PEERSPEAK_OWNED_VALUE: &str = "1"; /// Ownership carrier 2: a `node.name` prefix (v3.5 §5.1, round 8). /// /// Matched as a **union** with [`PEERSPEAK_OWNED_PROP`] — either one makes a /// node peerspeak-owned. Two carriers because a property is invisible to the /// registry `global` event and recoverable only by binding the node (v3.5 /// §6.7), which is precisely how the phase-5 gate failed; this one is /// announced directly. A union is also the fail-closed direction: a missed /// tag leaks call audio into the share, a spurious one only over-excludes. pub const PEERSPEAK_OWNED_NODE_PREFIX: &str = "peerspeak_owned_"; /// 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, /// No usable configured Format param has arrived. Fan-out cannot prove a /// second link is safe. Local to the node; does not propagate. FormatUnknown, /// An encoded stream — a second raw-audio link would refuse or corrupt. /// Local to the node; does not propagate. Encoded, /// An IEC958/S/PDIF passthrough stream. Local to the node; does not /// propagate. Iec958Passthrough, } 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::FormatUnknown => "format-unknown", Self::Encoded => "encoded", Self::Iec958Passthrough => "iec958-passthrough", } } /// A clean, otherwise-eligible stream whose known format/port shape this /// fan-out mode cannot link safely. These reasons are user-visible /// `stream_unsupported` statuses; taint roots and observation gating are /// intentional exclusions, not failures. `FormatUnknown` is deliberately /// omitted because the initial Node-info callback can precede the Format /// reply; reporting that transient would produce a false warning. pub(super) fn reports_stream_unsupported(self) -> bool { matches!( self, Self::PortExclusive | Self::Encoded | Self::Iec958Passthrough ) } /// 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::FormatUnknown => 10, Self::Encoded => 11, Self::Iec958Passthrough => 12, } } /// 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, /// 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` /// 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; 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 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, prior: &StickyState, ) -> (Decisions, StickyState) { // Built once and shared: it carries the Client → `pipewire.sec.pid` index // that key 4 falls back to (round 10, R10-3), so the components and the // key index must be derived from the *same* one or they would disagree // about which nodes are bounded. let owner_ctx = owner::OwnerCtx::new(snapshot, ctx.pipewire_pulse_pid); let components = OwnerComponents::build(snapshot, &owner_ctx); let keys = owner::OwnerKeyIndex::build(snapshot, &owner_ctx); // Pass 1 — the fail-closed view. Every decision is made from this one, so // "we could not see" counts as taint. let (taint, sticky_serials) = compute_taint( snapshot, ctx, &keys, &components, prior, Uncertainty::FailsClosed, ); let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials); // Pass 2 — the evidence-only view, and the only thing sticky state is // ever built from (see [`Uncertainty`]). let (evidence, _) = compute_taint( snapshot, ctx, &keys, &components, prior, Uncertainty::Ignored, ); // ⚠️ 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, &evidence, prior, ctx.graph_ready, ); (decisions, next_sticky) } /// Whether a pass treats "we could not see" as taint. /// /// **Both passes exist because stickiness is a claim about history, and /// uncertainty is not history.** A node tainted only because the graph was /// mid-enumeration has had nothing observed about it; remembering that as /// taint forever is over-exclusion with no evidence behind it, and phase 3r's /// bind-everything observer makes the window it happens in systematically /// wide (every node is withheld until its bind resolves, so any link observed /// across that gap raises [`Reason::UnresolvedAncestry`] on its input side). /// Measured on a live desktop: a hardware sink acquired a permanent sticky /// taint at every startup, from one link seen while its output node was still /// unbound. /// /// Retiring by *reason code* is not enough, because uncertainty launders /// itself: an unresolved node propagates [`Reason::TaintedUpstream`] to its /// downstream, and that reason is indistinguishable from real contamination /// once recorded. So the split is by **provenance** — the sticky pass never /// raises an uncertainty root at all, and nothing derived from one can reach /// it. Decisions are unaffected: they are made from the fail-closed pass, /// which is unchanged. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Uncertainty { /// Unresolved ancestry and an unbounded tainted reader are taint /// (v3.4 §6.1, §6.1.1, §6.1.4). FailsClosed, /// Only positively observed contamination counts. Ignored, } /// One taint fixpoint over the snapshot. The `uncertainty` mode decides /// whether absence of evidence is treated as evidence of contamination. fn compute_taint( snapshot: &GraphSnapshot, ctx: &ExclusionCtx, keys: &owner::OwnerKeyIndex, components: &OwnerComponents, prior: &StickyState, uncertainty: Uncertainty, ) -> (BTreeMap, BTreeSet) { let fails_closed = uncertainty == Uncertainty::FailsClosed; let mut taint: BTreeMap = BTreeMap::new(); let mut sticky_serials: BTreeSet = BTreeSet::new(); seed_local_roots(snapshot, ctx, &mut taint); if fails_closed { for serial in ambiguous_id_nodes(snapshot) { raise(&mut taint, serial, Reason::UnresolvedAncestry); } } seed_sticky( snapshot, keys, prior, components, &mut taint, &mut sticky_serials, ); // Edges are built identically in both passes — receiver status is a // topological fact and must not depend on the mode, or the owner bridge // would see two different graphs. let mut unresolved_input: BTreeSet = BTreeSet::new(); let edges = downstream_edges(snapshot, &mut unresolved_input); if fails_closed { for serial in unresolved_input { raise(&mut taint, serial, Reason::UnresolvedAncestry); } } // 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. loop { let mut changed = false; changed |= propagate_links(&edges.edges, &mut taint); changed |= propagate_owner_bridge(keys, components, &edges, &mut taint); if fails_closed { changed |= propagate_unresolved_owner(snapshot, keys, &edges, &mut taint); } if !changed { break; } } (taint, sticky_serials) } /// 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); } } } /// Nodes whose own global id is ambiguous: they cannot be the reliable /// endpoint of any link, so their ancestry is unresolvable. Uncertainty, not /// evidence — see [`Uncertainty`]. fn ambiguous_id_nodes(snapshot: &GraphSnapshot) -> BTreeSet { snapshot .nodes() .filter(|node| snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous)) .map(|node| node.serial) .collect() } /// Does this node carry either ownership carrier? **Tag presence only** — it /// deliberately says nothing about whether the tag is honoured, which is /// `local_root_reason`'s business (round 10 restricts that to producers). /// Split out so the "is it tagged?" and "does the tag count?" questions can /// be tested, and reported, independently. pub fn is_peerspeak_tagged(node: &NodeSnapshot) -> bool { node.props.peerspeak_owned || node .name .as_deref() .is_some_and(|name| name.starts_with(PEERSPEAK_OWNED_NODE_PREFIX)) } /// Nodes carrying an ownership carrier that `local_root_reason` **ignored** /// because the node is not a producer (round 10, R10-1). Ascending by serial. /// /// Purely diagnostic — nothing in the engine consumes it. It exists because /// R10-1 turns a formerly load-bearing tag into a no-op, and a silently /// ignored tag has exactly two causes, both of which someone wants to know /// about: peerspeak tagging a node it should not (a producer-side bug this /// would otherwise hide), or another process impersonating the tag (the F2 /// attack, now defanged but still worth seeing). pub fn misplaced_ownership_tags(snapshot: &GraphSnapshot) -> Vec<&NodeSnapshot> { let mut tagged: Vec<&NodeSnapshot> = snapshot .nodes() .filter(|node| node.role != MediaRole::StreamOutput && is_peerspeak_tagged(node)) .collect(); tagged.sort_by_key(|node| node.serial); tagged } fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option { // The two ownership carriers, as a union (v3.5 §5.1). Kept here rather // than folded together at the observer boundary so that the union is a // pure, directly-testable rule: an adapter that collapsed both into the // one `peerspeak_owned` bool would make each carrier untestable alone, // which is exactly how phase 3r's row 1 nearly gated nothing. // // ⚠️ **Producer roles only** (round 10, R10-1). Neither carrier is a // security boundary — both are strings any unprivileged process can put // on its own node — so an unrestricted root is a denial of the whole // feature: an unlinked `Stream/Input/Audio` named `peerspeak_owned_x` // is a tainted *reader* with no owner bound to it, which fails every // candidate closed machine-wide (Codex phase-1 F2, reproduced live). // Restricting the root to `Stream/Output/Audio` costs nothing real — // peerspeak only ever tags playback streams — and the attack needs the // impostor to be a plausible playback node instead, which taints only // its own descendants. The AEC's virtual sink/source is unaffected: it // roots on [`Reason::AecIdentity`] below, by module id, not by this tag. // A tag on a non-producer falls through: ignored for taint, but not // nothing — it is either a peerspeak bug or an impostor, and // [`misplaced_ownership_tags`] surfaces it so neither is silent. if is_peerspeak_tagged(node) && node.role == MediaRole::StreamOutput { 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). /// /// 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, sticky_serials: &mut BTreeSet, ) { for entry in &prior.owners { 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. ObjectRef::Client(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 { 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); } } } } } /// 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. /// /// 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) .map(|c| c.id) else { return Vec::new(); }; snapshot .nodes() .filter(|node| node.props.client_id == Some(id)) .filter(|node| keys.uses_client_key(node.serial)) .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, unresolved_input: &mut BTreeSet) -> 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. Reported // rather than raised here, because whether "cannot say" is // taint depends on which pass is running ([`Uncertainty`]). unresolved_input.insert(to); receivers.insert(to); } (_, Some(IdLookup::Ambiguous)) => { // 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), ); } _ => {} } } // A physical sound device may route playback back into capture in its // own mixer/firmware without publishing a PipeWire Link (HDA "Stereo // Mix", USB loopback channels, vendor DSPs). Control-name inspection is // neither portable nor proof of absence, so v1 fails closed: once a // passive hardware sink is tainted, passive capture terminals exported // by the same Device are downstream too. // // Both guards are load-bearing. `session_device` limits this to the // observer's positive hardware-terminal allowlist, so an app-associated // filter cannot invent a bridge. `device_id` limits it to one physical // Device, so the shared WirePlumber client does not fuse every card. let hardware_outputs: Vec<(Serial, snapshot::GlobalId)> = snapshot .nodes() .filter(|node| { node.props.session_device && matches!(node.role, MediaRole::Sink | MediaRole::Duplex) }) .filter_map(|node| node.props.device_id.map(|id| (node.serial, id))) .collect(); let hardware_inputs: Vec<(Serial, snapshot::GlobalId)> = snapshot .nodes() .filter(|node| { node.props.session_device && matches!(node.role, MediaRole::Source | MediaRole::Duplex) }) .filter_map(|node| node.props.device_id.map(|id| (node.serial, id))) .collect(); for (from, output_device) in hardware_outputs { for &(to, input_device) in &hardware_inputs { if from != to && output_device == input_device { edges.entry(from).or_default().push(to); receivers.insert(to); } } } for targets in edges.values_mut() { targets.sort_unstable(); targets.dedup(); } // 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( 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( keys: &owner::OwnerKeyIndex, components: &OwnerComponents, edges: &Edges, taint: &mut BTreeMap, ) -> bool { let mut changed = false; for members in components.components() { let sources: BTreeSet = members .iter() .copied() .filter(|serial| { taint.get(serial).is_some_and(|r| r.propagates()) && edges.receivers.contains(serial) }) .collect(); if sources.is_empty() { continue; } for target in members { if sources.contains(target) { 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| keys.strongest_shared(*source, *target)) .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): 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. /// /// The trigger and the sweep, precisely (both edges hard-won across four /// Codex rounds): /// /// - **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 /// round-1 case): /// /// - A *bounded* tainted reader has a strong key, or a usable PID **backed by /// a resolved Client** (F11-1 — a node's self-claimed /// `application.process.id` no longer bounds anything on its own; see /// [`owner::owner_is_bounded`]), 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 be **any** output on the box, and no property /// on an output leg can prove it is unrelated. → exclude every output /// candidate. /// /// ⚠️ 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, keys: &owner::OwnerKeyIndex, edges: &Edges, taint: &mut BTreeMap, ) -> bool { 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 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 && (has_unbounded_tainted_reader || !keys.is_bounded(node.serial)) { 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 snapshot.ports_of(node.id).any(|port| port.exclusive) { return Some(Reason::PortExclusive); } if node.props.passthrough { return Some(Reason::Iec958Passthrough); } match node.props.stream_format { snapshot::StreamFormat::Raw => None, snapshot::StreamFormat::Unknown => Some(Reason::FormatUnknown), snapshot::StreamFormat::Encoded => Some(Reason::Encoded), snapshot::StreamFormat::Iec958 => Some(Reason::Iec958Passthrough), } } /// 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, keys: &owner::OwnerKeyIndex, components: &OwnerComponents, taint: &BTreeMap, 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 — but only a *trustworthy* // snapshot is allowed to conclude that a member is absent. for entry in &prior.owners { if !retire_absent || 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, 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, }); } 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); entry.fingerprints.extend(other.fingerprints); 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 } } }