host/taint: pure graph model + taint engine (phase 2)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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,
|
||||
/// 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<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>,
|
||||
/// 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 (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<Serial, Reason> = BTreeMap::new();
|
||||
let mut sticky_serials: BTreeSet<Serial> = 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<Serial, Reason>,
|
||||
) {
|
||||
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<Reason> {
|
||||
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<Serial, Reason>,
|
||||
sticky_serials: &mut BTreeSet<Serial>,
|
||||
) {
|
||||
for entry in &prior.owners {
|
||||
let live_nodes: Vec<Serial> = 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<Serial, Reason>,
|
||||
) -> BTreeMap<Serial, Vec<Serial>> {
|
||||
let mut edges: BTreeMap<Serial, Vec<Serial>> = 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<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(
|
||||
snapshot: &GraphSnapshot,
|
||||
ctx: &ExclusionCtx,
|
||||
components: &OwnerComponents,
|
||||
taint: &mut BTreeMap<Serial, Reason>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
for members in components.components() {
|
||||
let sources: Vec<Serial> = 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<Serial, Reason>,
|
||||
) -> 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<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 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<Serial, Reason>,
|
||||
prior: &StickyState,
|
||||
) -> 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.
|
||||
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<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, 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<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);
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user