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>
339 lines
12 KiB
Rust
339 lines
12 KiB
Rust
//! Synthetic graph builders for the taint-engine tests.
|
|
//!
|
|
//! Serials are handed out monotonically and never reused, exactly as
|
|
//! PipeWire does; global ids are handed out separately and **may be reused
|
|
//! on purpose**, which is what the recycling tests need.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use super::snapshot::{
|
|
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
|
|
PortDirection, PortSnapshot, Serial,
|
|
};
|
|
|
|
/// pipewire-pulse's PID, as measured on the target machine.
|
|
pub const PULSE_PID: u32 = 2541;
|
|
/// WirePlumber's PID — one process owning every device node on the box.
|
|
pub const SESSION_PID: u32 = 900;
|
|
|
|
/// A node's identity in a fixture: what tests pass around.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub struct NodeRef {
|
|
pub serial: Serial,
|
|
pub id: GlobalId,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct Graph {
|
|
next_serial: u64,
|
|
next_id: u32,
|
|
nodes: Vec<NodeSnapshot>,
|
|
ports: Vec<PortSnapshot>,
|
|
links: Vec<LinkSnapshot>,
|
|
clients: Vec<ClientSnapshot>,
|
|
/// One client connection per process / per module, which is what the
|
|
/// live graph looks like. Tests that need the *split*-client shape
|
|
/// (GStreamer opens one per stream) pass clients explicitly instead.
|
|
client_by_app: BTreeMap<u32, GlobalId>,
|
|
client_by_module: BTreeMap<u64, GlobalId>,
|
|
session_client: Option<GlobalId>,
|
|
}
|
|
|
|
impl Graph {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
// Start past u32::MAX so every fixture also exercises the phase
|
|
// 0a widening: a serial that a u32 model would have truncated.
|
|
next_serial: u64::from(u32::MAX) + 1,
|
|
next_id: 1,
|
|
..Self::default()
|
|
}
|
|
}
|
|
|
|
fn serial(&mut self) -> Serial {
|
|
self.next_serial += 1;
|
|
Serial(self.next_serial)
|
|
}
|
|
|
|
fn id(&mut self) -> GlobalId {
|
|
self.next_id += 1;
|
|
GlobalId(self.next_id)
|
|
}
|
|
|
|
/// A client object. `sec_pid` is `pipewire.sec.pid` — pipewire-pulse's
|
|
/// PID for Pulse-emulated clients.
|
|
pub fn client(&mut self, sec_pid: Option<u32>) -> GlobalId {
|
|
let serial = self.serial();
|
|
let id = self.id();
|
|
self.clients.push(ClientSnapshot {
|
|
serial,
|
|
id,
|
|
sec_pid,
|
|
});
|
|
id
|
|
}
|
|
|
|
/// The client connection an ordinary process holds — one per PID,
|
|
/// created on demand.
|
|
pub fn client_of_app(&mut self, pid: u32) -> GlobalId {
|
|
if let Some(id) = self.client_by_app.get(&pid) {
|
|
return *id;
|
|
}
|
|
let id = self.client(Some(PULSE_PID));
|
|
self.client_by_app.insert(pid, id);
|
|
id
|
|
}
|
|
|
|
/// An ordinary application stream: its own client, its own PID.
|
|
pub fn app_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
|
let client = self.client_of_app(pid);
|
|
self.node(name, role, app(client, pid))
|
|
}
|
|
|
|
/// The client a pactl module holds. Measured: each module gets its own
|
|
/// (`sink-sunshine-*` were clients 83/86/92), which is why one tainted
|
|
/// module does not fuse with the next.
|
|
pub fn client_of_module(&mut self, module: u64) -> GlobalId {
|
|
match self.client_by_module.get(&module) {
|
|
Some(id) => *id,
|
|
None => {
|
|
let id = self.client(Some(PULSE_PID));
|
|
self.client_by_module.insert(module, id);
|
|
id
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A leg of a pactl-loaded module: one client per module, and the
|
|
/// node's `application.process.id` is **pipewire-pulse's own**, because
|
|
/// pipewire-pulse genuinely is the client.
|
|
pub fn module_node(&mut self, name: &str, role: MediaRole, module: u64) -> NodeRef {
|
|
let client = self.client_of_module(module);
|
|
self.node(name, role, pulse_module(client, module, PULSE_PID))
|
|
}
|
|
|
|
/// A leg joined to its siblings by `node.link-group` — loopback,
|
|
/// filter-chain, echo-cancel.
|
|
pub fn group_node(&mut self, name: &str, role: MediaRole, group: &str, pid: u32) -> NodeRef {
|
|
let client = self.client_of_app(pid);
|
|
self.node(name, role, link_group(group, client, pid))
|
|
}
|
|
|
|
/// A device node as the session manager creates it: no strong key,
|
|
/// WirePlumber's client and PID — shared with every other device — and
|
|
/// a `device.id`, which is what marks it as session-manager-exported.
|
|
pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef {
|
|
let session = match self.session_client {
|
|
Some(id) => id,
|
|
None => {
|
|
let id = self.client(None);
|
|
self.session_client = Some(id);
|
|
id
|
|
}
|
|
};
|
|
self.node(name, role, device(session, SESSION_PID))
|
|
}
|
|
|
|
/// A node that *belongs to* a Device but is not a passive device node —
|
|
/// a filter associated with a card. Phase 3 must not classify this as a
|
|
/// session device, or it loses both its coarse owner keys and its
|
|
/// ability to trip the fail-closed backstop.
|
|
pub fn device_associated_filter(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
|
let client = self.client_of_app(pid);
|
|
self.node(name, role, app(client, pid))
|
|
}
|
|
|
|
/// A **virtual** sink an application created natively: an `Audio/Sink`
|
|
/// with no `device.id` and no strong key, sharing one client with the
|
|
/// stream that re-emits what it receives. Coarse keys must still bridge
|
|
/// these two, or the whole call leaks through the re-emitting leg.
|
|
pub fn native_virtual_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
|
let client = self.client_of_app(pid);
|
|
self.node(name, role, app(client, pid))
|
|
}
|
|
|
|
pub fn peerspeak_node(&mut self, name: &str, pid: u32) -> NodeRef {
|
|
let client = self.client_of_app(pid);
|
|
self.node(name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
|
|
}
|
|
|
|
pub fn node(&mut self, name: &str, role: MediaRole, props: NodeProps) -> NodeRef {
|
|
let id = self.id();
|
|
self.node_with_id(name, role, id, props)
|
|
}
|
|
|
|
/// Force a global id — for reproducing id recycling after teardown.
|
|
pub fn node_with_id(
|
|
&mut self,
|
|
name: &str,
|
|
role: MediaRole,
|
|
id: GlobalId,
|
|
props: NodeProps,
|
|
) -> NodeRef {
|
|
let serial = self.serial();
|
|
self.nodes.push(NodeSnapshot {
|
|
serial,
|
|
id,
|
|
name: Some(name.to_string()),
|
|
role,
|
|
props,
|
|
});
|
|
NodeRef { serial, id }
|
|
}
|
|
|
|
pub fn port(&mut self, node: NodeRef, direction: PortDirection, exclusive: bool) {
|
|
let serial = self.serial();
|
|
let id = self.id();
|
|
self.ports.push(PortSnapshot {
|
|
serial,
|
|
id,
|
|
node: node.id,
|
|
direction,
|
|
exclusive,
|
|
monitor: false,
|
|
});
|
|
}
|
|
|
|
/// A signal edge: audio flows `from → to`.
|
|
pub fn link(&mut self, from: NodeRef, to: NodeRef) {
|
|
self.link_ids(from.id, to.id);
|
|
}
|
|
|
|
/// A link naming raw ids, so a test can dangle an endpoint.
|
|
pub fn link_ids(&mut self, from: GlobalId, to: GlobalId) {
|
|
let serial = self.serial();
|
|
let id = self.id();
|
|
self.links.push(LinkSnapshot {
|
|
serial,
|
|
id,
|
|
output_node: from,
|
|
input_node: to,
|
|
output_port: None,
|
|
input_port: None,
|
|
});
|
|
}
|
|
|
|
/// An id that belongs to nothing — for unresolved-endpoint tests.
|
|
pub fn dangling_id(&mut self) -> GlobalId {
|
|
self.id()
|
|
}
|
|
|
|
pub fn build(&self) -> GraphSnapshot {
|
|
self.build_without(&[])
|
|
}
|
|
|
|
/// A later snapshot in which some nodes have gone away, along with
|
|
/// their ports and every link touching them. Surviving objects keep
|
|
/// their serials, which is what makes sticky-taint sequences testable.
|
|
pub fn build_without(&self, dropped: &[NodeRef]) -> GraphSnapshot {
|
|
let gone_serials: Vec<Serial> = dropped.iter().map(|n| n.serial).collect();
|
|
let nodes: Vec<NodeSnapshot> = self
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| !gone_serials.contains(&n.serial))
|
|
.cloned()
|
|
.collect();
|
|
// Filter by what was *dropped*, not by what is live: a link to an id
|
|
// that never had a node is a dangling endpoint, and dropping those
|
|
// here would quietly disarm every unresolved-ancestry test.
|
|
let gone_ids: Vec<GlobalId> = dropped.iter().map(|n| n.id).collect();
|
|
GraphSnapshot::new(
|
|
nodes,
|
|
self.ports
|
|
.iter()
|
|
.filter(|p| !gone_ids.contains(&p.node))
|
|
.cloned()
|
|
.collect(),
|
|
self.links
|
|
.iter()
|
|
.filter(|l| !gone_ids.contains(&l.output_node) && !gone_ids.contains(&l.input_node))
|
|
.cloned()
|
|
.collect(),
|
|
self.clients.clone(),
|
|
)
|
|
}
|
|
|
|
/// Drop clients too — full owner teardown.
|
|
///
|
|
/// Invalidates the per-app/per-module caches as well: leaving them
|
|
/// stale made a later `client_of_app` hand back the *removed* client's
|
|
/// id, so a test that meant "a brand-new client after teardown" was
|
|
/// really building a node pointing at a client object that no longer
|
|
/// existed (Codex round 1, finding 8).
|
|
pub fn drop_clients(&mut self, ids: &[GlobalId]) {
|
|
self.clients.retain(|c| !ids.contains(&c.id));
|
|
self.client_by_app.retain(|_, id| !ids.contains(id));
|
|
self.client_by_module.retain(|_, id| !ids.contains(id));
|
|
if self.session_client.is_some_and(|id| ids.contains(&id)) {
|
|
self.session_client = None;
|
|
}
|
|
}
|
|
|
|
/// A client that reuses a global id a dead client had — the recycling
|
|
/// case, with a fresh serial.
|
|
pub fn client_with_id(&mut self, id: GlobalId, sec_pid: Option<u32>) -> GlobalId {
|
|
let serial = self.serial();
|
|
self.clients.push(ClientSnapshot {
|
|
serial,
|
|
id,
|
|
sec_pid,
|
|
});
|
|
id
|
|
}
|
|
}
|
|
|
|
/// An ordinary application stream: real PID, one client connection.
|
|
pub fn app(client: GlobalId, pid: u32) -> NodeProps {
|
|
NodeProps {
|
|
client_id: Some(client),
|
|
process_id: Some(pid),
|
|
..NodeProps::default()
|
|
}
|
|
}
|
|
|
|
/// A pactl-module-created stream: the daemon is the client, so the node's
|
|
/// `application.process.id` is pipewire-pulse's own.
|
|
pub fn pulse_module(client: GlobalId, module: u64, pulse_pid: u32) -> NodeProps {
|
|
NodeProps {
|
|
pulse_module_id: Some(module),
|
|
client_id: Some(client),
|
|
process_id: Some(pulse_pid),
|
|
..NodeProps::default()
|
|
}
|
|
}
|
|
|
|
/// A PipeWire-module leg joined to its siblings by `node.link-group`
|
|
/// (loopback, filter-chain, echo-cancel).
|
|
pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps {
|
|
NodeProps {
|
|
link_group: Some(group.to_string()),
|
|
client_id: Some(client),
|
|
process_id: Some(pid),
|
|
..NodeProps::default()
|
|
}
|
|
}
|
|
|
|
/// A device node as the session manager creates it: no strong key, and the
|
|
/// session manager's own client and PID — shared with every other device.
|
|
///
|
|
/// Measured 2026-07-21: real ALSA device nodes carry the shared
|
|
/// `client.id` but **no** `application.process.id` at all. Giving them one
|
|
/// here is deliberately *more* pessimistic than reality — it hands the
|
|
/// engine a second coarse key it could fuse devices on, so a test that
|
|
/// passes here also passes against the real props.
|
|
pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps {
|
|
NodeProps {
|
|
client_id: Some(session_client),
|
|
process_id: Some(session_pid),
|
|
session_device: true,
|
|
..NodeProps::default()
|
|
}
|
|
}
|
|
|
|
pub fn peerspeak_owned(client: GlobalId, pid: u32) -> NodeProps {
|
|
NodeProps {
|
|
peerspeak_owned: true,
|
|
..app(client, pid)
|
|
}
|
|
}
|