Files
pixelpass/src/repair/plan.rs
T
molluskandClaude Opus 5 104a95a6d7 repair: let the token beat the namespace guesswork, and ask liveness first
Round 5. Two blocking P2s, both in the execution shell rather than the token
design, plus an overstated claim of mine.

**The degradation signals were defeating the token in exactly the case it exists
for.** A host crashing inside a container leaves a token that matches this
machine, boot and pid namespace perfectly — but the probe saw a container marker
or a multi-entry `NSpid` and answered `Unknown` for everything, so token-qualified
repair did nothing precisely where it had just become safe. Those signals are
guesswork about whether a bare pid is meaningful, and for an attributed pid that
is no longer a guess. Liveness is now asked with the module's attribution in hand:
a tokened pid goes straight to `kill`, while an untagged one still has to get past
the signals, because there they are the only protection left.

**Liveness now runs before the fresh snapshot, not after it.** The natural order —
verify the module, check liveness, unload — leaves the dangerous window open:
after `kill` returns ESRCH this process can be descheduled while the planned module
vanishes, a new host inherits both the pid and the module index, and its
differently-nonced arguments occupy that index. Nothing re-read those arguments, so
the reused index would have been unloaded. Asking liveness first means the
post-liveness fingerprint check catches that replacement, leaving only the
irreducible snapshot-to-unload interval.

**The nonce claim was overstated and is now true.** A token was minted once per
`Routing` session and reused for every subsequent reload, making it a host-session
nonce rather than a per-load one. It is now minted inside `load_module`, mixing a
bumped counter with the clock, so two loads by the same pid really do render
different arguments — which, combined with the reordering above, is what lets a
fingerprint tell a module from its replacement at the same index.

⚠️ **The first version of the attribution fix had no gate, and the mutation said
so.** Swapping `of_attributed` back to `of` passed all 254 tests, because on an
ordinary desktop the probe is not degraded and the two paths agree, while the
planner tests use a fake liveness closure that never touches the probe at all. The
new test constructs a *deliberately degraded* probe, which is the only state where
the distinction is observable. Both mutants — routing a tokened pid through `of`,
and re-applying the degradation gate inside `of_attributed` — now fail it.

Codex's answers to the questions I raised, recorded because they close them:
omitting the pid from the token loses nothing, since the canonical argument already
binds the token to exactly one pid; namespace inode reuse is real but only after
the old namespace is destroyed, so its host is necessarily gone and no live owner is
endangered; and refusing foreign tokens even under `--repair-legacy-untagged` is the
right line, because the flag speaks to missing evidence rather than wrong evidence.
No finding against the two-hole template derivation.

255 tests, clippy clean, fmt clean. All four live field gates re-run green: tokened
cleaned, foreign refused with and without the flag, untagged refused then cleaned on
request, A/B orphan removal byte-identical elsewhere, reference gate still firing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:18:52 -04:00

1385 lines
57 KiB
Rust

