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:
+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