1219 lines
51 KiB
Rust
1219 lines
51 KiB
Rust
//! 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<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
|
||
}
|
||
}
|
||
}
|