//! The plain, owned graph model the taint engine reasons over. //! //! **No PipeWire types appear in this file, by design** (impl plan §4, //! phase 2). The registry observer (phase 3) translates live globals into //! these structs; every test builds them by hand. Nothing here ever links //! against libpipewire. //! //! Two id-ish things live in this model and confusing them is the bug the //! whole file is shaped to prevent: //! //! - [`Serial`] — `object.serial`, 64-bit, monotonic, **never reused**. //! This is *identity*. Sticky taint is keyed on it. //! - [`GlobalId`] — the PipeWire global id, 32-bit and **recycled**. It is //! a *lookup key within one snapshot* and nothing else: links name their //! endpoints with it, nodes name their client with it. It must never //! outlive the snapshot it was read from (design v3.4 §6.1.3). use std::collections::BTreeMap; /// `object.serial` — 64-bit, monotonic, never recycled. Identity. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)] pub struct Serial(pub u64); /// A PipeWire global id — 32-bit and **recycled**. Snapshot-local lookup /// key only; see the module docs. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)] pub struct GlobalId(pub u32); /// What a node does with audio, parsed from `media.class`. /// /// Taint is computed at **node** granularity (v3.4 §6.1 edge type 2: the /// monitor connection is already a real Link whose output node is the sink /// itself, so a node-level walk crosses `app → sink → monitor-reader` for /// free). Ports exist in the model for link creation in phase 6 and for the /// `port.exclusive` predicate, not for taint. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)] pub enum MediaRole { /// `Stream/Output/Audio` — an application playing audio. The only /// fan-out candidate. StreamOutput, /// `Stream/Input/Audio` — an application capturing audio. StreamInput, /// `Audio/Sink` — a real or virtual sink. Sink, /// `Audio/Source` — a real or virtual source. Source, /// `Audio/Duplex`. ⚠️ Node granularity smears taint across both roles /// of these; accepted for v1 as fail-closed over-exclusion /// (v3.4 §6.1, edge type 2 caveat). Duplex, /// Anything else, including video and unparseable/absent `media.class`. Other, } impl MediaRole { pub fn parse(media_class: Option<&str>) -> Self { match media_class { Some("Stream/Output/Audio") => Self::StreamOutput, Some("Stream/Input/Audio") => Self::StreamInput, Some("Audio/Sink") => Self::Sink, Some("Audio/Source") => Self::Source, Some("Audio/Duplex") => Self::Duplex, _ => Self::Other, } } /// Can this node *receive* audio? This is the gate on the owner bridge: /// taint crosses the intra-process hop only when the owner is actually /// reading tainted audio (v3.4 §6.1.1 — "this client has both an input /// and an output leg ⇒ exclude the output" is the catastrophic rule /// that excludes every app with a microphone). /// /// `Sink` counts: EasyEffects' `ee_sink` is an `Audio/Sink` that /// receives the tainted mix, and its re-emitting leg is joined to it by /// `node.link-group` with no Link between them. pub fn receives_audio(self) -> bool { matches!(self, Self::StreamInput | Self::Sink | Self::Duplex) } /// Device-ish nodes — everything that is not a `Stream/*`. Coarse owner /// keys are not allowed to bridge these; see [`super::owner`]. pub fn is_device_role(self) -> bool { matches!(self, Self::Sink | Self::Source | Self::Duplex) } /// Only `Stream/Output/Audio` nodes are fan-out candidates (v3.4 §6.2). pub fn is_candidate(self) -> bool { matches!(self, Self::StreamOutput) } /// Stable machine-readable code for the audit output. Not the raw /// `media.class`: `Other` has no single one, and the audit's codes are a /// contract with the matrix, not with PipeWire. pub fn code(self) -> &'static str { match self { Self::StreamOutput => "stream-output", Self::StreamInput => "stream-input", Self::Sink => "sink", Self::Source => "source", Self::Duplex => "duplex", Self::Other => "other", } } } /// The subset of node properties the engine actually reasons about. /// /// Deliberately a struct of parsed fields rather than a property bag: the /// parsing (and its failure modes) belongs at the observer boundary, and a /// bag invites `props.get("...")` typos that silently read `None` — which /// on this feature means "not tainted". #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct NodeProps { /// `peerspeak.owned` is present and **exactly** /// [`super::PEERSPEAK_OWNED_VALUE`] (v3.4 §5.1, tightened by round 10's /// R10-4 — it is not "present and truthy", and the round-10 review found /// this doc still saying so). A correctness mechanism, explicitly *not* a /// security boundary. /// /// ⚠️ **Ownership carrier 1 of 2, so this being `false` does not mean /// "not peerspeak's".** Carrier 2 is the [`NodeSnapshot::name`] prefix /// [`super::PEERSPEAK_OWNED_NODE_PREFIX`], matched as a union in /// `local_root_reason`. Read that function, not this field, to answer /// "is this node owned?". pub peerspeak_owned: bool, /// `pulse.module.id`, parsed as `u64` — never `u32`, per v3.4 §5.2's /// parse-defensively note and the phase 0a truncation bug. pub pulse_module_id: Option, /// `node.link-group` — owner key 1, and the `echo-cancel-` hazard /// prefix (v3.4 §5.4 / D3). pub link_group: Option, /// `client.id` — owner key 3. A **connection**, not an owner: GStreamer /// opens one per stream (v3.4 §6.1.2, measured refutation). pub client_id: Option, /// `application.process.id` **on the node** — owner key 4. For /// module-created streams this is pipewire-pulse's own PID, which is /// why [`super::ExclusionCtx::pipewire_pulse_pid`] exists. pub process_id: Option, /// The stream negotiated an encoded/passthrough format; a second link /// would refuse or corrupt it (v3.4 §6.2). pub passthrough: bool, /// This node is a **passive device node exported by the session /// manager** — a real sound card's sink or source, not something that /// forwards audio. /// /// ⚠️ **A positive high-confidence classification the observer owes, not /// a raw property** (Codex rounds 2–3). PipeWire defines `device.id` /// only as "the Device this node belongs to" and `device.api` as that /// Device's access API; **neither promises the node passively terminates /// audio**, so a card-associated filter can satisfy both. Setting this /// flag *removes* two protections at once — the node's coarse owner keys /// (`owner` exception 2) and its ability to trip the fail-closed /// backstop — so a false positive is a leak, not over-exclusion. /// /// **Phase-3 contract:** /// - Set `true` only on positively-identified passive hardware /// terminals: a resolved `device.id` on a real backend /// (`device.api` present) whose `factory.name` is on an **explicit /// hardware-PCM allowlist** — `api.alsa.pcm.sink`, `api.alsa.pcm.source`, /// and the equivalent for other real backends (bluez5, v4l2 for the /// media case) as phase 3 enumerates them — never a filter, loopback, /// or `support.null-audio-sink` factory. An allowlist, not a /// substring or a denylist: an unknown factory is not a device. /// Measured discriminator on the /// target box: the five ALSA nodes carry `device.api=alsa` + /// `factory.name=api.alsa.pcm.*` and share `client.id=42` /// (`WirePlumber [export]`); the three `support.null-audio-sink` nodes /// carry neither. (`node.physical` was measured **null** on the ALSA /// nodes here, so it is *not* a usable discriminator — do not rely on /// it.) /// - **Fail closed: unknown ⇒ `false`.** A node that cannot be /// positively classified keeps its owner keys and can trip the /// backstop; both are the safe direction. /// - A node MUST NOT enter a snapshot with this field provisional. If /// the Device backing a node has not yet been bound, withhold the node /// and keep the epoch not-ready — otherwise a provisional `false` /// during not-ready fuses sink and mic on the shared session client /// and that fusion can persist as sticky over-exclusion (round-3 /// finding 3). /// /// ⚠️ **A false positive is leak-capable — do not treat it as braced.** /// I claimed a mis-classified filter could not leak because its legs /// share a `node.link-group` (strong-key bridge) or trip the unbounded /// backstop. Codex refuted it (round 4): a filter *without* a shared /// strong key, marked `session_device=true`, cannot activate the /// backstop from its reading leg, so a differently-keyed re-emitting leg /// leaks. Those braces catch *some* shapes, not all. The only real /// defence is a correct classifier — hence "positive high-confidence" /// and "fail closed to false" above, without exception. /// /// What it is for: every real device node shares the session manager's /// `client.id`, so coarse owner keys must not bridge them — else /// peerspeak's playback (which taints the default sink every recompute) /// would reach the microphone. See [`super::owner`] exception 2. pub session_device: bool, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct NodeSnapshot { pub serial: Serial, pub id: GlobalId, /// `node.name`, for diagnostics and for `pixelpass_capture_*` ancestry /// detection (v3.4 §6.2, cycle prevention). pub name: Option, pub role: MediaRole, pub props: NodeProps, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum PortDirection { In, Out, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct PortSnapshot { pub serial: Serial, pub id: GlobalId, /// Owning node, by snapshot-local id. pub node: GlobalId, pub direction: PortDirection, /// `port.exclusive` — fan-out will be refused (v3.4 §6.2). pub exclusive: bool, /// `port.monitor`. Recorded for phase 6 link creation; taint does not /// need it at node granularity. pub monitor: bool, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct LinkSnapshot { pub serial: Serial, pub id: GlobalId, /// `link.output.node` — the node audio flows **from**. pub output_node: GlobalId, /// `link.input.node` — the node audio flows **to**. pub input_node: GlobalId, pub output_port: Option, pub input_port: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ClientSnapshot { pub serial: Serial, pub id: GlobalId, /// `pipewire.sec.pid` — for Pulse-emulated clients this is /// **pipewire-pulse's** PID, identical across every unrelated app /// (v3.4 §5.2 correction 5). Phase 3 derives the daemon PID from the /// consistency of this value; the engine only consumes the result. pub sec_pid: Option, } /// How a snapshot-local id resolves. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum IdLookup { Unique(Serial), /// Two live objects in one snapshot claim the same global id — the /// observer missed a removal, so the recycled id is ambiguous. Every /// edge touching it is treated as unresolved, i.e. fail closed. Ambiguous, } /// One coherent observation of the graph. /// /// Built through [`GraphSnapshot::new`] so the id indexes and the ambiguity /// detection cannot be skipped. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct GraphSnapshot { nodes: BTreeMap, ports: BTreeMap, links: BTreeMap, clients: BTreeMap, node_ids: BTreeMap, client_ids: BTreeMap, } impl GraphSnapshot { pub fn new( nodes: Vec, ports: Vec, links: Vec, clients: Vec, ) -> Self { let node_ids = index_ids(nodes.iter().map(|n| (n.id, n.serial))); let client_ids = index_ids(clients.iter().map(|c| (c.id, c.serial))); Self { nodes: nodes.into_iter().map(|n| (n.serial, n)).collect(), ports: ports.into_iter().map(|p| (p.serial, p)).collect(), links: links.into_iter().map(|l| (l.serial, l)).collect(), clients: clients.into_iter().map(|c| (c.serial, c)).collect(), node_ids, client_ids, } } pub fn nodes(&self) -> impl Iterator { self.nodes.values() } pub fn node(&self, serial: Serial) -> Option<&NodeSnapshot> { self.nodes.get(&serial) } pub fn links(&self) -> impl Iterator { self.links.values() } pub fn ports(&self) -> impl Iterator { self.ports.values() } pub fn clients(&self) -> impl Iterator { self.clients.values() } /// Resolve a snapshot-local node id. `None` means "no such node in this /// snapshot", which for a link endpoint means unresolved ancestry. pub fn node_by_id(&self, id: GlobalId) -> Option { self.node_ids.get(&id).copied() } pub fn client_by_id(&self, id: GlobalId) -> Option { self.client_ids.get(&id).copied() } /// Every node claiming a global id. More than one means the id is /// [`IdLookup::Ambiguous`] and each claimant must be treated as a /// possible endpoint of any link naming it. pub fn nodes_with_id(&self, id: GlobalId) -> impl Iterator { self.nodes.values().filter(move |node| node.id == id) } /// Every client claiming a global id — same fail-closed reasoning. pub fn clients_with_id(&self, id: GlobalId) -> impl Iterator { self.clients.values().filter(move |client| client.id == id) } /// Ports belonging to a node, by the node's snapshot-local id. pub fn ports_of(&self, node: GlobalId) -> impl Iterator { self.ports.values().filter(move |p| p.node == node) } } fn index_ids(entries: impl Iterator) -> BTreeMap { let mut out: BTreeMap = BTreeMap::new(); for (id, serial) in entries { out.entry(id) .and_modify(|slot| { if *slot != IdLookup::Unique(serial) { *slot = IdLookup::Ambiguous; } }) .or_insert(IdLookup::Unique(serial)); } out }