The verification round earned its place: five of the six round-1 fixes were partial, and two of the gaps were worse than the bugs they replaced. 1. ⚠️ The round-1 sticky fix smuggled the suppressed key back in. `client_serials_of` recorded the shared `WirePlumber [export]` client as a member of a tainted hardware sink's owner, so the *second* recompute expanded that client to every sound card on the box, tainted the microphone, and excluded every app holding one — the §6.1.1 catastrophe arriving one epoch late instead of never. `client.id` may now only be recorded, or expanded, for nodes where it is a usable owner key. The regression test evaluates an unchanged snapshot three times: a correct engine's answer must not drift when nothing has. 2. Sticky followed a surviving *connection*, not a surviving *owner*. A process can leave one client idle and open a second — GStreamer opens one per stream as a matter of course — and the new leg escaped. `StickyOwner` now carries owner **fingerprints** (strong keys and a usable PID, never `client.id`), applied only while some serial member is still live, so a recyclable key cannot resurrect a dead owner. 3. An **ambiguous** link input endpoint tainted every claimant but made none of them a receiver, so their sibling output legs stayed eligible. Taint without receiver status cannot start an owner bridge. 4. `device.id` is a raw observation, not the classification the coarse-key exception needs — PipeWire defines it only as "the Device this node belongs to", so a forwarding node carrying one would have lost both its owner keys and its ability to trip the backstop. Replaced by `session_device`, a phase-3 obligation (`device.id` AND `device.api`) documented to fail closed when it cannot classify. 5. Readiness now gates sticky **retirement only**. Round 1 stopped a not-ready epoch erasing history; it also stopped it recording any, so a reader could consume and buffer the call during that epoch, vanish before readiness, and leave its output eligible. 6. Added the unresolved-output-plus-unknown-role fixture: deleting one `receivers.insert` survived all 42 previous tests. Mutation-verified: 7/7 reverts killed by their intended test. Two attempts did not land first time and both were my error, not the engine's — the client-key guard is applied at two sites so removing one is not a revert (removing the pair is, and that is killed), and the fingerprint-lifetime test put the recycled node in a snapshot *after* the entry had already been retired, so the guard was never consulted. Rewritten to place it in the same snapshot that first sees the owner gone. Cost comment corrected again, to O(D·(V+E+Σ|sources|·|targets|)). 49 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
391 lines
16 KiB
Rust
391 lines
16 KiB
Rust
//! The owner bridge — grouping nodes that belong to the same *owner* even
|
||
//! though the graph shows no Link between them.
|
||
//!
|
||
//! This is the subtlest part of the design (v3.4 §6.1.2). Measured fact it
|
||
//! exists to handle: a `module-loopback` forwarder's input leg and output
|
||
//! leg have **no Link between them**, so walking Links alone from the
|
||
//! leaking output leg finds no inbound links at all — a dead end that reads
|
||
//! as "clean". The legs are related only by shared properties.
|
||
//!
|
||
//! ## The rule
|
||
//!
|
||
//! A union of keys, strongest first:
|
||
//!
|
||
//! | # | key | scope |
|
||
//! | --- | --- | --- |
|
||
//! | 1 | `node.link-group` | per module/filter instance |
|
||
//! | 2 | `pulse.module.id` | per pactl module |
|
||
//! | 3 | `client.id` | per **connection** |
|
||
//! | 4 | `application.process.id` | per process |
|
||
//!
|
||
//! ⚠️ **"Resolves" means the two legs carry the key AND the values are
|
||
//! EQUAL — not "the first key present".** A first-present implementation
|
||
//! reproduces the exact measured leak: for `gst-launch pulsesrc ! pulsesink`
|
||
//! both legs carry `client.id` (209 and 210) but the values *differ*, so
|
||
//! first-present stops at key 3, sees a mismatch, and concludes "different
|
||
//! owners". The legs are in fact one process (`application.process.id`
|
||
//! 20172 on both). So: try each key in order, and a key resolves only if
|
||
//! both legs carry it and the values are equal; otherwise fall through.
|
||
//!
|
||
//! ## Two exceptions, both guarding against mass over-exclusion
|
||
//!
|
||
//! 1. **Never bridge on key 4 when the value is pipewire-pulse's own PID**
|
||
//! (v3.4 §6.1.2). Module-created streams all carry the daemon's PID, so
|
||
//! bridging on it fuses every Pulse module into one owner and a single
|
||
//! tainted module input would exclude every module-created stream on the
|
||
//! box. Keys 1 and 2 already cover those cases precisely.
|
||
//!
|
||
//! 2. **Coarse keys (3 and 4) may not bridge nodes exported from a real
|
||
//! `Device`** — i.e. nodes carrying `device.id`. ⚠️ This rule is *not*
|
||
//! in design v3.4; it was found while implementing, and it is the exact
|
||
//! analogue of exception 1 for the session manager.
|
||
//! ✅ **MEASURED on the live graph 2026-07-21:**
|
||
//!
|
||
//! | node | `client.id` | `device.id` | `factory.name` |
|
||
//! | --- | --- | --- | --- |
|
||
//! | 5 × `alsa_{output,input}.*` | **42** (`WirePlumber [export]`) | 43/45/46 | `api.alsa.pcm.{sink,source}` |
|
||
//! | 3 × `sink-sunshine-*` | 83 / 86 / 92 (each its own) | **absent** | `support.null-audio-sink` |
|
||
//!
|
||
//! So one shared coarse key genuinely does relate every hardware device
|
||
//! on the box, and `device.id` cleanly separates that set from virtual
|
||
//! sinks. Without the rule, the hardware sink carrying peerspeak's
|
||
//! playback (tainted by design, every single recompute) would bridge to
|
||
//! *every other device node including the microphone source*, whose
|
||
//! readers would then taint their owners' playback legs — reproducing
|
||
//! precisely the §6.1.1 catastrophe ("excludes any app using a
|
||
//! microphone") through a different door.
|
||
//!
|
||
//! ⚠️ **Keyed on `device.id`, NOT on `media.class` being `Audio/Sink`.**
|
||
//! The first cut suppressed coarse keys for every device-*role* node,
|
||
//! and Codex refuted it: a **native virtual sink** — an app that creates
|
||
//! an `Audio/Sink` plus a re-emitting stream on one client, with no
|
||
//! `link-group` and no `pulse.module.id` — would then have had its only
|
||
//! correlation stripped, and it would have leaked the whole call. Such a
|
||
//! sink has no `device.id`, so it now bridges on `client.id` as it
|
||
//! should.
|
||
//!
|
||
//! Grouping is **transitive** (union-find). That is the fail-closed
|
||
//! direction: bigger owner components mean more taint, never less.
|
||
|
||
use std::collections::BTreeMap;
|
||
|
||
use super::snapshot::{GlobalId, GraphSnapshot, NodeSnapshot, Serial};
|
||
|
||
/// Which key bridged two legs. Ordered strongest first; the `Ord` derive is
|
||
/// load-bearing for "report the strongest shared key".
|
||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
||
pub enum OwnerKey {
|
||
LinkGroup,
|
||
PulseModuleId,
|
||
ClientId,
|
||
ProcessId,
|
||
}
|
||
|
||
impl OwnerKey {
|
||
/// Stable, machine-readable — this ends up in the phase 5 audit output
|
||
/// and the phase 6 status event.
|
||
pub fn code(self) -> &'static str {
|
||
match self {
|
||
Self::LinkGroup => "node.link-group",
|
||
Self::PulseModuleId => "pulse.module.id",
|
||
Self::ClientId => "client.id",
|
||
Self::ProcessId => "application.process.id",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The value a node presents for a given key, if it presents one at all.
|
||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||
enum KeyValue {
|
||
Text(String),
|
||
Num(u64),
|
||
}
|
||
|
||
/// Owner keys usable on this node, strongest first.
|
||
///
|
||
/// A key that is present but unusable (the pipewire-pulse PID; a coarse key
|
||
/// on a device node) is **absent** here — that is the whole mechanism of the
|
||
/// two exceptions.
|
||
fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKey, KeyValue)> {
|
||
let mut out = Vec::new();
|
||
if let Some(group) = &node.props.link_group {
|
||
out.push((OwnerKey::LinkGroup, KeyValue::Text(group.clone())));
|
||
}
|
||
if let Some(module) = node.props.pulse_module_id {
|
||
out.push((OwnerKey::PulseModuleId, KeyValue::Num(module)));
|
||
}
|
||
// Exception 2: coarse keys never bridge passive session-manager device
|
||
// nodes — they all share the session manager's client.
|
||
if node.props.session_device {
|
||
return out;
|
||
}
|
||
if let Some(client) = node.props.client_id {
|
||
out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0))));
|
||
}
|
||
if let Some(pid) = node.props.process_id {
|
||
// Exception 1. Note the fail-closed asymmetry when the daemon PID is
|
||
// unknown (`None`): the exception does *not* fire, key 4 applies to
|
||
// everything, and Pulse modules fuse into one owner. That is broad
|
||
// over-exclusion — annoying and safe — which is the direction v3.4
|
||
// §6.1.2's failure-mode paragraph asks for.
|
||
if Some(pid) != pipewire_pulse_pid {
|
||
out.push((OwnerKey::ProcessId, KeyValue::Num(u64::from(pid))));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Can this node's owner be positively bounded — i.e. can we enumerate its
|
||
/// sibling legs and be right?
|
||
///
|
||
/// ⚠️ Not the same as "has any usable key", and the difference is a leak.
|
||
/// `client.id` alone does **not** bound an owner: that is the measured
|
||
/// GStreamer refutation, where one process presented two different
|
||
/// `client.id`s for its two legs. So an owner is bounded only by a strong
|
||
/// key (link-group / pulse.module.id) or by a *usable* process id — usable
|
||
/// meaning key 4 was not suppressed as pipewire-pulse's own PID.
|
||
///
|
||
/// The case this exists for is v3.4 §12's "module forwarder with neither
|
||
/// `link-group` nor `pulse.module.id`": its process id is the daemon's and
|
||
/// therefore suppressed, its two legs may carry different `client.id`s, and
|
||
/// nothing else relates them. Its sibling output leg cannot be found, so
|
||
/// the engine must fail closed rather than declare it clean
|
||
/// (v3.4 §6.1.1, final paragraph).
|
||
pub fn owner_is_bounded(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> bool {
|
||
keys_of(node, pipewire_pulse_pid)
|
||
.iter()
|
||
.any(|(key, _)| *key != OwnerKey::ClientId)
|
||
}
|
||
|
||
/// Owner keys computed once per snapshot.
|
||
///
|
||
/// `keys_of` allocates a `Vec` and clones the `link-group` string, and the
|
||
/// bridge asks for keys once per (tainted member × component member) pair —
|
||
/// so recomputing was the hot spot in an otherwise linear pass.
|
||
#[derive(Debug, Default)]
|
||
pub struct OwnerKeyIndex {
|
||
keys: BTreeMap<Serial, Vec<(OwnerKey, KeyValue)>>,
|
||
}
|
||
|
||
impl OwnerKeyIndex {
|
||
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
|
||
Self {
|
||
keys: snapshot
|
||
.nodes()
|
||
.map(|node| (node.serial, keys_of(node, pipewire_pulse_pid)))
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
/// The strongest key these two nodes share directly, if any.
|
||
pub fn strongest_shared(&self, a: Serial, b: Serial) -> Option<OwnerKey> {
|
||
let (Some(a_keys), Some(b_keys)) = (self.keys.get(&a), self.keys.get(&b)) else {
|
||
return None;
|
||
};
|
||
// Stored strongest-first, so the first match is the strongest.
|
||
a_keys.iter().find_map(|(key, value)| {
|
||
b_keys
|
||
.iter()
|
||
.any(|(other_key, other_value)| other_key == key && other_value == value)
|
||
.then_some(*key)
|
||
})
|
||
}
|
||
|
||
/// Is `client.id` a usable owner key for this node?
|
||
///
|
||
/// ⚠️ Load-bearing for sticky state. A device node's `client.id` is
|
||
/// suppressed by exception 2, so recording the session manager's Client
|
||
/// as a *member* of a tainted device's sticky owner would smuggle the
|
||
/// suppressed key back in: the next recompute would expand that Client
|
||
/// to every hardware node on the box — the microphone included — and
|
||
/// the §6.1.1 catastrophe would arrive one epoch late instead of never.
|
||
/// (Codex round 2, finding 1.)
|
||
pub fn uses_client_key(&self, serial: Serial) -> bool {
|
||
self.keys
|
||
.get(&serial)
|
||
.is_some_and(|keys| keys.iter().any(|(key, _)| *key == OwnerKey::ClientId))
|
||
}
|
||
|
||
/// The owner keys that are safe to remember *across* connections, for
|
||
/// sticky taint: the strong keys plus a usable process id.
|
||
///
|
||
/// `client.id` is deliberately excluded — it identifies a *connection*,
|
||
/// and the whole point of a fingerprint is to survive one process
|
||
/// closing a connection and opening another. A live Client member is
|
||
/// what covers the same-connection case, precisely.
|
||
///
|
||
/// These are recyclable strings and numbers, so they are only ever
|
||
/// applied while some **serial** member of the owner is still live
|
||
/// (v3.4 §6.1.3): while the process is alive, its PID cannot have been
|
||
/// handed to anyone else.
|
||
pub fn fingerprints(&self, serial: Serial) -> Vec<Fingerprint> {
|
||
self.keys
|
||
.get(&serial)
|
||
.map(|keys| {
|
||
keys.iter()
|
||
.filter(|(key, _)| *key != OwnerKey::ClientId)
|
||
.map(|(key, value)| Fingerprint(*key, value.clone()))
|
||
.collect()
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// Does this node currently present `fingerprint`?
|
||
pub fn has_fingerprint(&self, serial: Serial, fingerprint: &Fingerprint) -> bool {
|
||
self.keys.get(&serial).is_some_and(|keys| {
|
||
keys.iter()
|
||
.any(|(key, value)| *key == fingerprint.0 && *value == fingerprint.1)
|
||
})
|
||
}
|
||
|
||
/// See [`owner_is_bounded`].
|
||
pub fn is_bounded(&self, serial: Serial) -> bool {
|
||
self.keys
|
||
.get(&serial)
|
||
.is_some_and(|keys| keys.iter().any(|(key, _)| *key != OwnerKey::ClientId))
|
||
}
|
||
}
|
||
|
||
/// The strongest key two nodes share, or `None` if they share none. Used to
|
||
/// *name* the key in a bridge decision; membership itself is transitive and
|
||
/// comes from [`OwnerComponents`].
|
||
pub fn strongest_shared_key(
|
||
a: &NodeSnapshot,
|
||
b: &NodeSnapshot,
|
||
pipewire_pulse_pid: Option<u32>,
|
||
) -> Option<OwnerKey> {
|
||
let a_keys = keys_of(a, pipewire_pulse_pid);
|
||
let b_keys = keys_of(b, pipewire_pulse_pid);
|
||
// `keys_of` yields strongest-first, so the first match is the strongest.
|
||
a_keys.iter().find_map(|(key, value)| {
|
||
b_keys
|
||
.iter()
|
||
.any(|(other_key, other_value)| other_key == key && other_value == value)
|
||
.then_some(*key)
|
||
})
|
||
}
|
||
|
||
/// A remembered owner key — see [`OwnerKeyIndex::fingerprints`].
|
||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||
pub struct Fingerprint(OwnerKey, KeyValue);
|
||
|
||
/// Nodes partitioned into owner components.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct OwnerComponents {
|
||
/// node serial → component index.
|
||
of_node: BTreeMap<Serial, usize>,
|
||
/// component index → member node serials, ascending.
|
||
members: Vec<Vec<Serial>>,
|
||
}
|
||
|
||
impl OwnerComponents {
|
||
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
|
||
let serials: Vec<Serial> = snapshot.nodes().map(|n| n.serial).collect();
|
||
let index: BTreeMap<Serial, usize> =
|
||
serials.iter().enumerate().map(|(i, s)| (*s, i)).collect();
|
||
let mut uf = UnionFind::new(serials.len());
|
||
|
||
// Group by (key, value) and union within each group. Equivalent to
|
||
// the pairwise "some key resolves" rule, and O(n log n).
|
||
let mut buckets: BTreeMap<(OwnerKey, KeyValue), Vec<usize>> = BTreeMap::new();
|
||
for node in snapshot.nodes() {
|
||
let slot = index[&node.serial];
|
||
for (key, value) in keys_of(node, pipewire_pulse_pid) {
|
||
buckets.entry((key, value)).or_default().push(slot);
|
||
}
|
||
}
|
||
for group in buckets.values() {
|
||
for pair in group.windows(2) {
|
||
uf.union(pair[0], pair[1]);
|
||
}
|
||
}
|
||
|
||
// Compact roots into dense component indices, deterministically.
|
||
let mut root_to_component: BTreeMap<usize, usize> = BTreeMap::new();
|
||
let mut members: Vec<Vec<Serial>> = Vec::new();
|
||
let mut of_node = BTreeMap::new();
|
||
for (slot, serial) in serials.iter().enumerate() {
|
||
let root = uf.find(slot);
|
||
let component = *root_to_component.entry(root).or_insert_with(|| {
|
||
members.push(Vec::new());
|
||
members.len() - 1
|
||
});
|
||
members[component].push(*serial);
|
||
of_node.insert(*serial, component);
|
||
}
|
||
Self { of_node, members }
|
||
}
|
||
|
||
pub fn component_of(&self, serial: Serial) -> Option<usize> {
|
||
self.of_node.get(&serial).copied()
|
||
}
|
||
|
||
/// Member serials of the component containing `serial`, including it.
|
||
/// Empty if the node is not in this snapshot.
|
||
pub fn members_with(&self, serial: Serial) -> &[Serial] {
|
||
match self.component_of(serial) {
|
||
Some(component) => &self.members[component],
|
||
None => &[],
|
||
}
|
||
}
|
||
|
||
pub fn components(&self) -> impl Iterator<Item = &[Serial]> {
|
||
self.members.iter().map(Vec::as_slice)
|
||
}
|
||
}
|
||
|
||
struct UnionFind {
|
||
parent: Vec<usize>,
|
||
}
|
||
|
||
impl UnionFind {
|
||
fn new(len: usize) -> Self {
|
||
Self {
|
||
parent: (0..len).collect(),
|
||
}
|
||
}
|
||
|
||
fn find(&mut self, mut node: usize) -> usize {
|
||
while self.parent[node] != node {
|
||
self.parent[node] = self.parent[self.parent[node]];
|
||
node = self.parent[node];
|
||
}
|
||
node
|
||
}
|
||
|
||
fn union(&mut self, a: usize, b: usize) {
|
||
let (a, b) = (self.find(a), self.find(b));
|
||
if a != b {
|
||
// Lowest root wins, so components are deterministic.
|
||
let (low, high) = if a < b { (a, b) } else { (b, a) };
|
||
self.parent[high] = low;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Client objects belonging to an owner component, so sticky taint can be
|
||
/// keyed on every object that constitutes the owner (v3.4 §6.1.3: clear the
|
||
/// entry only once **all** member objects are gone).
|
||
pub fn client_serials_of(
|
||
snapshot: &GraphSnapshot,
|
||
keys: &OwnerKeyIndex,
|
||
nodes: &[Serial],
|
||
) -> Vec<Serial> {
|
||
let mut out: Vec<Serial> = nodes
|
||
.iter()
|
||
// Only nodes for which `client.id` is a *usable* owner key. See
|
||
// `uses_client_key`: recording a device node's shared session-manager
|
||
// Client here would defeat exception 2 on the next recompute.
|
||
.filter(|serial| keys.uses_client_key(**serial))
|
||
.filter_map(|serial| snapshot.node(*serial))
|
||
.filter_map(|node| node.props.client_id)
|
||
// An ambiguous client id means two Clients claim it and we cannot
|
||
// say which one is ours, so remember both: an entry that recorded
|
||
// neither could be retired while its owner was still live.
|
||
.flat_map(|id: GlobalId| snapshot.clients_with_id(id).map(|client| client.serial))
|
||
.collect();
|
||
out.sort_unstable();
|
||
out.dedup();
|
||
out
|
||
}
|