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,272 @@
|
||||
//! 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 device-role nodes.** ⚠️ 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:
|
||||
//! every ALSA `Audio/Sink` and `Audio/Source` on the box is created by
|
||||
//! WirePlumber and therefore shares one `client.id` and one
|
||||
//! `application.process.id`. Without this rule, the hardware sink
|
||||
//! carrying peerspeak's playback (tainted by design, every single time)
|
||||
//! 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. Device nodes that genuinely
|
||||
//! belong to a module still bridge, via keys 1 and 2.
|
||||
//!
|
||||
//! 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 device-role nodes.
|
||||
if node.role.is_device_role() {
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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, nodes: &[Serial]) -> Vec<Serial> {
|
||||
let mut out: Vec<Serial> = nodes
|
||||
.iter()
|
||||
.filter_map(|serial| snapshot.node(*serial))
|
||||
.filter_map(|node| node.props.client_id)
|
||||
.filter_map(|id: GlobalId| match snapshot.client_by_id(id) {
|
||||
Some(super::snapshot::IdLookup::Unique(serial)) => Some(serial),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user