Closes the last blocking finding: a pid is only a number, and the same number is a different process in a different pid namespace. Repair running inside a container that can reach the host's Pulse socket saw a live host's modules, asked about that pid in its own namespace, was told nothing existed, and unloaded a running host's audio. No negative signal closes that — `NSpid == 1` does not prove the initial namespace, since its leftmost value is relative to whichever procfs was mounted. So the module now carries the answer with it. Every module a host loads gets `pixelpass.owner=<version>-<machine>-<boot>-<pid_ns>-<nonce>`, and repair only asks about a pid when all three identities match its own. Anything else is reported and left alone, and its pid is never even looked up — asking is the bug, because the answer would be meaningless. **Untagged modules are refused by default.** Everything loaded before tokens existed is unattributable, so `--repair` now lists those and does nothing, with `--repair-legacy-untagged` to opt into the old pid-only heuristic after seeing the candidates. That is a deliberate loss of reach: the failure being optimised against is a false-positive destructive repair, and leaving an old orphan behind is recoverable where destroying live routing is not. A foreign token is refused even with the flag, since the flag speaks to missing evidence, not wrong evidence. The vehicle was verified on the live server before anything was built on it: all three shapes accept a property-list argument (`sink_properties`, `sink_input_properties`, `source_output_properties`), the recorded argument comes back byte-identical — so exact-form matching still holds — and the property really lands on the resulting sink, sink-input and source-output. **Audit gate passed, with the variable isolated.** The token rides on real graph objects that phases 2/3 observe, so the partition had to be re-measured. Running the same fixture with and without tokens gives an identical partition: 2 eligible (FFXIV, Chromium), 2 excluded with the same `tainted-owner-bridge` reason, and the same six-entry taint set. Everything that differs from the empty-graph baseline is the fixture's own doing — a local-monitor loopback genuinely bridges our owned sink into the real default sink — and none of it is the property's. Attributing that to the token without the untokened control would have been the mistake. A side benefit: the per-load nonce narrows the ABA window I previously documented as unclosable. Two loads by the same pid no longer render byte-identical arguments, so a fingerprint taken from one no longer matches the other. Six new tests, three mutation-verified gates: `can_judge` always true, `pid_ns` dropped from the comparison, and untagged treated as judgeable regardless of policy — each killed by its own test. ⚠️ The third "survived" on first run because my mutation script's indentation did not match and the edit silently did nothing; the re-run asserts the file actually changed. A mutation that was never applied proves the same amount as no mutation at all. Field-verified live, three fixtures for one dead pid in one run: tokened with this machine's identity is cleaned, tokened with a foreign pid namespace is left alone and reported (and the legacy flag does not override it), and untagged is refused then cleaned only when asked. The two older field fixtures were tokenised too — without that the A/B test would have failed and the reference-gate test would have passed for the wrong reason, which is a vacuous gate in the harness rather than the code. 253 tests, clippy clean, fmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
459 lines
19 KiB
Rust
459 lines
19 KiB
Rust
//! `--repair`: clean up the Pulse modules left behind by a crashed pixelpass
|
|
//! host.
|
|
//!
|
|
//! All of the judgement lives in [`plan`], which is pure. What remains here is
|
|
//! I/O plus the two rules that cannot be expressed in a plan:
|
|
//!
|
|
//! - **Re-verify immediately before destroying anything.** Pulse module indices
|
|
//! are reused verbatim, and a host can die (or come back) between the snapshot
|
|
//! and the unload, so the plan is treated as evidence that expires — never as a
|
|
//! licence.
|
|
//! - **Gate the sink on what is still attached to it**, not on the plan's ordering
|
|
//! having succeeded. An unload can fail or be skipped, and a loopback can appear
|
|
//! after the plan was made.
|
|
//!
|
|
//! Repair never touches native PipeWire nodes. Since phase 0c the capture sink is
|
|
//! connection-owned and removes itself when its host dies, so there is nothing
|
|
//! there for repair to do and no safe way for it to help.
|
|
//!
|
|
//! # Where the observations come from
|
|
//!
|
|
//! Structured Pulse introspection over one verified-local connection — see
|
|
//! [`introspect`], which also documents the three defects that parsing `pactl`'s
|
|
//! text output turned out to have. Listing *and* unloading both go through that
|
|
//! same connection.
|
|
|
|
pub mod introspect;
|
|
pub mod plan;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use std::path::Path;
|
|
|
|
use introspect::PulseSession;
|
|
use plan::{Fingerprint, Liveness, Shape};
|
|
|
|
pub async fn run(clean_untagged: bool) -> Result<()> {
|
|
let liveness = LivenessProbe::new();
|
|
if let Some(reason) = liveness.degraded_reason() {
|
|
eprintln!(
|
|
"[pixelpass] --repair: cannot determine process liveness ({reason}); \
|
|
refusing to unload anything."
|
|
);
|
|
}
|
|
|
|
let local = local_identity().context("could not establish this process's own identity")?;
|
|
let policy = plan::Policy {
|
|
local,
|
|
untagged: if clean_untagged {
|
|
plan::UntaggedPolicy::CleanByPidAlone
|
|
} else {
|
|
plan::UntaggedPolicy::Refuse
|
|
},
|
|
};
|
|
if clean_untagged {
|
|
eprintln!(
|
|
"[pixelpass] --repair: --repair-legacy-untagged given; untagged modules will be \
|
|
judged by process id ALONE. That is only safe on the machine and in the pid \
|
|
namespace that ran the crashed host."
|
|
);
|
|
}
|
|
|
|
let mut pulse = PulseSession::connect().context("could not observe the Pulse module table")?;
|
|
let modules = pulse
|
|
.list_modules()
|
|
.context("could not list Pulse modules")?;
|
|
|
|
// Say so loudly when something names our sinks but matches no shape we know:
|
|
// that is either a third party using our names, or a newer pixelpass whose
|
|
// modules this build cannot recognise. The second is how repair would go
|
|
// silently blind, so it never gets inferred from a clean exit.
|
|
let unrecognised = plan::unrecognised_pixelpass_modules(&modules);
|
|
if !unrecognised.is_empty() {
|
|
eprintln!(
|
|
"[pixelpass] --repair: {} module(s) name a pixelpass capture sink but do not match \
|
|
any shape this build knows; they are being LEFT ALONE:",
|
|
unrecognised.len()
|
|
);
|
|
for obs in &unrecognised {
|
|
eprintln!(
|
|
"[pixelpass] --repair: #{} {} {}",
|
|
obs.id, obs.name, obs.args
|
|
);
|
|
}
|
|
}
|
|
|
|
let planned = plan::plan(&modules, &policy, |pid| liveness.of(pid));
|
|
|
|
// Ours by shape, but carrying no proof of whose pid they name. Never unloaded by
|
|
// default — listed, so an explicit legacy run has something to look at first.
|
|
if !planned.untagged.is_empty() {
|
|
eprintln!(
|
|
"[pixelpass] --repair: {} module(s) are pixelpass's but carry no ownership token, so \
|
|
the process id in their name cannot be attributed to this machine or pid namespace. \
|
|
LEFT ALONE. Re-run with --repair-legacy-untagged to clean them by pid alone:",
|
|
planned.untagged.len()
|
|
);
|
|
for fp in &planned.untagged {
|
|
eprintln!(
|
|
"[pixelpass] --repair: #{} {} (claims pid {})",
|
|
fp.id,
|
|
fp.shape.label(),
|
|
fp.pid
|
|
);
|
|
}
|
|
}
|
|
// Tokened, but the token belongs to another machine, boot or namespace.
|
|
if !planned.foreign.is_empty() {
|
|
eprintln!(
|
|
"[pixelpass] --repair: {} module(s) belong to another machine, boot or pid namespace; \
|
|
their process ids mean nothing here. LEFT ALONE:",
|
|
planned.foreign.len()
|
|
);
|
|
for fp in &planned.foreign {
|
|
eprintln!(
|
|
"[pixelpass] --repair: #{} {} (claims pid {})",
|
|
fp.id,
|
|
fp.shape.label(),
|
|
fp.pid
|
|
);
|
|
}
|
|
}
|
|
|
|
if planned.is_empty() {
|
|
let mut held = Vec::new();
|
|
if !planned.live_pids.is_empty() {
|
|
held.push(format!(
|
|
"{} live pixelpass host(s)",
|
|
planned.live_pids.len()
|
|
));
|
|
}
|
|
if !planned.unknown_pids.is_empty() {
|
|
held.push(format!(
|
|
"{} pid(s) of undeterminable liveness",
|
|
planned.unknown_pids.len()
|
|
));
|
|
}
|
|
if held.is_empty() {
|
|
println!("[pixelpass] --repair: nothing to clean up.");
|
|
} else {
|
|
println!(
|
|
"[pixelpass] --repair: nothing to clean up ({} left alone).",
|
|
held.join(", ")
|
|
);
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
let mut unloaded = 0u32;
|
|
let mut skipped = 0u32;
|
|
let mut failed = 0u32;
|
|
|
|
for fp in &planned.unload {
|
|
// Fresh snapshot per action. Deliberately not hoisted out of the loop:
|
|
// each unload changes the module table, and the point is to decide
|
|
// against the table as it is *now*, not as it was when the plan was made.
|
|
let current = pulse
|
|
.list_modules()
|
|
.context("could not re-list Pulse modules")?;
|
|
let Some(obs) = current.iter().find(|m| m.id == fp.id) else {
|
|
println!(
|
|
"[pixelpass] --repair: module #{} is already gone; skipping",
|
|
fp.id
|
|
);
|
|
skipped += 1;
|
|
continue;
|
|
};
|
|
if !fp.still_matches(obs) {
|
|
// The index now names something else, or the same module's
|
|
// arguments changed. Either way we no longer know what we would be
|
|
// destroying, so we do not destroy it.
|
|
eprintln!(
|
|
"[pixelpass] --repair: module #{} no longer matches what was planned \
|
|
(index reused?); refusing to unload it",
|
|
fp.id
|
|
);
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
// The sink goes last in the plan, but "last" is not the same as "nothing
|
|
// is attached any more": a loopback unload may have failed or been
|
|
// skipped, or a new one may have arrived since. Ask the fresh snapshot.
|
|
if fp.shape == Shape::LegacyCaptureSink
|
|
&& let Some(holder) = plan::sink_still_referenced(¤t, fp.pid, fp.id)
|
|
{
|
|
eprintln!(
|
|
"[pixelpass] --repair: module #{} (capture sink for pid {}) is still referenced \
|
|
by module #{}; leaving the sink loaded",
|
|
fp.id, fp.pid, holder
|
|
);
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
// Liveness last, and closest to the unload: a host that came back — or
|
|
// a stranger that inherited the pid — outranks any amount of evidence
|
|
// that this module looked orphaned. Undecidable counts as back.
|
|
match liveness.of(fp.pid) {
|
|
Liveness::Dead => {}
|
|
Liveness::Alive => {
|
|
println!(
|
|
"[pixelpass] --repair: pid {} is alive again; leaving module #{} alone",
|
|
fp.pid, fp.id
|
|
);
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
Liveness::Unknown => {
|
|
eprintln!(
|
|
"[pixelpass] --repair: pid {}'s liveness became undeterminable; \
|
|
leaving module #{} alone",
|
|
fp.pid, fp.id
|
|
);
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
match pulse.unload_module(fp.id) {
|
|
Ok(()) => {
|
|
println!("[pixelpass] --repair: {}", describe(fp));
|
|
unloaded += 1;
|
|
}
|
|
Err(e) => {
|
|
eprintln!("[pixelpass] --repair: failed to unload #{}: {e:#}", fp.id);
|
|
failed += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if !planned.live_pids.is_empty() {
|
|
println!(
|
|
"[pixelpass] --repair: left {} live pixelpass host(s) alone.",
|
|
planned.live_pids.len()
|
|
);
|
|
}
|
|
if !planned.unknown_pids.is_empty() {
|
|
println!(
|
|
"[pixelpass] --repair: left {} pid(s) alone whose liveness could not be determined.",
|
|
planned.unknown_pids.len()
|
|
);
|
|
}
|
|
if skipped > 0 {
|
|
// Deliberately not "changed under us": a skip can also mean the module is
|
|
// still referenced, or its owner's liveness stopped being decidable. The
|
|
// per-module reason was printed above.
|
|
println!("[pixelpass] --repair: skipped {skipped} module(s) (reasons above).");
|
|
}
|
|
if failed > 0 {
|
|
bail!("--repair: {failed} module(s) failed to unload (see errors above)");
|
|
}
|
|
println!("[pixelpass] --repair: cleaned up {unloaded} module(s).");
|
|
Ok(())
|
|
}
|
|
|
|
fn describe(fp: &Fingerprint) -> String {
|
|
format!(
|
|
"unloaded {} #{} (orphaned from pid {})",
|
|
fp.shape.label(),
|
|
fp.id,
|
|
fp.pid
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Identity
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
/// This process's machine, boot and pid-namespace identity.
|
|
///
|
|
/// Read from the kernel and the system, never guessed: without all three, a token
|
|
/// cannot be compared and no module can be attributed. Dashes are stripped so every
|
|
/// component is safe inside a single unquoted Pulse property value.
|
|
pub fn local_identity() -> Result<plan::LocalIdentity> {
|
|
let machine = read_identity_file("/etc/machine-id")
|
|
.or_else(|_| read_identity_file("/var/lib/dbus/machine-id"))
|
|
.context("could not read a machine id")?;
|
|
let boot = read_identity_file("/proc/sys/kernel/random/boot_id")
|
|
.context("could not read the boot id")?;
|
|
let pid_ns = pid_namespace_id().context("could not read this process's pid namespace")?;
|
|
Ok(plan::LocalIdentity {
|
|
machine,
|
|
boot,
|
|
pid_ns,
|
|
})
|
|
}
|
|
|
|
fn read_identity_file(path: &str) -> Result<String> {
|
|
let raw = std::fs::read_to_string(path).with_context(|| format!("could not read {path}"))?;
|
|
let cleaned: String = raw
|
|
.trim()
|
|
.chars()
|
|
.filter(|c| c.is_ascii_hexdigit())
|
|
.collect();
|
|
if cleaned.is_empty() {
|
|
bail!("{path} held no usable identity");
|
|
}
|
|
Ok(cleaned)
|
|
}
|
|
|
|
/// The inode of `/proc/self/ns/pid` — the kernel's identity for a pid namespace.
|
|
///
|
|
/// This is the value that makes a pid meaningful: two processes in different pid
|
|
/// namespaces can hold the same number, and only this distinguishes them.
|
|
fn pid_namespace_id() -> Result<u64> {
|
|
use std::os::unix::fs::MetadataExt;
|
|
let meta =
|
|
std::fs::metadata("/proc/self/ns/pid").context("could not stat /proc/self/ns/pid")?;
|
|
Ok(meta.ino())
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Liveness
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
/// Answers "is this pid still around", and knows when it must refuse to answer.
|
|
///
|
|
/// **An error is not an absence.** `Path::exists()` folds permission errors and a
|
|
/// missing `/proc` into `false`, which here would read as "dead, go ahead and
|
|
/// unload". Liveness is asked with `kill(pid, 0)` instead, where `EPERM` *proves*
|
|
/// existence.
|
|
///
|
|
/// # The limit of what this can prove, stated rather than papered over
|
|
///
|
|
/// A pid can be alive and invisible. Inside a pid namespace — a container, a
|
|
/// distrobox — `/proc/self` is perfectly visible while every process in the
|
|
/// *parent* namespace is not, and `hidepid` has the same self-visible,
|
|
/// others-invisible shape. Repair in such a place can reach the host's Pulse
|
|
/// socket, see a live host's modules, get `ESRCH` for its pid and unload a running
|
|
/// host's audio.
|
|
///
|
|
/// The signals below are **negative** ones: they detect *some* cases where pid
|
|
/// numbers cannot be trusted, and every one of them fails closed. What they cannot
|
|
/// do is prove the converse. `NSpid` reports this process's pid in each namespace
|
|
/// that its procfs can see, and its leftmost value is relative to the pid namespace
|
|
/// that mounted that procfs — so a nested namespace with its own `/proc` reports a
|
|
/// single entry quite legitimately. `NSpid > 1` therefore means "definitely
|
|
/// nested", while `NSpid == 1` means only "not detectably nested".
|
|
///
|
|
/// Closing that properly needs the module itself to carry an owner token (machine
|
|
/// and boot identity plus pid-namespace identity) written at load time, with
|
|
/// token-less modules treated as `Unknown`. That changes what pixelpass writes into
|
|
/// the graph and how far back `--repair` can clean up, so it is a design decision
|
|
/// recorded in the impl plan rather than guessed at here.
|
|
struct LivenessProbe {
|
|
/// `None` when no signal says pid numbers are untrustworthy; `Some(reason)`
|
|
/// when every answer must be [`Liveness::Unknown`].
|
|
degraded: Option<String>,
|
|
}
|
|
|
|
impl LivenessProbe {
|
|
fn new() -> Self {
|
|
Self {
|
|
degraded: Self::detect_degradation(),
|
|
}
|
|
}
|
|
|
|
fn detect_degradation() -> Option<String> {
|
|
// Locality is deliberately NOT checked here. `PULSE_SERVER` is a fallback
|
|
// *list*, so `unix:/missing tcp:remote:4713` starts with "unix:" and still
|
|
// connects to another machine, and a remote server can be selected by client
|
|
// configuration with the variable unset entirely. The authoritative answer
|
|
// comes from `pa_context_is_local()` on the connection that actually got
|
|
// established — see `introspect::PulseSession::connect`.
|
|
match std::fs::read_to_string("/proc/self/status") {
|
|
Ok(status) => {
|
|
let nspid = status
|
|
.lines()
|
|
.find_map(|line| line.strip_prefix("NSpid:"))
|
|
.map(|rest| rest.split_whitespace().count());
|
|
match nspid {
|
|
Some(n) if n > 1 => {
|
|
return Some(format!(
|
|
"this process is in a nested pid namespace (NSpid has {n} entries), \
|
|
so pids in module names may belong to processes it cannot see"
|
|
));
|
|
}
|
|
// NB: a single entry is not proof of the initial namespace — see
|
|
// the type's doc comment. It only means nothing detected it.
|
|
// A kernel too old to report NSpid cannot rule nesting out.
|
|
None => {
|
|
return Some(
|
|
"/proc/self/status does not report NSpid, so pid-namespace identity \
|
|
cannot be established"
|
|
.to_string(),
|
|
);
|
|
}
|
|
Some(_) => {}
|
|
}
|
|
}
|
|
Err(e) => return Some(format!("/proc/self/status could not be read: {e}")),
|
|
}
|
|
// Belt and braces: container runtimes that leave a marker.
|
|
for marker in ["/run/.containerenv", "/.dockerenv"] {
|
|
if Path::new(marker).try_exists().unwrap_or(false) {
|
|
return Some(format!("{marker} exists, so this is a container"));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn degraded_reason(&self) -> Option<&str> {
|
|
self.degraded.as_deref()
|
|
}
|
|
|
|
fn of(&self, pid: u32) -> Liveness {
|
|
if self.degraded.is_some() {
|
|
return Liveness::Unknown;
|
|
}
|
|
// `kill(0, …)` signals our whole process group and a negative pid signals
|
|
// another group, so neither may ever reach `kill`. Neither is a pid we
|
|
// could have written into a sink name anyway.
|
|
if pid == 0 || pid > i32::MAX as u32 {
|
|
return Liveness::Unknown;
|
|
}
|
|
match nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid as i32), None) {
|
|
Ok(()) => Liveness::Alive,
|
|
// The process exists; we merely may not signal it.
|
|
Err(nix::errno::Errno::EPERM) => Liveness::Alive,
|
|
Err(nix::errno::Errno::ESRCH) => Liveness::Dead,
|
|
Err(_) => Liveness::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// The `pactl`-text parser that used to live here is gone, and so are its
|
|
// tests: `introspect` gets index, name and argument as structured fields, so
|
|
// there is no format left to mis-parse. What replaced those tests is the live
|
|
// field gate, since the remaining risk is in talking to the server, which no
|
|
// unit test can exercise. The decisions all live in `plan`, which is pure and
|
|
// tested there.
|
|
|
|
/// The probe must never say `Dead` when it cannot see the whole pid space, and
|
|
/// must never ask `kill` about a pid that would signal something other than one
|
|
/// process.
|
|
#[test]
|
|
fn a_degraded_probe_never_reports_dead() {
|
|
let degraded = LivenessProbe {
|
|
degraded: Some("test".to_string()),
|
|
};
|
|
assert_eq!(degraded.of(1), Liveness::Unknown);
|
|
assert_eq!(degraded.of(u32::MAX), Liveness::Unknown);
|
|
|
|
let probe = LivenessProbe::new();
|
|
// Our own pid is alive by construction — unless this test itself runs
|
|
// somewhere the probe must abstain, which is exactly the other branch.
|
|
let me = std::process::id();
|
|
match probe.degraded_reason() {
|
|
None => assert_eq!(probe.of(me), Liveness::Alive),
|
|
Some(_) => assert_eq!(probe.of(me), Liveness::Unknown),
|
|
}
|
|
// `kill(0, …)` would signal our whole process group, and a pid past
|
|
// `i32::MAX` cannot be expressed to `kill` at all.
|
|
assert_eq!(probe.of(0), Liveness::Unknown);
|
|
assert_eq!(probe.of(u32::MAX), Liveness::Unknown);
|
|
}
|
|
}
|