Files
pixelpass/src/host/taint/mod.rs
T

1219 lines
51 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 56) — 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<OwnerKey> },
/// 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<u64>,
/// pipewire-pulse's own PID, derived by the observer (phase 3) from a
/// consistent `pipewire.sec.pid` across Pulse clients validated against
/// `/proc/<pid>/comm`. `None` is safe but coarse — see [`owner`].
pub pipewire_pulse_pid: Option<u32>,
/// Serials of objects pixelpass itself created this run.
pub pixelpass_owned: BTreeSet<Serial>,
/// 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<ObjectRef>,
/// 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<owner::Fingerprint>,
/// 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<Serial, Reason>,
}
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<StickyOwner>,
}
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<String>,
pub eligibility: Eligibility,
}
impl NodeDecision {
pub fn is_eligible(&self) -> bool {
matches!(self.eligibility, Eligibility::Eligible)
}
pub fn reason(&self) -> Option<Reason> {
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<Serial, NodeDecision>,
/// Taint over *all* node roles, for diagnostics and for the phase 5
/// audit output.
pub taint: BTreeMap<Serial, TaintEntry>,
}
impl Decisions {
/// Serials of eligible candidates, ascending.
pub fn eligible(&self) -> Vec<Serial> {
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<Serial, Reason>, BTreeSet<Serial>) {
let fails_closed = uncertainty == Uncertainty::FailsClosed;
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
let mut sticky_serials: BTreeSet<Serial> = 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<Serial> = 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<Serial, Reason>,
) {
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<Serial> {
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<Reason> {
// 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<Serial, Reason>,
sticky_serials: &mut BTreeSet<Serial>,
) {
for entry in &prior.owners {
let mut live_nodes: Vec<Serial> = 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<Serial> {
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<Serial>) -> Edges {
let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new();
let mut receivers: BTreeSet<Serial> = 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<Serial, Vec<Serial>>,
/// ⚠️ 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<Serial>,
}
fn propagate_links(
downstream: &BTreeMap<Serial, Vec<Serial>>,
taint: &mut BTreeMap<Serial, Reason>,
) -> bool {
let mut changed = false;
let mut queue: VecDeque<Serial> = 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<Serial, Reason>,
) -> bool {
let mut changed = false;
for members in components.components() {
let sources: BTreeSet<Serial> = 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<Serial, Reason>,
) -> 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<Serial, Reason>,
sticky_serials: &BTreeSet<Serial>,
) -> 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<Reason> {
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<Serial, Reason>,
prior: &StickyState,
retire_absent: bool,
) -> StickyState {
let mut entries: Vec<StickyOwner> = 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<Serial, Reason> = 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<ObjectRef> = 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<StickyOwner>) -> Vec<StickyOwner> {
let mut merged: Vec<StickyOwner> = 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, Reason>, serial: Serial, reason: Reason) -> bool {
match taint.get(&serial) {
Some(existing) if existing.priority() <= reason.priority() => false,
_ => {
taint.insert(serial, reason);
true
}
}
}