My half of the phase-3 split (impl plan §4). Pure, no PipeWire: the adapter (Codex's half) translates live registry callbacks / binds / /proc reads / core.sync into RegEvents and feeds this reducer. - RegistryModel::apply folds RegEvents into serial-keyed maps with an insertion-ordered id index so global_remove accounts for the oldest generation first; recycled ids stay Ambiguous until accounted (v3.4 §6.1.3). - Readiness epoch: graph_ready false until ServerSynced + no outstanding obligations (withheld nodes, pending link binds); bounded timeout fails closed. Gates sticky retirement only; sticky once terminal. - session_device classifier: hardware-PCM factory allowlist, exact match, fail closed to false; a node on an unresolved Device is withheld, never admitted provisional. - pulse-PID derivation split into pure candidate (repeated sec_pid) + validate (/proc comm), so the 6-case failure matrix is unit-testable; any failure => None (key 4 unusable). 34 tests cover 5 of 6 exit-gate rows (the live topology-diff row is the adapter's). cargo test --bins 117 green, fmt + clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
90 lines
4.2 KiB
Rust
90 lines
4.2 KiB
Rust
//! 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/<pid>/comm` — stays in the adapter:
|
|
//!
|
|
//! 1. [`candidate`] finds the PID that *looks* like pulse from the graph
|
|
//! alone: the `pipewire.sec.pid` value shared across multiple Clients.
|
|
//! Native PipeWire clients carry their own distinct PID; only the
|
|
//! Pulse shim repeats one value, so a repeated value is the signal.
|
|
//! 2. [`validate`] confirms that candidate against the `comm` the adapter
|
|
//! read from `/proc`. This is what closes **PID reuse**: a recycled PID
|
|
//! that coincidentally repeats in the graph is rejected because
|
|
//! `/proc/<pid>/comm` now names a different process.
|
|
//!
|
|
//! Any failure at either stage — no repeated value, two repeated values,
|
|
//! the property missing, `/proc` gone, a `comm` mismatch — yields `None`.
|
|
|
|
use crate::host::taint::snapshot::ClientSnapshot;
|
|
use std::collections::BTreeMap;
|
|
|
|
/// 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: the PID that looks like pipewire-pulse from the client graph.
|
|
///
|
|
/// Returns `Some(pid)` only when **exactly one** `pipewire.sec.pid` value is
|
|
/// shared by two or more clients. Rationale, matched to the failure matrix:
|
|
///
|
|
/// - **consistent** — one value repeats, the rest (native clients) are
|
|
/// distinct ⇒ that value.
|
|
/// - **inconsistent** — two or more values each repeat ⇒ we cannot tell which
|
|
/// is pulse ⇒ `None`.
|
|
/// - **missing property** — the Pulse clients carry no `sec_pid` ⇒ nothing
|
|
/// repeats ⇒ `None`.
|
|
///
|
|
/// A count threshold of two is deliberate: a single client carrying a PID is
|
|
/// indistinguishable from a lone native app, and pulse always mints many.
|
|
pub fn candidate(clients: &[ClientSnapshot]) -> Option<u32> {
|
|
let mut counts: BTreeMap<u32, usize> = BTreeMap::new();
|
|
for client in clients {
|
|
if let Some(pid) = client.sec_pid {
|
|
*counts.entry(pid).or_insert(0) += 1;
|
|
}
|
|
}
|
|
|
|
// Every PID seen on 2+ clients is a pulse candidate. If there is exactly
|
|
// one such PID we trust it; zero or several ⇒ fail closed.
|
|
let mut repeated = counts.iter().filter(|&(_, &n)| n >= 2).map(|(&pid, _)| pid);
|
|
let first = repeated.next()?;
|
|
if repeated.next().is_some() {
|
|
// Ambiguous: more than one value repeats.
|
|
return None;
|
|
}
|
|
Some(first)
|
|
}
|
|
|
|
/// Stage 2: confirm the candidate against the `comm` read from
|
|
/// `/proc/<candidate>/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<u32> {
|
|
match comm {
|
|
Some(PULSE_COMM) => Some(candidate),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The two stages composed, for callers that already hold the probed `comm`.
|
|
/// The model keeps them separate (it recomputes the candidate as clients
|
|
/// churn, and only re-probes when the candidate *changes*), so this is a
|
|
/// convenience for tests and for the fully-resolved path.
|
|
pub fn derive(clients: &[ClientSnapshot], comm_of: impl Fn(u32) -> Option<String>) -> Option<u32> {
|
|
let candidate = candidate(clients)?;
|
|
validate(candidate, comm_of(candidate).as_deref())
|
|
}
|