Round 10 review, findings 1, 4 and 6. Finding 1 (P1, phase 6) — key 4 was `node.or_else(client)`, so a node's client-controlled application.process.id REPLACED its Client's protected pipewire.sec.pid. One process using two Clients could therefore split its identity: the tainted reader reports a bogus node pid, the output leg omits the node pid and falls back to the Client's real one, the legs are bounded by different values, and they neither bridge nor trip the unbounded sweep — the output stays eligible while re-emitting the call. Now a union of both values, deduplicated, with exception 1 applied to each independently so the pipewire-pulse pid still cannot fuse unrelated Clients. Mutation-verified: reverting to or_else fails ONLY the new split-Client test (so the union changes nothing else), dropping exception 1 fails 32 rows, and using the Client pid alone fails 16. Not reachable today — evaluate() is reached only by the dry-run audit, which creates no links. It becomes live when phase 6 consumes these decisions. Finding 4 — R10-4's test called peerspeak_owned() directly, so reverting node_observation_from_props to truthy() left it green; the only case it shared with production, exact "1", passes under both. A new test builds a real pw_properties dict and drives the production wiring, and the mutation now fails exactly that test while the helper test still passes. Finding 6 — the cross-repo fixture still documented carrier 1 as "any value other than false/0", which R10-4 made exact-"1". A producer following it could emit "true" and silently lose the carrier. Fixture updated in both repos (byte-identical, verified), along with the stale prose in taint/tests and snapshot.rs, and the contract is now also exercised through the production adapter rather than only against the constants. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
356 lines
15 KiB
Rust
356 lines
15 KiB
Rust
//! 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<u64>,
|
||
/// `node.link-group` — owner key 1, and the `echo-cancel-` hazard
|
||
/// prefix (v3.4 §5.4 / D3).
|
||
pub link_group: Option<String>,
|
||
/// `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<GlobalId>,
|
||
/// `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<u32>,
|
||
/// 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<String>,
|
||
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<GlobalId>,
|
||
pub input_port: Option<GlobalId>,
|
||
}
|
||
|
||
#[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<u32>,
|
||
}
|
||
|
||
/// 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<Serial, NodeSnapshot>,
|
||
ports: BTreeMap<Serial, PortSnapshot>,
|
||
links: BTreeMap<Serial, LinkSnapshot>,
|
||
clients: BTreeMap<Serial, ClientSnapshot>,
|
||
node_ids: BTreeMap<GlobalId, IdLookup>,
|
||
client_ids: BTreeMap<GlobalId, IdLookup>,
|
||
}
|
||
|
||
impl GraphSnapshot {
|
||
pub fn new(
|
||
nodes: Vec<NodeSnapshot>,
|
||
ports: Vec<PortSnapshot>,
|
||
links: Vec<LinkSnapshot>,
|
||
clients: Vec<ClientSnapshot>,
|
||
) -> 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<Item = &NodeSnapshot> {
|
||
self.nodes.values()
|
||
}
|
||
|
||
pub fn node(&self, serial: Serial) -> Option<&NodeSnapshot> {
|
||
self.nodes.get(&serial)
|
||
}
|
||
|
||
pub fn links(&self) -> impl Iterator<Item = &LinkSnapshot> {
|
||
self.links.values()
|
||
}
|
||
|
||
pub fn ports(&self) -> impl Iterator<Item = &PortSnapshot> {
|
||
self.ports.values()
|
||
}
|
||
|
||
pub fn clients(&self) -> impl Iterator<Item = &ClientSnapshot> {
|
||
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<IdLookup> {
|
||
self.node_ids.get(&id).copied()
|
||
}
|
||
|
||
pub fn client_by_id(&self, id: GlobalId) -> Option<IdLookup> {
|
||
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<Item = &NodeSnapshot> {
|
||
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<Item = &ClientSnapshot> {
|
||
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<Item = &PortSnapshot> {
|
||
self.ports.values().filter(move |p| p.node == node)
|
||
}
|
||
}
|
||
|
||
fn index_ids(entries: impl Iterator<Item = (GlobalId, Serial)>) -> BTreeMap<GlobalId, IdLookup> {
|
||
let mut out: BTreeMap<GlobalId, IdLookup> = 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
|
||
}
|