The consumer half of phase 1 (plan §5.1, impl plan §3). The engine's tag root becomes a union: `peerspeak.owned` truthy OR `node.name` starting with `peerspeak_owned_`. Round 8 added the second carrier because a node property is invisible to the registry `global` event and recoverable only by binding the node — which is exactly how the phase-5 gate failed — while `node.name` is announced directly. The union lives in `local_root_reason`, not in the adapter. Folding both into the one `peerspeak_owned` bool at the observation boundary would make each carrier untestable alone, which is the phase-3r lesson: a gate asserting a value two sources can satisfy gates neither. The existing `peerspeak_tagged_nodes_…` fixture now carries both carriers, so it would keep passing if either were deleted; two new tests pin them individually, and a third pins that the prefix matches only at the start of a name. Both literals are now named constants — they are a cross-repo wire contract with peerspeak, not local naming — and asserted against tests/fixtures/ownership-tag-contract.txt, committed byte-identical in both repos. That test also runs the fixture's own worked example name through the engine, so the shared file cannot document a value this side does not actually exclude. Five mutations verified: drop either carrier, loosen `starts_with` to `contains`, or rename either constant, and exactly the intended test fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
361 lines
13 KiB
Rust
361 lines
13 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))
|
|
}
|
|
|
|
/// 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<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)
|
|
}
|
|
}
|