//! Deriving pipewire-pulse's own PID — pure, no PipeWire and no `/proc` I/O. //! //! The owner bridge's key 4 is `application.process.id`. For a stream created //! by a **Pulse-emulated** client that PID is *pipewire-pulse's own*, shared //! verbatim across every unrelated Pulse app, so bridging on it would fuse //! every Pulse module into one tainted owner (design v3.4 §5.2 correction 5, //! §6.1.2). The engine therefore needs to know that one PID so it can refuse //! to bridge on it — and **every** way of deriving it can fail, in which case //! the safe answer is `None`: key 4 becomes unusable (coarser, never wrong). //! //! The derivation is split into two pure stages so the I/O — reading //! `/proc//comm` — stays in the adapter: //! //! 1. [`candidates`] lists the PIDs worth probing from the graph alone: every //! distinct `pipewire.sec.pid` any Client presents. //! 2. [`resolve`] picks the one whose `comm`, as read from `/proc` by the //! adapter, is exactly pipewire-pulse's. This is also what closes **PID //! reuse**: a recycled PID is rejected because `/proc//comm` now names //! a different process. //! //! Any failure — no Client carries the property, no `comm` matches, `/proc` //! gone, or *several* PIDs claim to be pipewire-pulse — yields `None`. //! //! ## ⚠️ Round 10 (MEASURED): repetition is not the signal //! //! Stage 1 used to return a single candidate: the one `sec_pid` value shared by //! two or more Clients, reasoning that "native PipeWire clients carry their own //! distinct PID; only the Pulse shim repeats one value". **That is false on a //! stock desktop, and the phase-5 §5.1 matrix caught it on row 1.** Measured on //! this host (PipeWire 1.6.8 / WirePlumber 0.5.15): WirePlumber holds *two* //! Clients — `WirePlumber` and `WirePlumber [export]` — both carrying //! `sec_pid` 1747. So two values repeated (1747 and pipewire-pulse's 2528), the //! old rule called that ambiguous and returned `None`, and the consequence was //! not a missing optimisation but a machine-wide over-exclusion cascade: with //! the daemon PID unknown, key 4's suppression never fires, every //! Pulse-emulated node fuses into one owner, and the eligible half of every row //! empties out (see `owner::keys_of`'s fail-closed asymmetry note). //! //! The rule failed in *both* directions, which is why the prefilter is gone //! rather than patched: //! //! - **False ambiguity** — any second process holding two Clients defeats it. //! WirePlumber always does, so this was permanent, not a corner case. //! - **False absence** — a session where pipewire-pulse happens to hold exactly //! one Client (one Pulse app running) never repeats a value at all, so the //! candidate is missed and the same cascade follows. //! //! `comm` was always the authoritative check; repetition was a heuristic //! standing in front of it, and it was wrong. Probing every distinct `sec_pid` //! costs one `/proc` read per *distinct* PID (single digits — bounded by the //! Client count, cached, and re-read only when the candidate set changes), //! which is a cheap price for a signal that does not encode an assumption about //! how many Clients anyone else opens. use crate::host::taint::snapshot::ClientSnapshot; use std::collections::BTreeSet; /// The kernel `comm` of the pipewire-pulse process. `comm` is truncated to /// 15 bytes by the kernel; `pipewire-pulse` is 14 bytes, so it is exact — /// and exact is the only safe match, since a prefix match would accept a /// recycled PID belonging to e.g. `pipewire-pulseX`. const PULSE_COMM: &str = "pipewire-pulse"; /// Stage 1: every PID worth probing — the distinct `pipewire.sec.pid` values /// the Clients present. /// /// No filtering, and deliberately so (see the module docs): any rule applied /// here is a guess about other processes' Client counts, while stage 2 has the /// kernel's own answer. A `BTreeSet` because the adapter diffs successive /// candidate sets to decide what to re-probe, and that diff must not depend on /// Client iteration order. pub fn candidates(clients: &[ClientSnapshot]) -> BTreeSet { clients.iter().filter_map(|client| client.sec_pid).collect() } /// Stage 2: confirm one candidate against the `comm` read from /// `/proc//comm`. /// /// `comm` is `None` when the adapter's read failed — the `/proc` entry is /// gone (the process exited between derivation and probe) — which is itself a /// reason to fail closed. A present-but-different `comm` is the **PID reuse** /// guard: the number is live but now belongs to someone else. pub fn validate(candidate: u32, comm: Option<&str>) -> Option { match comm { Some(PULSE_COMM) => Some(candidate), _ => None, } } /// Stage 2 across the whole candidate set: the *unique* PID whose `comm` is /// pipewire-pulse's. /// /// `None` when none matches (nothing to suppress that we can prove) and also /// when **several** do. Several means either two pipewire-pulse daemons are /// live — a nested or sandboxed session — or a `comm` collision, and a single /// `Option` cannot suppress two owners. Failing closed here lands on the /// over-exclusion side, matching the asymmetry `owner::keys_of` already /// documents: broad over-exclusion is annoying, a missed suppression is an /// echo. Suppressing a *set* of daemon PIDs is the real answer if a /// multi-daemon host ever turns up; it is not v1, and it is recorded rather /// than silently approximated. pub fn resolve(candidates: &BTreeSet, comm_of: impl Fn(u32) -> Option) -> Option { let mut found = None; for &pid in candidates { if validate(pid, comm_of(pid).as_deref()).is_some() { if found.is_some() { return None; } found = Some(pid); } } found } /// Both stages composed, for callers that can probe on demand. /// /// The model keeps them separate — it recomputes the candidate set as Clients /// churn and only re-probes PIDs entering it — so this is a convenience for /// tests and for the fully-resolved path. pub fn derive(clients: &[ClientSnapshot], comm_of: impl Fn(u32) -> Option) -> Option { resolve(&candidates(clients), comm_of) }