repair: a pid is not an owner — modules carry a machine/boot/namespace token
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>
This commit is contained in:
+12
@@ -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
|
||||
|
||||
+35
-6
@@ -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<repair_plan::OwnerToken> {
|
||||
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<u32> {
|
||||
fn load_module(shape: Shape, pid: u32, owner: &repair_plan::OwnerToken) -> Result<u32> {
|
||||
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() {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+101
-2
@@ -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<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
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
+566
-77
@@ -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_<pid>` 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 {
|
||||
/// `<version>-<machine>-<boot>-<pid_ns>-<nonce>`.
|
||||
///
|
||||
/// 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<Self> {
|
||||
let mut parts = raw.split('-');
|
||||
let version = parts.next()?.parse::<u32>().ok()?;
|
||||
if version != OWNER_TOKEN_VERSION {
|
||||
return None;
|
||||
}
|
||||
let machine = parts.next()?;
|
||||
let boot = parts.next()?;
|
||||
let pid_ns = parts.next()?.parse::<u64>().ok()?;
|
||||
let nonce = parts.next()?.parse::<u64>().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<String>,
|
||||
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 ++ <pid> ++ 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<u32> {
|
||||
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<OwnerToken>)> {
|
||||
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::<u32>().ok()
|
||||
Some((digits.parse::<u32>().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<String> {
|
||||
///
|
||||
/// `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<String> {
|
||||
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<OwnerToken>,
|
||||
}
|
||||
|
||||
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<u32>,
|
||||
/// 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<Fingerprint>,
|
||||
/// 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<Fingerprint>,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
@@ -341,12 +560,14 @@ impl Plan {
|
||||
/// quietly accepting the old form.
|
||||
pub fn classify(obs: &ModuleObservation) -> Option<Fingerprint> {
|
||||
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<Fingerprint> {
|
||||
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<Fingerprint> = Vec::new();
|
||||
let mut foreign: Vec<Fingerprint> = Vec::new();
|
||||
let mut judgeable: Vec<Fingerprint> = 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::<BTreeSet<_>>() {
|
||||
for pid in judgeable.iter().map(|fp| fp.pid).collect::<BTreeSet<_>>() {
|
||||
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<Fingerprint> = seen
|
||||
.into_values()
|
||||
let mut unload: Vec<Fingerprint> = 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user