//! The pure half of `--repair`: turn one observed module snapshot into an
//! ordered list of unloads, with no I/O and no destruction.
//!
//! # Why this is a separate, pure module
//!
//! Repair destroys server-side state belonging to processes it does not own, so
//! every interesting property is a *decision* property — which pid is dead, which
//! module belongs to it, in what order to unload — and none of them need PipeWire
//! to be exercised. Splitting the decision out means the safety rules below are
//! unit-testable exactly, and the I/O shell in the parent module has nothing left
//! in it worth arguing about.
//!
//! # The discovery rule (phase 0c)
//!
//! Before 0c the capture sink was a `module-null-sink`, so repair could learn a
//! host's pid from that module and then match its loopbacks. After 0c the sink is
//! a **connection-owned native node**: it disappears on its own when the host
//! dies, and it is not a Pulse module at all. A dead host therefore leaves
//! loopbacks with **no null-sink module to learn the pid from**, and the old
//! discovery could not see them — repair did not get smaller, it went blind.
//!
//! So candidate pids are derived **independently from all three shapes**
//! ([`Shape`]), and a shape's absence is never taken as permission to skip
//! another shape's cleanup.
//!
//! # 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
//! is not. **An *undecidable* pid counts as live** ([`Liveness::Unknown`]):
//! "I cannot see whether that process exists" must never become "it is dead,
//! go ahead".
//! 2. **Native nodes are never destroyed.** Repair only ever unloads Pulse
//! modules it can fingerprint. It has no business touching a live graph object,
//! and after 0c the sink cleans itself up anyway.
//! 3. **A plan is not a licence.** Pulse module indices are reused verbatim, so an
//! id planned against one module can name a *different* live module by the time
//! the unload runs. Every action therefore carries a full [`Fingerprint`] which
//! the caller must re-verify against a fresh snapshot immediately before each
//! unload ([`Fingerprint::still_matches`]). Anything that does not match
//! exactly is skipped, never unloaded.
//! 4. **Ordering is not a licence either.** Planning loopbacks before the sink
//! they reference is necessary but not sufficient: an unload can *fail* or be
//! skipped, and a loopback can be created after the plan was made. So the sink
//! unload is additionally gated at execution time on
//! [`sink_still_referenced`] against the fresh snapshot — never on the plan's
//! own ordering having been followed.
//! 5. **Only the canonical forms are ours.** A module is recognised only if its
//! recorded argument string matches, exactly, what pixelpass itself would have
//! written ([`Shape::template`]). Recognising "a loopback with one
//! pixelpass-looking endpoint" would let repair unload a third party's module
//! that merely names one of our sinks.
//!
//! # Why the templates are generated, not written out
//!
//! The matcher's prefixes and suffixes are derived at runtime from the *same*
//! renderer the loader uses — [`Shape::render_args`], which owns the module name
//! too. Hard-coding `latency_msec=20` in a matcher would mean that changing the
//! loader silently blinds repair to every module the new version loads: the
//! fail-closed-and-silent failure this project has now been bitten by three times.
//! With one source of truth, a loader change moves the matcher with it.
//!
//! The template is only a pre-filter, too. [`classify`] re-renders the pid it
//! extracted and demands byte equality, so the renderer — not a derived pair of
//! strings — is always the authority on what one of our modules looks like.
//!
//! Blindness is also reported rather than assumed impossible:
//! [`unrecognised_pixelpass_modules`] finds modules that name a
//! `pixelpass_capture_*` sink but do **not** match any canonical form, so the I/O
//! shell can say so loudly instead of quietly cleaning up nothing.
use std::collections::{BTreeMap, BTreeSet};
/// Every pixelpass capture sink is named `pixelpass_capture_<pid>`; the pid in
/// that name is the only owner identity these modules carry.
pub const SINK_NAME_PREFIX: &str = "pixelpass_capture_";
/// Loopback latency. Pulse's default of 200 ms is perceptible; 20 ms keeps the
/// mirrored audio tight. Shared with the matcher, so it cannot drift.
pub const LOOPBACK_LATENCY_MSEC: u32 = 20;
/// The capture sink name for a host pid.
pub fn sink_name_for(pid: u32) -> String {
format!("{SINK_NAME_PREFIX}{pid}")
}
/// How the server records an argument vector we passed as separate argv entries.
///
/// Measured on pactl 17.0 against a live pipewire-pulse: the arguments come back
/// byte-for-byte as passed, joined with single spaces, in the order given, with
/// `@DEFAULT_SINK@` **not** resolved to the concrete device name. Both facts are
/// load-bearing for exact-form matching, so both have their own test.
pub fn recorded_argument(args: &[String]) -> String {
args.join(" ")
}
/// One module exactly as the server reported it.
///
/// `args` is the **exact** argument string from `pa_module_info`, not a normalised
/// one, and not a reconstruction from a text listing. Normalising would only make
/// two genuinely different arguments compare equal — whitespace inside a quoted
/// property value is not layout — and every snapshot within one invocation comes
/// from the same connection, so there is no re-rendering to absorb.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModuleObservation {
pub id: u32,
pub name: String,
pub args: String,
}
impl ModuleObservation {
pub fn new(id: u32, name: &str, args: &str) -> Self {
Self {
id,
name: name.to_string(),
args: args.to_string(),
}
}
}
/// Whether the owner of a module is still around.
///
/// Three states, not two, because "I cannot see that process" and "that process
/// does not exist" are different answers and only one of them permits destroying
/// anything. A pid can be alive and invisible: inside a pid namespace every
/// process in the parent namespace is, and `hidepid` hides others while leaving
/// `self` visible. Collapsing those into "absent" would point the wrong way.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Liveness {
Alive,
Dead,
/// Undecidable. Treated exactly like `Alive` for the purposes of destroying
/// anything, and reported separately so the user knows repair held back.
Unknown,
}
/// The three module shapes pixelpass is capable of loading. Each one carries the
/// owner pid in a different place, which is exactly why discovery must consider
/// all three independently.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Shape {
/// `module-loopback source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_<pid>
/// latency_msec=20` — the default-sink mirror, so the viewer hears system
/// audio.
LoopbackIntoCapture,
/// `module-loopback source=pixelpass_capture_<pid>.monitor sink=@DEFAULT_SINK@
/// latency_msec=20` — the local monitor, so the sharer hears the app they are
/// sharing.
LoopbackOutOfCapture,
/// `module-null-sink sink_name=pixelpass_capture_<pid>` — the legacy capture
/// sink. Post-0c hosts do not load this at all; it exists for hosts that
/// predate the connection-owned sink, and for mixed sessions.
///
/// Declared **last** on purpose: `Ord` gives the unload ordering, and the
/// sink must go after the loopbacks that reference it.
LegacyCaptureSink,
}
/// Every shape, in unload order.
pub const ALL_SHAPES: [Shape; 3] = [
Shape::LoopbackIntoCapture,
Shape::LoopbackOutOfCapture,
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
}
}
/// Whether a module proved which pid space its pid belongs to.
///
/// Passed to the liveness callback because the answer changes *how* the question
/// may be asked: an attributed pid needs no guessing about namespaces, while an
/// untagged one is a bare number whose meaning has to be guarded some other way.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Attribution {
/// Carries a token matching this machine, boot and pid namespace.
Tokened,
/// Carries no token; judged by pid alone, and only under
/// [`UntaggedPolicy::CleanByPidAlone`].
Untagged,
}
/// 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 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 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!(
!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(),
mid,
suffix,
}
}
/// The pid and token this argument string names, if it is *exactly* this shape.
///
/// 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;
}
Some((digits.parse::<u32>().ok()?, token))
}
}
impl Shape {
/// The Pulse module this shape loads. Owned here rather than written out at
/// the call site, so the module *name* is as drift-proof as the arguments —
/// the loader asks for it too.
pub fn module_name(self) -> &'static str {
match self {
Shape::LoopbackIntoCapture | Shape::LoopbackOutOfCapture => "module-loopback",
Shape::LegacyCaptureSink => "module-null-sink",
}
}
/// 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.
///
/// `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);
let mut args = match self {
// The default-sink mirror: the viewer hears system audio.
Shape::LoopbackIntoCapture => vec![
"source=@DEFAULT_SINK@.monitor".to_string(),
format!("sink={sink}"),
format!("latency_msec={LOOPBACK_LATENCY_MSEC}"),
],
// The local monitor: the sharer hears the app they are sharing.
Shape::LoopbackOutOfCapture => vec![
format!("source={sink}.monitor"),
"sink=@DEFAULT_SINK@".to_string(),
format!("latency_msec={LOOPBACK_LATENCY_MSEC}"),
],
// 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-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.
pub fn label(self) -> &'static str {
match self {
Shape::LoopbackIntoCapture => "default-sink mirror",
Shape::LoopbackOutOfCapture => "local monitor",
Shape::LegacyCaptureSink => "legacy capture sink",
}
}
}
/// Everything that must *still* be true of a module at the moment it is
/// 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 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,
pub module_name: String,
/// The exact recorded argument string.
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 {
/// Does `obs` still present the same observable module this fingerprint was
/// taken from?
///
/// Deliberately total: id, module name, exact args, derived pid and shape must
/// all agree. A module index that has been reused fails on the name or the
/// args; a module whose arguments were rewritten fails on the args. Either way
/// the caller must skip it rather than guess.
pub fn still_matches(&self, obs: &ModuleObservation) -> bool {
classify(obs).as_ref() == Some(self)
}
}
/// The result of planning: what to unload, and what was deliberately left alone.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Plan {
/// In execution order: every loopback before any legacy sink.
pub unload: Vec<Fingerprint>,
/// Pids that are still alive and were therefore skipped entirely. Includes
/// this process and any other running pixelpass.
pub live_pids: BTreeSet<u32>,
/// Pids we concluded are gone.
pub dead_pids: BTreeSet<u32>,
/// Pids whose liveness could not be determined. Skipped like live ones, but
/// 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 {
pub fn is_empty(&self) -> bool {
self.unload.is_empty()
}
}
/// Recognise one of pixelpass's three module shapes, or `None` for a module that
/// is not ours.
///
/// Exact-form only. A loopback that merely mentions one of our sink names — say a
/// third-party controller's `module-loopback source=some_mic
/// sink=pixelpass_capture_4242` — is **not** ours and must never be unloaded, and
/// a `sink=` token nested inside a quoted `sink_input_properties` value cannot be
/// mistaken for a top-level argument because the whole string must match.
///
/// The template only proposes a pid; the shape's own renderer decides. Re-rendering
/// and demanding byte equality means the loader is the authority, so a shape that
/// grows an argument, changes a latency, or repeats the pid cannot leave a matcher
/// quietly accepting the old form.
pub fn classify(obs: &ModuleObservation) -> Option<Fingerprint> {
for shape in ALL_SHAPES {
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 {
id: obs.id,
module_name: obs.name.clone(),
args: obs.args.clone(),
pid,
shape,
owner,
});
}
}
None
}
/// Modules that name a `pixelpass_capture_*` sink but match no canonical form.
///
/// These are never touched. They exist to be *reported*: either a third party is
/// naming our sinks, or a newer pixelpass loads a shape this build does not
/// recognise. The second case is how repair would go silently blind, so it gets
/// said out loud instead of inferred from a clean exit.
pub fn unrecognised_pixelpass_modules(
observations: &[ModuleObservation],
) -> Vec<&ModuleObservation> {
observations
.iter()
.filter(|obs| obs.args.contains(SINK_NAME_PREFIX) && classify(obs).is_none())
.collect()
}
/// Is anything else in this snapshot still attached to `pid`'s capture sink?
///
/// Returns the id of the first module that references it. Deliberately textual and
/// broad — any mention of the sink name by any *other* module counts, canonical or
/// not — because the question here is "would destroying this sink break something
/// that is attached to it", not "is that attachment ours". Answering it wrongly in
/// the permissive direction is the one thing rule 4 exists to prevent.
pub fn sink_still_referenced(
observations: &[ModuleObservation],
pid: u32,
sink_module_id: u32,
) -> Option<u32> {
let sink_name = sink_name_for(pid);
observations
.iter()
.find(|obs| obs.id != sink_module_id && obs.args.contains(&sink_name))
.map(|obs| obs.id)
}
/// Turn one snapshot into an ordered unload plan.
///
/// `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],
policy: &Policy,
liveness: impl Fn(u32, Attribution) -> 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.
let mut seen: BTreeMap<u32, Fingerprint> = BTreeMap::new();
for obs in observations {
if let Some(fp) = classify(obs) {
seen.entry(fp.id).or_insert(fp);
}
}
// 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, Attribution)> = Vec::new();
for fp in seen.into_values() {
match &fp.owner {
Some(token) if policy.local.can_judge(token) => {
judgeable.push((fp, Attribution::Tokened))
}
Some(_) => foreign.push(fp),
None => match policy.untagged {
UntaggedPolicy::CleanByPidAlone => judgeable.push((fp, Attribution::Untagged)),
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, attribution) in judgeable
.iter()
.map(|(fp, attribution)| (fp.pid, *attribution))
.collect::<BTreeMap<_, _>>()
{
match liveness(pid, attribution) {
Liveness::Alive => live_pids.insert(pid),
Liveness::Dead => dead_pids.insert(pid),
Liveness::Unknown => unknown_pids.insert(pid),
};
}
let mut unload: Vec<Fingerprint> = judgeable
.into_iter()
.map(|(fp, _)| fp)
.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,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 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,
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 {
obs(id, Shape::LoopbackIntoCapture, pid, Some(&our_token(2)))
}
fn local_monitor(id: u32, pid: u32) -> ModuleObservation {
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, _: Attribution) -> Liveness {
Liveness::Dead
}
fn ids(plan: &Plan) -> Vec<u32> {
plan.unload.iter().map(|fp| fp.id).collect()
}
/// The renderers are the contract with the live server. These strings were
/// measured on pactl 17.0 / pipewire-pulse: arguments come back joined with
/// single spaces, in order, with `@DEFAULT_SINK@` unresolved. If this test is
/// ever changed, the matcher's exactness claim has to be re-measured.
#[test]
fn the_canonical_argument_strings_are_what_the_server_records() {
assert_eq!(
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, None)),
"source=pixelpass_capture_4242.monitor sink=@DEFAULT_SINK@ latency_msec=20"
);
assert_eq!(
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
/// what makes the loader and the matcher one source of truth: change a
/// renderer and this fails unless the template follows.
#[test]
fn every_shape_round_trips_through_its_generated_template() {
for shape in ALL_SHAPES {
let [tokened, legacy] = shape.templates();
for pid in [1_u32, 7, 4242, 999_999, u32::MAX - 1] {
let plain = recorded_argument(&shape.render_args(pid, None));
assert_eq!(
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}"
);
}
}
}
/// The defect 0c introduces and this rewrite exists for: a dead host whose
/// capture sink was connection-owned leaves loopbacks behind with **no**
/// null-sink module to learn its pid from. The old discovery derived dead
/// pids only from `module-null-sink`, so it found nothing here.
#[test]
fn orphan_loopbacks_are_found_without_any_null_sink() {
let modules = [mirror(10, 4242), local_monitor(11, 4242)];
let plan = plan(&modules, &our_policy(), nothing_is_alive);
assert_eq!(ids(&plan), vec![10, 11]);
assert_eq!(plan.dead_pids, BTreeSet::from([4242]));
}
/// Either loopback shape alone must be enough to identify the owner — the
/// local monitor names the capture sink only as its *source*.
#[test]
fn each_loopback_shape_identifies_the_owner_on_its_own() {
assert_eq!(
ids(&plan(
&[local_monitor(11, 7)],
&our_policy(),
nothing_is_alive
)),
vec![11]
);
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
/// loopbacks that reference it.
#[test]
fn legacy_sink_unloads_after_the_loopbacks_that_reference_it() {
// Deliberately snapshot-ordered sink-first, so passing requires the
// plan to reorder rather than to preserve input order.
let modules = [
null_sink(5, 4242),
local_monitor(11, 4242),
mirror(10, 4242),
];
let plan = plan(&modules, &our_policy(), nothing_is_alive);
assert_eq!(ids(&plan), vec![10, 11, 5]);
assert_eq!(
plan.unload.last().unwrap().shape,
Shape::LegacyCaptureSink,
"the sink must be last or PipeWire is asked to destroy a sink with a live loopback"
);
}
/// A live pid is never touched, even when a dead one is being cleaned up in
/// the same run.
#[test]
fn a_live_host_is_left_alone_while_a_dead_one_is_cleaned() {
let modules = [
null_sink(5, 100),
mirror(10, 100),
null_sink(6, 200),
mirror(12, 200),
];
let plan = plan(&modules, &our_policy(), |pid, _| {
if pid == 200 {
Liveness::Alive
} else {
Liveness::Dead
}
});
assert_eq!(ids(&plan), vec![10, 5]);
assert_eq!(plan.live_pids, BTreeSet::from([200]));
assert_eq!(plan.dead_pids, BTreeSet::from([100]));
}
/// Undecidable liveness must behave exactly like alive. `/proc` answering
/// "no" because of a pid namespace, a permission error or `hidepid` is the
/// one way a wrong verdict destroys a *running* host's audio.
#[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, &our_policy(), |pid, _| {
if pid == 100 {
Liveness::Unknown
} else {
Liveness::Dead
}
});
assert_eq!(
ids(&plan),
vec![11],
"only the decidably-dead pid is planned"
);
assert_eq!(plan.unknown_pids, BTreeSet::from([100]));
assert!(plan.dead_pids.contains(&200));
assert!(
!plan.dead_pids.contains(&100),
"unknown must not be recorded as dead"
);
}
/// Two live hosts: repair must be a complete no-op. This is the unit-level
/// half of the two-host gate — the live half still has to be run for real.
#[test]
fn two_live_hosts_produce_no_actions_at_all() {
let modules = [
null_sink(5, 100),
mirror(10, 100),
local_monitor(11, 100),
null_sink(6, 200),
mirror(12, 200),
];
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());
}
/// Modules belonging to anything else are invisible to repair, including
/// near-misses that mention a sink we do not own.
#[test]
fn unrelated_and_malformed_modules_are_never_planned() {
let modules = [
ModuleObservation::new(1, "module-null-sink", "sink_name=some_other_sink"),
ModuleObservation::new(
2,
"module-loopback",
"source=alsa_output.pci.monitor sink=x",
),
ModuleObservation::new(3, "module-echo-cancel", "sink_name=pixelpass_capture_9"),
// Our prefix, but no parseable pid — we do not guess an owner.
ModuleObservation::new(4, "module-null-sink", "sink_name=pixelpass_capture_"),
ModuleObservation::new(5, "module-null-sink", "sink_name=pixelpass_capture_abc"),
ModuleObservation::new(6, "module-null-sink", "sink_name=pixelpass_capture_-1"),
// `u32::from_str` accepts a leading `+`; a name we wrote never has one.
ModuleObservation::new(7, "module-null-sink", "sink_name=pixelpass_capture_+1"),
// Leading zeroes are not a name we render.
ModuleObservation::new(8, "module-null-sink", "sink_name=pixelpass_capture_007"),
ModuleObservation::new(9, "module-loopback", "latency_msec=20"),
];
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");
}
/// A loopback that merely *names* one of our sinks is not ours. This is the
/// case where over-eager recognition would unload a live third party's
/// module: only one endpoint is a pixelpass name, so no canonical form
/// matches.
#[test]
fn a_loopback_with_only_one_pixelpass_endpoint_is_not_ours() {
let modules = [
// A third-party controller routing some microphone into our sink.
ModuleObservation::new(
20,
"module-loopback",
"source=some_mic sink=pixelpass_capture_4242 latency_msec=20",
),
// Our sink's monitor into somewhere that is not the default sink.
ModuleObservation::new(
21,
"module-loopback",
"source=pixelpass_capture_4242.monitor sink=other_sink latency_msec=20",
),
// The canonical shape with a different latency — a version of
// pixelpass this build does not know how to recognise.
ModuleObservation::new(
22,
"module-loopback",
"source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_4242 latency_msec=50",
),
// Extra arguments appended: not the string we write.
ModuleObservation::new(
23,
"module-loopback",
"source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_4242 latency_msec=20 \
remix=false",
),
];
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);
}
/// A pixelpass-looking token nested inside a quoted property value must not
/// promote a foreign module to ours. `module-loopback` really does accept
/// `sink_input_properties`, so this argument is legal.
#[test]
fn a_nested_quoted_property_is_not_a_top_level_argument() {
let obs = ModuleObservation::new(
30,
"module-loopback",
"source=some_mic sink=real_sink \
sink_input_properties=\"media.name=sink=pixelpass_capture_4242\" latency_msec=20",
);
assert_eq!(classify(&obs), None);
assert!(plan(&[obs], &our_policy(), nothing_is_alive).is_empty());
}
/// A repeated observation of one module must not become two unloads of an
/// index that may have been reused between them.
#[test]
fn a_duplicated_observation_yields_one_action() {
let modules = [mirror(10, 4242), mirror(10, 4242)];
assert_eq!(
ids(&plan(&modules, &our_policy(), nothing_is_alive)),
vec![10]
);
}
/// Liveness is asked once per pid. Without this, a `liveness` that flips
/// mid-plan could unload some of a host's modules and keep others — the
/// worst possible outcome, since a half-repaired host is neither working
/// nor cleanable. Two pids, counted separately: one pid cannot prove
/// "once *per* pid".
#[test]
fn liveness_is_decided_once_per_pid_not_once_per_module() {
use std::cell::RefCell;
let calls: RefCell<BTreeMap<u32, u32>> = RefCell::new(BTreeMap::new());
let modules = [
null_sink(5, 42),
mirror(10, 42),
local_monitor(11, 42),
mirror(12, 99),
local_monitor(13, 99),
];
let plan = plan(&modules, &our_policy(), |pid, _| {
*calls.borrow_mut().entry(pid).or_insert(0) += 1;
Liveness::Dead
});
let calls = calls.into_inner();
assert_eq!(calls.get(&42), Some(&1), "one question for pid 42");
assert_eq!(calls.get(&99), Some(&1), "one question for pid 99");
assert_eq!(calls.len(), 2, "no pid asked that we have no module for");
assert_eq!(ids(&plan), vec![10, 12, 11, 13, 5]);
}
/// Re-verification: the same module still matches, and a reused index
/// carrying a different module does not.
#[test]
fn a_fingerprint_only_matches_the_module_it_was_taken_from() {
let obs = mirror(10, 4242);
let fp = classify(&obs).expect("ours");
assert!(fp.still_matches(&obs));
// Same index, someone else's module — the reuse case that makes a plan
// unsafe to execute blind.
assert!(!fp.still_matches(&mirror(10, 9999)));
assert!(!fp.still_matches(&null_sink(10, 4242)));
// Same module, different index.
assert!(!fp.still_matches(&mirror(11, 4242)));
// Same identity, arguments rewritten.
assert!(!fp.still_matches(&ModuleObservation::new(
10,
"module-loopback",
"source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_4242 latency_msec=200"
)));
// Same shape and pid, but observed under the *other* loopback shape.
assert!(!fp.still_matches(&local_monitor(10, 4242)));
}
/// The module *name* is part of the identity, and this is provable because the
/// argument grammars overlap: `module-echo-cancel sink_name=pixelpass_capture_9`
/// is byte-identical to the canonical null-sink argument. So a comparator using
/// only `id + args` accepts a foreign module — and unloads it.
///
/// (I previously argued this case could not be constructed non-vacuously, on the
/// grounds that the name determines which grammar can match. That was wrong: the
/// grammars are not disjoint across names.)
#[test]
fn an_identical_argument_under_another_module_name_is_not_a_match() {
let ours = null_sink(5, 9);
let fp = classify(&ours).expect("ours");
let impostor = ModuleObservation::new(5, "module-echo-cancel", &ours.args);
assert_eq!(fp.args, impostor.args, "the arguments really are identical");
assert_eq!(fp.id, impostor.id, "and so is the index");
assert!(
!fp.still_matches(&impostor),
"only the module name distinguishes these, so it must be compared"
);
assert_eq!(
classify(&impostor),
None,
"and it is not ours to begin with"
);
}
/// Whitespace is identity, not layout. The exact recorded argument is
/// compared, so a re-spaced string is a *different* argument — inside a
/// quoted property value that difference can be semantic.
#[test]
fn respaced_arguments_do_not_match() {
let fp = classify(&mirror(10, 4242)).expect("ours");
assert!(!fp.still_matches(&ModuleObservation::new(
10,
"module-loopback",
"source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_4242 latency_msec=20"
)));
}
/// Rule 4: the sink's unload is gated on nothing else being attached, not on
/// the plan's ordering having been carried out. A loopback that failed to
/// unload — or one created after planning — must block it.
#[test]
fn a_remaining_loopback_blocks_the_sink_unload() {
// The mirror is still there (its unload failed, or it arrived late).
let snapshot = [null_sink(5, 4242), mirror(10, 4242)];
assert_eq!(sink_still_referenced(&snapshot, 4242, 5), Some(10));
// A foreign module attached to our sink blocks it too: the question is
// what would break, not who owns it.
let foreign = [
null_sink(5, 4242),
ModuleObservation::new(
77,
"module-loopback",
"source=some_mic sink=pixelpass_capture_4242 latency_msec=20",
),
];
assert_eq!(sink_still_referenced(&foreign, 4242, 5), Some(77));
// Nothing else attached: clear to unload. The sink's own argument names
// itself, which must not count.
let alone = [null_sink(5, 4242)];
assert_eq!(sink_still_referenced(&alone, 4242, 5), None);
// Another host's loopback is not a reference to *this* sink.
let other_host = [null_sink(5, 4242), mirror(10, 9999)];
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"
);
}
/// The attribution handed to the liveness callback decides *how* the question
/// may be asked, so it must be right per module. Getting this wrong made the
/// token useless in a container — the one place it exists for — because the
/// probe's namespace guesswork answered `Unknown` for a pid the token had
/// already proven local.
#[test]
fn attribution_is_reported_per_module() {
let modules = [mirror(10, 100), legacy_mirror(11, 200)];
let asked = std::cell::RefCell::new(Vec::new());
let plan = plan(
&modules,
&Policy {
local: our_identity(),
untagged: UntaggedPolicy::CleanByPidAlone,
},
|pid, attribution| {
asked.borrow_mut().push((pid, attribution));
Liveness::Dead
},
);
let asked = asked.into_inner();
assert_eq!(
asked,
vec![(100, Attribution::Tokened), (200, Attribution::Untagged)],
"each pid must be asked about with its own module's attribution"
);
assert_eq!(ids(&plan), vec![10, 11]);
}
/// 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, &our_policy(), nothing_is_alive),
plan(&b, &our_policy(), nothing_is_alive)
);
}
}