//! 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, ports: Vec, links: Vec, clients: Vec, /// 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, client_by_module: BTreeMap, session_client: Option, } 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) -> 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)) } /// A peerspeak-owned node carrying **both** ownership carriers, as a /// live one does. `name` gets the real `node.name` prefix so the fixture /// cannot pass on the property alone. pub fn peerspeak_node(&mut self, name: &str, pid: u32) -> NodeRef { let client = self.client_of_app(pid); let name = format!("{}{name}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX); self.node(&name, MediaRole::StreamOutput, peerspeak_owned(client, pid)) } /// Carrier 1 alone: the `peerspeak.owned` property present, the /// `node.name` prefix absent. What the engine sees for a node it had to /// bind to observe (v3.5 §6.7). pub fn peerspeak_node_prop_only(&mut self, name: &str, pid: u32) -> NodeRef { let client = self.client_of_app(pid); self.node(name, MediaRole::StreamOutput, peerspeak_owned(client, pid)) } /// Carrier 2 alone: the `node.name` prefix present, the property absent /// — indistinguishable from an ordinary app in every other respect. /// This is the case that survives the F1 observation defect, and the /// reason round 8 added a second carrier at all. pub fn peerspeak_node_name_only(&mut self, role: &str, pid: u32) -> NodeRef { let client = self.client_of_app(pid); let name = format!("{}{role}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX); self.node(&name, MediaRole::StreamOutput, app(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 = dropped.iter().map(|n| n.serial).collect(); let nodes: Vec = 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 = 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) -> 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) } }