diff --git a/src/cli.rs b/src/cli.rs index aa7e4e8..680b9bc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -105,6 +105,18 @@ pub struct Cli { #[arg(long)] pub repair: bool, + /// With `--repair`: also clean up modules that carry no ownership token, + /// judging them by process id alone. + /// + /// Modules loaded by pixelpass versions before ownership tokens existed cannot + /// be attributed to a machine, boot or pid namespace, so `--repair` refuses them + /// by default: a process id means different processes in different namespaces, + /// and acting on the wrong one unloads a *running* host's audio. Use this only + /// on the machine that ran the crashed host, and only when the reported + /// candidates look right. + #[arg(long, requires = "repair")] + pub repair_legacy_untagged: bool, + /// Print an environment diagnostic report (display server, capture/encode /// dependencies, VA-API H.264 support, viewer player, relay reachability), /// then exit. Use this to check a machine can host or view before a real diff --git a/src/host/audio.rs b/src/host/audio.rs index de36328..c0bf2e3 100644 --- a/src/host/audio.rs +++ b/src/host/audio.rs @@ -67,7 +67,13 @@ impl Routing { let pid = std::process::id(); let sink_name = repair_plan::sink_name_for(pid); - let sink_module = load_module(Shape::LegacyCaptureSink, pid) + // Every module this host loads carries an ownership token, so `--repair` + // can tell whose pid the name refers to instead of assuming the number + // means the same thing everywhere. Without it a repair run in another pid + // namespace can unload a live host's audio; see `repair::plan::OwnerToken`. + let owner = owner_token(pid).context("could not build an audio ownership token")?; + + let sink_module = load_module(Shape::LegacyCaptureSink, pid, &owner) .context("failed to load module-null-sink")?; // In strict per-app mode we never mirror the default sink: the viewer @@ -83,7 +89,7 @@ impl Routing { None } else { Some( - load_module(Shape::LoopbackIntoCapture, pid) + load_module(Shape::LoopbackIntoCapture, pid, &owner) .context("failed to load module-loopback (null-sink cleaned up on Drop)")?, ) }; @@ -111,6 +117,7 @@ impl Routing { let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?; let loopback_for_task = Arc::clone(&loopback_arc); let local_monitor_for_task = Arc::clone(&local_monitor_arc); + let owner_for_task = owner.clone(); let strict = opts.strict_audio; let event_task = tokio::spawn(async move { use crate::common::output::{self, AppAudioState}; @@ -132,7 +139,8 @@ impl Routing { // only, never the desktop/call — so it can't echo into // the capture. if local_monitor_for_task.lock().unwrap().is_none() { - match load_module(Shape::LoopbackOutOfCapture, pid) { + match load_module(Shape::LoopbackOutOfCapture, pid, &owner_for_task) + { Ok(id) => { tracing::info!( module = id, @@ -185,7 +193,7 @@ impl Routing { tracing::info!( "audio routing: last routed stream gone → restoring default-sink loopback" ); - match load_module(Shape::LoopbackIntoCapture, pid) { + match load_module(Shape::LoopbackIntoCapture, pid, &owner_for_task) { Ok(id) => { *loopback_for_task.lock().unwrap() = Some(id); } @@ -334,6 +342,27 @@ struct SinkInputProperties { // pactl module helpers // ────────────────────────────────────────────────────────────────────── +/// Mint this host's ownership token. +/// +/// The nonce is what makes two loads by the same pid distinguishable, which is why +/// it is per-call rather than per-process: it narrows the window where a module that +/// vanished and a replacement that inherited its index look byte-identical. +fn owner_token(pid: u32) -> Result { + let local = crate::repair::local_identity()?; + // A nonce only has to be unlikely to repeat, not unguessable. + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) + ^ u64::from(pid) << 32; + Ok(repair_plan::OwnerToken { + machine: local.machine, + boot: local.boot, + pid_ns: local.pid_ns, + nonce, + }) +} + /// Load the Pulse module for one [`Shape`] and return its index. /// /// Both the module name and its arguments come from the shape itself @@ -341,11 +370,11 @@ struct SinkInputProperties { /// `--repair`'s exact-form matcher and this loader are one source of truth. A /// latency or argument change that moved only one of them would leave repair /// silently unable to recognise the modules this build loads. -fn load_module(shape: Shape, pid: u32) -> Result { +fn load_module(shape: Shape, pid: u32, owner: &repair_plan::OwnerToken) -> Result { let output = Command::new("pactl") .arg("load-module") .arg(shape.module_name()) - .args(shape.render_args(pid)) + .args(shape.render_args(pid, Some(owner))) .output() .context("failed to run pactl load-module")?; if !output.status.success() { diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs index b70da24..afc854e 100644 --- a/src/host/taint/tests.rs +++ b/src/host/taint/tests.rs @@ -2683,5 +2683,12 @@ fn an_ambiguous_client_id_remembers_every_claimant_for_stickiness() { client_members >= 2, "both ambiguous-id clients should be remembered: {sticky:#?}" ); - let _ = (ClientSnapshot { serial: Serial(0), id: GlobalId(0), sec_pid: None }, firefox); + let _ = ( + ClientSnapshot { + serial: Serial(0), + id: GlobalId(0), + sec_pid: None, + }, + firefox, + ); } diff --git a/src/main.rs b/src/main.rs index b01fddf..1b9ac65 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,7 +49,7 @@ async fn main() -> Result<()> { pipewire::init(); if cli.repair { - return repair::run().await; + return repair::run(cli.repair_legacy_untagged).await; } // Read-only diagnostic: observe the graph, report what the audio-exclusion diff --git a/src/repair/mod.rs b/src/repair/mod.rs index 2a96352..45d1cf2 100644 --- a/src/repair/mod.rs +++ b/src/repair/mod.rs @@ -32,7 +32,7 @@ use std::path::Path; use introspect::PulseSession; use plan::{Fingerprint, Liveness, Shape}; -pub async fn run() -> Result<()> { +pub async fn run(clean_untagged: bool) -> Result<()> { let liveness = LivenessProbe::new(); if let Some(reason) = liveness.degraded_reason() { eprintln!( @@ -41,6 +41,23 @@ pub async fn run() -> Result<()> { ); } + 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() @@ -65,7 +82,42 @@ pub async fn run() -> Result<()> { } } - let planned = plan::plan(&modules, |pid| liveness.of(pid)); + 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(); @@ -207,6 +259,53 @@ fn describe(fp: &Fingerprint) -> String { ) } +// ────────────────────────────────────────────────────────────────────── +// 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 { + 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 { + 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 { + 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 // ────────────────────────────────────────────────────────────────────── diff --git a/src/repair/plan.rs b/src/repair/plan.rs index e575cdd..5a72ad9 100644 --- a/src/repair/plan.rs +++ b/src/repair/plan.rs @@ -25,6 +25,12 @@ //! //! # The safety rules, in order of how much damage they prevent //! +//! 0. **A pid is not an owner.** The same number is a different process in a +//! different pid namespace, so before liveness can even be *asked*, the module +//! must prove which machine, boot and namespace its pid belongs to — see +//! [`OwnerToken`]. Modules that cannot be attributed are never touched, and +//! their pids are never even looked up: asking is the bug, because the answer +//! would be meaningless. This rule comes first because it gates the others. //! 1. **A live pid is never touched**, even if it is not pixelpass. Pid reuse is //! real, so "this pid is alive" always wins over "this module looks orphaned". //! Leaving a stale module behind is recoverable; unloading a live host's audio @@ -164,62 +170,237 @@ pub const ALL_SHAPES: [Shape; 3] = [ Shape::LegacyCaptureSink, ]; +// ────────────────────────────────────────────────────────────────────── +// Ownership +// ────────────────────────────────────────────────────────────────────── + +/// The Pulse property every pixelpass module carries its owner token in. +/// +/// Measured on the live server before being relied on: all three shapes accept a +/// property-list argument (`sink_properties` / `sink_input_properties` / +/// `source_output_properties`), the recorded argument comes back byte-identical, and +/// the property really does land on the resulting sink, sink-input and +/// source-output. +pub const OWNER_PROPERTY: &str = "pixelpass.owner"; + +/// Bumped if the token's shape ever changes. A token this build cannot parse is +/// **not** treated as ours, so an older `--repair` meeting a newer token refuses it +/// and reports it rather than guessing. +pub const OWNER_TOKEN_VERSION: u32 = 1; + +/// Proof of *which* machine, boot and pid namespace a module's pid refers to. +/// +/// # Why a pid alone is not ownership +/// +/// The pid in `pixelpass_capture_` is only a number, and a number means +/// different processes in different pid namespaces. Repair running inside a +/// container that can reach the host's Pulse socket sees a live host's modules, +/// asks about that pid in *its own* namespace, is told nothing exists, and unloads +/// a running host's audio. No negative signal closes that: `NSpid == 1` does not +/// prove the initial namespace, because its leftmost value is relative to whichever +/// procfs was mounted. +/// +/// So the module carries the answer with it. If the token's machine, boot and pid +/// namespace all match ours, then its pid is a number we can meaningfully ask +/// about. Otherwise the only safe verdict is [`Liveness::Unknown`]. +/// +/// The `nonce` is per-load, and it narrows the residual ABA window as a side +/// effect: two loads by the same pid no longer render byte-identical arguments, so +/// a module that vanished and a replacement that took its index are distinguishable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnerToken { + /// `/etc/machine-id`, dash-free. + pub machine: String, + /// `/proc/sys/kernel/random/boot_id`, dash-free — a fresh value per boot. + pub boot: String, + /// Inode of `/proc/self/ns/pid`: which pid namespace the pid belongs to. + pub pid_ns: u64, + /// Distinguishes two loads that are otherwise identical. + pub nonce: u64, +} + +impl OwnerToken { + /// `----`. + /// + /// Every component is dash-free and free of spaces and `=`, so the whole token + /// is a single unquoted Pulse property value and survives the round trip + /// through the module's recorded argument untouched. + pub fn render(&self) -> String { + format!( + "{OWNER_TOKEN_VERSION}-{}-{}-{}-{}", + self.machine, self.boot, self.pid_ns, self.nonce + ) + } + + /// Parse a token, or `None` for anything this build does not fully understand. + pub fn parse(raw: &str) -> Option { + let mut parts = raw.split('-'); + let version = parts.next()?.parse::().ok()?; + if version != OWNER_TOKEN_VERSION { + return None; + } + let machine = parts.next()?; + let boot = parts.next()?; + let pid_ns = parts.next()?.parse::().ok()?; + let nonce = parts.next()?.parse::().ok()?; + if parts.next().is_some() { + return None; + } + // Identities are hex strings; anything else is not a token we wrote. + let identity_ok = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit()); + if !identity_ok(machine) || !identity_ok(boot) { + return None; + } + Some(Self { + machine: machine.to_string(), + boot: boot.to_string(), + pid_ns, + nonce, + }) + } +} + +/// Who *this* process is, for comparison against a module's [`OwnerToken`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalIdentity { + pub machine: String, + pub boot: String, + pub pid_ns: u64, +} + +impl LocalIdentity { + /// Does this token describe a pid we can meaningfully ask about? + /// + /// All three must agree. A different boot means the pid space has been recycled + /// wholesale; a different machine means the token came from somewhere else + /// entirely; a different namespace means the number is not ours to interpret. + pub fn can_judge(&self, token: &OwnerToken) -> bool { + self.machine == token.machine && self.boot == token.boot && self.pid_ns == token.pid_ns + } +} + +/// What repair may do about modules that carry no token at all. +/// +/// Every module loaded before tokens existed is untagged, and there is no way to +/// establish whose pid it names. The default therefore refuses them: leaving an old +/// orphan behind is recoverable, while unloading a live host's routing is not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UntaggedPolicy { + /// Untagged modules are [`Liveness::Unknown`]: reported, never unloaded. + Refuse, + /// Judge untagged modules by pid liveness alone — the pre-token heuristic, + /// available only behind an explicit flag. + CleanByPidAlone, +} + +/// Everything the planner needs in order to decide ownership. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Policy { + pub local: LocalIdentity, + pub untagged: UntaggedPolicy, +} + /// The pid used to render a shape's argument string when deriving its template. /// Any value works as long as its decimal form appears exactly once in the /// rendered arguments, which [`Template::derive`] asserts — unconditionally, so a /// future shape that repeats the pid cannot slip through a release build. const TEMPLATE_SENTINEL_PID: u32 = u32::MAX; +/// The token used when deriving a template. Its rendering must not contain the pid +/// sentinel's digits, which [`Template::derive`] also asserts. +fn template_sentinel_token() -> OwnerToken { + OwnerToken { + machine: "ffffffffffffffff".to_string(), + boot: "eeeeeeeeeeeeeeee".to_string(), + pid_ns: u64::MAX, + nonce: u64::MAX - 1, + } +} + /// An exact-match matcher for one shape, derived from that shape's own renderer. /// -/// The template is only ever a *pre-filter*: it finds the candidate pid cheaply, -/// and [`classify`] then re-renders that pid through the real renderer and demands -/// byte equality. So the authority is always the renderer the loader uses, never -/// this derived pair of strings. +/// The template is only ever a *pre-filter*: it locates the candidate pid and token +/// cheaply, and [`classify`] then re-renders both through the real renderer and +/// demands byte equality. So the authority is always the renderer the loader uses, +/// never these derived strings. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Template { pub module_name: &'static str, prefix: String, + /// Between the pid and the token. `None` for the legacy, token-less form, which + /// has only one hole. + mid: Option, suffix: String, } impl Template { - /// Split a shape's rendered arguments around the pid, giving a total matcher. - fn derive(shape: Shape) -> Self { - let rendered = recorded_argument(&shape.render_args(TEMPLATE_SENTINEL_PID)); - let sentinel = TEMPLATE_SENTINEL_PID.to_string(); - let (prefix, suffix) = rendered - .split_once(&sentinel) + /// Split a shape's rendered arguments around its variable parts. + fn derive(shape: Shape, tokened: bool) -> Self { + let sentinel_token = template_sentinel_token(); + let token = tokened.then(|| sentinel_token.clone()); + let rendered = recorded_argument(&shape.render_args(TEMPLATE_SENTINEL_PID, token.as_ref())); + let pid_sentinel = TEMPLATE_SENTINEL_PID.to_string(); + + let (prefix, rest) = rendered + .split_once(&pid_sentinel) .expect("a rendered shape must contain its pid"); assert!( - !suffix.contains(&sentinel), + !rest.contains(&pid_sentinel), "a shape must name its pid exactly once, but {shape:?} rendered {rendered:?}" ); + + let (mid, suffix) = match &token { + None => (None, rest.to_string()), + Some(token) => { + let token_sentinel = token.render(); + assert!( + !token_sentinel.contains(&pid_sentinel), + "the sentinel token must not contain the sentinel pid's digits" + ); + let (mid, suffix) = rest + .split_once(&token_sentinel) + .expect("a tokened shape must contain its token"); + assert!( + !suffix.contains(&token_sentinel), + "a shape must carry its token exactly once, but {shape:?} rendered \ + {rendered:?}" + ); + (Some(mid.to_string()), suffix.to_string()) + } + }; + Self { module_name: shape.module_name(), prefix: prefix.to_string(), - suffix: suffix.to_string(), + mid, + suffix, } } - /// The pid this argument string names, if it is *exactly* this shape. + /// The pid and token this argument string names, if it is *exactly* this shape. /// - /// Total: the argument must equal `prefix ++ ++ suffix` with nothing - /// left over. A canonical decimal is required — no sign, no leading zeroes, - /// no whitespace — because `u32::from_str` accepts a leading `+`, and because - /// we only ever render a pid one way, so `pixelpass_capture_007` is not a name - /// we wrote. - pub fn pid_of(&self, args: &str) -> Option { - let digits = args - .strip_prefix(self.prefix.as_str())? - .strip_suffix(self.suffix.as_str())?; + /// Total: the argument must equal `prefix ++ pid ++ mid ++ token ++ suffix` with + /// nothing left over. A canonical decimal pid is required — no sign, no leading + /// zeroes, no whitespace — because `u32::from_str` accepts a leading `+`, and + /// because we only ever render a pid one way, so `pixelpass_capture_007` is not + /// a name we wrote. + pub fn parse(&self, args: &str) -> Option<(u32, Option)> { + let rest = args.strip_prefix(self.prefix.as_str())?; + let (digits, token) = match &self.mid { + None => (rest.strip_suffix(self.suffix.as_str())?, None), + Some(mid) => { + let (digits, after) = rest.split_once(mid.as_str())?; + let raw = after.strip_suffix(self.suffix.as_str())?; + (digits, Some(OwnerToken::parse(raw)?)) + } + }; if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { return None; } if digits.len() > 1 && digits.starts_with('0') { return None; } - digits.parse::().ok() + Some((digits.parse::().ok()?, token)) } } @@ -234,13 +415,30 @@ impl Shape { } } - /// The exact `pactl load-module` arguments for this shape, for `pid`. + /// Which property-list argument this shape carries its owner token in. + fn owner_property_argument(self) -> &'static str { + match self { + // The mirror's stream is a sink-input on our capture sink. + Shape::LoopbackIntoCapture => "sink_input_properties", + // The local monitor's stream is a source-output on our capture monitor. + Shape::LoopbackOutOfCapture => "source_output_properties", + // The sink itself carries the property. + Shape::LegacyCaptureSink => "sink_properties", + } + } + + /// The exact `pactl load-module` arguments for this shape, for `pid`, optionally + /// carrying an owner token. /// /// **This is the single source of truth.** `host/audio.rs` loads through it and /// `--repair` matches through it, so a change here moves both at once. - pub fn render_args(self, pid: u32) -> Vec { + /// + /// `token: None` renders the **legacy** form — what every pixelpass before + /// ownership tokens loaded. It is still rendered, because repair must be able to + /// recognise those modules in order to report them. + pub fn render_args(self, pid: u32, token: Option<&OwnerToken>) -> Vec { let sink = sink_name_for(pid); - match self { + let mut args = match self { // The default-sink mirror: the viewer hears system audio. Shape::LoopbackIntoCapture => vec![ "source=@DEFAULT_SINK@.monitor".to_string(), @@ -255,12 +453,22 @@ impl Shape { ], // The legacy capture sink (pre-0c hosts only). Shape::LegacyCaptureSink => vec![format!("sink_name={sink}")], + }; + if let Some(token) = token { + args.push(format!( + "{}={OWNER_PROPERTY}={}", + self.owner_property_argument(), + token.render() + )); } + args } - /// The exact-match pre-filter for this shape, generated from `render_args`. - pub fn template(self) -> Template { - Template::derive(self) + /// The exact-match pre-filters for this shape, tokened form first. + /// + /// Both are generated from `render_args`, so neither can drift from the loader. + pub fn templates(self) -> [Template; 2] { + [Template::derive(self, true), Template::derive(self, false)] } /// Human label for reporting. @@ -277,10 +485,11 @@ impl Shape { /// unloaded — not merely when the plan was made. /// /// It is an identity of the *observable* module, not of a generation: if the -/// planned module vanishes and a byte-identical one takes its index, this -/// compares equal. That residual ABA window cannot be closed through an unload -/// API whose only argument is an index; what closes it in practice is the -/// liveness recheck, which happens after this and nearer the unload. +/// planned module vanishes and a byte-identical one takes its index, this compares +/// equal. That residual ABA window cannot be closed through an unload API whose only +/// argument is an index; what narrows it is the per-load nonce in [`OwnerToken`] +/// (two loads no longer render identical arguments), and what closes the rest in +/// practice is the liveness recheck nearer the unload. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Fingerprint { pub id: u32, @@ -289,6 +498,9 @@ pub struct Fingerprint { pub args: String, pub pid: u32, pub shape: Shape, + /// The owner token, or `None` for a legacy module loaded before tokens existed. + /// A pid without one cannot be attributed to a namespace — see [`OwnerToken`]. + pub owner: Option, } impl Fingerprint { @@ -318,6 +530,13 @@ pub struct Plan { /// reported separately: this is the case where repair is not safe rather than /// not needed. pub unknown_pids: BTreeSet, + /// Modules recognised as ours but carrying no owner token, while the policy + /// refuses to judge those. Reported so the user can see what an explicit + /// legacy-cleanup run would act on. + pub untagged: Vec, + /// Modules whose token belongs to another machine, boot or pid namespace. Their + /// pids are numbers this process cannot interpret, so they are never touched. + pub foreign: Vec, } impl Plan { @@ -341,12 +560,14 @@ impl Plan { /// quietly accepting the old form. pub fn classify(obs: &ModuleObservation) -> Option { for shape in ALL_SHAPES { - let template = shape.template(); - if obs.name != template.module_name { - continue; - } - if let Some(pid) = template.pid_of(&obs.args) { - if recorded_argument(&shape.render_args(pid)) != obs.args { + for template in shape.templates() { + if obs.name != template.module_name { + continue; + } + let Some((pid, owner)) = template.parse(&obs.args) else { + continue; + }; + if recorded_argument(&shape.render_args(pid, owner.as_ref())) != obs.args { continue; } return Some(Fingerprint { @@ -355,6 +576,7 @@ pub fn classify(obs: &ModuleObservation) -> Option { args: obs.args.clone(), pid, shape, + owner, }); } } @@ -400,7 +622,11 @@ pub fn sink_still_referenced( /// `liveness` is injected rather than read from `/proc` so the decision is /// testable, and so the caller can re-ask at execution time — this plan is /// evidence, not permission. -pub fn plan(observations: &[ModuleObservation], liveness: impl Fn(u32) -> Liveness) -> Plan { +pub fn plan( + observations: &[ModuleObservation], + policy: &Policy, + liveness: impl Fn(u32) -> Liveness, +) -> Plan { // Deduplicate by module id: a snapshot should not repeat one, but a repeated // entry must not become a repeated unload of an index that has since been // reused by someone else. @@ -411,12 +637,29 @@ pub fn plan(observations: &[ModuleObservation], liveness: impl Fn(u32) -> Livene } } + // Ownership first, because it decides whether the pid is even a question we can + // ask. A token from another machine, boot or pid namespace names a process this + // one cannot see, and an absent token names nothing at all. + let mut untagged: Vec = Vec::new(); + let mut foreign: Vec = Vec::new(); + let mut judgeable: Vec = Vec::new(); + for fp in seen.into_values() { + match &fp.owner { + Some(token) if policy.local.can_judge(token) => judgeable.push(fp), + Some(_) => foreign.push(fp), + None => match policy.untagged { + UntaggedPolicy::CleanByPidAlone => judgeable.push(fp), + UntaggedPolicy::Refuse => untagged.push(fp), + }, + } + } + // Liveness is asked once per distinct pid, not once per module: a host with // three modules must not be able to change its own verdict mid-plan. let mut live_pids = BTreeSet::new(); let mut dead_pids = BTreeSet::new(); let mut unknown_pids = BTreeSet::new(); - for pid in seen.values().map(|fp| fp.pid).collect::>() { + for pid in judgeable.iter().map(|fp| fp.pid).collect::>() { match liveness(pid) { Liveness::Alive => live_pids.insert(pid), Liveness::Dead => dead_pids.insert(pid), @@ -424,19 +667,23 @@ pub fn plan(observations: &[ModuleObservation], liveness: impl Fn(u32) -> Livene }; } - let mut unload: Vec = seen - .into_values() + let mut unload: Vec = judgeable + .into_iter() .filter(|fp| dead_pids.contains(&fp.pid)) .collect(); // `Shape`'s declaration order is the unload order: loopbacks before the sink // they reference. Id breaks ties so the plan is deterministic. unload.sort_by_key(|fp| (fp.shape, fp.id)); + untagged.sort_by_key(|fp| fp.id); + foreign.sort_by_key(|fp| fp.id); Plan { unload, live_pids, dead_pids, unknown_pids, + untagged, + foreign, } } @@ -444,28 +691,57 @@ pub fn plan(observations: &[ModuleObservation], liveness: impl Fn(u32) -> Livene mod tests { use super::*; - fn null_sink(id: u32, pid: u32) -> ModuleObservation { + /// The identity this test process pretends to be. + fn our_identity() -> LocalIdentity { + LocalIdentity { + machine: "aa11bb22".to_string(), + boot: "cc33dd44".to_string(), + pid_ns: 4_026_531_836, + } + } + + /// A token minted by "us". + fn our_token(nonce: u64) -> OwnerToken { + let local = our_identity(); + OwnerToken { + machine: local.machine, + boot: local.boot, + pid_ns: local.pid_ns, + nonce, + } + } + + /// The default policy: our identity, untagged modules refused. + fn our_policy() -> Policy { + Policy { + local: our_identity(), + untagged: UntaggedPolicy::Refuse, + } + } + + fn obs(id: u32, shape: Shape, pid: u32, token: Option<&OwnerToken>) -> ModuleObservation { ModuleObservation::new( id, - "module-null-sink", - &recorded_argument(&Shape::LegacyCaptureSink.render_args(pid)), + shape.module_name(), + &recorded_argument(&shape.render_args(pid, token)), ) } + fn null_sink(id: u32, pid: u32) -> ModuleObservation { + obs(id, Shape::LegacyCaptureSink, pid, Some(&our_token(1))) + } + fn mirror(id: u32, pid: u32) -> ModuleObservation { - ModuleObservation::new( - id, - "module-loopback", - &recorded_argument(&Shape::LoopbackIntoCapture.render_args(pid)), - ) + obs(id, Shape::LoopbackIntoCapture, pid, Some(&our_token(2))) } fn local_monitor(id: u32, pid: u32) -> ModuleObservation { - ModuleObservation::new( - id, - "module-loopback", - &recorded_argument(&Shape::LoopbackOutOfCapture.render_args(pid)), - ) + obs(id, Shape::LoopbackOutOfCapture, pid, Some(&our_token(3))) + } + + /// The pre-token form: recognisable as ours, but attributable to nobody. + fn legacy_mirror(id: u32, pid: u32) -> ModuleObservation { + obs(id, Shape::LoopbackIntoCapture, pid, None) } fn nothing_is_alive(_: u32) -> Liveness { @@ -483,17 +759,44 @@ mod tests { #[test] fn the_canonical_argument_strings_are_what_the_server_records() { assert_eq!( - recorded_argument(&Shape::LoopbackIntoCapture.render_args(4242)), + recorded_argument(&Shape::LoopbackIntoCapture.render_args(4242, None)), "source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_4242 latency_msec=20" ); assert_eq!( - recorded_argument(&Shape::LoopbackOutOfCapture.render_args(4242)), + recorded_argument(&Shape::LoopbackOutOfCapture.render_args(4242, None)), "source=pixelpass_capture_4242.monitor sink=@DEFAULT_SINK@ latency_msec=20" ); assert_eq!( - recorded_argument(&Shape::LegacyCaptureSink.render_args(4242)), + recorded_argument(&Shape::LegacyCaptureSink.render_args(4242, None)), "sink_name=pixelpass_capture_4242" ); + // And the tokened forms, which is what a host actually loads. These exact + // strings were verified against the live server: all three shapes accept the + // property argument and record it byte-identically. + let token = our_token(7); + assert_eq!( + recorded_argument(&Shape::LoopbackIntoCapture.render_args(4242, Some(&token))), + format!( + "source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_4242 latency_msec=20 \ + sink_input_properties=pixelpass.owner={}", + token.render() + ) + ); + assert_eq!( + recorded_argument(&Shape::LoopbackOutOfCapture.render_args(4242, Some(&token))), + format!( + "source=pixelpass_capture_4242.monitor sink=@DEFAULT_SINK@ latency_msec=20 \ + source_output_properties=pixelpass.owner={}", + token.render() + ) + ); + assert_eq!( + recorded_argument(&Shape::LegacyCaptureSink.render_args(4242, Some(&token))), + format!( + "sink_name=pixelpass_capture_4242 sink_properties=pixelpass.owner={}", + token.render() + ) + ); } /// Every shape must round-trip through its own generated template. This is @@ -502,13 +805,21 @@ mod tests { #[test] fn every_shape_round_trips_through_its_generated_template() { for shape in ALL_SHAPES { - let template = shape.template(); + let [tokened, legacy] = shape.templates(); for pid in [1_u32, 7, 4242, 999_999, u32::MAX - 1] { - let args = recorded_argument(&shape.render_args(pid)); + let plain = recorded_argument(&shape.render_args(pid, None)); assert_eq!( - template.pid_of(&args), - Some(pid), - "{shape:?} failed to round-trip pid {pid}" + legacy.parse(&plain), + Some((pid, None)), + "{shape:?} failed to round-trip legacy pid {pid}" + ); + + let token = our_token(u64::from(pid)); + let tagged = recorded_argument(&shape.render_args(pid, Some(&token))); + assert_eq!( + tokened.parse(&tagged), + Some((pid, Some(token))), + "{shape:?} failed to round-trip tokened pid {pid}" ); } } @@ -521,7 +832,7 @@ mod tests { #[test] fn orphan_loopbacks_are_found_without_any_null_sink() { let modules = [mirror(10, 4242), local_monitor(11, 4242)]; - let plan = plan(&modules, nothing_is_alive); + let plan = plan(&modules, &our_policy(), nothing_is_alive); assert_eq!(ids(&plan), vec![10, 11]); assert_eq!(plan.dead_pids, BTreeSet::from([4242])); } @@ -531,10 +842,17 @@ mod tests { #[test] fn each_loopback_shape_identifies_the_owner_on_its_own() { assert_eq!( - ids(&plan(&[local_monitor(11, 7)], nothing_is_alive)), + ids(&plan( + &[local_monitor(11, 7)], + &our_policy(), + nothing_is_alive + )), vec![11] ); - assert_eq!(ids(&plan(&[mirror(10, 7)], nothing_is_alive)), vec![10]); + assert_eq!( + ids(&plan(&[mirror(10, 7)], &our_policy(), nothing_is_alive)), + vec![10] + ); } /// The legacy shape still works, and the sink unloads *after* both @@ -548,7 +866,7 @@ mod tests { local_monitor(11, 4242), mirror(10, 4242), ]; - let plan = plan(&modules, nothing_is_alive); + let plan = plan(&modules, &our_policy(), nothing_is_alive); assert_eq!(ids(&plan), vec![10, 11, 5]); assert_eq!( plan.unload.last().unwrap().shape, @@ -567,7 +885,7 @@ mod tests { null_sink(6, 200), mirror(12, 200), ]; - let plan = plan(&modules, |pid| { + let plan = plan(&modules, &our_policy(), |pid| { if pid == 200 { Liveness::Alive } else { @@ -585,7 +903,7 @@ mod tests { #[test] fn an_undecidable_pid_is_never_touched() { let modules = [null_sink(5, 100), mirror(10, 100), local_monitor(11, 200)]; - let plan = plan(&modules, |pid| { + let plan = plan(&modules, &our_policy(), |pid| { if pid == 100 { Liveness::Unknown } else { @@ -616,7 +934,7 @@ mod tests { null_sink(6, 200), mirror(12, 200), ]; - let plan = plan(&modules, |_| Liveness::Alive); + let plan = plan(&modules, &our_policy(), |_| Liveness::Alive); assert!(plan.is_empty(), "no live host may be touched: {plan:?}"); assert_eq!(plan.live_pids, BTreeSet::from([100, 200])); assert!(plan.dead_pids.is_empty()); @@ -644,7 +962,7 @@ mod tests { ModuleObservation::new(8, "module-null-sink", "sink_name=pixelpass_capture_007"), ModuleObservation::new(9, "module-loopback", "latency_msec=20"), ]; - let plan = plan(&modules, nothing_is_alive); + let plan = plan(&modules, &our_policy(), nothing_is_alive); assert!(plan.is_empty(), "{plan:?}"); assert!(plan.dead_pids.is_empty(), "no owner may be invented"); } @@ -683,7 +1001,7 @@ mod tests { remix=false", ), ]; - let plan = plan(&modules, nothing_is_alive); + let plan = plan(&modules, &our_policy(), nothing_is_alive); assert!(plan.is_empty(), "none of these are ours: {plan:?}"); // …but every one of them is *reported*, so blindness is never silent. assert_eq!(unrecognised_pixelpass_modules(&modules).len(), 4); @@ -701,7 +1019,7 @@ mod tests { sink_input_properties=\"media.name=sink=pixelpass_capture_4242\" latency_msec=20", ); assert_eq!(classify(&obs), None); - assert!(plan(&[obs], nothing_is_alive).is_empty()); + assert!(plan(&[obs], &our_policy(), nothing_is_alive).is_empty()); } /// A repeated observation of one module must not become two unloads of an @@ -709,7 +1027,10 @@ mod tests { #[test] fn a_duplicated_observation_yields_one_action() { let modules = [mirror(10, 4242), mirror(10, 4242)]; - assert_eq!(ids(&plan(&modules, nothing_is_alive)), vec![10]); + assert_eq!( + ids(&plan(&modules, &our_policy(), nothing_is_alive)), + vec![10] + ); } /// Liveness is asked once per pid. Without this, a `liveness` that flips @@ -728,7 +1049,7 @@ mod tests { mirror(12, 99), local_monitor(13, 99), ]; - let plan = plan(&modules, |pid| { + let plan = plan(&modules, &our_policy(), |pid| { *calls.borrow_mut().entry(pid).or_insert(0) += 1; Liveness::Dead }); @@ -834,12 +1155,180 @@ mod tests { assert_eq!(sink_still_referenced(&other_host, 4242, 5), None); } + /// A token must survive the round trip through a module argument exactly, and + /// anything this build does not fully understand must not parse at all. + #[test] + fn tokens_round_trip_and_reject_what_they_do_not_understand() { + let token = our_token(42); + assert_eq!(OwnerToken::parse(&token.render()), Some(token.clone())); + + for raw in [ + "", + "1", + "1-aa11bb22", + "1-aa11bb22-cc33dd44", + "1-aa11bb22-cc33dd44-4026531836", + // A version this build does not know: not ours to touch. + "2-aa11bb22-cc33dd44-4026531836-42", + "0-aa11bb22-cc33dd44-4026531836-42", + // Trailing junk. + "1-aa11bb22-cc33dd44-4026531836-42-extra", + // Non-hex identities. + "1-zzzz-cc33dd44-4026531836-42", + "1-aa11bb22-zzzz-4026531836-42", + // Non-numeric namespace or nonce. + "1-aa11bb22-cc33dd44-abc-42", + "1-aa11bb22-cc33dd44-4026531836-abc", + // Empty identity components. + "1--cc33dd44-4026531836-42", + ] { + assert_eq!(OwnerToken::parse(raw), None, "should reject {raw:?}"); + } + } + + /// The defect the token exists for: a pid means different processes in different + /// pid namespaces, so a module whose token names another namespace, boot or + /// machine must never be judged by asking about that number here. + #[test] + fn a_module_from_another_namespace_boot_or_machine_is_never_touched() { + let local = our_identity(); + let elsewhere = [ + // Same machine and boot, different pid namespace: the number is not ours. + OwnerToken { + pid_ns: local.pid_ns + 1, + ..our_token(1) + }, + // Same machine, earlier boot: the whole pid space has been recycled. + OwnerToken { + boot: "ffffffff".to_string(), + ..our_token(1) + }, + // Another machine entirely. + OwnerToken { + machine: "99998888".to_string(), + ..our_token(1) + }, + ]; + + for token in elsewhere { + let modules = [obs(10, Shape::LoopbackIntoCapture, 4242, Some(&token))]; + // `nothing_is_alive` would happily call the pid dead, so a plan that + // consults liveness at all here is already wrong. + let plan = plan(&modules, &our_policy(), nothing_is_alive); + assert!( + plan.is_empty(), + "a module from {token:?} must not be planned: {plan:?}" + ); + assert_eq!(plan.foreign.len(), 1, "and it must be reported: {plan:?}"); + assert!( + plan.dead_pids.is_empty(), + "its pid must not even be considered" + ); + } + } + + /// Liveness must not be consulted at all for a module we cannot attribute — + /// asking is the bug, because the answer is meaningless. + #[test] + fn an_unattributable_module_is_never_asked_about() { + let foreign = OwnerToken { + pid_ns: our_identity().pid_ns + 1, + ..our_token(1) + }; + let modules = [ + obs(10, Shape::LoopbackIntoCapture, 4242, Some(&foreign)), + legacy_mirror(11, 5555), + ]; + let asked = std::cell::RefCell::new(Vec::new()); + let plan = plan(&modules, &our_policy(), |pid| { + asked.borrow_mut().push(pid); + Liveness::Dead + }); + assert!( + asked.borrow().is_empty(), + "no pid should have been asked about, but these were: {:?}", + asked.borrow() + ); + assert!(plan.is_empty()); + assert_eq!(plan.foreign.len(), 1); + assert_eq!(plan.untagged.len(), 1); + } + + /// Untagged (pre-token) modules are refused by default and reported, and only an + /// explicit policy judges them by pid alone. + #[test] + fn untagged_modules_are_refused_by_default_and_only_cleaned_on_request() { + let modules = [ + legacy_mirror(10, 4242), + obs(11, Shape::LegacyCaptureSink, 4242, None), + ]; + + let refused = plan(&modules, &our_policy(), nothing_is_alive); + assert!( + refused.is_empty(), + "default must not touch them: {refused:?}" + ); + assert_eq!(refused.untagged.len(), 2); + assert!(refused.dead_pids.is_empty()); + + let opted_in = plan( + &modules, + &Policy { + local: our_identity(), + untagged: UntaggedPolicy::CleanByPidAlone, + }, + nothing_is_alive, + ); + assert_eq!(ids(&opted_in), vec![10, 11], "{opted_in:?}"); + assert!(opted_in.untagged.is_empty()); + // Even opted in, a live pid still wins. + let live = plan( + &modules, + &Policy { + local: our_identity(), + untagged: UntaggedPolicy::CleanByPidAlone, + }, + |_| Liveness::Alive, + ); + assert!(live.is_empty(), "{live:?}"); + } + + /// The nonce narrows the ABA window: two loads by the same pid no longer render + /// identical arguments, so a fingerprint taken from one does not match the other. + #[test] + fn the_nonce_distinguishes_two_loads_by_the_same_pid() { + let first = obs(10, Shape::LoopbackIntoCapture, 4242, Some(&our_token(1))); + let second = obs(10, Shape::LoopbackIntoCapture, 4242, Some(&our_token(2))); + assert_ne!(first.args, second.args, "the nonce must reach the argument"); + + let fp = classify(&first).expect("ours"); + assert!(fp.still_matches(&first)); + assert!( + !fp.still_matches(&second), + "a different load must not satisfy the first load's fingerprint" + ); + } + + /// Tokened and legacy forms must both classify, and carry the difference. + #[test] + fn both_forms_classify_and_record_whether_they_are_attributable() { + let tokened = classify(&mirror(10, 4242)).expect("tokened is ours"); + assert_eq!(tokened.owner, Some(our_token(2))); + + let legacy = classify(&legacy_mirror(11, 4242)).expect("legacy is still ours"); + assert_eq!(legacy.owner, None); + assert_eq!(legacy.shape, Shape::LoopbackIntoCapture); + } + /// The plan must be a pure function of the snapshot: same input, same /// order, every time. #[test] fn planning_is_deterministic_regardless_of_snapshot_order() { let a = [null_sink(5, 42), mirror(10, 42), local_monitor(11, 42)]; let b = [local_monitor(11, 42), null_sink(5, 42), mirror(10, 42)]; - assert_eq!(plan(&a, nothing_is_alive), plan(&b, nothing_is_alive)); + assert_eq!( + plan(&a, &our_policy(), nothing_is_alive), + plan(&b, &our_policy(), nothing_is_alive) + ); } }