diff --git a/src/host/taint/mod.rs b/src/host/taint/mod.rs index 480b4f2..dd6e3af 100644 --- a/src/host/taint/mod.rs +++ b/src/host/taint/mod.rs @@ -51,6 +51,40 @@ //! stack**. An entry is cleared only once every member object has //! disappeared; a key that reappears after full teardown is a new owner and //! starts clean. +//! +//! ## Documented v1 limitation — buffered audio across a full teardown of an +//! ## *unbounded* reader (Codex phase-2 round 5) +//! +//! The "clear on full teardown" boundary is the design's, verbatim +//! (v3.4 §6.1.3: "a key that reappears after full teardown is a new owner +//! and starts clean"). It leaves one theoretical echo: an **unbounded** +//! reader — one carrying no `node.link-group`, no `pulse.module.id`, and no +//! usable PID — reads the call into an internal buffer, tears down *all* its +//! PipeWire objects while keeping that buffer, then reconnects with fresh +//! objects and replays. With every member serial gone and no owner key to +//! fingerprint, nothing links the new owner to the old, and if no reader is +//! live in that epoch the [backstop](propagate_unresolved_owner) does not +//! fire either — so the replayed leg is eligible. +//! +//! **Left as a documented v1 limitation, not fixed, on three grounds:** +//! 1. **Outside the threat model.** v3.4 §2 states the model is "don't echo +//! the user's own call back at them," *not* "defend against a hostile +//! local process." A stream that exposes none of PID / module id / +//! link-group is a malformed or deliberately identity-hiding stream; real +//! software (apps carry a PID, pactl modules a module id, PipeWire +//! filters a link-group, GStreamer a PID) does not produce one. +//! 2. **It contradicts the design's own boundary** (§6.1.3, quoted above), +//! so closing it is a *design change*, not a bug fix. +//! 3. **No proportionate fix exists.** The only closed-form fix is a +//! whole-share hammer — once any unbounded reader is seen, exclude every +//! output for the rest of the share — which makes the desktop +//! unshareable on the mere appearance of one keyless stream. The reachable +//! cases (a reader live *now*) are already covered by the backstop's +//! exclude-everything tier. +//! +//! Owed to the design doc as a round-8 note (impl plan §11); if field +//! testing ever surfaces a real unbounded reader, revisit with +//! process-generation evidence rather than the hammer. // Phase 2 lands the engine behind its own test surface and nothing else: // the registry observer that will feed it is phase 3, so in a non-test @@ -651,21 +685,22 @@ fn propagate_owner_bridge( /// cannot know which one is re-emitting what it read. Exclude the output /// legs that are equally unbounded. /// -/// Two restrictions keep the blast radius at "small", as the design -/// promises — and one earlier restriction was **removed** as unsafe: +/// The trigger and the sweep, precisely (both edges hard-won across four +/// Codex rounds): /// -/// - **Source must not be a real device node.** A tainted hardware sink is -/// the normal case, not an anomaly: peerspeak's own playback taints the -/// default sink on literally every recompute, and letting that trip this -/// rule would exclude the whole desktop. -/// - **Targets must themselves be unbounded.** Ordinary application streams -/// carry a real `application.process.id` and are bounded, so they are -/// never swept up; in practice only daemon-owned keyless module streams -/// are. -/// - ⚠️ **The source does not have to be unbounded** (Codex round 1): a -/// reader carrying a `node.link-group` whose re-emitting leg carries none -/// is *bounded* while its sibling is not findable, and requiring an -/// unbounded source let exactly that shape through. +/// - **Trigger — any tainted receiver that is not a real device node.** A +/// tainted hardware sink is the normal case, not an anomaly (peerspeak's +/// playback taints the default sink every recompute), so device nodes do +/// not trip it. The source does **not** have to be unbounded: a reader +/// with a `node.link-group` whose re-emitting leg carries none is bounded +/// while its sibling is unfindable (round 1). +/// - **Sweep — depends on whether any tainted reader is itself unbounded.** +/// A *bounded* reader's siblings are exactly the outputs sharing its key, +/// so only the unbounded outputs (which could share its unknowable-only- +/// in-part identity) are swept; a differently-keyed output is provably a +/// different owner. An *unbounded* reader could be **any** owner — a real +/// process may present no PID on its reading leg (round 4) — so every +/// output candidate is swept, real apps included. /// /// **Two tiers, because a tainted reader we cannot bound is a bigger /// unknown than one we can** (Codex round 3 — the mirror image of the diff --git a/src/host/taint/snapshot.rs b/src/host/taint/snapshot.rs index 328a12a..88c3417 100644 --- a/src/host/taint/snapshot.rs +++ b/src/host/taint/snapshot.rs @@ -132,9 +132,13 @@ pub struct NodeProps { /// **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 a hardware PCM - /// factory (`api.alsa.pcm.{sink,source}` and the like), never a - /// filter/loopback/null-sink factory. Measured discriminator on the + /// (`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 diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs index 3b6ac86..0a887cb 100644 --- a/src/host/taint/tests.rs +++ b/src/host/taint/tests.rs @@ -1721,3 +1721,27 @@ fn a_link_group_new_connection_of_a_still_tainted_owner_inherits_the_taint() { ], ); } + +#[test] +fn a_local_root_receiver_bridges_without_an_inbound_link() { + // Round 5 finding 2: a mutation deleting the *role-based* receiver + // insertion survived all 55 tests, because every tested bridge source + // also had an inbound Link that put it in `receivers` anyway. A + // pixelpass capture sink is a taint root the moment it exists — before + // anything links into it — and its owner's re-emitting leg must bridge + // from it on the strength of its role alone. + let mut graph = Graph::new(); + // A capture sink (PixelpassOwned by name), sharing module id 55 with a + // re-emitting output leg, and NO inbound link yet. + let sink = graph.module_node("pixelpass_capture_4242", MediaRole::Sink, 55); + let leg = graph.module_node("capture-reemit", MediaRole::StreamOutput, 55); + let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114); + + let decisions = run(&graph, &ctx()); + assert_partition( + &decisions, + &[("firefox", firefox)], + &[("leg", leg, "tainted-owner-bridge")], + ); + assert_tainted(&decisions, sink, "pixelpass-owned"); +}