Author SHA1 Message Date
molluskandClaude Opus 5 6dd9b2d25a repair: say "once per (pid, attribution)", because that is what it is now
Codex's non-blocking round-7 nit. The comment and test name still claimed liveness
is asked once per pid, which stopped being true when one pid became two questions.
No behaviour change; the wording was the last thing pointing at the old model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:40:44 -04:00
molluskandClaude Opus 5 58bc8e99e8 repair: one pid can carry two attributions, and they are two questions
Round 6, one blocking P2 — in code I wrote in the round-5 fix, not in the token
design, which the review passed again.

The verdict cache was keyed on pid alone. A pid can legitimately be claimed by a
tokened module *and* an untagged one at the same time — a host crashes, an older
build restarts and reuses the number — and those are not the same question: one is
answerable directly from the token, the other only if the degradation signals
allow it. Collapsing them let whichever module sorted last decide both, so under
`--repair-legacy-untagged` with a degraded probe an untagged winner made safely
attributable debris `Unknown`, and a tokened winner planned the untagged debris as
dead (the execution recheck happened to stop the destruction, which is luck, not
design). Verdicts are now cached per `(pid, Attribution)` and each fingerprint is
filtered by its own, never by looking its pid up in `dead_pids`. Those three pid
sets are documented as reporting-only, since a pid can now honestly appear in two
of them.

Mutation-verified: restoring the `dead_pids.contains(pid)` filter fails the new
test, which runs both module-id orders because the bug was order-dependent, and
both polarities — the second asserts that a *live* tokened owner does not lose its
module because an untagged claim on the same pid looked dead.

Also, the degraded-probe warning had become false (P3): it announced "refusing to
unload anything" while the tokened path can now legitimately unload, which in the
exact container-recovery case the token was added for would print a categorical
refusal and then destroy state. It is now scoped to what it actually means —
modules *without* a token will be left alone.

256 tests, clippy clean, fmt clean, field gates green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:31:17 -04:00
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
molluskandClaude Opus 5 d9ef38c1c5 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>
2026-07-27 01:57:55 -04:00
molluskandClaude Opus 5 1cf6e915c8 repair: drop the settling ritual, record the binding's timeout leak
Both P3s from the round-4 review.

The eight non-blocking mainloop iterations in `Drop` were a ritual, not a barrier:
a fixed number of polls cannot guarantee any particular event became ready. They
were also unnecessary — PulseAudio's context unlink cancels outstanding operations
and tears down the context's socket machinery synchronously, so once
`drop(context)` returns the mainloop has no obligation left to service. Removed
rather than replaced with a time-bounded drain, since there is no asynchronous
obligation for such a drain to wait on. Re-verified live: both field gates and a
clean-graph run still exit 0, with no abort.

Also recorded, at the constant it depends on: on a request timeout the `Operation`
wrapper is dropped while still running, and libpulse-binding 2.30.1 only unrefs the
C operation, so the boxed callback and its captured `Rc`s leak until the context
cancels the operation at disconnect. Harmless here — `--repair` is a one-shot
process that exits immediately after — and it cannot become a use-after-free, since
the closure owns its clones and the context clears callbacks before the mainloop is
touched. It would NOT be acceptable in the long-lived host, so the note says so
where someone would otherwise reuse this module for host-side loading.

The review's verdict on the teardown itself: disconnect, destroy the context while
the mainloop lives, then the mainloop, is the correct order, and taking the context
explicitly makes it independent of field declaration order.

247 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:41:16 -04:00
molluskandClaude Opus 5 45464b4af5 repair: read and unload Pulse modules through libpulse, not pactl text
Third review round found three more blocking P2s, and they shared one root cause:
`pactl`'s text output cannot carry the guarantees repair was claiming. All three
are closed by talking to the protocol instead. New dependency taken with the
user's explicit sign-off after vetting.

**Record boundaries were unprovable.** `pactl list short modules` prints a
module's argument raw into a tab/newline-delimited format with no escaping. A
*genuine* module whose argument contains a newline renders a first line that reads
byte-exactly like one of our canonical forms, with the remainder dropped as an
unparseable continuation — no forged index, so the duplicate-index check could not
see it. Repair would have classified and unloaded a module it never saw in full.
**Field-confirmed on the live server**, because this needed no adversary: loading a
loopback whose argument is canonical-then-newline-then-`remix=false` (a real
loopback option) produces exactly that listing. A tab in the same position is
worse: it hid a sink reference from the gate that protects a still-referenced sink.

**Index and argument could be mis-paired.** The listing carrying exact arguments
(`-f json`) carries no index at all on pactl 17; the one carrying the index cannot
carry the argument faithfully. Correlating them by position — which the previous
commit did — is unsound whenever module names repeat: another client loading one
module and unloading another between the two calls leaves counts and names aligned
while every argument has shifted by one, so a foreign module inherits a canonical
fingerprint. The name check cannot see it and the retry never fires.

**Locality was a guess.** `PULSE_SERVER` is a fallback *list*, so
`unix:/missing tcp:remote:4713` passes any "starts with unix:" test and then
connects to another machine, where local pids mean nothing and a live remote host's
modules look dead. A remote server can also be selected by client config with the
variable unset entirely.

New `repair/introspect.rs` owns one verified-local connection: `pa_module_info`
gives index, name and exact argument in a single record, `pa_context_is_local()`
answers locality about the connection actually established, and unloading goes back
through that same connection so listing and destruction cannot disagree about which
server they mean. It holds no policy beyond refusing the wrong server; every
decision stays in the pure planner.

⚠️ **The field test caught a real bug that no unit test could have.** The first
version did its work correctly and then aborted on the way out:

    Assertion '!e->dead' failed at ../pulseaudio/src/pulse/mainloop.c:207,
    function mainloop_io_free(). Aborting.

SIGABRT, core dumped, exit 134 — a fully successful repair reporting failure to its
caller. Cause: Rust drops fields in declaration order and the context's teardown
frees IO events living in the mainloop, which I had declared first. **This is
exactly the invariant phase 0b exists for, met again one layer down.** Fixed, and
then hardened past the fix: `Drop` explicitly takes and destroys the context before
the mainloop, so the ordering no longer depends on where the fields are written.

Liveness keeps its `NSpid`/container checks but the claim is corrected: `NSpid > 1`
means "definitely nested", while `NSpid == 1` is NOT proof of the initial namespace
— its leftmost value is relative to the procfs that was mounted, so a nested
namespace with its own `/proc` reports one entry legitimately. These are negative
signals that fail closed, not a proof of trustworthy pids. Closing that properly
needs modules to carry an owner token (machine/boot plus pid-namespace identity),
which changes what pixelpass writes into the graph and how far back `--repair` can
clean up: recorded as a design decision, not guessed at.

libpulse-binding 2.30.1 vetted before use: MIT/Apache-2.0, 5.5M downloads, 3 new
crates total, build script does nothing but probe pkg-config, no network or
subprocess use anywhere in the sources, and all three historical RustSec advisories
(2018-0020, 2018-0021, 2019-0038) were fixed by 2.6.0. The reasoning is recorded in
Cargo.toml beside the dependency.

247 tests, clippy clean, fmt clean apart from the pre-existing taint/tests.rs:2683.
The text parser's tests are gone with the parser; the liveness probe keeps its own,
and the live field gates — A/B orphan removal, the reference/unrecognised fixture,
and the newline fixture — all pass with exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:23:23 -04:00
molluskandClaude Opus 5 01c582427b repair: one atomic listing, namespace-aware liveness, renderer as authority
Second review round. Two blocking P2s and two P3s, all applied.

**The two-listing correlation was unsound, so it is gone** (P2). Pairing the short
listing's indices with the JSON listing's exact arguments by position breaks with
repeated module names: if another client loads one module and unloads another
between the two calls, the counts and names still line up while the arguments have
shifted by one — and a *foreign* module inherits a canonical fingerprint. The name
check cannot see it and the retry never fires, because correlation "succeeded".

Observations now come from a single `pactl list short modules` invocation, so every
`(index, name, argument)` comes from one server response and cannot be
mis-assembled. The two costs of that format are handled rather than hoped away:

- A tab inside an argument is invisible here, so the row is marked
  `args_complete: false`. `classify` refuses such a row outright — a truncation
  could otherwise coincide with a canonical form — while the reference gate can
  still see that it names a sink.
- A crafted argument containing a newline can fabricate a row, but it only does
  damage if it claims a *real* module's index, which makes that index appear twice.
  A duplicated index now refuses the whole run.

libpulse introspection (`pa_module_info` carries index, name and argument in one
record) remains the exact route. It is a new dependency plus a mainloop in a
one-shot CLI path, so it is recorded as the upgrade rather than taken unilaterally.

**Liveness was still converting invisible-but-alive into dead** (P2). A
`/proc/self` preflight proves nothing: inside a pid namespace — a container, a
distrobox — `self` is visible while every process in the parent namespace is not,
and `hidepid` has the same shape. Repair there can reach the host's Pulse socket,
see a live host's modules, call its pid dead and unload a running host's audio.

So the probe now asks for positive confidence instead: `NSpid` in
`/proc/self/status` reports this process's pid in every namespace it appears in, so
more than one entry means our pid numbers are not the outer namespace's and every
verdict becomes `Unknown`. A kernel that does not report `NSpid`, a container
marker, and a non-local `PULSE_SERVER` all fail closed the same way. Liveness
itself is `kill(pid, 0)` via the existing `nix` dep, where `EPERM` proves
existence; pid 0 and pids past `i32::MAX` are never asked, since `kill(0, …)`
would signal our own process group.

**The renderer is the authority, not the derived template** (P3). `classify` now
re-renders the pid it extracted and demands byte equality, so the template is only
a pre-filter. `Shape` also owns the module *name* now, and `host/audio.rs` loads
through `Shape::{module_name, render_args}` — previously the "cannot drift" claim
covered only arguments while the names were still written out at both ends. The
sentinel assertion is unconditional (`assert!`), so a future shape that repeats the
pid cannot slip through a release build.

**A test I wrongly called unclosable** (P3). I argued no non-vacuous case could
prove the module name is part of the identity, because the name determines which
argument grammar can match. That was wrong: the grammars are not disjoint —
`module-echo-cancel sink_name=pixelpass_capture_9` is byte-identical to the
canonical null-sink argument, which the suite already constructs. Same index, same
arguments, different name, and `still_matches` must say no.

252 tests (+1 net; the correlation tests were replaced by parser and probe tests),
clippy clean, fmt clean apart from the pre-existing taint/tests.rs:2683. Both live
field gates re-run against the rewritten observation path: the A/B orphan test
still removes exactly the two orphans with the module table otherwise identical,
and the reference/unrecognised fixture still leaves both modules alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:07:58 -04:00
molluskandClaude Opus 5 9145b2a726 repair: exact-form matching, tri-state liveness, and a reference-gated sink
Codex's review of the repair planner returned changes-requested: no P1s, four
reachable P2s plus a P3, all in the observation and execution layers rather than
in the discovery fix itself. All applied.

**Only the canonical forms are ours** (P2, plan.rs). `classify` recognised any
loopback with one pixelpass-looking endpoint, so a third party's
`module-loopback source=some_mic sink=pixelpass_capture_4242` was ours to unload
once that pid died — and a `sink=` token nested inside a quoted
`sink_input_properties` value could be mistaken for a top-level argument. The
whole recorded argument string must now equal what pixelpass itself would have
written.

The matcher's templates are **generated from the loader's own renderers**, not
written out beside them: hard-coding `latency_msec=20` in a matcher means a
loader change silently blinds repair to every module the new build loads, which
is the fail-closed-and-silent failure this project has been bitten by three
times. `host/audio.rs` now loads through those same renderers, so the two cannot
drift. Blindness is also reported rather than assumed impossible —
`unrecognised_pixelpass_modules` finds modules that name our sinks but match no
canonical form, and `--repair` says so loudly.

Measured on the live server before relying on it (pactl 17.0): arguments come
back byte-for-byte as passed, joined with single spaces, with `@DEFAULT_SINK@`
NOT resolved. Both facts are load-bearing for exact matching and both have a
test.

**Ordering is not a licence either** (P2, mod.rs). The plan put loopbacks before
the sink, but an unload can fail or be skipped and a loopback can appear after
planning, so the executor could still destroy a sink that something was attached
to. The sink unload is now gated on `sink_still_referenced` against the fresh
snapshot — any other module naming that sink blocks it, ours or not, because the
question is what would break, not who owns it.

**Undecidable is not dead** (P2, mod.rs). `Path::exists()` maps permission
errors, a missing `/proc` and a foreign pid namespace all to `false`, which here
read as "dead, go ahead and unload". Liveness is now `Alive | Dead | Unknown`
via `try_exists()` behind a `/proc/self/stat` preflight, and `Unknown` is
treated exactly like alive and reported separately.

**The short listing cannot carry a fingerprint** (P2, mod.rs). Its arguments are
tab-delimited text that a module argument may itself contain, and a continuation
line beginning with a digit could fabricate a row. Observations now come from two
listings: the short one for the module index, and `pactl -f json list modules`
for the exact argument. Codex proposed JSON alone; on pactl 17 its records carry
`"index": null`, so it cannot be used on its own — verified, hence the
correlation. The pairing is positional and *checked* (same count, same name at
every position, else refuse), which is also what makes a fabricated row harmless
instead of exploitable: it has no JSON counterpart, so the sequences misalign.

Normalisation is gone (P3). Within one invocation every snapshot comes from the
same server, so re-rendering does not happen, and normalising only made
genuinely different arguments compare equal. The residual ABA window — planned
module vanishes, byte-identical one takes its index — cannot be closed through an
unload API whose only argument is an index; that is now said plainly in the
fingerprint's own doc comment rather than implied away.

Five vacuity gaps Codex found in the tests, closed: a raw-pactl-output-to-plan
test (the planner suite survived a parser that dropped every argument), the
liveness-once test now uses two pids with per-pid counters, non-canonical and
nested-quoted arguments have their own cases, and the reference gate has one.

Field-verified on the live graph, both new rules: the A/B orphan test still
removes exactly the two orphans with the module table otherwise byte-identical,
and a fixture of a dead pid's legacy sink plus a non-canonical loopback naming it
leaves both alone and reports why.

251 tests (+9), clippy clean, fmt clean apart from the pre-existing
taint/tests.rs:2683.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:31:30 -04:00
molluskandClaude Opus 5 919d5bdef2 repair: find the orphans a connection-owned sink leaves behind
Phase 0c's first landed piece. `--repair` does not get smaller when the capture
sink becomes connection-owned — it goes blind, which is the opposite of the
assumption I started from and the finding that reordered this phase.

Today repair learns a dead host's pid ONLY from `module-null-sink
sink_name=pixelpass_capture_<pid>`, and matches loopbacks only if their pid is
already in that set. After 0c the sink is a native node that removes itself with
its owning connection, so a hard-killed host leaves two Pulse loopbacks and *no
null-sink module to learn the pid from*. The set stays empty, nothing matches,
and the orphans are invisible forever. Candidate pids are now derived
independently from all three module shapes.

Split into a pure planner (`repair/plan.rs`) and an I/O shell, because every
interesting property here is a decision — which pid is dead, which module is
whose, in what order to unload — and none of them need PipeWire to exercise.

The rule that is new, and that the old code could not express: **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; a pid
recheck alone does not catch that. Every action now carries a full fingerprint
(id, module name, normalized args, derived pid, shape) which is re-verified
against a FRESH snapshot immediately before each unload, with liveness rechecked
last, closest to the destruction. Anything that does not match exactly is
skipped and said out loud — never unloaded on the strength of a stale plan.

Ordering is carried by `Shape`'s declaration order rather than by two separate
passes, so loopbacks unload before the sink they reference by construction.
Liveness is asked once per pid, not once per module: a flapping answer must not
be able to half-repair a host, which is the one outcome worse than doing nothing.

Field-tested against a real post-0c orphan, not just mocked: a connection-owned
sink created via `pw-cli create-node adapter` (module-null-sink count: 0, so the
old discovery provably could not see it), both loopback shapes loaded against it,
then SIGKILL of the owning connection. The sink vanished on its own, both
loopbacks survived, `--repair` removed exactly those two, and the module table
was otherwise byte-identical before and after — the collateral-damage half of the
two-host safety property.

Mutation-verified, five mutants, each killed by its own gate: null-sink-only
discovery (the 0c blindness itself), id-ordered unloads instead of shape-ordered,
a fingerprint that compares only the index (exactly one), no liveness filter, and
un-normalized args (exactly one).

Still owed: the live two-host gate (two real hosts, kill one, prove the other's
graph and modules are untouched) cannot run until the native sink exists, so it
is deferred to 0c's combined exit gate. This lands with test + single-host field
proof only, which Codex agreed is an acceptable phase dependency rather than an
objection to landing repair first.

242 tests (+11), clippy clean, fmt clean apart from the known pre-existing
`taint/tests.rs:2683` — pixelpass is never `cargo fmt`ed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:09:40 -04:00
molluskandClaude Opus 5 c78eb2dd39 host/taint: a self-claimed pid is not provenance (F11-1)
Boundedness now requires a strong key, or a key-4 value backed by a
**resolved Client** — an unambiguous Client yielding Some(pipewire.sec.pid),
read before pipewire-pulse suppression. A node whose Client cannot be
resolved at all is unbounded whatever `application.process.id` it puts on
itself, so it can no longer spare itself from `propagate_unresolved_owner`'s
sweep with a value it made up.

Closes the round-11 review's finding 1: the key-4 union could *reduce* taint,
because the same key list feeds boundedness and the sweep is armed by an
UNbounded tainted reader. The recorded three-step path (reader bounded by its
Client's real pid, output leg on an ambiguous Client claiming a bogus pid, no
shared key so no bridge either) is now a test.

Bridging is untouched: it still uses the full union, so boundedness is stored
on OwnerKeyIndex rather than re-derived from the key set, and `bounded_by` is
the single implementation of the predicate.

Five-case Client matrix as tests (absent · ambiguous · unique-but-pid-less ·
resolved-native · resolved-to-pipewire-pulse). The pid-less row is the one
that distinguishes the correct reading of "resolved" from "a unique Client
exists", which would have left the hole open. Mutation-verified: dropping the
provenance test fails four of the six rows and passes the two that must not
regress.

Measured cost on the live graph: zero. Before- and after-binaries audited the
same graph simultaneously (tagged producer + parec on the monitor as a real
tainted reader, so the sweep was armed) — 181 records each, the same 14
distinct decision states, none exclusive to either side, no unresolved-owner
on either, eligible half non-empty throughout. O5 unmoved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 04:44:58 -04:00
mollusk 91c4dedcb0 host/observer: derive pipewire-pulse's PID from comm, not from repetition
MEASURED DEFECT, found by the §5.1 row-1 matrix run: the daemon PID was
unresolvable on this host, permanently, which switched key 4's suppression off
and fused every Pulse-emulated node into a single owner. Row 1's clean control
forwarder was excluded, Firefox was excluded across an application.process.id
bridge, and the taint reached the sunshine sinks and both sound cards -- the
same machine-wide over-exclusion cascade as phase 5's F2, from a new cause.

Stage 1 returned the one sec_pid shared by 2+ Clients, reasoning that only the
Pulse shim repeats a value. WirePlumber repeats one too: it holds Clients
'WirePlumber' and 'WirePlumber [export]', both sec_pid 1747. Two values
repeated, the rule called that ambiguous and returned None, and
owner::keys_of's documented fail-closed asymmetry did the rest.

The rule was wrong in both directions, so the prefilter is gone rather than
patched: a second process holding two Clients defeats it (permanent, not a
corner case), and a session where pipewire-pulse holds exactly one Client never
repeats anything so the candidate is missed. comm was always the authoritative
check; repetition stood in front of it and was a guess about other processes'
Client counts.

Now: candidates() lists every distinct sec_pid, resolve() picks the unique one
whose /proc comm is exactly pipewire-pulse, and several matches still fail
closed (a single Option<u32> cannot suppress two daemons -- recorded, not
approximated). The adapter probes only PIDs entering the set, and
retain_probed_comms bounds the map to live PIDs so a PID that leaves and
returns is re-probed instead of answered from a stale comm.

Row 1 now passes its exact partition, key named: tainted forwarder leg excluded
on node.link-group, clean forwarder leg and Firefox eligible, taint confined to
the tainted half. 225 tests green (WirePlumber-pair and single-Client
regressions covered), clippy clean.
2026-07-26 02:32:07 -04:00
mollusk d462754894 host/audit: name the owner key the bridge resolved on
Impl plan section 5.1 row 1 asserts "reason = owner bridge, naming the key",
and the record could not express that: Reason::code collapses
TaintedOwnerBridge { key } to one string, so an exclusion that arrived across a
named owner key was indistinguishable from one that arrived by an incidental
link walk reaching the same verdict. Telling those apart is the entire point of
the row.

OwnerKey::code already documented itself as ending up in the phase 5 audit
output; it was simply never wired to it. Adds owner_key to AuditRow and
TaintRow, omitted when absent, and absent is meaningful: a bridge whose tainted
member shared no key directly reached the node transitively, so there is no
single key to name and naming one would be a false diagnosis.

Read-only and diagnostic-only. New test mutation-verified (stubbing the key to
None fails it); 221 tests green, clippy clean.
2026-07-26 02:21:07 -04:00
mollusk 90efa51c26 Merge phase 1: ownership carriers (taint roots from peerspeak's tags)
pixelpass-side half of phase 1: the taint engine recognises both registry-visible
ownership carriers peerspeak now emits (peerspeak.owned and the
peerspeak_owned_ node.name prefix), giving the primary taint root a path that
survives the filtered registry global event (design v3.5 section 6.7).

Reviewed by Codex rounds 10-12. F11-1 (the owner-key union can REDUCE taint via
owner_is_bounded, switching the unresolved-owner sweep off) remains OPEN and
still blocks phase 6; it is deliberately deferred to be decided with section 5.1
matrix data, and its sharpened rule is recorded in owner_is_bounded's doc.

220 tests green, clippy clean.
2026-07-26 02:14:34 -04:00
molluskandClaude Opus 5 b8b8b78b09 host/taint: pin what "resolved" must mean before F11-1 is implemented
Round 12 re-examined the deferral and agreed it holds while evaluate()
is audit-only, and that the recorded rule closes the path without
unbounding Pulse-emulated apps — but only under one reading of
"a node whose Client cannot be resolved at all".

The trap is worth writing down before anyone implements it: reading
"resolved" as "a unique Client object exists" passes for a unique Client
with sec_pid = None, which supplies no protected identity and leaves
exactly the self-claimed-PID hole the rule exists to close. It has to
mean an unambiguous Client yielding Some(pipewire.sec.pid), taken before
pipewire-pulse suppression.

That also means the §5.1 matrix needs five Client cases rather than two:
absent, ambiguous, unique-but-pid-less, resolved-native, and
resolved-to-pipewire-pulse. The pid-less row is the one that
distinguishes the two readings and the one a two-case matrix skips
without saying so.

Docs only. Still deferred, still to be decided with matrix data in hand.
220 tests green, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 02:01:07 -04:00
molluskandClaude Opus 5 289016d071 host/taint: record the open owner-union/boundedness interaction
Round 11 review, finding 1. Verified correct: round 10's claim that the key
union was "strictly additive" was too strong. The same key list feeds
owner_is_bounded, and the unresolved-owner sweep is triggered by an UNbounded
tainted reader -- so adding the Client's PID can move a reader from unbounded
to bounded and switch the sweep off, letting a same-process output leg with an
ambiguous Client and a bogus self-claimed PID stay eligible.

Cannot leak today (evaluate() is audit-only); becomes live in phase 6.

Not fixed in this round, and the doc says why: the blunt repair -- only
protected keys bound an owner -- makes every Pulse-emulated app unbounded,
which re-triggers the mass over-exclusion the design exists to avoid and would
empty the eligible half of the 5.1 matrix. The targeted rule (a node whose
Client cannot be resolved at all is not bounded by its own self-claimed PID)
is written down along with what it needs structurally, to be implemented with
matrix data in hand rather than argued from a whiteboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:09:17 -04:00
molluskandClaude Opus 5 4b2b192601 host/taint: correct the constant's own doc, and record the ProcessId ambiguity
Verification round on the round-10 review fixes.

Finding 6 named the fixture, taint/tests.rs and snapshot.rs, but the same
stale claim was also on PEERSPEAK_OWNED_VALUE itself — the definition site
for the very literal the finding was about, still arguing that any truthy
value counts and that this is the fail-closed direction. Corrected with the
reason the argument fails.

Also records a known imprecision the union widened: OwnerKey::ProcessId now
covers both application.process.id and the Client's pipewire.sec.pid, so a
bridge reported under the former may have resolved on the latter.
Pre-existing since R10-3; not fixed here because these codes are a stable
contract for the audit output and the phase 6 status event, so splitting one
wants its own decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:00:38 -04:00
molluskandClaude Opus 5 993befdedd host/taint: carry both pids as owner keys; gate the wiring and the contract
Round 10 review, findings 1, 4 and 6.

Finding 1 (P1, phase 6) — key 4 was `node.or_else(client)`, so a node's
client-controlled application.process.id REPLACED its Client's protected
pipewire.sec.pid. One process using two Clients could therefore split its
identity: the tainted reader reports a bogus node pid, the output leg omits
the node pid and falls back to the Client's real one, the legs are bounded
by different values, and they neither bridge nor trip the unbounded sweep —
the output stays eligible while re-emitting the call. Now a union of both
values, deduplicated, with exception 1 applied to each independently so the
pipewire-pulse pid still cannot fuse unrelated Clients.

Mutation-verified: reverting to or_else fails ONLY the new split-Client test
(so the union changes nothing else), dropping exception 1 fails 32 rows, and
using the Client pid alone fails 16.

Not reachable today — evaluate() is reached only by the dry-run audit, which
creates no links. It becomes live when phase 6 consumes these decisions.

Finding 4 — R10-4's test called peerspeak_owned() directly, so reverting
node_observation_from_props to truthy() left it green; the only case it
shared with production, exact "1", passes under both. A new test builds a
real pw_properties dict and drives the production wiring, and the mutation
now fails exactly that test while the helper test still passes.

Finding 6 — the cross-repo fixture still documented carrier 1 as "any value
other than false/0", which R10-4 made exact-"1". A producer following it
could emit "true" and silently lose the carrier. Fixture updated in both
repos (byte-identical, verified), along with the stale prose in taint/tests
and snapshot.rs, and the contract is now also exercised through the
production adapter rather than only against the constants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:57:09 -04:00
molluskandClaude Opus 5 abaf5d9c10 host/taint: a pid-less Client still makes its id ambiguous
Verification round on R10-3's own fix. The ambiguity guard detected a
duplicate client id by looking it up in the pid map — which is only
populated for Clients that carry a sec_pid at all. A pid-less first
claimant therefore left no trace, so the next Client claiming the same id
looked unique and its pid was used, resolving an ambiguous id: exactly
the guess the guard exists to refuse.

Reachable, not theoretical — pid-less Clients are ordinary here (the
session manager's is one). Reproduced: the bystander app went eligible
off a coin-toss owner attribution.

Claimed ids are now tracked separately from resolved pids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:07:42 -04:00
molluskandClaude Opus 5 67f4ff931e host/observer: match the ownership carrier exactly, not leniently
The lenient `truthy` spelling was wrong for this one property. Under it,
`peerspeak.owned=""` and `peerspeak.owned="false "` both read as owned,
so any process could suppress a rival application's audio from the share
with a property it did not have to spell correctly.

The justification for leniency was that treating an unexpected value as
"owned" over-excludes and is therefore safe. That does not hold: leniency
here buys false-positive exclusion, not safety. Fail-closed on this
feature is about ancestry — an unresolvable graph is not eligible — not
about parsing. The producer emits exactly PEERSPEAK_OWNED_VALUE at all
three of its sites and is pinned to it by the shared cross-repo fixture,
and a garbled property still leaves carrier 2's node.name prefix, which
is a union with this one.

`truthy` stays as it is for port.exclusive, port.monitor and
node.passthrough: those are PipeWire's own, their spelling varies by
producer, and each causes exclusion when true, so leniency really is the
safe direction there. Both halves now have a row saying so.

Codex phase-1 review F6. Round 10, R10-4. Mutation-verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:59:17 -04:00
molluskandClaude Opus 5 295a575b15 host/taint: owner key 4 falls back to the Client's pipewire.sec.pid
Native PipeWire clients put no application.process.id on their nodes —
only client.id. keys_of read node properties alone, so those nodes had no
key 4, were therefore unbounded, and propagate_unresolved_owner excluded
them the moment any tainted reader existed anywhere on the machine.

Measured: an untagged mpv was eligible alone, and became unresolved-owner
the instant peerspeak played audio. Since peerspeak playing audio is the
only situation in which this feature runs, that amounted to "native
PipeWire apps are never shareable". The tainted reader that armed it was
sunshine, which is itself bounded — so this is the bounded-reader arm,
not the keyless-reader case §6.1.1 narrates.

The pid is one hop away, on the node's Client, already in the snapshot.

RISK, and the guard on it: every Pulse-emulated Client carries
pipewire-pulse's own PID as sec_pid — measured, 15 unrelated Clients
sharing 2528 on this host. An unguarded fallback would fuse all of them
into one owner. Exception 1 therefore applies to the fallback exactly as
it does to the node's own property, so the fallback strictly *adds*
correct bounding rather than trading it.

Ambiguous client ids yield no fallback pid: inventing an owner key is the
one direction that can reduce taint, so a coin toss is the wrong guess.

The client index is threaded through a new OwnerCtx rather than a sixth
positional Option<u32>, and evaluate() builds one and shares it, so the
components and the key index cannot disagree about who is bounded.

Round 10, R10-3. 6 new rows; 3 mutations verified — removing the
fallback, dropping the pulse-pid exception (11 rows die), and resolving
an ambiguous client id instead of dropping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:57:47 -04:00
molluskandClaude Opus 5 bf5f2508b8 host/taint: honour the ownership carriers on producers only
Neither ownership carrier is a security boundary — both are strings any
unprivileged process can put on its own node — so an unrestricted taint
root is a denial of the whole feature. An unlinked Stream/Input/Audio
named `peerspeak_owned_rogue` is a tainted *reader* (receivers includes
nodes by role, no link required) and an unbounded one, so
propagate_unresolved_owner fails every candidate on the machine closed.

Measured before this change: BASELINE eligible=1 excluded=[] became
WITH IMPOSTOR eligible=0 excluded=[firefox -> unresolved-owner].

Restricting the root to Stream/Output/Audio costs nothing real —
peerspeak only ever tags playback streams — and the AEC's virtual
sink/source is untouched, since it roots on module id, not on this tag.

A tag that is ignored is not silent: misplaced_ownership_tags feeds a
new `ignored_ownership_tags` audit field (omitted when empty), because
the fix *removes* an exclusion, and the two causes of a dropped tag —
a peerspeak tagging bug, or an impersonation attempt — both want seeing.

Codex phase-1 review F2, reproduced live. Round 10, R10-1.
5 new rows, mutation-verified: dropping the role restriction kills both
engine rows, and stubbing the diagnostic kills the third.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:46:40 -04:00
molluskandClaude Opus 5 45ca5057f8 host/taint: refuse an ambiguous contract fixture instead of resolving it
Codex phase-1 review, finding 3 (P2), concrete half. This side searched
a list and took the first match for a key; peerspeak's side collected
into a map and took the last. A byte-identical fixture containing a
duplicated key would therefore leave both suites green while the two
repos had selected *different* contracts — the precise drift the shared
file exists to prevent.

Both sides now assert the key is not already defined. Verified by
appending a duplicate `prop_value` to both fixtures: both suites fail.

The rest of finding 3 — one CI gate that feeds peerspeak's real
tag_child output through this repo's actual adapter and classifier,
rather than two per-repo literal tests — is a larger piece of work and
is not attempted here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:25:22 -04:00
molluskandClaude Opus 5 8b41f64e12 host/observer: tag the live prop-recovery row with the real wire value
Verification-round follow-up to 1b01847. The phase-3r live row proves
carrier 1 survives the bind, which is the property F1 destroyed — but
it tagged its fixture sink with `peerspeak.owned=true`, not the `1`
the contract pins and peerspeak actually emits. It would have passed
even if the real literal did not.

Adds PEERSPEAK_OWNED_VALUE so the fixture can name the producer's
value, and asserts it against the shared contract file alongside the
other two literals. The sink's name still deliberately avoids the
`peerspeak_owned_` prefix, so carrier 2 cannot stand in for carrier 1
in that row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:06:06 -04:00
molluskandClaude Opus 5 1b01847c66 host/taint: match peerspeak's second ownership carrier
The consumer half of phase 1 (plan §5.1, impl plan §3). The engine's
tag root becomes a union: `peerspeak.owned` truthy OR `node.name`
starting with `peerspeak_owned_`. Round 8 added the second carrier
because a node property is invisible to the registry `global` event
and recoverable only by binding the node — which is exactly how the
phase-5 gate failed — while `node.name` is announced directly.

The union lives in `local_root_reason`, not in the adapter. Folding
both into the one `peerspeak_owned` bool at the observation boundary
would make each carrier untestable alone, which is the phase-3r
lesson: a gate asserting a value two sources can satisfy gates
neither. The existing `peerspeak_tagged_nodes_…` fixture now carries
both carriers, so it would keep passing if either were deleted; two
new tests pin them individually, and a third pins that the prefix
matches only at the start of a name.

Both literals are now named constants — they are a cross-repo wire
contract with peerspeak, not local naming — and asserted against
tests/fixtures/ownership-tag-contract.txt, committed byte-identical
in both repos. That test also runs the fixture's own worked example
name through the engine, so the shared file cannot document a value
this side does not actually exclude.

Five mutations verified: drop either carrier, loosen `starts_with` to
`contains`, or rename either constant, and exactly the intended test
fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:19:39 -04:00
mollusk 0af0124e17 Merge round 9: uncertainty is not history
Found by the phase-5 audit minutes after phase 3r landed — a permanent sticky
taint on a hardware sink, from one link seen during enumeration. Sticky state
is now built from an evidence-only pass; decisions still fail closed.
2026-07-25 18:49:06 -04:00
mollusk e34289fdb5 Merge phase 3r: the registry global is an index, properties come from a bind
v3.5 §6.7, the fix for the phase-5 gate failure (F1/F2). Pure core + adapter,
each half reviewed by the other author; four-part exit gate passed, including
row 1 live on this host, plus an added live gate for the Device-side path.
2026-07-25 18:49:06 -04:00
mollusk 471b8221ff host/observer: address the Codex phase-3r review (2 fixes, both verified)
Codex's adversarial review of the pure core found no *certain* P1. Two
findings taken, both mutation-verified (the fix reverted, the intended test
dies, nothing else moves):

**F2, certain, P2 — `device_props` tested the wrong kind of ambiguity.** It
required exactly one live *Device* on the claimed id rather than exactly one
live *global*. With `[Device, Port]` on one id — a missed removal, the same
precondition as every other recycled-id hazard — it kept answering from the
older Device, so a node claiming that id held a stale `session_device = true`.
That flag strips the node's owner keys and its fail-closed backstop, so a
forwarder wearing it can put its output leg back on the eligible side. Now:
one slot total, and it must be the Device.

**F3, worth checking, P3 — `device.api` was corroborating by presence.**
`device.api=v4l2` under `factory.name=api.alsa.pcm.sink` satisfied the
positive classifier. No truthful configuration produces that pair, which is
the argument for reading it as an observation gone wrong rather than as
corroboration. The API must now equal the one the factory allowlist is
written for, an empty value is not a value, and the two sides disagreeing
fails closed. Tied to the allowlist being ALSA-only via a named constant.

Two findings NOT fixed here, both pre-existing and neither introduced by
round 8 — raised to the design doc instead:

- **P1, worth checking: hardware playback-to-capture paths** (Stereo Mix,
  Digital Loopback) on a card whose driver is an ordinary `snd_hda_intel`.
  Both its sink and source classify `session_device`, taint cannot cross the
  hardware hop, and a capture app reading that source can re-emit the call.
  This is `snd_aloop` again in a form the driver name cannot detect;
  distinguishing it needs ALSA control inspection, which is a design change
  and a new I/O surface, not a local fix.
- **P3: the 2 s readiness budget** can in principle never see an
  obligation-free instant under sustained startup churn, and `TimedOut` is
  sticky by design, so the process would be silent for its lifetime.
  Measured here: readiness at ~3 ms with 19 binds, so the margin is three
  orders of magnitude — but it wants a calibration argument, not a guess.

197 unit + 3 live green, clippy -D warnings and fmt clean.
2026-07-25 18:48:53 -04:00
mollusk 64f98990c8 host/taint: uncertainty is not history — it never enters sticky state
Found by the phase-5 audit on the live graph, immediately after phase 3r
landed: a hardware sink carried a permanent `unresolved-ancestry` taint. The
cause was one link observed while its output node was still unbound — a
correct fail-closed answer — which was then written into sticky state, where
retirement requires every member object to be absent. A live sound card never
is, so the mark survived readiness, 21 recomputes and deliberate churn.

Phase 3r makes this systematic rather than rare: every node is now withheld
until its bind resolves, so any link seen across that gap raises
`UnresolvedAncestry` on its input side. It fires at startup, every startup.

User decision (2026-07-25): uncertainty-based taint retires once the
uncertainty is gone; evidence-based taint keeps the absence rule.

Retiring by reason *code* would not be enough, because uncertainty launders
itself — an unresolved node propagates `TaintedUpstream`, which is
indistinguishable from real contamination once recorded. So the split is by
**provenance**: `evaluate` runs the fixpoint twice. Pass 1 fails closed
exactly as before and is what every decision is made from; pass 2 raises no
uncertainty root at all, and is the only thing sticky state is built from.
Nothing derived from an uncertainty can reach the sticky path.

Decisions are unchanged by construction — all 57 existing taint tests pass
untouched, including the fail-closed and sticky-survival rows.

3 new tests, mutation-verified (pointing `build_sticky` back at the
fail-closed taint kills exactly the two new uncertainty tests and nothing
else): unresolved ancestry clears once resolved; taint laundered downstream
of an uncertainty clears with it; real taint still survives its topology
disappearing.

Live: the audit's post-readiness records now report taint 0 where they
reported a permanent sticky entry before. Recompute cost roughly doubles as
expected (two fixpoints) — 80 µs worst case observed, against a 47 Hz event
rate.

`taint/tests.rs` keeps its one pre-existing hand-formatted line; everything
else in both files is rustfmt-clean.
2026-07-25 18:46:56 -04:00
mollusk 306b601490 host/observer: phase 3r adapter — bind every Node and Device
The I/O half of round 8 (Codex, gpt-5.6-sol xhigh; reviewed, formatted and
extended here). The adapter now reads `object.serial` and nothing else off a
Node or Device global, binds the object, and takes every property the engine
reasons about from its `info` props.

- `BoundProxy` generalises `BoundLink` to Node/Device/Link, each holding its
  listener *before* its proxy so the listener is dropped first — the original
  Link variant had that order inverted.
- Bind attachment now finds its slot by never-recycled serial rather than
  taking the queue's back, so nested callback activity during a bind cannot
  attach one generation's proxy to another's slot on a recycled id. A proxy
  that finds no slot is returned to the caller and dropped after the borrow
  ends. Removal still pops oldest-first, matching the model's `live_ids`.
- An `info` is parsed and emitted on the first callback carrying props and
  thereafter only when `change_mask` contains PROPS. I considered emitting
  unconditionally and leaning on the model's suppression rule, and rejected
  it: if a state-only `info` ever delivered a partial props dict, that would
  overwrite a complete observation with an incomplete one — a worse failure
  than the one it guards against, and the same class as F1.
- Ports stay unbound (v3.5 §6.7 / impl plan §4 item 6).

Gates: exit-gate row 1 (live prop recovery) passes on this host — the tagged
null sink projects `peerspeak.owned`, `pulse.module.id`, `node.passthrough`,
the loopback legs share a `node.link-group`, and a real ALSA node classifies
`session_device`.

Added a second live test for the Device half. Row 1's `session_device`
assertion is satisfied by a *union*: WirePlumber 0.5.15 copies `device.api`
and `alsa.driver_name` onto ALSA nodes here, so it passes through the node
fallback and would keep passing if the Device bind delivered nothing —
leaving §6.7 decision 4 ungated on the development machine. The new test
binds every Device and requires an ALSA card to announce both keys.
Mutation-verified: breaking the Device-side driver read fails the new test
while row 1 still passes, which is the gap as claimed.

195 unit + 3 live green, clippy -D warnings and fmt clean.
2026-07-25 18:32:27 -04:00
mollusk b3d71724ae host/observer: phase 3r pure core — node/device props come from a bind
v3.5 §6.7. The registry `global` event announces only a fixed 13-key subset
of a Node's properties, and eight the engine depends on are never among them
(phase-5 gate failure F1/F2). The core now treats the global as an index and
takes every property from the object's bound `info`.

- `RegEvent::NodeAdded { serial, id }` is identity only; `RegEvent::NodeInfo`
  carries the properties and is both the first resolution and every later
  PROPS change for the node's lifetime (decision 2). Same split for Device
  (`DeviceAdded` / `DeviceInfo`).
- A node with no `info` is withheld from the snapshot and is a readiness
  obligation; an unresolvable bind ends in sticky `TimedOut`, fail closed
  (decision 3). Devices are keyed by serial too, so a recycled device id with
  two live claimants is ambiguous ⇒ withheld rather than guessed.
- One live-node map replaces the admitted/withheld pair; classification is
  recomputed at projection time from current inputs, since both sides of it
  now change over an object's lifetime.
- `classify` takes the bound Device's props: presence is a union with the
  Device winning (this recovers a real card whose node was never given
  `alsa.driver_name` — the phase-3 review's owed fix), while the
  non-terminal-driver denylist is a union in the safe direction.
- `apply` returns `Outcome`, the only sound place to enforce the suppression
  rule: a property update is dropped only when model state provably did not
  change, i.e. the resulting projection is identical.

Adapter: stops reading properties off Node/Device globals and emits the new
index events. Binding every Node and Device — the I/O half — is the next
commit (Codex's), so until then every node is withheld and readiness times
out by design.

Tests: 55 observer (was 38) — the prop-update matrix, readiness with node
binds, and recycled-Node-id churn (phase 3r gate rows 2–4). 195 green,
clippy -D warnings and fmt clean.
2026-07-25 18:17:48 -04:00
molluskandClaude Opus 5 a1ac7ea8d5 Merge phase 5: dry-run audit mode (read-only)
The audit machinery is complete and verified live. The §5.1 gate itself
FAILED — see peerspeak docs/screenshare-audio-exclusion-phase5-results.md —
but both findings are defects in phase 3's observation boundary, not in this
code, and round 8 needs the audit tool on main to re-run the matrix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:43:39 -04:00
molluskandClaude Opus 5 bbf6744444 host/audit: phase 5 — dry-run audit mode (read-only)
Runs phases 2-4 against the live PipeWire graph on every registry event and
reports the complete eligible/excluded candidate partition with stable reason
codes. Creates no links, loads no modules, changes no routing.

Impl plan §5. Two entry points behind the hidden PIXELPASS_AUDIO_AUDIT=1
trigger: inside a real `pixelpass host` run (the plan-literal reading, proves
the path phase 6 will mutate), and a hidden `--audit-audio` standalone mode
with no iroh endpoint or capture pipeline, which is what drives the §5.1
matrix.

The recompute runs inline on the observer thread via a new ProjectionSink
hook, once per applied event. Polling `latest()` was rejected: it coalesces,
and phase 4 detects a module unload by observing the empty gap before the next
module appears — with indices reused verbatim (v3.4 §5.2 correction 3), a
missed gap aliases a fresh module onto a dead identity. Running inline is what
makes phase 4's "one observe per graph event" contract true, and it puts the
cost where O5 can measure it.

Split as usual: the auditor and the metrics are pure and unit-tested; the
clock, the writer and the env parsing are the thin edge in `sink`/`run`.

- audit/mod.rs   Auditor: AEC validator + taint engine + record building.
                 The AEC gate and the engine's own reasons stay
                 distinguishable — a shut gate must not erase the reason codes
                 the §5.1 rows assert.
- audit/metrics.rs  O5: event rate, bucketed recompute distribution + exact
                 max, busy fraction, and a documented lower-bound queueing
                 proxy (libpipewire exposes no queue depth).
- audit/sink.rs  JSON Lines to stderr, or PIXELPASS_AUDIO_AUDIT_FILE. Never
                 stdout — peerspeak parses that stream.
- audit/run.rs   Env parsing; a malformed AEC value is fatal, matching phase
                 4's rule that it must not silently become "no AEC".

Observer gains `EventKind` (derived from RegEvent, so a consumer's view of
"was this a real graph change?" cannot disagree with the model's) and
`Projection::readiness`, which distinguishes the three ways graph_ready can be
false. taint::fixture is now pub(crate) so audit tests share one graph
vocabulary with the taint tests.

33 new tests, 178 green, clippy -D warnings and fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:43:10 -04:00
molluskandClaude Opus 4.8 e0fe47d5a9 Merge phase 4: AEC identity validation state machine (pure core)
Bounded read-only state machine (NotConfigured/Validating/Validated/
Failed/Revoked) that validates peerspeak's live echo-cancel module
identity and fills ExclusionCtx.aec_module_id — the last field the taint
engine needed. Design v3.4 §5.2/§5.3, impl plan §4.

Adversarial Codex review: no merge-blockers; five worth-checking items
addressed via documentation + closing two test holes (both fixes
mutation-verified). No core logic change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:07:10 -04:00
molluskandClaude Opus 4.8 4a844ac6fa host/aec: address Codex phase-4 review (no merge-blockers; docs + test holes)
Codex adversarial review found no merge-blocking defects. Five
worth-checking items, all triaged for reachability:

- F1 (spurious pre-ready revoke): unreachable — the AEC's four nodes are
  two Stream/* legs + a null-sink-like virtual sink/source, none claiming
  a device.id, so the phase-3 observer never withholds them; index_present
  goes false only on a genuine full unload. Documented why revoke is NOT
  gated on graph_ready, and why gating it would reopen the reused-index
  alias trap (F4) during a hot-reload-under-churn. Pinned with
  revokes_on_empty_even_while_not_ready (mutation-verified: `&& graph_ready`
  on the revoke guard dies here).
- F4 (test relies on observing the empty gap): documented the phase-5/6
  integration contract it rests on (one observe per graph event, no
  coalescing across a module lifetime boundary) and owed the robust fix
  (serial-continuity / observer-generation) to a later hardening round.
- F2 (late positive evidence beats the deadline): intentional and correct
  — a demonstrably-present identity is ground truth. Documented +
  late_positive_evidence_wins_over_expired_deadline (both arms: node-first
  validates, Tick-first fails closed and stays sticky).
- F3 (real P3 coverage hole): strengthened deadline_is_not_armed_until_
  graph_ready to prove the budget starts at first-ready, not construction
  (mutation-verified: a construction-relative deadline now dies).
- F5 (`+7` grammar mismatch): documented the producer contract — peerspeak
  emits bare decimal (pactl returns unsigned decimal), the narrow parser
  is deliberate. Unreachable on the measured stack.

No core logic change. 24 pure aec tests, cargo test --bins green (145 unit
+ 1 ignored live), clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:07:01 -04:00
molluskandClaude Opus 4.8 b74ed17823 host/aec: phase 4 — AEC identity validation state machine (pure core)
Bounded, read-only state machine that validates peerspeak's live
echo-cancel module identity before the taint engine trusts it, filling
the last ExclusionCtx field (aec_module_id). Design v3.4 §5.2/§5.3,
impl plan §4.

States (v3.4 §5.3 verbatim): NotConfigured / Validating / Validated /
Failed / Revoked. No fan-out while Validating; Failed and Revoked are
sticky terminals so a reused module index (indices ARE reused, §5.2
correction 3) cannot alias a Revoked epoch onto an unrelated reload.
Revocation is loss of the whole identity (every node bearing the index
gone), never one leg corking. The Failed deadline is armed only on the
first graph_ready, so a slow initial enumeration is "unknown" not
"absent" and never times out spuriously.

parse_aec_arg handles --aec=off|pulse-module:<idx> (D5): bare-u64
decimal accepted past u32::MAX, rejecting sign/whitespace/non-digit/
overflow/unknown-form.

22 pure tests (the exit-gate transition matrix), cargo test --bins
green (143 unit + 1 ignored live), clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:51:49 -04:00
8973e5dc19 Merge phase 3: registry observer (pure core + I/O adapter)
Split-seam mutual-review build: pure reducer/classifiers (Claude) + libpipewire
adapter (Codex), each reviewed by the other. Two review rounds closed 3 P1s
(dynamic graph_ready over invisible edges; snd_aloop absent-driver fail-closed;
FIFO lockstep). Exit gate incl. live topology-diff row passes on the host.

Additive/read-only — does not yet replace the audio.rs router (integration
phase). DAG: 0a -> 2 -> 3 done; next is Phase 4 (AEC validation state machine).

Co-Authored-By: Codex (gpt-5.6-sol) <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:01:32 -04:00
molluskandClaude Opus 4.8 f90bee63f7 host/observer: close the snd_aloop absent-driver leak (Codex re-review)
Codex's re-review of the phase-3 fixes confirmed finding 1/5/6 closed but
found the finding-2 fix incomplete: the denylist only rejected a *present*
snd_aloop driver, so an snd_aloop node whose alsa.driver_name was not copied
onto the node still classified session_device=true — the original leak. The
absence is reachable: PipeWire >=1.2.6 stopped overwriting node props with
card props, and WirePlumber only began copying alsa.* onto nodes in 0.5.13.

Fix: session_device now requires a PRESENT, non-denied ALSA driver; a missing
alsa.driver_name fails closed to NotSessionDevice (a real card without the
prop is over-excluded — safe; recovering it needs reading the driver from the
backing Device global, owed to a later round). Mutation-verified: reverting to
fail-open on absence is killed by classify_alsa_without_driver_name_fails_closed.

Also: corrected the finding-3 limitation doc to cite PipeWire's object.serial
identity contract rather than overclaiming the live gate proves it (Codex P3,
non-blocking).

121 unit + live gate row 6 green, clippy clean, observer files fmt-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:40:47 -04:00
molluskandClaude Opus 4.8 557c1030a7 host/observer: address Codex phase-3 review (2 P1 + P3s)
Cross-review round: Codex adversarially reviewed my pure core, found two
merge-blocking P1s and several P3s. Triaged each for reachability; fixes below,
each mutation-verified (revert killed by its intended test).

P1 finding 1 — graph_ready was sticky-once-Complete, so a Link added
post-enumeration whose endpoints are still binding (an INVISIBLE edge, absent
from the snapshot) left graph_ready=true and a candidate could be reported
eligible over unseen tainted ancestry. graph_ready is now dynamic:
Complete AND no outstanding obligations. Readiness::Complete stays sticky as
the epoch marker. New regression test + flipped the old sticky-churn test.

P1 finding 2 — snd_aloop presents with an allowlisted ALSA factory and
device.api=alsa exactly like a real card but forwards audio through a kernel
hop the Link graph cannot see; it was classified session_device=true, dropping
its owner keys + backstop (leak). Added alsa.driver_name to DeviceClaim and a
NON_TERMINAL_ALSA_DRIVERS denylist under the factory allowlist; adapter now
populates it. Negative fixture added.

P3 finding 5 — the BlueZ allowlist entries (api.bluez5.pcm.*) were invented;
removed them (real names are api.bluez5.media.*). A BT sink now over-excludes
(safe) pending a measured fixture. P3 finding 6 — strengthened the timeout
test to assert TimedOut stays sticky through later DeviceAdded/sync/tick.

Findings 3 (dropped-link unrepresented) and 4 (missed-removal generation
ambiguity) documented as accepted low-reachability limitations (links carry
object.serial — confirmed by the live gate; registry does not drop removals).

Codex confirmed the pulse-PID matrix fails safe and the adapter add() FIFO is
lockstep. 120 unit + live gate row 6 green, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:28:31 -04:00
66a0dd54df host/observer: phase 3 I/O adapter (read-only registry observer)
Codex authored the adapter half of the phase-3 split; I reviewed it and
applied one robustness fix (below). Additive/read-only — does not yet replace
the existing audio.rs router (that migration is a later integration phase).

adapter.rs: a dedicated libpipewire main-loop thread translating registry
globals into RegEvents and publishing the latest Projection via
RegistryObserverHandle::latest(). core.sync(0)/done is matched one-shot →
ServerSynced; a 250 ms loop timer emits Tick for the fail-closed readiness
timeout; pulse-PID candidates are probed from /proc/<pid>/comm only when the
candidate changes; Links missing endpoint props are bound (LinkInfoRef, weak
back-ref to avoid the listener cycle) and resolved via LinkEndpointsResolved.
mod.rs: `pub mod adapter;`. audio.rs: parse_object_serial → pub(crate) so the
adapter reuses the strict 64-bit parser.

Review fix: record_global was called unconditionally per global (including
unknown object types and dropped globals), which could desync the bound-link
FIFO from the model's live_ids and leak a Link proxy on a recycled id. Now
folded into `add()` so a slot is recorded only when an Added event is applied
— the two id queues are provably lockstep.

Exit gate complete: 5 pure rows + the live topology-diff row (row 6) — the
#[ignore] adapter test PASSES on this host against the live daemon
(module-null-sink + module-loopback observed appearing and disappearing).
cargo test --bins 117 + 1 live green, clippy clean, no fmt sweep.

Co-Authored-By: Codex (gpt-5.6-sol) <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:06:36 -04:00
molluskandClaude Opus 4.8 8206864a43 host/observer: phase 3 pure core — RegistryModel reducer + classifiers
My half of the phase-3 split (impl plan §4). Pure, no PipeWire: the adapter
(Codex's half) translates live registry callbacks / binds / /proc reads /
core.sync into RegEvents and feeds this reducer.

- RegistryModel::apply folds RegEvents into serial-keyed maps with an
  insertion-ordered id index so global_remove accounts for the oldest
  generation first; recycled ids stay Ambiguous until accounted (v3.4 §6.1.3).
- Readiness epoch: graph_ready false until ServerSynced + no outstanding
  obligations (withheld nodes, pending link binds); bounded timeout fails
  closed. Gates sticky retirement only; sticky once terminal.
- session_device classifier: hardware-PCM factory allowlist, exact match,
  fail closed to false; a node on an unresolved Device is withheld, never
  admitted provisional.
- pulse-PID derivation split into pure candidate (repeated sec_pid) + validate
  (/proc comm), so the 6-case failure matrix is unit-testable; any failure =>
  None (key 4 unusable).

34 tests cover 5 of 6 exit-gate rows (the live topology-diff row is the
adapter's). cargo test --bins 117 green, fmt + clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:43:59 -04:00
molluskandClaude Opus 4.8 ab597c332d Merge phase 2: pure graph model + taint engine
Impl-plan §4/Phase 2, design v3.4 §6.1-§6.1.3. Pure engine
(evaluate(&GraphSnapshot,&ExclusionCtx,&StickyState)->(Decisions,StickyState)),
never linked against libpipewire; fed by the phase-3 observer to come.

Six adversarial review rounds with Codex (gpt-5.6-sol xhigh). Real echo
leaks found and closed in rounds 1-3 (owner-bridge, sticky-client
contamination, asymmetric forwarders, device mis-classification); my F1
narrowing refuted and conceded in round 4; contract strengthenings in 5-6.
Every fix mutation-verified (reverting it is killed by its intended test).
57 tests, each asserting an exact eligible/excluded partition.

KNOWN v1 LIMITATION (user-accepted 2026-07-22, owed to design doc round 8):
an app that buffers the call, fully tears down its PipeWire objects, and
replays after reconnecting can leak. In-threat-model but contrived; the
fix (process-generation sticky lifetime, revising v3.4 §6.1.3) is deferred
to phase 3's process-liveness work. Documented in src/host/taint/mod.rs.

Phase-3 obligations recorded in the taint module docs and Codex's round-6
report. Unblocks phase 3 (registry observer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 04:42:52 -04:00
29 changed files with 10719 additions and 336 deletions
Generated
+39
View File
@@ -2958,6 +2958,33 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libpulse-binding"
version = "2.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397"
dependencies = [
"bitflags 2.13.0",
"libc",
"libpulse-sys",
"num-derive",
"num-traits",
"winapi",
]
[[package]]
name = "libpulse-sys"
version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61"
dependencies = [
"libc",
"num-derive",
"num-traits",
"pkg-config",
"winapi",
]
[[package]]
name = "libredox"
version = "0.1.18"
@@ -3539,6 +3566,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -4163,6 +4201,7 @@ dependencies = [
"iroh",
"iroh-tickets",
"ksni",
"libpulse-binding",
"nix 0.30.1",
"notify-rust",
"pipewire",
+10
View File
@@ -46,6 +46,16 @@ serde_json = "1"
directories = "5"
ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
pipewire = "0.9"
# `--repair` reads and unloads Pulse modules through libpulse introspection rather
# than by parsing `pactl` output. `pa_module_info` carries index, name and the exact
# argument in one record, and `pa_context_is_local()` answers whether the server we
# actually reached is local — neither of which the text listings can do (an argument
# may contain tabs and newlines that the short format cannot escape, the JSON
# listing carries no module index at all, and `PULSE_SERVER` is a fallback list, so
# it never proved locality). Vetted at 2.30.1: MIT/Apache-2.0, no build script
# beyond a pkg-config probe, no network or subprocess use, and all three historical
# RustSec advisories (2018-0020, 2018-0021, 2019-0038) fixed by 2.6.0.
libpulse-binding = "2.30"
x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] }
uuid = { version = "1", features = ["v4"] }
iroh-tickets = "1.0.0"
+24
View File
@@ -105,6 +105,18 @@ pub struct Cli {
#[arg(long)]
pub repair: bool,
/// With `--repair`: also clean up modules that carry no ownership token,
/// judging them by process id alone.
///
/// Modules loaded by pixelpass versions before ownership tokens existed cannot
/// be attributed to a machine, boot or pid namespace, so `--repair` refuses them
/// by default: a process id means different processes in different namespaces,
/// and acting on the wrong one unloads a *running* host's audio. Use this only
/// on the machine that ran the crashed host, and only when the reported
/// candidates look right.
#[arg(long, requires = "repair")]
pub repair_legacy_untagged: bool,
/// Print an environment diagnostic report (display server, capture/encode
/// dependencies, VA-API H.264 support, viewer player, relay reachability),
/// then exit. Use this to check a machine can host or view before a real
@@ -118,6 +130,18 @@ pub struct Cli {
/// or if the previously saved test result is stale.
#[arg(long)]
pub reconfigure: bool,
/// Run the read-only audio-exclusion dry-run audit against the live
/// PipeWire graph, then exit on ctrl-c. Emits one JSON object per line to
/// stderr (or to `PIXELPASS_AUDIO_AUDIT_FILE`) describing which audio
/// streams would be eligible for a screen share and why the rest would not.
/// Creates no links and changes no routing.
///
/// Hidden: this is development instrumentation for the screen-share audio
/// exclusion work (impl plan phase 5), not a user-facing feature, and the
/// record schema is free to change until phase 6 fixes it.
#[arg(long, hide = true)]
pub audit_audio: bool,
}
#[derive(ValueEnum, Clone, Copy, Debug)]
+10
View File
@@ -1,5 +1,15 @@
use anyhow::{Context, Result};
use tokio::signal::unix::{Signal, SignalKind};
use tokio_util::sync::CancellationToken;
/// A stream of SIGTERMs, for the callers that need to shut down cleanly when
/// something other than a human at a terminal asks them to (`timeout`, a test
/// harness, a service manager). Ctrl-c alone covers only the interactive case.
pub fn terminate_stream() -> Result<Signal> {
tokio::signal::unix::signal(SignalKind::terminate())
.context("could not install a SIGTERM handler")
}
/// Install a ctrl-c handler that triggers the returned token.
///
/// The first ctrl-c cancels gracefully; a second ctrl-c terminates the process.
+311
View File
@@ -0,0 +1,311 @@
//! Phase 4 — the AEC identity validation state machine (impl plan §4, design
//! v3.4 §5.2/§5.3).
//!
//! peerspeak's echo canceller (`module-echo-cancel`) creates four graph nodes
//! that all carry `pulse.module.id == <the index pactl returned>`, and the
//! playback leg among them is a `Stream/Output/Audio` node wired straight to
//! the speakers — a fan-out candidate that would copy the whole remote call
//! into the share unless it is excluded (v3.4 §5.2, measured ≈desktop level).
//! The taint engine (phase 2) already excludes it *given* the module index in
//! [`ExclusionCtx::aec_module_id`](crate::host::taint::ExclusionCtx); this
//! module is what decides, at runtime and fail-closed, whether that index may
//! be trusted and handed over.
//!
//! **Why a state machine and not a one-shot check (v3.4 §5.3).** The identity
//! is an *observed correlation on PipeWire 1.6.8*, not a documented contract,
//! and a start-time enumeration races in both directions: peerspeak's
//! `enable()` returns before the playback hazard leg is even in the graph, and
//! pixelpass's capture spawns lazily on the first viewer, at a moment peerspeak
//! does not control. So validation is a bounded epoch, and the identity can be
//! *lost* mid-share (the module unloads) as well as *gained*.
//!
//! **The two traps this is shaped around:**
//!
//! - **Revocation is loss of the whole module identity, not one leg corking**
//! (v3.4 §5.3). Each [`AecValidator::observe`] rescans the snapshot for *any*
//! node bearing the index; [`AecState::Validated`] drops to
//! [`AecState::Revoked`] only when that set becomes **empty**. A single leg
//! corking or relinking (still ≥1 present) stays `Validated` — getting this
//! wrong turns a normal cork into a spurious share-wide audio stop.
//! - **Module indices are reused verbatim across unload/reload** (v3.4 §5.2
//! correction 3 — both a reload's module index *and* its `node.link-group`
//! came back byte-identical, and node ids were recycled *and reassigned
//! across legs*). So [`AecState::Failed`] and [`AecState::Revoked`] are
//! **sticky terminal**: a later node reappearing with the same index does
//! **not** un-revoke and alias onto the new module. A genuine reload gets a
//! *fresh* [`AecValidator`] (peerspeak re-tells pixelpass the index on every
//! load), never a resurrected one.
//!
//! **Scope.** This is the validation state machine + `--aec` parsing only.
//! Foreign / second-AEC detection (a non-owned `echo-cancel-*` group, v3.4
//! §5.4 / D3) and the `foreign_aec_warning`/`aec_failed`/`aec_revoked` status
//! *events* are phase 6's, which reads this machine's [`AecState`]. Wiring the
//! parsed [`AecConfig`] out of the CLI and calling [`AecValidator::observe`]
//! in the recompute loop is integration (phases 5/8). The node-side
//! `pulse.module.id` parse (JSON-number-vs-string, u64-not-u32) is phase 3's
//! adapter; this module consumes the already-parsed
//! [`NodeProps::pulse_module_id`](crate::host::taint::snapshot::NodeProps).
#![allow(dead_code)] // Wired into `--aec` parsing + the recompute loop by later phases.
#[cfg(test)]
mod tests;
use crate::host::observer::Millis;
use crate::host::taint::snapshot::GraphSnapshot;
/// The parsed `--aec=off|pulse-module:<idx>` argument (decision D5).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecConfig {
/// `--aec=off` — peerspeak's AEC is not in play, so there is nothing to
/// exclude and fan-out proceeds with no AEC identity. Not the same as an
/// *absent* argument (that default is the caller's; see [`parse_aec_arg`]).
Off,
/// `--aec=pulse-module:<idx>` — validate this live module index before
/// trusting it. The index is compared as `u64`, never `u32` (v3.4 §5.2).
PulseModule(u64),
}
/// Why an `--aec` argument was rejected. Rejection is fatal at the CLI edge —
/// there is no fail-closed *default* index, because a wrong index would exclude
/// the wrong node (or nothing), so a malformed value must not silently become
/// "no AEC".
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecParseError {
/// The value was empty.
Empty,
/// Not `off` and not `pulse-module:...`.
UnknownForm,
/// `pulse-module:` with nothing after the colon.
MissingIndex,
/// The index was not a bare `u64` decimal (sign, whitespace, non-digit, or
/// `> u64::MAX`).
InvalidIndex,
}
/// Parse one `--aec` value. `off` and `pulse-module:<idx>` are the only forms.
///
/// The index accepts values `> u32::MAX` (v3.4 §5.2: `pulse.module.id` sits
/// next to the `object.serial` u32-truncation bug, so it is only ever compared
/// as `u64`) and requires a **bare decimal** — stricter than Rust's [`u64`]
/// parser, which also accepts a leading `+`. Rejected: any sign, surrounding or
/// interior whitespace, non-decimal digits, and overflow. Matching is exact and
/// case-sensitive: the argument is machine-generated by peerspeak from
/// `EchoCancelGuard::module_index`, not typed by a user.
///
/// ⚠️ **Producer contract** (Codex phase-4 review, finding 5): because the
/// grammar is narrower than Rust's parser, peerspeak must emit a bare decimal.
/// `pactl load-module` returns an unsigned decimal, so the stored index is
/// already canonical and no reachable value is rejected; if peerspeak ever
/// changes how it formats the index it must canonicalize (`value.to_string()`),
/// not widen this parser — the narrow grammar is the point.
pub fn parse_aec_arg(value: &str) -> Result<AecConfig, AecParseError> {
if value.is_empty() {
return Err(AecParseError::Empty);
}
if value == "off" {
return Ok(AecConfig::Off);
}
if let Some(index) = value.strip_prefix("pulse-module:") {
if index.is_empty() {
return Err(AecParseError::MissingIndex);
}
// A bare decimal only: reject a leading sign (Rust's `u64` parser
// accepts `+7`), interior/surrounding whitespace, and any non-digit,
// before letting the parser catch overflow. Leading zeros are harmless.
if !index.bytes().all(|b| b.is_ascii_digit()) {
return Err(AecParseError::InvalidIndex);
}
return index
.parse::<u64>()
.map(AecConfig::PulseModule)
.map_err(|_| AecParseError::InvalidIndex);
}
Err(AecParseError::UnknownForm)
}
/// The validation epoch (v3.4 §5.3, verbatim).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecState {
/// `--aec=off` — no AEC identity, fan-out proceeds with no exclusion.
/// Terminal.
NotConfigured,
/// Waiting for the first node bearing the index. **No fan-out occurs here**
/// — silence is the safe direction. Ends at `Validated` on first sight, or
/// `Failed` once the graph is fully enumerated and the bounded deadline
/// passes with the index never seen.
Validating,
/// The index was observed live. Fan-out is permitted, excluding that
/// identity transitively (phase 2 / v3.4 §6.1).
Validated,
/// The deadline expired with the index never observed. **Fail closed** — no
/// fan-out; the caller reports a capability failure rather than sharing.
/// Sticky terminal.
Failed,
/// The whole module identity disappeared mid-share (every node bearing the
/// index gone). **Stop fan-out now** and drop the owned link proxies; do
/// not keep the numeric index and hope, because it is reused. Sticky
/// terminal — see the module header's second trap.
Revoked,
}
/// The bounded, read-only AEC identity validator. Fold the live graph in with
/// [`AecValidator::observe`] once per recompute; read the result with
/// [`AecValidator::state`], [`AecValidator::fan_out_permitted`], and
/// [`AecValidator::validated_module_id`].
#[derive(Clone, Debug)]
pub struct AecValidator {
/// The index to validate. `None` iff [`AecConfig::Off`] (state stays
/// [`AecState::NotConfigured`] forever).
target: Option<u64>,
state: AecState,
/// The `Validating → Failed` budget, applied *after* the deadline is armed.
timeout: Millis,
/// The absolute `Failed` deadline, armed the first time the graph reports
/// ready (the "registry sync barrier" of v3.4 §5.3) and never re-armed —
/// `graph_ready` is dynamic and can flap, but the epoch budget must not
/// restart. `None` until then: while the initial enumeration is still in
/// flight, a not-yet-seen index is *unknown*, not *absent*, so it must not
/// time out to `Failed`.
deadline: Option<Millis>,
}
impl AecValidator {
/// `timeout` is the `Validating → Failed` budget, counted from the moment
/// the graph first becomes ready (not from construction). An `Off` config
/// starts (and stays) [`AecState::NotConfigured`].
pub fn new(config: AecConfig, timeout: Millis) -> Self {
match config {
AecConfig::Off => Self {
target: None,
state: AecState::NotConfigured,
timeout,
deadline: None,
},
AecConfig::PulseModule(index) => Self {
target: Some(index),
state: AecState::Validating,
timeout,
deadline: None,
},
}
}
pub fn state(&self) -> AecState {
self.state
}
/// The validated index to place in
/// [`ExclusionCtx::aec_module_id`](crate::host::taint::ExclusionCtx) —
/// `Some` **only** in [`AecState::Validated`]. `None` everywhere else,
/// including `NotConfigured` (no AEC ⇒ nothing to exclude) and the
/// fail-closed states (whose `None` must be paired with
/// [`Self::fan_out_permitted`] `== false`, i.e. no fan-out at all — *not*
/// a fan-out that merely skips AEC exclusion).
pub fn validated_module_id(&self) -> Option<u64> {
match self.state {
AecState::Validated => self.target,
_ => None,
}
}
/// Whether fan-out may proceed at all right now. True only in
/// [`AecState::NotConfigured`] (fan out, no exclusion) and
/// [`AecState::Validated`] (fan out, excluding the identity). `Validating`,
/// `Failed` and `Revoked` all forbid it — silence over echo.
pub fn fan_out_permitted(&self) -> bool {
matches!(self.state, AecState::NotConfigured | AecState::Validated)
}
/// Fold one recompute's view of the graph into the machine.
///
/// `graph_ready` is the observer's dynamic readiness
/// ([`Projection::graph_ready`](crate::host::observer::Projection)); `now`
/// is a monotonic millisecond clock. Positive evidence (a node bearing the
/// index) is authoritative and validates regardless of `graph_ready` —
/// seeing the node *is* seeing it — but the `Failed` deadline only begins
/// once `graph_ready` has first become true, so a slow initial enumeration
/// can never masquerade as a genuinely-absent module.
pub fn observe(&mut self, snapshot: &GraphSnapshot, graph_ready: bool, now: Millis) {
// `Off` (NotConfigured) and both sticky terminals are no-ops: there is
// nothing to look for, and a reappearing reused index must not revive a
// Failed/Revoked epoch (v3.4 §5.2 correction 3).
let Some(target) = self.target else {
return;
};
match self.state {
AecState::Validating => {
// Presence is checked *before* the deadline on purpose: a
// demonstrably-present identity validates regardless of the
// clock, even if the node is first seen just past the deadline
// (Codex phase-4 review, finding 2). The deadline only bounds
// the wait for an identity that is never seen — seeing it, late
// or not, is ground truth that the module exists, and excluding
// a real echo leg is always the safe answer. (A `Failed` can
// still pre-empt this when a `Tick`-only observation crosses the
// deadline first; that only makes the machine *more* fail-closed,
// never less.)
if self.index_present(snapshot, target) {
self.state = AecState::Validated;
return;
}
// Arm the deadline once, on the first ready graph.
if self.deadline.is_none() && graph_ready {
self.deadline = Some(now.saturating_add(self.timeout));
}
if self.deadline.is_some_and(|deadline| now >= deadline) {
self.state = AecState::Failed;
}
}
AecState::Validated => {
// Revocation is the whole identity gone (no node bears the
// index), not one leg corking — see the module header.
//
// ⚠️ **Deliberately NOT gated on `graph_ready`** (Codex
// phase-4 review, findings 1 + 4). Two forces pull opposite
// ways and this is the resolution:
//
// - Gating revoke on readiness would avoid a *spurious* revoke
// from a transient empty snapshot seen while the module is
// still live. But for the AEC that transient does not exist:
// its four nodes are two `Stream/*` legs plus a null-sink-like
// virtual sink/source, none of which claim a `device.id`, so
// the phase-3 observer never *withholds* them
// (`observer::classify` withholds only device-claiming nodes).
// `index_present` therefore goes false only on a genuine
// `global_remove` of every leg — a real unload — and a real
// unload *should* revoke.
// - Worse, gating on readiness would REOPEN the reused-index
// alias trap: if an unload+reload (indices recycle, §5.2
// correction 3) both complete inside one not-ready churn
// window, the ready snapshot would already show the *new*
// module's node and we would never observe the empty gap —
// silently aliasing onto an unrelated module. Revoking the
// instant the gap appears, ready or not, is what closes it.
//
// This correctness rests on the phase-5/6 integration contract:
// **one `observe` per graph event, no coalescing across a module
// lifetime boundary.** Under coalescing, the empty gap between an
// old unload and a reused-index reload can be skipped. The
// robust fix that would not depend on that contract is a
// serial-continuity / observer-generation signal (the AEC nodes'
// `object.serial`s are fresh across a reload even when the index
// is not) — owed to a later hardening round, not built here.
if !self.index_present(snapshot, target) {
self.state = AecState::Revoked;
}
}
AecState::NotConfigured | AecState::Failed | AecState::Revoked => {}
}
}
/// Whether any node in the snapshot bears the target module index. The same
/// exact-`u64`-equality predicate the taint engine roots on
/// (`taint/mod.rs`), kept here so "is the identity live?" has one
/// definition.
fn index_present(&self, snapshot: &GraphSnapshot, target: u64) -> bool {
snapshot
.nodes()
.any(|node| node.props.pulse_module_id == Some(target))
}
}
+374
View File
@@ -0,0 +1,374 @@
//! Phase 4 exit gate (impl plan §4): a fake-clock / event-sequence transition
//! matrix, because these are timing semantics a live poke cannot cover.
use super::*;
use crate::host::taint::snapshot::{
GlobalId, GraphSnapshot, MediaRole, NodeProps, NodeSnapshot, Serial,
};
/// A `Stream/Output/Audio` node carrying `pulse.module.id == module` (or none).
/// Only the fields the validator reads matter; the rest take their defaults.
fn node(serial: u64, module: Option<u64>) -> NodeSnapshot {
NodeSnapshot {
serial: Serial(serial),
id: GlobalId(serial as u32),
name: None,
role: MediaRole::StreamOutput,
props: NodeProps {
pulse_module_id: module,
..NodeProps::default()
},
}
}
/// A snapshot holding exactly the given nodes (no ports/links/clients — the
/// validator reads only nodes).
fn snapshot(nodes: Vec<NodeSnapshot>) -> GraphSnapshot {
GraphSnapshot::new(nodes, vec![], vec![], vec![])
}
fn empty() -> GraphSnapshot {
snapshot(vec![])
}
const IDX: u64 = 536_870_919; // 0x20000007 — a real pipewire-pulse module index.
const TIMEOUT: Millis = 2_000;
// ---------------------------------------------------------------------------
// Parsing (D5): off / pulse-module:<idx> / > u32::MAX / absent / malformed.
// ---------------------------------------------------------------------------
#[test]
fn parses_off() {
assert_eq!(parse_aec_arg("off"), Ok(AecConfig::Off));
}
#[test]
fn parses_pulse_module_index() {
assert_eq!(
parse_aec_arg("pulse-module:536870919"),
Ok(AecConfig::PulseModule(536_870_919)),
);
}
#[test]
fn parses_index_beyond_u32() {
// v3.4 §5.2: compare as u64, never u32. A value one past u32::MAX must
// round-trip, not truncate or reject.
let big = u64::from(u32::MAX) + 1;
assert_eq!(
parse_aec_arg(&format!("pulse-module:{big}")),
Ok(AecConfig::PulseModule(big)),
);
assert_eq!(
parse_aec_arg(&format!("pulse-module:{}", u64::MAX)),
Ok(AecConfig::PulseModule(u64::MAX)),
);
}
#[test]
fn rejects_empty() {
assert_eq!(parse_aec_arg(""), Err(AecParseError::Empty));
}
#[test]
fn rejects_unknown_form() {
assert_eq!(parse_aec_arg("on"), Err(AecParseError::UnknownForm));
assert_eq!(parse_aec_arg("module:5"), Err(AecParseError::UnknownForm));
assert_eq!(parse_aec_arg("536870919"), Err(AecParseError::UnknownForm));
}
#[test]
fn rejects_missing_index() {
assert_eq!(
parse_aec_arg("pulse-module:"),
Err(AecParseError::MissingIndex),
);
}
#[test]
fn rejects_malformed_index() {
for bad in [
"pulse-module:-1", // sign
"pulse-module:+7", // sign
"pulse-module: 7", // leading whitespace
"pulse-module:7 ", // trailing whitespace
"pulse-module:0x7", // hex
"pulse-module:7.0", // non-integer
"pulse-module:abc", // non-numeric
"pulse-module:18446744073709551616", // u64::MAX + 1 (overflow)
] {
assert_eq!(
parse_aec_arg(bad),
Err(AecParseError::InvalidIndex),
"{bad} should be InvalidIndex",
);
}
}
// ---------------------------------------------------------------------------
// NotConfigured (--aec=off): benign, terminal, fan-out with no exclusion.
// ---------------------------------------------------------------------------
#[test]
fn off_is_not_configured_and_permits_fan_out_with_no_identity() {
let mut v = AecValidator::new(AecConfig::Off, TIMEOUT);
assert_eq!(v.state(), AecState::NotConfigured);
assert!(v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
// Even a snapshot full of module nodes never moves it off NotConfigured.
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 10_000);
assert_eq!(v.state(), AecState::NotConfigured);
assert!(v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
}
// ---------------------------------------------------------------------------
// Row: Validating → Validated on first matching node; no fan-out before.
// ---------------------------------------------------------------------------
#[test]
fn validating_forbids_fan_out_and_exposes_no_identity() {
let v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
assert_eq!(v.state(), AecState::Validating);
assert!(!v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
}
#[test]
fn validating_to_validated_on_first_matching_node() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
// A node with a *different* index does not validate.
v.observe(&snapshot(vec![node(1, Some(IDX + 1))]), true, 0);
assert_eq!(v.state(), AecState::Validating);
v.observe(&snapshot(vec![node(2, Some(IDX))]), true, 100);
assert_eq!(v.state(), AecState::Validated);
assert!(v.fan_out_permitted());
assert_eq!(v.validated_module_id(), Some(IDX));
}
#[test]
fn positive_evidence_validates_even_before_graph_ready() {
// Seeing the node is authoritative; readiness only gates the Failed clock.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), false, 0);
assert_eq!(v.state(), AecState::Validated);
assert_eq!(v.validated_module_id(), Some(IDX));
}
#[test]
fn validated_index_is_compared_beyond_u32() {
let big = u64::from(u32::MAX) + 7;
let mut v = AecValidator::new(AecConfig::PulseModule(big), TIMEOUT);
// A node whose id equals `big` only in its low 32 bits must not match.
v.observe(
&snapshot(vec![node(1, Some(big & u64::from(u32::MAX)))]),
true,
0,
);
assert_eq!(v.state(), AecState::Validating);
v.observe(&snapshot(vec![node(2, Some(big))]), true, 1);
assert_eq!(v.state(), AecState::Validated);
assert_eq!(v.validated_module_id(), Some(big));
}
// ---------------------------------------------------------------------------
// Row: Validating → Failed on deadline expiry; and the deadline is armed only
// once the graph is ready (the registry sync barrier).
// ---------------------------------------------------------------------------
#[test]
fn validating_to_failed_on_deadline_expiry() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&empty(), true, 0); // arms deadline at 0 + 2000
assert_eq!(v.state(), AecState::Validating);
v.observe(&empty(), true, TIMEOUT - 1);
assert_eq!(v.state(), AecState::Validating);
v.observe(&empty(), true, TIMEOUT); // now >= deadline
assert_eq!(v.state(), AecState::Failed);
assert!(!v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
}
#[test]
fn deadline_is_not_armed_until_graph_ready() {
// The whole point of arming-on-ready: a slow initial enumeration is
// "unknown", not "absent", and must never time out to Failed.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
// Long past the would-be deadline, but the graph has never been ready.
v.observe(&empty(), false, 10 * TIMEOUT);
assert_eq!(v.state(), AecState::Validating);
// Still no Failed even much later, as long as ready stays false.
v.observe(&empty(), false, 100 * TIMEOUT);
assert_eq!(v.state(), AecState::Validating);
// And when readiness finally arrives, the FULL budget starts *there*, not
// relative to construction (Codex phase-4 review, finding 3): a mutant that
// armed a construction-relative deadline would fail immediately here.
let late = 200_000;
v.observe(&empty(), true, late); // first ready → arm at `late`
assert_eq!(v.state(), AecState::Validating);
v.observe(&empty(), true, late + TIMEOUT - 1);
assert_eq!(v.state(), AecState::Validating);
v.observe(&empty(), true, late + TIMEOUT);
assert_eq!(v.state(), AecState::Failed);
}
#[test]
fn late_positive_evidence_wins_over_expired_deadline() {
// A node first seen just past the deadline still validates: the deadline
// only bounds the wait for an identity that is never seen, and a
// demonstrably-present module is ground truth (Codex phase-4 review,
// finding 2). Reachable only when the first post-deadline observation
// carries the node with no intervening Tick-only observation.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&empty(), true, 0); // arm deadline at 2000
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, TIMEOUT + 1);
assert_eq!(v.state(), AecState::Validated);
assert_eq!(v.validated_module_id(), Some(IDX));
// Whereas a Tick-only observation that crosses the deadline first pre-empts
// it to Failed (stickily), even if the node then shows up — fail-closed.
let mut w = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
w.observe(&empty(), true, 0);
w.observe(&empty(), true, TIMEOUT); // Tick-only crosses the line first
assert_eq!(w.state(), AecState::Failed);
w.observe(&snapshot(vec![node(1, Some(IDX))]), true, TIMEOUT + 1);
assert_eq!(w.state(), AecState::Failed);
}
#[test]
fn revokes_on_empty_even_while_not_ready() {
// Revocation is deliberately NOT gated on graph_ready (Codex phase-4 review,
// findings 1 + 4): the instant every node bearing the index is gone we
// revoke, ready or not, because gating on readiness would let an
// unload+reload that reused the index inside one not-ready churn window
// silently alias onto the new module. A mutant adding `&& graph_ready` to
// the revoke guard survives every other test but dies here.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
assert_eq!(v.state(), AecState::Validated);
v.observe(&empty(), false, 10); // identity gone during not-ready churn
assert_eq!(v.state(), AecState::Revoked);
assert!(!v.fan_out_permitted());
}
#[test]
fn deadline_armed_once_survives_ready_flapping() {
// graph_ready is dynamic (it drops back to false while a Link is binding).
// The epoch budget must be armed on the *first* ready and not restarted.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&empty(), true, 1_000); // arm at 1000 → deadline 3000
v.observe(&empty(), false, 2_000); // ready flaps off; must not disarm
assert_eq!(v.state(), AecState::Validating);
// At the original deadline it fails, even though ready is false now — the
// budget did not restart from the flap.
v.observe(&empty(), false, 3_000);
assert_eq!(v.state(), AecState::Failed);
}
#[test]
fn failed_is_sticky_even_if_the_index_reappears() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&empty(), true, 0);
v.observe(&empty(), true, TIMEOUT);
assert_eq!(v.state(), AecState::Failed);
// A node bearing the index shows up late — must not resurrect the epoch.
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, TIMEOUT + 1);
assert_eq!(v.state(), AecState::Failed);
assert!(!v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
}
// ---------------------------------------------------------------------------
// Row: partial-node disappearance ⇒ stays Validated; all gone ⇒ Revoked.
// ---------------------------------------------------------------------------
#[test]
fn partial_leg_disappearance_stays_validated() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
// The module's four nodes all carry the index.
let four = snapshot(vec![
node(1, Some(IDX)),
node(2, Some(IDX)),
node(3, Some(IDX)),
node(4, Some(IDX)),
]);
v.observe(&four, true, 0);
assert_eq!(v.state(), AecState::Validated);
// Three legs cork/relink away; one still bears the index → still Validated.
v.observe(&snapshot(vec![node(4, Some(IDX))]), true, 10);
assert_eq!(v.state(), AecState::Validated);
assert_eq!(v.validated_module_id(), Some(IDX));
}
#[test]
fn all_nodes_gone_revokes() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
assert_eq!(v.state(), AecState::Validated);
// The whole identity unloads: no node bears the index any more.
v.observe(&empty(), true, 10);
assert_eq!(v.state(), AecState::Revoked);
}
#[test]
fn revoked_stops_fan_out_and_exposes_no_identity() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
v.observe(&empty(), true, 10);
assert_eq!(v.state(), AecState::Revoked);
assert!(!v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
}
#[test]
fn a_node_that_merely_changes_index_revokes() {
// Not a disappearance in the id sense, but the *identity* is gone: no node
// bears our index any more, even though a same-serial node lingers.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
v.observe(&snapshot(vec![node(1, Some(IDX + 1))]), true, 10);
assert_eq!(v.state(), AecState::Revoked);
}
// ---------------------------------------------------------------------------
// Row: a retained stale index does not alias onto a reloaded module — indices
// ARE reused (v3.4 §5.2 correction 3). This is the sharpest safety property.
// ---------------------------------------------------------------------------
#[test]
fn revoked_index_does_not_alias_onto_a_reloaded_module() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
v.observe(&empty(), true, 10);
assert_eq!(v.state(), AecState::Revoked);
// A *different* module later reloads and pactl hands it the very same
// index (measured: 536870919 came back verbatim). A resurrecting machine
// would silently start excluding this unrelated module's node. Ours must
// stay Revoked and fail closed; a real reload gets a fresh validator.
v.observe(&snapshot(vec![node(99, Some(IDX))]), true, 20);
assert_eq!(v.state(), AecState::Revoked);
assert!(!v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
}
#[test]
fn a_fresh_validator_re_validates_the_reused_index() {
// The counterpart: because peerspeak re-tells pixelpass the index on every
// load, the correct response to a reload is a new machine, which validates
// the reused index cleanly — proving stickiness costs nothing legitimate.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
assert_eq!(v.state(), AecState::Validated);
assert_eq!(v.validated_module_id(), Some(IDX));
}
+52 -25
View File
@@ -39,6 +39,7 @@ use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use crate::cli::HostOpts;
use crate::repair::plan::{self as repair_plan, Shape};
/// Owns the pactl-loaded modules plus, when filtering is active, the
/// libpipewire stream-router thread. Drop unloads modules as a backstop;
@@ -64,9 +65,14 @@ impl Routing {
/// also spawn the libpipewire thread that reroutes matching streams.
pub async fn start(opts: &HostOpts) -> Result<Self> {
let pid = std::process::id();
let sink_name = format!("pixelpass_capture_{pid}");
let sink_name = repair_plan::sink_name_for(pid);
let sink_module = load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
// Every module this host loads carries an ownership token, minted per
// load, so `--repair` can tell whose pid the name refers to instead of
// assuming the number means the same thing everywhere. Without it a repair
// run in another pid namespace can unload a live host's audio; see
// `repair::plan::OwnerToken`.
let sink_module = load_module(Shape::LegacyCaptureSink, pid)
.context("failed to load module-null-sink")?;
// In strict per-app mode we never mirror the default sink: the viewer
@@ -82,13 +88,8 @@ impl Routing {
None
} else {
Some(
load_module(&[
"module-loopback",
"source=@DEFAULT_SINK@.monitor",
&format!("sink={sink_name}"),
"latency_msec=20",
])
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?,
load_module(Shape::LoopbackIntoCapture, pid)
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?,
)
};
@@ -115,7 +116,6 @@ impl Routing {
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
let loopback_for_task = Arc::clone(&loopback_arc);
let local_monitor_for_task = Arc::clone(&local_monitor_arc);
let sink_name_for_task = sink_name.clone();
let strict = opts.strict_audio;
let event_task = tokio::spawn(async move {
use crate::common::output::{self, AppAudioState};
@@ -137,12 +137,7 @@ impl Routing {
// only, never the desktop/call — so it can't echo into
// the capture.
if local_monitor_for_task.lock().unwrap().is_none() {
match load_module(&[
"module-loopback",
&format!("source={sink_name_for_task}.monitor"),
"sink=@DEFAULT_SINK@",
"latency_msec=20",
]) {
match load_module(Shape::LoopbackOutOfCapture, pid) {
Ok(id) => {
tracing::info!(
module = id,
@@ -195,12 +190,7 @@ impl Routing {
tracing::info!(
"audio routing: last routed stream gone → restoring default-sink loopback"
);
match load_module(&[
"module-loopback",
"source=@DEFAULT_SINK@.monitor",
&format!("sink={sink_name_for_task}"),
"latency_msec=20",
]) {
match load_module(Shape::LoopbackIntoCapture, pid) {
Ok(id) => {
*loopback_for_task.lock().unwrap() = Some(id);
}
@@ -349,10 +339,47 @@ struct SinkInputProperties {
// pactl module helpers
// ──────────────────────────────────────────────────────────────────────
fn load_module(args: &[&str]) -> Result<u32> {
/// Mint an ownership token for one module load.
///
/// **Per load, not per session.** The nonce is what makes two loads by the same pid
/// render different arguments, which is what lets a fingerprint tell a module from
/// its replacement at the same index. A token minted once and reused for every
/// reload would be a host-session nonce and would not do that, so the counter is
/// bumped on every call and mixed with the clock.
fn owner_token(pid: u32) -> Result<repair_plan::OwnerToken> {
use std::sync::atomic::{AtomicU64, Ordering};
static LOADS: AtomicU64 = AtomicU64::new(0);
let local = crate::repair::local_identity()?;
// A nonce only has to be unlikely to repeat, not unguessable. The counter makes
// two loads within the same clock tick distinct; the clock keeps two runs of the
// same process distinct.
let counter = LOADS.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
Ok(repair_plan::OwnerToken {
machine: local.machine,
boot: local.boot,
pid_ns: local.pid_ns,
nonce: nanos ^ (counter << 48) ^ (u64::from(pid) << 32),
})
}
/// Load the Pulse module for one [`Shape`] and return its index.
///
/// Both the module name and its arguments come from the shape itself
/// ([`crate::repair::plan::Shape`]) rather than being written out here, so that
/// `--repair`'s exact-form matcher and this loader are one source of truth. A
/// latency or argument change that moved only one of them would leave repair
/// silently unable to recognise the modules this build loads.
fn load_module(shape: Shape, pid: u32) -> Result<u32> {
let owner = owner_token(pid).context("could not build an audio ownership token")?;
let output = Command::new("pactl")
.arg("load-module")
.args(args)
.arg(shape.module_name())
.args(shape.render_args(pid, Some(&owner)))
.output()
.context("failed to run pactl load-module")?;
if !output.status.success() {
@@ -615,7 +642,7 @@ fn run_router(
/// (empty, signed, whitespace-padded, non-numeric, overflowing) is a
/// property we do not understand and must not guess at. Leading zeroes
/// are accepted — they are unambiguous and parse to the same value.
fn parse_object_serial(raw: &str) -> Option<u64> {
pub(crate) fn parse_object_serial(raw: &str) -> Option<u64> {
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
+269
View File
@@ -0,0 +1,269 @@
//! O5 measurement, pure (impl plan §5.2).
//!
//! v3.4 §6.4 asserts "a full recompute per graph event is fine for v1". The
//! impl plan closes O5 by refusing to let that rest on a node count: what has
//! to be recorded is the **graph-event rate**, the **recompute duration
//! distribution and maximum**, and **whether events queue behind recompute or
//! logging**.
//!
//! Everything here is arithmetic over samples the caller supplies. The clock
//! reads live at the I/O edge ([`super::sink`]), which is what keeps the
//! statistics unit-testable: a test feeds a hand-written sample sequence and
//! asserts the summary exactly, with no timing flake.
//!
//! **The queueing measure is a proxy, and a one-directional one.** libpipewire
//! dispatches registry callbacks serially on its own loop thread and exposes no
//! queue depth, so nothing here can read a backlog directly. What it can see is
//! that the observer thread was *continuously busy*: if an event begins being
//! handled within [`QUEUE_THRESHOLD_US`] of the previous sample's completion,
//! it was almost certainly already waiting while that recompute ran. That makes
//! [`Summary::queued_events`] a **lower bound** — a genuine backlog always shows
//! up in it, but a burst that happens to arrive exactly as the loop goes idle is
//! counted as un-queued. Combined with [`Summary::busy_fraction`] (which needs
//! no inference at all) it is enough to answer O5 in the direction that matters:
//! a low busy fraction with zero queued events is headroom, and anything else is
//! a number to argue about rather than an assumption to inherit.
use serde::Serialize;
use crate::host::observer::EventKind;
/// An event beginning this close behind the previous sample's completion is
/// counted as having queued. Deliberately tight: the cost of being wrong in the
/// generous direction is a metric that overstates backlog and sends a later
/// round chasing a non-problem.
pub const QUEUE_THRESHOLD_US: u64 = 100;
/// Upper bounds of the duration histogram, microseconds. A twelfth (overflow)
/// bucket catches everything at or above the last bound. Log-ish spacing: the
/// interesting question is which order of magnitude a recompute lands in, not
/// its exact microsecond.
pub const BUCKET_BOUNDS_US: [u64; 11] = [
50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000,
];
/// Human labels for the histogram buckets, parallel to [`BUCKET_BOUNDS_US`]
/// plus the overflow bucket.
pub const BUCKET_LABELS: [&str; 12] = [
"<50us", "<100us", "<250us", "<500us", "<1ms", "<2.5ms", "<5ms", "<10ms", "<25ms", "<50ms",
"<100ms", ">=100ms",
];
/// A bucketed duration distribution with exact count, sum and maximum.
///
/// Bounded memory by construction — the audit runs for as long as a share does,
/// and keeping every sample to compute an exact percentile would grow without
/// limit. The maximum, which is the number O5 actually cares about, is kept
/// exactly; percentiles are reported as the bucket they fall in.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Histogram {
buckets: [u64; 12],
count: u64,
sum_us: u64,
max_us: u64,
}
impl Histogram {
pub fn record(&mut self, us: u64) {
let index = BUCKET_BOUNDS_US
.iter()
.position(|&bound| us < bound)
.unwrap_or(BUCKET_BOUNDS_US.len());
self.buckets[index] += 1;
self.count += 1;
self.sum_us = self.sum_us.saturating_add(us);
self.max_us = self.max_us.max(us);
}
pub fn count(&self) -> u64 {
self.count
}
pub fn max_us(&self) -> u64 {
self.max_us
}
pub fn sum_us(&self) -> u64 {
self.sum_us
}
pub fn mean_us(&self) -> Option<u64> {
(self.count > 0).then(|| self.sum_us / self.count)
}
/// The label of the bucket the `q`-quantile falls in (`q` in `0.0..=1.0`),
/// or `None` when nothing has been recorded.
///
/// Uses the *nearest-rank* definition: the bucket containing the
/// `ceil(q · count)`-th sample in ascending order. Reported as a bucket
/// rather than a number because interpolating inside a bucket would invent
/// precision the histogram does not have.
pub fn quantile_bucket(&self, q: f64) -> Option<&'static str> {
if self.count == 0 {
return None;
}
let q = q.clamp(0.0, 1.0);
// Rank is 1-based; q = 0 still names the bucket holding the smallest
// sample rather than degenerating to "no samples".
let rank = ((q * self.count as f64).ceil() as u64).max(1);
let mut cumulative = 0u64;
for (index, &n) in self.buckets.iter().enumerate() {
cumulative += n;
if cumulative >= rank {
return Some(BUCKET_LABELS[index]);
}
}
// Unreachable while `count` is the sum of the buckets, but returning the
// top bucket is the fail-loud answer rather than a panic in a metric.
Some(BUCKET_LABELS[BUCKET_LABELS.len() - 1])
}
/// Non-empty buckets as `(label, count)`, ascending. Empty buckets are
/// dropped so a summary line stays readable.
pub fn distribution(&self) -> Vec<(&'static str, u64)> {
self.buckets
.iter()
.enumerate()
.filter(|&(_, &n)| n > 0)
.map(|(index, &n)| (BUCKET_LABELS[index], n))
.collect()
}
}
/// One handled event, as timed by the I/O edge.
///
/// Ticks are the AEC validator's clock, not graph changes, so [`Metrics`] counts
/// them separately — folding them into the event rate would inflate it by a
/// constant 4 Hz and hide the real graph churn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Sample {
/// Monotonic microseconds (since observer start) at which handling began.
pub at_us: u64,
/// Microseconds between the previous sample's completion and `at_us`. Zero
/// for the first sample.
pub gap_us: u64,
/// Time spent in the AEC observe + taint recompute.
pub recompute_us: u64,
/// Time spent serialising and writing the record, zero when nothing was
/// emitted. Separate from `recompute_us` because O5 asks about queueing
/// behind recompute **or logging** — and if logging turns out to dominate,
/// that is a fixable problem of a different kind.
pub emit_us: u64,
pub kind: EventKind,
}
/// Rolling O5 state. Fold samples in with [`Metrics::record`]; read with
/// [`Metrics::summary`].
#[derive(Clone, Debug, Default)]
pub struct Metrics {
graph_events: u64,
tick_events: u64,
emitted_records: u64,
recompute: Histogram,
emit: Histogram,
busy_us: u64,
queued_events: u64,
first_event_us: Option<u64>,
last_completion_us: u64,
}
impl Metrics {
pub fn record(&mut self, sample: Sample) {
match sample.kind {
EventKind::Graph => self.graph_events += 1,
EventKind::Tick => self.tick_events += 1,
}
self.recompute.record(sample.recompute_us);
if sample.emit_us > 0 {
self.emitted_records += 1;
self.emit.record(sample.emit_us);
}
self.busy_us = self
.busy_us
.saturating_add(sample.recompute_us)
.saturating_add(sample.emit_us);
// The first sample has no predecessor to have queued behind.
if self.first_event_us.is_some() && sample.gap_us <= QUEUE_THRESHOLD_US {
self.queued_events += 1;
}
self.first_event_us.get_or_insert(sample.at_us);
self.last_completion_us = sample
.at_us
.saturating_add(sample.recompute_us)
.saturating_add(sample.emit_us);
}
pub fn summary(&self) -> Summary {
let span_us = self
.first_event_us
.map(|first| self.last_completion_us.saturating_sub(first))
.unwrap_or(0);
// A rate needs a span to divide by; one event in zero elapsed time has
// no rate, and reporting a made-up one is worse than reporting none.
let graph_events_per_sec = (span_us > 0)
.then(|| self.graph_events as f64 * 1_000_000.0 / span_us as f64)
.map(round_2);
let busy_fraction = (span_us > 0).then(|| round_4(self.busy_us as f64 / span_us as f64));
Summary {
graph_events: self.graph_events,
tick_events: self.tick_events,
emitted_records: self.emitted_records,
span_us,
graph_events_per_sec,
recompute_max_us: self.recompute.max_us(),
recompute_mean_us: self.recompute.mean_us(),
recompute_p50: self.recompute.quantile_bucket(0.50),
recompute_p90: self.recompute.quantile_bucket(0.90),
recompute_p99: self.recompute.quantile_bucket(0.99),
recompute_distribution: self.recompute.distribution(),
emit_max_us: self.emit.max_us(),
emit_mean_us: self.emit.mean_us(),
emit_distribution: self.emit.distribution(),
busy_us: self.busy_us,
busy_fraction,
queued_events: self.queued_events,
queue_threshold_us: QUEUE_THRESHOLD_US,
}
}
}
/// The O5 answer, as emitted.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Summary {
pub graph_events: u64,
pub tick_events: u64,
pub emitted_records: u64,
/// First event to last completion, microseconds.
pub span_us: u64,
pub graph_events_per_sec: Option<f64>,
pub recompute_max_us: u64,
pub recompute_mean_us: Option<u64>,
pub recompute_p50: Option<&'static str>,
pub recompute_p90: Option<&'static str>,
pub recompute_p99: Option<&'static str>,
pub recompute_distribution: Vec<(&'static str, u64)>,
pub emit_max_us: u64,
pub emit_mean_us: Option<u64>,
pub emit_distribution: Vec<(&'static str, u64)>,
/// Total observer-thread time spent recomputing and logging.
pub busy_us: u64,
/// `busy_us / span_us` — the share of wall time the observer thread could
/// not be servicing PipeWire. Needs no inference, unlike `queued_events`.
pub busy_fraction: Option<f64>,
/// Events that began within `queue_threshold_us` of the previous sample's
/// completion — a **lower bound** on backlog, see the module header.
pub queued_events: u64,
pub queue_threshold_us: u64,
}
/// Keep the JSON readable: a rate to two decimals and a fraction to four are
/// well past the precision any of this is good to.
fn round_2(value: f64) -> f64 {
(value * 100.0).round() / 100.0
}
fn round_4(value: f64) -> f64 {
(value * 10_000.0).round() / 10_000.0
}
+518
View File
@@ -0,0 +1,518 @@
//! Phase 5 — dry-run audit mode 🚦 (impl plan §5).
//!
//! **This phase adds no capability. Its entire purpose is to be wrong loudly
//! and safely.** It runs phases 24 against the *live* graph on every graph
//! event and reports what they conclude. It creates no links, loads no modules,
//! and changes no routing — the only thing it produces is a line of JSON.
//!
//! Why this is the gate the plan marks 🚦: the defects that matter here are
//! graph-*reasoning* defects. The 57 phase-2 fixture tests prove the engine
//! matches my model of PipeWire; only a live run proves my model matches
//! PipeWire. A wrong answer at this phase costs a log line. The same wrong
//! answer in phase 6 costs an echo — the sharer's own voice, copied back into
//! the share, which is the failure this whole design exists to prevent.
//!
//! ## The one structural requirement (§5.1)
//!
//! Every emitted record carries the **complete candidate universe partitioned
//! into exact eligible and excluded sets**, with a stable reason code on each
//! excluded row — never a spot check on named nodes. Checking only the nodes a
//! row names constrains nothing about the rest, and it lets the degenerate
//! "exclude everything" implementation pass: that build is silent, produces no
//! echo, and satisfies any assertion phrased purely as *this must be excluded*.
//! Asserting the eligible half of each row is what fails it. That requirement is
//! also the plan's answer to open question O7 (over-exclusion needs no separate
//! gate — it is subsumed by this one).
//!
//! ## What is deliberately *not* here
//!
//! - **No link creation, and no code path that could reach one.** The auditor
//! consumes a [`Projection`] and returns a record. It has no handle to
//! anything mutable.
//! - **No stdout.** Records go to stderr as JSON Lines
//! ([`sink`]) because peerspeak parses pixelpass's stdout event stream
//! (`screenshare/mod.rs:92`); a stray line there corrupts it.
//! - **No `--aec` CLI flag.** That surface is phase 7's mode selector. The audit
//! takes its AEC identity from `PIXELPASS_AUDIO_AUDIT_AEC` through the
//! *same* [`parse_aec_arg`] the real flag will use, so the parser and the
//! validator are both exercised without committing to a public interface
//! before it is designed.
//!
//! ## Fan-out gating vs. taint (read before interpreting a record)
//!
//! Two independent things can exclude a candidate and the record keeps them
//! distinguishable:
//!
//! - The **taint engine** (phase 2) excludes individual nodes with its own
//! reason codes — `peerspeak-owned`, `aec-identity`, `tainted-upstream`, …
//! - The **AEC validator** (phase 4) can forbid fan-out *entirely*, regardless
//! of taint, whenever the configured identity is unvalidated, failed or
//! revoked. Silence over echo.
//!
//! When the gate is shut, a candidate the engine would have called eligible is
//! reported excluded with an audit-level reason ([`GateReason`]); a candidate
//! the engine excluded on its own keeps *its* reason, because that names the
//! mechanism that actually applies to it. `fan_out_permitted` on the record
//! carries the gate state, so the two cases are always tellable apart.
//!
//! **Consequence for the §5.1 matrix:** every row whose point is the
//! eligible/excluded partition must run with `PIXELPASS_AUDIO_AUDIT_AEC=off`
//! (state `NotConfigured`, gate open). Row 12 — the AEC lifecycle row — is the
//! one that runs with a real `pulse-module:<idx>`, and the gate slamming shut is
//! precisely what it asserts.
#![allow(dead_code)] // Trigger paths are wired by `sink` + `run`; rows are read by tests.
pub mod metrics;
pub mod run;
pub mod sink;
#[cfg(test)]
mod tests;
use serde::Serialize;
use crate::host::aec::{AecConfig, AecState, AecValidator};
use crate::host::observer::{EventKind, Millis, Projection, Readiness};
use crate::host::taint::owner::OwnerKey;
use crate::host::taint::snapshot::Serial;
use crate::host::taint::{Decisions, Eligibility, ExclusionCtx, Reason, StickyState, evaluate};
/// How long the AEC validator may sit in `Validating` after the graph first
/// reports ready before failing closed. Generous relative to the observer's own
/// 2 s readiness budget: in the audit a `Failed` is a diagnostic, and timing out
/// early would report an absent module that was merely slow to appear.
pub const AEC_VALIDATION_TIMEOUT_MILLIS: Millis = 5_000;
/// Everything the auditor needs beyond the live graph.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AuditConfig {
/// The AEC identity to validate, as parsed from
/// `PIXELPASS_AUDIO_AUDIT_AEC`. Defaults to [`AecConfig::Off`] — an audit
/// run is not a share, so "there is no echo canceller in play" is the
/// honest default, and it is what leaves the fan-out gate open for the
/// partition rows.
pub aec: AecConfig,
pub aec_timeout: Millis,
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
aec: AecConfig::Off,
aec_timeout: AEC_VALIDATION_TIMEOUT_MILLIS,
}
}
}
/// An audit-level exclusion: the AEC validator has shut the fan-out gate. These
/// codes are disjoint from the taint engine's
/// [`Reason::code`](crate::host::taint::Reason::code) values, so a reader never
/// has to know which layer produced a code to interpret it.
// The shared `Aec` prefix is the point: `GateReason::Validating` and
// `AecState::Validating` would be one careless glob import away from being
// confused, and these three are the *audit's* view of that machine, not the
// machine itself.
#[allow(clippy::enum_variant_names)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GateReason {
/// The configured AEC identity has not been seen yet. Not an error — the
/// module may still be loading — but no fan-out happens meanwhile.
AecValidating,
/// The deadline passed with the identity never observed.
AecFailed,
/// The whole identity disappeared mid-run: every node bearing the index is
/// gone (v3.4 §5.3).
AecRevoked,
}
impl GateReason {
pub fn code(self) -> &'static str {
match self {
Self::AecValidating => "aec-validating",
Self::AecFailed => "aec-failed",
Self::AecRevoked => "aec-revoked",
}
}
/// The gate reason implied by a validator state, or `None` when fan-out is
/// permitted. Mirrors [`AecValidator::fan_out_permitted`] — kept as one
/// `match` over the same enum so the two cannot drift: every state that
/// permits fan-out maps to `None` and every state that forbids it maps to a
/// code.
pub fn from_state(state: AecState) -> Option<Self> {
match state {
AecState::NotConfigured | AecState::Validated => None,
AecState::Validating => Some(Self::AecValidating),
AecState::Failed => Some(Self::AecFailed),
AecState::Revoked => Some(Self::AecRevoked),
}
}
}
/// Stable string for an [`AecState`], for the record's `aec_state` field.
///
/// Defined here rather than on [`AecState`] to keep the merged phase-4 module
/// untouched by a reporting concern.
fn aec_state_code(state: AecState) -> &'static str {
match state {
AecState::NotConfigured => "not-configured",
AecState::Validating => "validating",
AecState::Validated => "validated",
AecState::Failed => "failed",
AecState::Revoked => "revoked",
}
}
/// Stable string for the observer's readiness epoch.
fn readiness_code(readiness: Readiness) -> &'static str {
match readiness {
Readiness::Waiting => "waiting",
Readiness::Complete => "complete",
Readiness::TimedOut => "timed-out",
}
}
/// One candidate node's effective answer. `reason` is `None` exactly when
/// `eligible` is true.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditRow {
pub serial: u64,
pub name: Option<String>,
pub eligible: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<&'static str>,
/// The owner key that carried the taint across, when the reason is
/// `tainted-owner-bridge` *and* the tainted member shared a key directly.
///
/// §5.1 row 1 asserts "reason = owner bridge, **naming the key**" — the
/// point being that the exclusion is provably the owner bridge on a
/// specific key rather than an incidental link walk that happens to reach
/// the same verdict. [`Reason::code`] collapses the payload, so without
/// this field that row cannot be asserted from the record at all.
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_key: Option<&'static str>,
/// The exclusion was carried over from a previous snapshot rather than
/// derived from the current topology (phase-2 stickiness).
pub sticky: bool,
}
/// A tainted node of *any* media role, not just fan-out candidates. Candidates
/// already appear in [`AuditBody::candidates`]; this is the diagnostic view —
/// when a candidate's exclusion is a surprise, the taint that reached it is the
/// next question, and it usually sits on a node that is not itself a candidate.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct TaintRow {
pub serial: u64,
pub name: Option<String>,
pub reason: &'static str,
/// As [`AuditRow::owner_key`]. Present here too because the bridge that
/// matters for a row's diagnosis is often on a non-candidate node.
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_key: Option<&'static str>,
pub sticky: bool,
}
/// The owner key a `tainted-owner-bridge` reason resolved on, if it named one.
///
/// `None` for every other reason, and also for a bridge whose tainted member
/// shared no key *directly* — the taint reached it transitively, so there is no
/// single key to name and inventing one would be a false diagnosis.
fn owner_key_of(reason: Reason) -> Option<&'static str> {
match reason {
Reason::TaintedOwnerBridge { key } => key.map(OwnerKey::code),
_ => None,
}
}
/// A node carrying a peerspeak ownership carrier on a role the engine does not
/// honour it on (round 10, R10-1). `role` is the point of the row: it says
/// which non-producer role the tag turned up on, which is what distinguishes a
/// producer-side bug from an impersonation attempt.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct IgnoredTagRow {
pub serial: u64,
pub name: Option<String>,
pub role: &'static str,
}
/// The decision content of one recompute — everything except which recompute it
/// was. Split out from [`AuditRecord`] so "did anything actually change?" is a
/// derived `==` rather than a hand-maintained field comparison that a later
/// field addition could silently fall out of.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditBody {
/// The observer's dynamic readiness. False ⇒ every candidate is excluded
/// `graph-not-ready`; no decision from a partial graph is a decision.
pub graph_ready: bool,
/// The sticky readiness epoch, which distinguishes the three ways
/// `graph_ready` can be false (see [`Projection::readiness`]).
pub epoch: &'static str,
pub aec_state: &'static str,
/// The index handed to the taint engine — `Some` only while `Validated`.
#[serde(skip_serializing_if = "Option::is_none")]
pub aec_module_id: Option<u64>,
/// Whether the AEC validator permits fan-out at all right now.
pub fan_out_permitted: bool,
/// The audit-level reason fan-out is forbidden, when it is.
#[serde(skip_serializing_if = "Option::is_none")]
pub gate_reason: Option<&'static str>,
/// **The complete candidate universe**, ascending by serial — every
/// `Stream/Output/Audio` node in the snapshot, partitioned. §5.1's exact
/// partition is `candidates`, not a subset of it.
pub candidates: Vec<AuditRow>,
pub eligible_count: usize,
pub excluded_count: usize,
/// Taint across all node roles, ascending by serial.
pub taint: Vec<TaintRow>,
/// Nodes carrying a peerspeak ownership carrier that the engine
/// **ignored** because they are not `Stream/Output/Audio` (round 10,
/// R10-1). Normally empty; a non-empty list means either peerspeak is
/// tagging something it should not, or a process is impersonating the
/// tag. Neither is an exclusion, and neither should be silent.
///
/// Omitted from the JSONL when empty, so it costs nothing on the common
/// path and is impossible to miss when it is not.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub ignored_ownership_tags: Vec<IgnoredTagRow>,
}
impl AuditBody {
/// Serials of eligible candidates, ascending — the half of the partition an
/// exclude-everything build fails.
pub fn eligible(&self) -> Vec<u64> {
self.candidates
.iter()
.filter(|row| row.eligible)
.map(|row| row.serial)
.collect()
}
/// `(serial, reason code)` for excluded candidates, ascending.
pub fn excluded(&self) -> Vec<(u64, &'static str)> {
self.candidates
.iter()
.filter(|row| !row.eligible)
.map(|row| (row.serial, row.reason.unwrap_or("?")))
.collect()
}
/// The eligible candidate with this name, if any. Convenience for the
/// matrix rows, which name nodes rather than serials.
pub fn row_named(&self, name: &str) -> Option<&AuditRow> {
self.candidates
.iter()
.find(|row| row.name.as_deref() == Some(name))
}
}
/// One recompute, as emitted.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditRecord {
/// Monotonic per-run counter over *every* recompute, emitted or suppressed,
/// so a gap in the emitted sequence is visibly a suppression rather than a
/// lost line.
pub seq: u64,
pub trigger: &'static str,
/// Observer-clock milliseconds at which this recompute ran.
pub at_ms: Millis,
#[serde(flatten)]
pub body: AuditBody,
}
/// What one [`Auditor::observe`] produced.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuditOutcome {
pub record: AuditRecord,
/// Whether the record should be written. See [`Auditor::observe`].
pub emit: bool,
}
/// The dry-run auditor: phases 24 folded together over a live projection.
///
/// Read-only by construction — it borrows a [`Projection`] and owns only the
/// state phases 2 and 4 thread explicitly ([`StickyState`], [`AecValidator`]).
/// There is no field here through which a link could be created.
#[derive(Clone, Debug)]
pub struct Auditor {
validator: AecValidator,
sticky: StickyState,
seq: u64,
/// The body of the last record actually written, for change suppression.
last_emitted: Option<AuditBody>,
}
impl Auditor {
pub fn new(config: AuditConfig) -> Self {
Self {
validator: AecValidator::new(config.aec, config.aec_timeout),
sticky: StickyState::default(),
seq: 0,
last_emitted: None,
}
}
pub fn aec_state(&self) -> AecState {
self.validator.state()
}
pub fn sticky(&self) -> &StickyState {
&self.sticky
}
/// Fold one projection into the audit.
///
/// **Called once per applied registry event — never on a coalesced batch.**
/// That is not a performance preference, it is the phase-4 integration
/// contract (`aec/mod.rs`, the `Validated` arm): revocation is detected by
/// observing the *empty gap* between a module unload and the next reload,
/// and module indices are reused verbatim (v3.4 §5.2 correction 3). Coalesce
/// across that gap and a fresh module silently inherits a dead module's
/// validated identity. [`sink`] is what upholds this, by running the
/// recompute inline on the observer thread rather than polling
/// [`RegistryObserverHandle::latest`](crate::host::observer::adapter::RegistryObserverHandle::latest),
/// which coalesces by nature.
///
/// `emit` is true for every graph-triggered recompute, and for a
/// tick-triggered one only when the decision content changed. Ticks arrive
/// at a constant 4 Hz purely to drive the AEC deadline; emitting an
/// identical record four times a second would bury the graph events the
/// audit exists to show. `seq` still advances on suppressed records, so
/// nothing about the run is silently unaccounted for.
pub fn observe(
&mut self,
projection: &Projection,
kind: EventKind,
now: Millis,
) -> AuditOutcome {
self.seq += 1;
// Phase 4 first: its verdict is an *input* to phase 2 via
// `ExclusionCtx::aec_module_id`, so observing the graph in the other
// order would evaluate taint against the previous recompute's identity.
self.validator
.observe(&projection.snapshot, projection.graph_ready, now);
let aec_state = self.validator.state();
let gate_reason = GateReason::from_state(aec_state);
let ctx = ExclusionCtx {
aec_module_id: self.validator.validated_module_id(),
pipewire_pulse_pid: projection.pipewire_pulse_pid,
// The audit creates nothing, so it owns nothing. Another host's
// capture sink is still caught — by the `pixelpass_capture_*` name
// prefix (v3.4 §6.2), which is what §5.1 row 7 exercises — so an
// empty set costs the matrix nothing.
pixelpass_owned: Default::default(),
graph_ready: projection.graph_ready,
};
let (decisions, sticky) = evaluate(&projection.snapshot, &ctx, &self.sticky);
self.sticky = sticky;
let body = build_body(
projection,
&decisions,
aec_state,
self.validator.validated_module_id(),
gate_reason,
);
let emit = kind == EventKind::Graph || self.last_emitted.as_ref() != Some(&body);
if emit {
self.last_emitted = Some(body.clone());
}
AuditOutcome {
record: AuditRecord {
seq: self.seq,
trigger: kind.code(),
at_ms: now,
body,
},
emit,
}
}
}
fn build_body(
projection: &Projection,
decisions: &Decisions,
aec_state: AecState,
aec_module_id: Option<u64>,
gate_reason: Option<GateReason>,
) -> AuditBody {
let candidates: Vec<AuditRow> = decisions
.candidates
.values()
.map(|decision| {
// The engine's own reason wins when it has one: it names the
// mechanism that actually excluded *this* node, which is what the
// §5.1 rows assert. The gate reason applies only to candidates the
// engine would have passed — otherwise a shut gate would erase every
// reason code in the record and the matrix would stop constraining
// the engine at all.
let (eligible, reason, owner_key, sticky) = match decision.eligibility {
Eligibility::NotEligible { reason, sticky } => {
(false, Some(reason.code()), owner_key_of(reason), sticky)
}
Eligibility::Eligible => match gate_reason {
Some(gate) => (false, Some(gate.code()), None, false),
None => (true, None, None, false),
},
};
AuditRow {
serial: decision.serial.0,
name: decision.name.clone(),
eligible,
reason,
owner_key,
sticky,
}
})
.collect();
let eligible_count = candidates.iter().filter(|row| row.eligible).count();
let taint: Vec<TaintRow> = decisions
.taint
.iter()
.map(|(&serial, entry)| TaintRow {
serial: serial.0,
name: node_name(projection, serial),
reason: entry.reason.code(),
owner_key: owner_key_of(entry.reason),
sticky: entry.sticky,
})
.collect();
let ignored_ownership_tags: Vec<IgnoredTagRow> =
crate::host::taint::misplaced_ownership_tags(&projection.snapshot)
.into_iter()
.map(|node| IgnoredTagRow {
serial: node.serial.0,
name: node.name.clone(),
role: node.role.code(),
})
.collect();
AuditBody {
graph_ready: projection.graph_ready,
epoch: readiness_code(projection.readiness),
aec_state: aec_state_code(aec_state),
aec_module_id,
fan_out_permitted: gate_reason.is_none(),
gate_reason: gate_reason.map(GateReason::code),
excluded_count: candidates.len() - eligible_count,
eligible_count,
candidates,
taint,
ignored_ownership_tags,
}
}
fn node_name(projection: &Projection, serial: Serial) -> Option<String> {
projection
.snapshot
.node(serial)
.and_then(|node| node.name.clone())
}
+170
View File
@@ -0,0 +1,170 @@
//! Triggering the dry-run audit: environment parsing and the two entry points.
//!
//! The impl plan §5 specifies a **hidden trigger**, `PIXELPASS_AUDIO_AUDIT=1`.
//! It is honoured in two places, which answer two different questions:
//!
//! - **Inside a real `pixelpass host` run** ([`spawn_if_enabled`]) — proves the
//! audit works in the code path phase 6 will actually mutate. This is the
//! plan-literal reading of the trigger.
//! - **Standalone** ([`run_standalone`], behind the hidden `--audit-audio`
//! flag) — observer plus auditor and nothing else: no iroh endpoint, no
//! display-server detection, no capture pipeline, no ticket. This is what
//! drives the §5.1 matrix, because a row that fails should fail for a reason
//! about *audio*, not because a relay was unreachable.
//!
//! Both paths run the same [`AuditSink`] over the same observer, so neither is a
//! simulation of the other.
use std::fs::OpenOptions;
use std::io::Write;
use anyhow::{Context, Result, bail};
use super::sink::AuditSink;
use super::{AEC_VALIDATION_TIMEOUT_MILLIS, AuditConfig};
use crate::common::signal;
use crate::host::aec::{AecConfig, AecParseError, parse_aec_arg};
use crate::host::observer::adapter::RegistryObserverHandle;
/// The hidden trigger (impl plan §5). Exactly `1` enables the audit; anything
/// else, including `true` or `yes`, does not.
///
/// Deliberately strict. This variable can only arrive by someone typing it, and
/// a value that *looks* enabling but is not would produce a silent no-op — the
/// single most annoying failure mode for a diagnostic tool. A mistyped value
/// gets a warning (see [`enabled`]) rather than silence.
pub const AUDIT_ENV: &str = "PIXELPASS_AUDIO_AUDIT";
/// The AEC identity for the audit, in the `--aec` grammar (`off` or
/// `pulse-module:<idx>`). Absent ⇒ `off`.
pub const AUDIT_AEC_ENV: &str = "PIXELPASS_AUDIO_AUDIT_AEC";
/// Redirect the JSON Lines stream to this file instead of stderr.
pub const AUDIT_FILE_ENV: &str = "PIXELPASS_AUDIO_AUDIT_FILE";
/// Whether the hidden trigger is set.
pub fn enabled() -> bool {
match std::env::var(AUDIT_ENV) {
Ok(value) if value == "1" => true,
Ok(value) => {
tracing::warn!(
"{AUDIT_ENV}={value:?} is not `1`; the audio audit stays off. \
Set {AUDIT_ENV}=1 to enable it."
);
false
}
Err(_) => false,
}
}
/// Build the audit configuration from the environment.
///
/// A malformed `PIXELPASS_AUDIO_AUDIT_AEC` is **fatal**, matching the phase-4
/// rule that a bad `--aec` value must not silently become "no AEC": there is no
/// fail-closed default index, so a wrong or dropped one would exclude the wrong
/// node (or nothing at all) and the audit would confidently report a partition
/// computed against an identity nobody asked for.
pub fn config_from_env() -> Result<AuditConfig> {
let aec = match std::env::var(AUDIT_AEC_ENV) {
Ok(raw) => parse_aec_arg(&raw).map_err(|e| {
anyhow::anyhow!(
"{AUDIT_AEC_ENV}={raw:?} is not a valid AEC argument ({}). \
Expected `off` or `pulse-module:<index>`, where the index is a bare decimal.",
describe(e)
)
})?,
Err(std::env::VarError::NotPresent) => AecConfig::Off,
Err(e) => bail!("{AUDIT_AEC_ENV} is not readable: {e}"),
};
Ok(AuditConfig {
aec,
aec_timeout: AEC_VALIDATION_TIMEOUT_MILLIS,
})
}
fn describe(error: AecParseError) -> &'static str {
match error {
AecParseError::Empty => "the value was empty",
AecParseError::UnknownForm => "not `off` and not `pulse-module:...`",
AecParseError::MissingIndex => "`pulse-module:` with no index after the colon",
AecParseError::InvalidIndex => {
"the index was not a bare decimal (no sign, whitespace, or non-digits) that fits in u64"
}
}
}
/// Where the JSON Lines go. Stderr unless `PIXELPASS_AUDIO_AUDIT_FILE` names a
/// file, which is appended to rather than truncated — a matrix run that restarts
/// the process mid-scenario should not lose the rows it already recorded.
fn writer_from_env() -> Result<Box<dyn Write + Send>> {
match std::env::var(AUDIT_FILE_ENV) {
Ok(path) if !path.is_empty() => {
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("{AUDIT_FILE_ENV}={path:?} could not be opened"))?;
tracing::info!("audio audit: writing records to {path}");
Ok(Box::new(file))
}
_ => Ok(Box::new(std::io::stderr())),
}
}
/// Construct the sink and spawn the observer behind it.
fn spawn_audit() -> Result<RegistryObserverHandle> {
let config = config_from_env()?;
let sink = AuditSink::new(config, writer_from_env()?);
tracing::info!(
aec = ?config.aec,
"audio audit: dry run active — decisions are logged, no links are created"
);
RegistryObserverHandle::spawn_with_sink(Some(Box::new(sink)))
}
/// Start the audit if the hidden trigger is set, for a `pixelpass host` run.
///
/// The returned handle must be held for the lifetime of the run: dropping it
/// stops the observer thread and flushes the final O5 summary.
///
/// Returns `Err` only when the trigger *was* set and starting failed — a
/// misconfigured audit is worth failing the run over, because the alternative is
/// a host that silently is not being audited while its operator believes it is.
pub fn spawn_if_enabled() -> Result<Option<RegistryObserverHandle>> {
if !enabled() {
return Ok(None);
}
spawn_audit().map(Some)
}
/// The standalone audit: run the observer and the auditor, and nothing else,
/// until ctrl-c.
///
/// Does not consult [`AUDIT_ENV`] — reaching this function required passing the
/// hidden `--audit-audio` flag, which is already an explicit request. The
/// environment still supplies the AEC identity and the output file.
pub async fn run_standalone() -> Result<()> {
let cancel = signal::install_ctrl_c();
let handle = spawn_audit()?;
eprintln!(
"pixelpass audio audit (dry run): observing the live PipeWire graph.\n\
No links are created and no routing changes. Ctrl-C to stop."
);
// SIGTERM as well as ctrl-c, because this mode is driven by scripts as much
// as by hand — `timeout`, a matrix harness, and systemd all send SIGTERM,
// and the default disposition would kill the process before the sink's
// `Drop` writes the final O5 summary. Losing that summary is losing the
// whole §5.2 measurement for that run.
let mut sigterm = signal::terminate_stream()?;
tokio::select! {
_ = cancel.cancelled() => {}
_ = sigterm.recv() => tracing::info!("SIGTERM received, shutting down"),
}
// Explicit rather than incidental: this drop stops the PipeWire thread,
// which drops the sink, which writes the final metrics line. Letting it fall
// out of scope would do the same thing, but the ordering is the point.
drop(handle);
Ok(())
}
+184
View File
@@ -0,0 +1,184 @@
//! The audit's I/O edge: timing, JSON Lines emission, O5 accounting.
//!
//! Everything impure about phase 5 lives here, and it is deliberately thin —
//! read the clock, call [`Auditor::observe`], write a line, fold a
//! [`metrics::Sample`]. The decisions are all upstream in the pure core, which
//! is why the matrix can be argued about in unit tests rather than only in front
//! of a live daemon.
//!
//! ## Why this runs on the observer thread
//!
//! [`AuditSink`] is a [`ProjectionSink`], invoked inline from the PipeWire
//! observer thread once per applied registry event. The obvious alternative —
//! a consumer task polling
//! [`RegistryObserverHandle::latest`](super::super::observer::adapter::RegistryObserverHandle::latest)
//! — was rejected: polling **coalesces**, and phase 4's revocation logic
//! detects a module unload by observing the *empty gap* before the next module
//! appears. Module indices are reused verbatim across an unload/reload (v3.4
//! §5.2 correction 3), so a poller that misses the gap silently aliases a fresh
//! module onto a dead module's validated identity. Running inline is what makes
//! "one `observe` per graph event, no coalescing" — the contract phase 4
//! documents as owed — actually true.
//!
//! The cost of that choice is that recompute and logging happen on the thread
//! servicing PipeWire, which is precisely the risk O5 asks about. That is not an
//! accident: this arrangement puts the cost exactly where the measurement can
//! see it. See [`metrics`].
//!
//! ## Output contract
//!
//! One JSON object per line, to **stderr** by default, each tagged with a `kind`
//! discriminator (`"audit"` or `"metrics"`). Never stdout: peerspeak parses
//! pixelpass's stdout event stream, and the impl plan §5 is explicit that
//! unstructured output must not go there. `PIXELPASS_AUDIO_AUDIT_FILE`
//! redirects the records to a file instead, which is how the §5.1 matrix is
//! driven — it separates the audit stream from interleaved `tracing` output
//! without needing either side to change format.
use std::io::Write;
use std::time::Instant;
use serde::Serialize;
use super::metrics::{self, Metrics, Summary};
use super::{AuditConfig, AuditRecord, Auditor};
use crate::host::observer::adapter::ProjectionSink;
use crate::host::observer::{EventKind, Millis, Projection};
/// Emit a rolling metrics line every this many ticks. Ticks are 250 ms, so this
/// is every 10 s — often enough that a run killed abruptly still leaves a
/// usable O5 record, rare enough that it does not crowd out the audit records.
const SUMMARY_INTERVAL_TICKS: u64 = 40;
/// The live audit: pure auditor + clock + writer.
pub struct AuditSink {
auditor: Auditor,
metrics: Metrics,
writer: Box<dyn Write + Send>,
/// Set once the first sample has completed, so the first event is not
/// counted as having queued behind a predecessor that does not exist.
last_completion_us: Option<u64>,
ticks_since_summary: u64,
/// Wall-clock origin for the microsecond timings. Only used for durations,
/// never for the AEC deadline — that runs on the observer's own clock,
/// handed in as `now_us`, so the validator and the readiness epoch cannot
/// disagree about what time it is.
epoch: Instant,
}
impl AuditSink {
pub fn new(config: AuditConfig, writer: Box<dyn Write + Send>) -> Self {
Self {
auditor: Auditor::new(config),
metrics: Metrics::default(),
writer,
last_completion_us: None,
ticks_since_summary: 0,
epoch: Instant::now(),
}
}
fn elapsed_us(&self) -> u64 {
u64::try_from(self.epoch.elapsed().as_micros()).unwrap_or(u64::MAX)
}
/// Write one line. Failures are logged once per occurrence and otherwise
/// ignored: a broken stderr must not take down the observer thread, and the
/// audit is diagnostic — losing a line is a worse audit, not a worse share.
fn write_line<T: Serialize>(&mut self, line: &T) {
match serde_json::to_string(line) {
Ok(json) => {
if let Err(e) = writeln!(self.writer, "{json}") {
tracing::warn!("audit: failed to write record: {e}");
}
}
Err(e) => tracing::warn!("audit: failed to serialise record: {e}"),
}
}
fn write_summary(&mut self, at_ms: Millis) {
let summary = self.metrics.summary();
self.write_line(&MetricsLine {
kind: "metrics",
at_ms,
summary: &summary,
});
let _ = self.writer.flush();
}
}
impl ProjectionSink for AuditSink {
fn on_projection(&mut self, projection: &Projection, kind: EventKind, now_us: u64) {
let at_us = self.elapsed_us();
let gap_us = self
.last_completion_us
.map(|previous| at_us.saturating_sub(previous))
.unwrap_or(0);
let recompute_start = self.elapsed_us();
let outcome = self.auditor.observe(projection, kind, now_us / 1_000);
let recompute_us = self.elapsed_us().saturating_sub(recompute_start);
let emit_us = if outcome.emit {
let emit_start = self.elapsed_us();
self.write_line(&AuditLine {
kind: "audit",
recompute_us,
record: &outcome.record,
});
// Flushed per record so a run ended with SIGKILL (or a matrix row
// that reads the file while the process is still up) still shows
// every decision made before that instant. The cost is measured, not
// assumed — it is inside `emit_us`.
let _ = self.writer.flush();
self.elapsed_us().saturating_sub(emit_start).max(1)
} else {
0
};
self.metrics.record(metrics::Sample {
at_us,
gap_us,
recompute_us,
emit_us,
kind,
});
self.last_completion_us = Some(self.elapsed_us());
if kind == EventKind::Tick {
self.ticks_since_summary += 1;
if self.ticks_since_summary >= SUMMARY_INTERVAL_TICKS {
self.ticks_since_summary = 0;
self.write_summary(now_us / 1_000);
}
}
}
}
impl Drop for AuditSink {
/// The final O5 record. The observer thread drops its sink when the main
/// loop quits, so an ordinary ctrl-c leaves a complete summary behind
/// without the runner having to ask for one.
fn drop(&mut self) {
let at_ms = self.elapsed_us() / 1_000;
self.write_summary(at_ms);
}
}
#[derive(Serialize)]
struct AuditLine<'a> {
kind: &'static str,
/// This record's own recompute cost, so a surprising row can be correlated
/// with a cost spike without cross-referencing the periodic summary.
recompute_us: u64,
#[serde(flatten)]
record: &'a AuditRecord,
}
#[derive(Serialize)]
struct MetricsLine<'a> {
kind: &'static str,
at_ms: Millis,
#[serde(flatten)]
summary: &'a Summary,
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -1,5 +1,8 @@
pub mod aec;
pub mod audio;
pub mod audit;
mod capture;
mod observer;
mod pipeline;
mod quality;
mod serve;
@@ -75,6 +78,12 @@ pub async fn run(opts: HostOpts) -> Result<()> {
let cancel = signal::install_ctrl_c();
// Phase 5 dry-run audit, off unless `PIXELPASS_AUDIO_AUDIT=1`. Read-only:
// it observes the graph and logs what phases 24 conclude, creating no
// links. Bound to a name so the handle lives as long as the run — dropping
// it stops the observer thread and flushes the final O5 summary.
let _audio_audit = audit::run::spawn_if_enabled()?;
let endpoint = endpoint::bind(opts.relay.as_deref()).await?;
// Relay-only ticket: wait for the home relay to connect, then keep only
File diff suppressed because it is too large Load Diff
+228
View File
@@ -0,0 +1,228 @@
//! The `session_device` classifier — pure, no PipeWire.
//!
//! `NodeProps::session_device` (see [`super::super::taint::snapshot`]) is a
//! **positive high-confidence** claim that a node is a passive hardware
//! terminal: a real sound card's sink or source that terminates audio rather
//! than forwarding it. Setting it *removes* two protections at once — the
//! node's coarse owner keys and its ability to trip the fail-closed backstop
//! — so a false positive is a **leak**, and the whole classifier is shaped so
//! that anything less than a positive identification resolves to `false`.
//!
//! The observer (phase 3) owes this classification; the adapter must never
//! stuff a raw property through. Two facts from the design (v3.4 §6.1.1,
//! Codex rounds 24) drive the shape here:
//!
//! - `device.id` / `device.api` describe *which* Device a node belongs to and
//! *how* that Device is reached — **neither promises the node passively
//! terminates audio.** A filter chain associated with a card satisfies
//! both. So the discriminator is `factory.name` on an **allowlist** of
//! real hardware-PCM factories, never a substring or a denylist: an unknown
//! factory is not a device.
//! - The backing Device must actually have been **bound and resolved**. A node
//! that claims a `device.id` whose Device's properties we do not hold is
//! **withheld**, not admitted with a provisional `false` — a provisional
//! `false` during the not-ready window fuses sink and mic on the shared
//! session client and that fusion can persist as sticky over-exclusion
//! (round-3 finding 3).
//!
//! **Round 8 (v3.5 §6.7 decision 4): the Device is the authority on
//! `device.api` and `alsa.driver_name`.** Both are absent from the Node
//! *global* and both are present on the **bound Device**'s `info` props
//! (measured 2026-07-25). Reading them from the Device closes the phase-3
//! review's owed fix: on PipeWire ≥ 1.2.6 with WirePlumber < 0.5.13 the driver
//! name is not copied onto the node, and the fail-closed "absent driver ⇒ not
//! a session device" rule would over-exclude real sound cards. `factory.name`
//! exists only on the node, which is why the node bind is required regardless.
use crate::host::taint::snapshot::GlobalId;
/// Factory names that positively identify a passive hardware-PCM terminal.
///
/// **An allowlist, deliberately.** Membership *removes* protections, so the
/// safe error direction is to leave a genuine-but-unlisted device off the
/// list (it merely keeps its owner keys — over-exclusion, no echo). Adding a
/// backend here is a security-relevant change and wants the same measurement
/// the ALSA entries got (snapshot.rs `session_device` contract: the target
/// box's five ALSA nodes carry `factory.name=api.alsa.pcm.{sink,source}`; the
/// three `support.null-audio-sink` nodes carry neither).
///
/// `support.null-audio-sink`, `*.loopback`, and any filter factory are
/// intentionally **absent**: those forward audio, which is exactly the shape
/// this feature must be able to exclude.
///
/// ⚠️ **ALSA only, and only these two, because they are the only factories
/// measured on the target box.** BlueZ was previously listed here as
/// `api.bluez5.pcm.{sink,source}` — those are invented; the real BlueZ
/// terminals are `api.bluez5.media.{sink,source}` with profile aliases
/// (Codex phase-3 review, finding 5). Rather than allowlist an unmeasured
/// guess, BlueZ is left off entirely: a real Bluetooth sink then keeps its
/// owner keys (over-exclusion — safe). Add BlueZ back only with a *measured*
/// factory name and a fixture.
const HARDWARE_PCM_FACTORIES: &[&str] = &[
// ALSA — measured on the target box.
"api.alsa.pcm.sink",
"api.alsa.pcm.source",
];
/// The `device.api` every entry in [`HARDWARE_PCM_FACTORIES`] belongs to.
/// A single value rather than a list, because the allowlist is ALSA-only;
/// this constant is the thing to change when that stops being true.
const HARDWARE_PCM_API: &str = "alsa";
/// ALSA drivers that expose a hardware-PCM `factory.name` but are **not**
/// passive terminals — audio written in reappears on their capture side
/// through a path the PipeWire Link graph cannot see, so classifying them
/// `session_device` (which drops owner keys and the fail-closed backstop)
/// would let tainted audio loop back untainted (Codex phase-3 review,
/// finding 2). `factory.name` alone cannot distinguish these from a real
/// card — `snd_aloop` presents as `api.alsa.pcm.{sink,source}` exactly like
/// `snd_hda_intel` — so a real ALSA terminal must present an `alsa.driver_name`
/// that is **present and not on this denylist**; a missing driver fails closed
/// (see [`classify`]). `snd_dummy` is intentionally absent: it is virtual but
/// does not couple playback to capture, so it is not a loopback hazard.
const NON_TERMINAL_ALSA_DRIVERS: &[&str] = &["snd_aloop"];
/// The node-side properties the classifier reads, exactly as the adapter
/// parsed them off the **bound Node's `info`** (never off the registry
/// global — v3.5 §6.7). Kept separate from
/// [`super::super::taint::snapshot::NodeProps`] because these feed the
/// *decision* whose output is the `session_device` field — they are inputs,
/// not part of the graph the engine reasons over.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DeviceClaim {
/// `device.id` — the Device this node belongs to, if any. Absent on
/// `Stream/*` nodes, which is exactly why their absence means "not a
/// device", not "unknown".
pub device_id: Option<GlobalId>,
/// `device.api` **as copied onto the node**, when it is — the access API
/// of that Device (e.g. `alsa`, `bluez5`). Its mere presence is **not**
/// sufficient (a card-associated filter has it too); required only as a
/// corroborating signal alongside the factory allowlist. The
/// authoritative copy is [`DeviceProps::device_api`]; this is the
/// fallback.
pub device_api: Option<String>,
/// `factory.name` — the discriminator. Only an allowlisted hardware-PCM
/// factory earns `session_device`.
pub factory_name: Option<String>,
/// `alsa.driver_name` — the kernel driver behind an ALSA node (e.g.
/// `snd_hda_intel`, `snd_usb_audio`, `snd_aloop`). Needed because the
/// factory allowlist cannot tell a real card from a loopback driver that
/// shares the same factory. `session_device` requires this to be
/// **present and not** on [`NON_TERMINAL_ALSA_DRIVERS`]; a driver on the
/// denylist, or an absent value, both fail closed (see [`classify`]).
/// Frequently absent here — PipeWire ≥ 1.2.6 with WirePlumber < 0.5.13
/// does not copy `alsa.*` onto the node — which is why the authoritative
/// copy is [`DeviceProps::alsa_driver_name`] and this is only the
/// fallback.
pub alsa_driver_name: Option<String>,
}
/// The **bound Device's** `info` properties — the authoritative half of the
/// `session_device` decision (v3.5 §6.7 decision 4).
///
/// Absent from the Device *registry global* exactly as the node's properties
/// are absent from the Node global; both are recovered by binding. A node
/// claiming a `device.id` is withheld until this struct exists for that
/// Device (see [`Classification::Withhold`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DeviceProps {
/// `device.api` on the Device — `alsa`, `bluez5`, `v4l2`, …
pub device_api: Option<String>,
/// `alsa.driver_name` on the Device — the kernel driver behind the card,
/// authoritative regardless of whether the session manager copied it onto
/// the node.
pub alsa_driver_name: Option<String>,
}
/// The outcome of classifying one node's device claim.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Classification {
/// No `device.id` — a `Stream/*` node. Admit with `session_device=false`.
NotADevice,
/// A `device.id` is claimed but the backing Device's properties are not
/// held: never observed, its bind still outstanding, or its global id
/// ambiguously shared by two live Devices. **Withhold the node and keep
/// the readiness epoch not-ready**; re-classify when the Device resolves.
Withhold { device_id: GlobalId },
/// Positively a passive hardware terminal. Admit with
/// `session_device=true`.
SessionDevice,
/// Backed by a *resolved* Device but not a hardware-PCM terminal — a
/// filter or virtual node on a card, an unknown factory, or a Device with
/// no `device.api`. Admit with `session_device=false` (fail closed).
NotSessionDevice,
}
/// Classify a node's device claim against its backing Device.
///
/// `device` is the bound Device's properties, and `None` means the claim is
/// **unresolved** — never observed, bind outstanding, or an ambiguous
/// recycled id. It is only consulted when a `device_id` is present. Pure: the
/// model looks the Device up, and the I/O of *binding* it lives in the
/// adapter.
///
/// Where the two sides disagree the rule is deliberately asymmetric, and
/// safety picks the direction (v3.5 §6.7 decision 4):
///
/// - **Presence: the Device wins, the node is the fallback.** That is what
/// recovers a real card whose node was never given `alsa.driver_name`.
/// - **The denylist is a union.** If *either* side names a non-terminal
/// driver the node is not a session device. A disagreement here is not
/// expected on any measured configuration, and treating it as "the Device
/// says it is fine" would be the one reading that can leak.
pub fn classify(claim: &DeviceClaim, device: Option<&DeviceProps>) -> Classification {
let Some(device_id) = claim.device_id else {
// No backing Device: a stream. Not withheld, not a device.
return Classification::NotADevice;
};
let Some(device) = device else {
// Backed by a Device we have not resolved — the one case that blocks
// readiness. A provisional answer here is the leak the contract
// forbids.
return Classification::Withhold { device_id };
};
let on_factory_allowlist = claim
.factory_name
.as_deref()
.is_some_and(|f| HARDWARE_PCM_FACTORIES.contains(&f));
// A **present, non-denied** ALSA driver is required — absence fails closed
// (Codex phase-3 re-review). The factory allowlist cannot tell a real card
// from `snd_aloop`, which presents the same `api.alsa.pcm.*` factory, so a
// *missing* value must not be read as "not a loopback". Round 8 makes the
// bound Device the primary source, so a real card is no longer
// over-excluded merely because the session manager did not copy `alsa.*`
// onto its node.
let driver = device
.alsa_driver_name
.as_deref()
.or(claim.alsa_driver_name.as_deref());
let driver_denied = [
device.alsa_driver_name.as_deref(),
claim.alsa_driver_name.as_deref(),
]
.into_iter()
.flatten()
.any(|d| NON_TERMINAL_ALSA_DRIVERS.contains(&d));
let driver_ok = driver.is_some() && !driver_denied;
// The API must positively be the one the factory allowlist is written
// for, not merely present (Codex phase-3r review, finding 3). "Present"
// admitted `device.api=v4l2` alongside `factory.name=api.alsa.pcm.sink`
// — a contradiction no truthful configuration produces, which is exactly
// why it should be read as an observation gone wrong rather than as
// corroboration. Disagreement between the two sides fails closed for the
// same reason. ⚠️ Tied to [`HARDWARE_PCM_FACTORIES`] being ALSA-only:
// adding a BlueZ factory means allowing `bluez5` here too.
let api_ok = match (device.device_api.as_deref(), claim.device_api.as_deref()) {
(Some(from_device), Some(from_node)) if from_device != from_node => false,
(Some(api), _) | (None, Some(api)) => api == HARDWARE_PCM_API,
(None, None) => false,
};
let is_hardware_pcm = api_ok && on_factory_allowlist && driver_ok;
if is_hardware_pcm {
Classification::SessionDevice
} else {
// Resolved, but not positively a terminal: fail closed to false so
// the node keeps its owner keys and its backstop.
Classification::NotSessionDevice
}
}
+773
View File
@@ -0,0 +1,773 @@
//! The registry observer's **pure core** (impl plan §4, phases 3 and 3r).
//!
//! This is my half of the phase-3 split: a reducer that folds a stream of
//! typed [`RegEvent`]s into a live model of the PipeWire graph and projects
//! the [`GraphSnapshot`] + context the taint engine (phase 2) consumes. **No
//! PipeWire types appear here** — the I/O adapter (Codex's half) translates
//! live registry callbacks, binds, `/proc` reads, and the `core.sync`/`done`
//! round-trip into these events and feeds them in. Every test in this module
//! builds the event stream by hand.
//!
//! ## 🔴 Round 8 (v3.5 §6.7): the global is an INDEX, not a source of truth
//!
//! Phase 3 shipped reading node properties off the registry `global` event.
//! The registry announces only a fixed 13-key subset for a Node, and **eight
//! properties this feature depends on are never among them** — they read as
//! absent rather than failing, so the engine was silently, permanently
//! starved of both its primary taint root and every strong owner key (the
//! phase-5 gate failure, F1/F2). The rule that replaces it:
//!
//! > A node's properties come from a **bind**, never from the global. The
//! > global tells us an object exists, its id and its serial. Everything
//! > else — including `node.name` and `media.class`, so there is exactly one
//! > source — arrives on [`RegEvent::NodeInfo`]. Same for `Device`
//! > ([`RegEvent::DeviceInfo`]).
//!
//! Consequences visible in this file: a Node is admitted to the snapshot
//! **only** once its `info` has arrived (until then it is withheld and is a
//! readiness obligation); a Device resolves a node's claim only once *its*
//! `info` has arrived; and `info` may fire again for the lifetime of the
//! object, so [`RegEvent::NodeInfo`] is both the first resolution and every
//! later property change (v3.5 §6.7 decisions 14).
//!
//! Three things this core is shaped to get right, each an exit-gate row:
//!
//! - **Removal by recycled id.** `global_remove` names only a 32-bit global
//! id, and those recycle. The model keeps an insertion-ordered index per id
//! so a removal accounts for the *oldest* generation first, and the
//! snapshot projection treats any id still claimed by two live objects as
//! [`IdLookup::Ambiguous`] — fail closed (v3.4 §6.1.3). Everything the
//! model *owns* is keyed by never-recycled `object.serial`; ids are only
//! ever a lookup.
//! - **The readiness epoch.** `graph_ready` is false until the initial graph
//! is fully observed: the server has synced **and** no binds/withheld nodes
//! remain outstanding. A bounded timeout makes it fail closed. It gates
//! sticky *retirement* only; withholding after completion is per-object.
//! - **Withholding on unresolved input.** A node with no `info` yet, or one
//! claiming a `device.id` whose Device we have not resolved, is held out of
//! the snapshot entirely rather than admitted with provisional ownership
//! (see [`classify`]).
//!
//! **Three accepted limitations, all low-reachability, owed to a later
//! hardening round:**
//!
//! - *A Link dropped for a missing `object.serial`/props is unrepresented.*
//! The adapter drops such a global before it reaches [`RegistryModel`], so
//! readiness can reach `Complete` while permanently omitting that Link — an
//! invisible edge that could hide tainted ancestry. **Not reachable in
//! practice:** PipeWire's native protocol defines `object.serial` as the
//! unique identity every global carries, so a Link without one requires a
//! protocol/server failure, not ordinary churn. (The live gate is
//! consistent with this but does not *prove* it — it only counts Links the
//! strict parser already admitted.) A full fix needs a pure
//! "required-observation-failed" token that holds readiness false; deferred
//! rather than built for a case that does not occur.
//! - *Removal generation ordering assumes no removal is silently lost.* On a
//! recycled id with two live claimants, [`Self::on_removed`] retires the
//! oldest generation first; if the *first* generation's removal was never
//! delivered, a later removal is misattributed. PipeWire's registry does not
//! silently drop `global_remove`, so this needs callback loss to trigger.
//! The snapshot treats the two-claimant window as [`IdLookup::Ambiguous`]
//! (fail closed) meanwhile.
//! - *An unresolvable bind takes the whole graph down, not just its node*
//! (v3.5 §6.7 decision 3). A node whose `info` never arrives keeps
//! readiness false until the deadline, then sticky-[`Readiness::TimedOut`]
//! — no fan-out at all, identical to a never-resolving Link bind. Per-node
//! quarantine (that node ineligible **and** taint-bearing, the rest of the
//! graph still working) is strictly better and is deferred because it is a
//! new concept in the *pure engine*, not a fix to the observer.
#![allow(dead_code)] // Wired by the phase-3 adapter (Codex's half) and consumed by later phases.
pub mod adapter;
pub mod classify;
pub mod pulse_pid;
#[cfg(test)]
mod tests;
use crate::host::taint::snapshot::{
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
PortSnapshot, Serial,
};
use classify::{Classification, DeviceClaim, DeviceProps};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
/// A monotonic millisecond clock value, supplied by the adapter via
/// [`RegEvent::Tick`]. Kept as a bare integer rather than
/// [`std::time::Instant`] so the readiness timeout is deterministic in tests.
pub type Millis = u64;
/// A Node's **bound `info` properties** — the sole source of node properties
/// (v3.5 §6.7), delivered by [`RegEvent::NodeInfo`].
///
/// This carries no identity: the serial names the node on the event and the
/// global id was recorded by [`RegEvent::NodeAdded`], so the adapter cannot
/// contradict the index it already published. `session_device` inside
/// [`NodeObservation::props`] is left at its `false` default; the model
/// overwrites it from the [`classify`] result at projection time, once the
/// backing Device (if any) is resolved.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NodeObservation {
pub name: Option<String>,
pub role: MediaRole,
pub props: NodeProps,
pub device_claim: DeviceClaim,
}
/// The four endpoint references a Link carries. Node endpoints are required —
/// a Link with unknown nodes is useless — so this whole struct is what the
/// adapter must resolve (from the global's props if present, else by binding
/// `LinkInfoRef`, the correctness path) before a Link enters the snapshot.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LinkEndpoints {
pub output_node: GlobalId,
pub input_node: GlobalId,
pub output_port: Option<GlobalId>,
pub input_port: Option<GlobalId>,
}
/// A typed observation of the live graph. The adapter produces these; the
/// model consumes them in [`RegistryModel::apply`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RegEvent {
/// A Node global appeared. **Index only** — the global's properties are a
/// filtered subset and are not read (v3.5 §6.7). The node is withheld
/// from the snapshot and is a readiness obligation until its
/// [`RegEvent::NodeInfo`] arrives.
NodeAdded { serial: Serial, id: GlobalId },
/// A bound Node's `info` properties. **Both** the first resolution and
/// every later `PROPS` change for the node's lifetime — the model tells
/// them apart, so the adapter holds no per-node "have I seen info yet?"
/// state to get wrong. An `info` for a serial we do not hold (a node
/// already removed) is ignored.
NodeInfo {
serial: Serial,
observation: NodeObservation,
},
/// A Port global appeared.
PortAdded(PortSnapshot),
/// A Client global appeared. Feeds pulse-PID derivation via `sec_pid`.
ClientAdded(ClientSnapshot),
/// A Device global appeared. Index only, exactly as for a Node: it does
/// not resolve anything until [`RegEvent::DeviceInfo`] arrives.
DeviceAdded { serial: Serial, id: GlobalId },
/// A bound Device's `info` properties — the **authoritative** source of
/// `device.api` and `alsa.driver_name` (v3.5 §6.7 decision 4). Resolves
/// every node withheld on this Device's id.
DeviceInfo { serial: Serial, props: DeviceProps },
/// A Link global appeared. `endpoints` is `Some` when the global carried
/// them (the optimisation) and `None` when the adapter must bind to learn
/// them (the correctness path) — the latter is an outstanding obligation
/// until a matching [`RegEvent::LinkEndpointsResolved`] arrives.
///
/// Unlike Nodes and Devices, Link endpoint props **are** announced on the
/// global (measured, phase-5 results F1), so this asymmetry is real and
/// deliberate.
LinkAdded {
serial: Serial,
id: GlobalId,
endpoints: Option<LinkEndpoints>,
},
/// The bind-`LinkInfoRef` fallback resolved a Link's endpoints.
LinkEndpointsResolved {
serial: Serial,
endpoints: LinkEndpoints,
},
/// The adapter read `/proc/<pid>/comm` (`None` = the read failed / the
/// process is gone). Validates the pulse-PID candidate.
ProcCommProbed { pid: u32, comm: Option<String> },
/// Any global was removed. Only its 32-bit id is known.
Removed { id: GlobalId },
/// A `core.sync()` issued after the initial enumeration completed its
/// round-trip (`done`). One half of readiness; the other is that no
/// binds/withheld nodes are still outstanding.
ServerSynced,
/// A monotonic clock sample. Drives the readiness timeout only.
Tick { now: Millis },
}
/// What kind of observation drove a projection.
///
/// Derived from the event itself ([`RegEvent::kind`]) rather than passed
/// alongside it, so a consumer's view of "was this a real graph change?" cannot
/// disagree with what the model was actually fed. The distinction matters to the
/// phase-5 audit twice over: ticks arrive at a constant rate and would inflate
/// any measured graph-event rate, and a record that is identical to the previous
/// one is worth suppressing on a tick but never on a graph event.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EventKind {
/// A registry observation: an add, a removal, a bind resolution, a `/proc`
/// probe, or the server sync.
Graph,
/// The periodic clock sample. Carries no graph information; it exists so the
/// readiness timeout and the AEC validation deadline have a clock.
Tick,
}
impl EventKind {
pub fn code(self) -> &'static str {
match self {
Self::Graph => "graph",
Self::Tick => "tick",
}
}
}
impl RegEvent {
pub fn kind(&self) -> EventKind {
match self {
Self::Tick { .. } => EventKind::Tick,
_ => EventKind::Graph,
}
}
}
/// Whether an applied event could have changed the projection.
///
/// The suppression rule of v3.5 §6.7 decision 2, in the one place that can
/// enforce it: **a property update may be dropped only when the resulting
/// [`Projection`] is identical to the current one.** The projection is a pure
/// function of model state, so "state provably unchanged" *is* "projection
/// identical" — which is what [`Outcome::Suppressed`] means and why the check
/// is a cheap field comparison rather than building and diffing two snapshots.
///
/// Anything looser (dropping updates that do change state) breaks phase 4's
/// no-coalescing contract, which needs to see the empty gap between an AEC
/// module unload and a reload that reuses the index. Anything stricter
/// (publishing on every `info`, including the state-only changes PipeWire
/// emits constantly) inflates the O5 event rate with non-events.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
/// Model state may have changed; the caller must publish the projection.
Applied,
/// Model state provably did not change; publishing is optional and the
/// adapter skips it.
Suppressed,
}
/// Which slot in the id index a live object occupies. `global_remove` gives
/// only the id, so the index remembers what each id currently holds. Every
/// slot names its object by never-recycled serial.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Slot {
Node(Serial),
Port(Serial),
Link(Serial),
Client(Serial),
Device(Serial),
}
/// The readiness epoch. A one-time transition out of [`Readiness::Waiting`];
/// both terminal states are sticky (a completed graph is not un-completed by
/// later per-object withholding, and a timed-out observer stays fail-closed
/// for its lifetime).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Readiness {
/// The initial enumeration is still in flight.
Waiting,
/// The initial enumeration finished at least once (server synced with no
/// obligations then outstanding). **Sticky** — later per-object
/// withholding does not revert it. Note this is *not* the same as
/// [`RegistryModel::graph_ready`], which additionally requires no *current*
/// obligation (Codex finding 1); `Complete` only records that the epoch
/// was reached.
Complete,
/// The bounded deadline passed with obligations outstanding.
/// `graph_ready` stays false — fail closed.
TimedOut,
}
/// The pure handoff to the taint engine: a coherent [`GraphSnapshot`] plus the
/// two context fields phase 3 owns. The caller merges these into
/// [`crate::host::taint::ExclusionCtx`] alongside `aec_module_id` (phase 4)
/// and `pixelpass_owned` (pixelpass's own tracking).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Projection {
pub snapshot: GraphSnapshot,
pub pipewire_pulse_pid: Option<u32>,
pub graph_ready: bool,
/// The sticky readiness epoch behind `graph_ready`. Carried so a consumer
/// can tell the three not-ready causes apart — enumeration still in flight
/// ([`Readiness::Waiting`]), a fail-closed timeout ([`Readiness::TimedOut`]),
/// or a completed epoch momentarily blocked on a current obligation
/// ([`Readiness::Complete`] with `graph_ready == false`). `graph_ready`
/// alone collapses all three into "no". The phase-5 audit reports it as the
/// epoch column; nothing gates on it.
pub readiness: Readiness,
}
/// A live Node: its global id (for link endpoint lookup) plus its bound
/// properties once they arrive.
#[derive(Clone, Debug, PartialEq, Eq)]
struct NodeEntry {
id: GlobalId,
/// `None` while the bind is outstanding — withheld from the snapshot and
/// an outstanding readiness obligation (v3.5 §6.7 decision 3).
obs: Option<NodeObservation>,
}
/// A live Device: its global id plus its bound properties once they arrive.
#[derive(Clone, Debug, PartialEq, Eq)]
struct DeviceEntry {
id: GlobalId,
/// `None` while the bind is outstanding. A node claiming this Device
/// stays withheld until it is `Some` — the Device's `device.api` and
/// `alsa.driver_name` are the authoritative inputs to `session_device`
/// (v3.5 §6.7 decision 4), so classifying without them would be the same
/// provisional answer the contract forbids.
props: Option<DeviceProps>,
}
/// The live model. Folds [`RegEvent`]s; project with [`RegistryModel::project`].
#[derive(Clone, Debug)]
pub struct RegistryModel {
/// **Every** live Node, keyed by serial — admitted or withheld. Admission
/// is decided at projection time from the entry's own state, so there is
/// no admitted/withheld pair of maps to drift apart.
nodes: BTreeMap<Serial, NodeEntry>,
/// Every live Device, keyed by serial.
devices: BTreeMap<Serial, DeviceEntry>,
ports: BTreeMap<Serial, PortSnapshot>,
links: BTreeMap<Serial, LinkSnapshot>,
clients: BTreeMap<Serial, ClientSnapshot>,
/// Links whose endpoints the adapter is still binding; the id is kept so
/// removal and resolution can find them.
pending_links: BTreeMap<Serial, GlobalId>,
/// Insertion-ordered holders of each live global id. `global_remove`
/// accounts for the oldest generation first (v3.4 §6.1.3).
live_ids: BTreeMap<GlobalId, VecDeque<Slot>>,
/// `/proc/<pid>/comm` reads keyed by pid, for pulse-PID validation.
probed_comm: BTreeMap<u32, Option<String>>,
server_synced: bool,
readiness: Readiness,
deadline: Millis,
last_now: Millis,
}
impl RegistryModel {
/// `now` seeds the clock; `timeout` is the readiness budget. The deadline
/// is `now + timeout`; a [`RegEvent::Tick`] at or past it while still
/// [`Readiness::Waiting`] fails the epoch closed.
pub fn new(now: Millis, timeout: Millis) -> Self {
Self {
nodes: BTreeMap::new(),
devices: BTreeMap::new(),
ports: BTreeMap::new(),
links: BTreeMap::new(),
clients: BTreeMap::new(),
pending_links: BTreeMap::new(),
live_ids: BTreeMap::new(),
probed_comm: BTreeMap::new(),
server_synced: false,
readiness: Readiness::Waiting,
deadline: now.saturating_add(timeout),
last_now: now,
}
}
pub fn readiness(&self) -> Readiness {
self.readiness
}
/// Whether the graph is trustworthy enough to make eligibility and sticky
/// **retirement** decisions right now.
///
/// This is **dynamic**, not the sticky [`Readiness::Complete`] flag: it is
/// true only when the initial enumeration has completed **and** there are
/// no current obligations outstanding (a node whose bind is outstanding, a
/// node withheld on an unresolved Device, or a Link still being bound).
/// The distinction is the fix for Codex phase-3 review finding 1: a Link
/// whose endpoints are still resolving is an **invisible edge** — it is
/// absent from the snapshot, not merely dangling — so a decision made
/// while one exists can miss real tainted ancestry and wrongly report a
/// candidate eligible. Unresolved ancestry ⇒ fail closed is the governing
/// invariant (v3.4 §6.1), and round 8 adds the far more common case: an
/// unbound node is an invisible *vertex*, which hides everything the edge
/// case hides and its ownership besides.
///
/// [`Readiness::Complete`] stays sticky (it records that the initial
/// enumeration happened, for logging and to distinguish "not started" from
/// "momentarily churning"); `graph_ready` layers the dynamic obligation
/// check on top. Downstream (phase 6) may debounce the brief blips a
/// normal bind causes; the observer's job is to report the truth.
pub fn graph_ready(&self) -> bool {
matches!(self.readiness, Readiness::Complete) && !self.obligations_outstanding()
}
/// The pulse-PID candidates the adapter should be probing — every distinct
/// `sec_pid` on the current Clients. Exposed so the adapter re-probes only
/// the PIDs *entering* the set rather than all of them on every event.
pub fn pulse_pid_candidates(&self) -> BTreeSet<u32> {
let clients: Vec<ClientSnapshot> = self.clients.values().cloned().collect();
pulse_pid::candidates(&clients)
}
/// Forget probed `comm`s for PIDs no Client presents any more.
///
/// Cannot change the projection — [`Self::pulse_pid`] only ever reads
/// `comm`s for PIDs in the current candidate set — so it is deliberately
/// not an [`Outcome`]-returning `apply` arm: it must not publish, and it
/// must not count as a graph event for O5. Its purpose is to bound the map
/// (one entry per live Client PID) in a host process that runs for hours,
/// and to guarantee a PID that leaves and returns is re-probed rather than
/// answered from a stale `comm`.
pub fn retain_probed_comms(&mut self, live: &BTreeSet<u32>) {
self.probed_comm.retain(|pid, _| live.contains(pid));
}
/// Fold one observation into the model. The returned [`Outcome`] tells the
/// caller whether the projection can have changed; see [`Outcome`] for why
/// that is the only sound place to enforce the suppression rule.
pub fn apply(&mut self, event: RegEvent) -> Outcome {
match event {
RegEvent::NodeAdded { serial, id } => {
self.push_id(id, Slot::Node(serial));
self.nodes.insert(serial, NodeEntry { id, obs: None });
// A node awaiting its bind is a fresh obligation, so this can
// only ever *hold* readiness, never complete it — but the
// re-check is cheap and keeps the invariant local.
self.maybe_complete();
Outcome::Applied
}
RegEvent::NodeInfo {
serial,
observation,
} => self.on_node_info(serial, observation),
RegEvent::PortAdded(port) => {
self.push_id(port.id, Slot::Port(port.serial));
self.ports.insert(port.serial, port);
Outcome::Applied
}
RegEvent::ClientAdded(client) => {
self.push_id(client.id, Slot::Client(client.serial));
self.clients.insert(client.serial, client);
// A new client can change the pulse candidate; the adapter
// learns that via `pulse_pid_candidate`. No readiness effect.
Outcome::Applied
}
RegEvent::DeviceAdded { serial, id } => {
self.push_id(id, Slot::Device(serial));
self.devices.insert(serial, DeviceEntry { id, props: None });
self.maybe_complete();
Outcome::Applied
}
RegEvent::DeviceInfo { serial, props } => self.on_device_info(serial, props),
RegEvent::LinkAdded {
serial,
id,
endpoints,
} => {
self.on_link_added(serial, id, endpoints);
Outcome::Applied
}
RegEvent::LinkEndpointsResolved { serial, endpoints } => {
self.on_link_resolved(serial, endpoints)
}
RegEvent::ProcCommProbed { pid, comm } => {
let previous = self.probed_comm.insert(pid, comm.clone());
if previous.as_ref() == Some(&comm) {
Outcome::Suppressed
} else {
Outcome::Applied
}
}
RegEvent::Removed { id } => self.on_removed(id),
RegEvent::ServerSynced => {
let already = self.server_synced;
self.server_synced = true;
self.maybe_complete();
if already {
Outcome::Suppressed
} else {
Outcome::Applied
}
}
RegEvent::Tick { now } => {
self.last_now = now;
self.maybe_timeout(now);
Outcome::Applied
}
}
}
/// First resolution *and* every later property change (v3.5 §6.7
/// decision 2). The model distinguishes them by what it already holds, so
/// the adapter can forward every `info` callback unconditionally.
fn on_node_info(&mut self, serial: Serial, observation: NodeObservation) -> Outcome {
let Some(entry) = self.nodes.get_mut(&serial) else {
// A late `info` for a node already removed. Re-inserting it here
// would resurrect a dead node with no id index behind it.
tracing::debug!(serial = serial.0, "observer: node info for an unknown node");
return Outcome::Suppressed;
};
if entry.obs.as_ref() == Some(&observation) {
// The state-only `info` callbacks PipeWire emits constantly: same
// properties, so the projection is provably identical.
return Outcome::Suppressed;
}
entry.obs = Some(observation);
// The first `info` retires this node's obligation, which can be the
// last one outstanding.
self.maybe_complete();
Outcome::Applied
}
fn on_device_info(&mut self, serial: Serial, props: DeviceProps) -> Outcome {
let Some(entry) = self.devices.get_mut(&serial) else {
tracing::debug!(
serial = serial.0,
"observer: device info for an unknown device"
);
return Outcome::Suppressed;
};
if entry.props.as_ref() == Some(&props) {
return Outcome::Suppressed;
}
entry.props = Some(props);
// Resolving a Device admits every node that was withheld on it —
// which happens at projection time; here it can only retire
// obligations.
self.maybe_complete();
Outcome::Applied
}
fn on_link_added(&mut self, serial: Serial, id: GlobalId, endpoints: Option<LinkEndpoints>) {
self.push_id(id, Slot::Link(serial));
match endpoints {
Some(e) => {
self.links.insert(serial, link_snapshot(serial, id, e));
}
None => {
// Correctness path: withhold the Link until the bind fallback
// resolves it. Counts as an outstanding obligation.
self.pending_links.insert(serial, id);
}
}
self.maybe_complete();
}
fn on_link_resolved(&mut self, serial: Serial, endpoints: LinkEndpoints) -> Outcome {
// `remove` also guards against a stale resolution for a Link already
// gone: unknown serial ⇒ ignore.
if let Some(id) = self.pending_links.remove(&serial) {
self.links
.insert(serial, link_snapshot(serial, id, endpoints));
self.maybe_complete();
Outcome::Applied
} else {
Outcome::Suppressed
}
}
fn on_removed(&mut self, id: GlobalId) -> Outcome {
let Some(queue) = self.live_ids.get_mut(&id) else {
tracing::warn!(global_id = id.0, "observer: remove for an id we never saw");
return Outcome::Suppressed;
};
// Oldest generation first — the id may be shared during a
// missed-removal window.
let slot = queue.pop_front();
if queue.is_empty() {
self.live_ids.remove(&id);
}
match slot {
Some(Slot::Node(serial)) => {
self.nodes.remove(&serial);
}
Some(Slot::Port(serial)) => {
self.ports.remove(&serial);
}
Some(Slot::Link(serial)) => {
self.links.remove(&serial);
self.pending_links.remove(&serial);
}
Some(Slot::Client(serial)) => {
self.clients.remove(&serial);
}
Some(Slot::Device(serial)) => {
self.devices.remove(&serial);
}
None => {
tracing::warn!(global_id = id.0, "observer: empty id slot on remove");
return Outcome::Suppressed;
}
}
// A removal can drain the last obligation (an unbound node, a node
// withheld on a Device, or a pending link vanished before it
// resolved).
self.maybe_complete();
Outcome::Applied
}
fn push_id(&mut self, id: GlobalId, slot: Slot) {
self.live_ids.entry(id).or_default().push_back(slot);
}
/// The bound properties of the Device a node claims by global id, or
/// `None` when that claim is unresolved — which covers every fail-closed
/// case at once: no such Device observed, its bind still outstanding, or
/// **the id claimed by more than one live global**, where there is no way
/// to tell whose properties these are (v3.4 §6.1.3).
///
/// ⚠️ The ambiguity test is "**exactly one** live global holds this id",
/// not "exactly one live *Device*" (Codex phase-3r review, finding 2).
/// The weaker test looks equivalent and is not: with `[Device, Port]` on
/// one id — a missed removal, the same precondition as every other
/// recycled-id hazard — it keeps answering with the older Device's
/// properties, so a node claiming that id holds a stale
/// `session_device = true`. That flag *removes* the node's owner keys and
/// its fail-closed backstop, so a forwarder wearing it can put its output
/// leg back on the eligible side: echo, from a lookup that was merely
/// looking at the wrong object type.
fn device_props(&self, id: GlobalId) -> Option<&DeviceProps> {
let slots = self.live_ids.get(&id)?;
if slots.len() != 1 {
return None; // Ambiguous ⇒ unresolved ⇒ withheld.
}
let Slot::Device(serial) = slots.front()? else {
// The id is live, but it is not a Device any more.
return None;
};
self.devices.get(serial)?.props.as_ref()
}
/// Classify one node's device claim against the currently resolved
/// Devices. Recomputed per projection rather than cached at admission:
/// the inputs (this node's props, its Device's props) both change over an
/// object's lifetime now, and a cached classification is exactly the kind
/// of stale provisional answer §6.1.3 forbids.
fn classification(&self, obs: &NodeObservation) -> Classification {
let device = obs
.device_claim
.device_id
.and_then(|id| self.device_props(id));
classify::classify(&obs.device_claim, device)
}
/// Every obligation that must clear before the initial graph is trusted:
/// no node awaiting its bind, no node withheld on an unresolved Device,
/// no Link awaiting its bind.
fn obligations_outstanding(&self) -> bool {
if !self.pending_links.is_empty() {
return true;
}
self.nodes.values().any(|entry| match &entry.obs {
None => true,
Some(obs) => matches!(self.classification(obs), Classification::Withhold { .. }),
})
}
/// Completion needs no clock — only the sync flag and an empty obligation
/// set — so it may fire on any mutating event. Sticky once reached.
fn maybe_complete(&mut self) {
if self.readiness != Readiness::Waiting {
return;
}
if self.server_synced && !self.obligations_outstanding() {
self.readiness = Readiness::Complete;
tracing::info!("observer: readiness epoch reached (synced + no obligations)");
}
}
/// Only the timeout consults the clock.
fn maybe_timeout(&mut self, now: Millis) {
if self.readiness != Readiness::Waiting {
return;
}
if now >= self.deadline {
self.readiness = Readiness::TimedOut;
tracing::warn!(
unbound_nodes = self.unbound_node_count(),
withheld = self.withheld_node_count(),
pending_links = self.pending_links.len(),
"observer: readiness epoch timed out with obligations outstanding — fail closed"
);
}
}
/// Nodes whose bind has not delivered `info` yet — diagnostics only.
fn unbound_node_count(&self) -> usize {
self.nodes
.values()
.filter(|entry| entry.obs.is_none())
.count()
}
/// Nodes held out on an unresolved Device — diagnostics only.
fn withheld_node_count(&self) -> usize {
self.nodes
.values()
.filter(|entry| {
entry.obs.as_ref().is_some_and(|obs| {
matches!(self.classification(obs), Classification::Withhold { .. })
})
})
.count()
}
/// pipewire-pulse's PID from the current clients, validated against the
/// probed `comm`. `None` whenever anything is ambiguous or unconfirmed —
/// the safe answer (key 4 unusable).
fn pulse_pid(&self) -> Option<u32> {
pulse_pid::resolve(&self.pulse_pid_candidates(), |pid| {
self.probed_comm.get(&pid).cloned().flatten()
})
}
/// Project the current state into the taint engine's inputs.
///
/// A node enters the snapshot only if its bind has delivered `info`
/// **and** its device claim classifies terminally; anything else is
/// withheld (and is already holding `graph_ready` false).
pub fn project(&self) -> Projection {
let nodes: Vec<NodeSnapshot> = self
.nodes
.iter()
.filter_map(|(&serial, entry)| {
let obs = entry.obs.as_ref()?;
let session_device = match self.classification(obs) {
Classification::Withhold { .. } => return None,
Classification::SessionDevice => true,
Classification::NotADevice | Classification::NotSessionDevice => false,
};
let mut props = obs.props.clone();
props.session_device = session_device;
Some(NodeSnapshot {
serial,
id: entry.id,
name: obs.name.clone(),
role: obs.role,
props,
})
})
.collect();
let snapshot = GraphSnapshot::new(
nodes,
self.ports.values().cloned().collect(),
self.links.values().cloned().collect(),
self.clients.values().cloned().collect(),
);
Projection {
snapshot,
pipewire_pulse_pid: self.pulse_pid(),
graph_ready: self.graph_ready(),
readiness: self.readiness,
}
}
}
fn link_snapshot(serial: Serial, id: GlobalId, e: LinkEndpoints) -> LinkSnapshot {
LinkSnapshot {
serial,
id,
output_node: e.output_node,
input_node: e.input_node,
output_port: e.output_port,
input_port: e.input_port,
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Deriving pipewire-pulse's own PID — pure, no PipeWire and no `/proc` I/O.
//!
//! The owner bridge's key 4 is `application.process.id`. For a stream created
//! by a **Pulse-emulated** client that PID is *pipewire-pulse's own*, shared
//! verbatim across every unrelated Pulse app, so bridging on it would fuse
//! every Pulse module into one tainted owner (design v3.4 §5.2 correction 5,
//! §6.1.2). The engine therefore needs to know that one PID so it can refuse
//! to bridge on it — and **every** way of deriving it can fail, in which case
//! the safe answer is `None`: key 4 becomes unusable (coarser, never wrong).
//!
//! The derivation is split into two pure stages so the I/O — reading
//! `/proc/<pid>/comm` — stays in the adapter:
//!
//! 1. [`candidates`] lists the PIDs worth probing from the graph alone: every
//! distinct `pipewire.sec.pid` any Client presents.
//! 2. [`resolve`] picks the one whose `comm`, as read from `/proc` by the
//! adapter, is exactly pipewire-pulse's. This is also what closes **PID
//! reuse**: a recycled PID is rejected because `/proc/<pid>/comm` now names
//! a different process.
//!
//! Any failure — no Client carries the property, no `comm` matches, `/proc`
//! gone, or *several* PIDs claim to be pipewire-pulse — yields `None`.
//!
//! ## ⚠️ Round 10 (MEASURED): repetition is not the signal
//!
//! Stage 1 used to return a single candidate: the one `sec_pid` value shared by
//! two or more Clients, reasoning that "native PipeWire clients carry their own
//! distinct PID; only the Pulse shim repeats one value". **That is false on a
//! stock desktop, and the phase-5 §5.1 matrix caught it on row 1.** Measured on
//! this host (PipeWire 1.6.8 / WirePlumber 0.5.15): WirePlumber holds *two*
//! Clients — `WirePlumber` and `WirePlumber [export]` — both carrying
//! `sec_pid` 1747. So two values repeated (1747 and pipewire-pulse's 2528), the
//! old rule called that ambiguous and returned `None`, and the consequence was
//! not a missing optimisation but a machine-wide over-exclusion cascade: with
//! the daemon PID unknown, key 4's suppression never fires, every
//! Pulse-emulated node fuses into one owner, and the eligible half of every row
//! empties out (see `owner::keys_of`'s fail-closed asymmetry note).
//!
//! The rule failed in *both* directions, which is why the prefilter is gone
//! rather than patched:
//!
//! - **False ambiguity** — any second process holding two Clients defeats it.
//! WirePlumber always does, so this was permanent, not a corner case.
//! - **False absence** — a session where pipewire-pulse happens to hold exactly
//! one Client (one Pulse app running) never repeats a value at all, so the
//! candidate is missed and the same cascade follows.
//!
//! `comm` was always the authoritative check; repetition was a heuristic
//! standing in front of it, and it was wrong. Probing every distinct `sec_pid`
//! costs one `/proc` read per *distinct* PID (single digits — bounded by the
//! Client count, cached, and re-read only when the candidate set changes),
//! which is a cheap price for a signal that does not encode an assumption about
//! how many Clients anyone else opens.
use crate::host::taint::snapshot::ClientSnapshot;
use std::collections::BTreeSet;
/// The kernel `comm` of the pipewire-pulse process. `comm` is truncated to
/// 15 bytes by the kernel; `pipewire-pulse` is 14 bytes, so it is exact —
/// and exact is the only safe match, since a prefix match would accept a
/// recycled PID belonging to e.g. `pipewire-pulseX`.
const PULSE_COMM: &str = "pipewire-pulse";
/// Stage 1: every PID worth probing — the distinct `pipewire.sec.pid` values
/// the Clients present.
///
/// No filtering, and deliberately so (see the module docs): any rule applied
/// here is a guess about other processes' Client counts, while stage 2 has the
/// kernel's own answer. A `BTreeSet` because the adapter diffs successive
/// candidate sets to decide what to re-probe, and that diff must not depend on
/// Client iteration order.
pub fn candidates(clients: &[ClientSnapshot]) -> BTreeSet<u32> {
clients.iter().filter_map(|client| client.sec_pid).collect()
}
/// Stage 2: confirm one candidate against the `comm` read from
/// `/proc/<candidate>/comm`.
///
/// `comm` is `None` when the adapter's read failed — the `/proc` entry is
/// gone (the process exited between derivation and probe) — which is itself a
/// reason to fail closed. A present-but-different `comm` is the **PID reuse**
/// guard: the number is live but now belongs to someone else.
pub fn validate(candidate: u32, comm: Option<&str>) -> Option<u32> {
match comm {
Some(PULSE_COMM) => Some(candidate),
_ => None,
}
}
/// Stage 2 across the whole candidate set: the *unique* PID whose `comm` is
/// pipewire-pulse's.
///
/// `None` when none matches (nothing to suppress that we can prove) and also
/// when **several** do. Several means either two pipewire-pulse daemons are
/// live — a nested or sandboxed session — or a `comm` collision, and a single
/// `Option<u32>` cannot suppress two owners. Failing closed here lands on the
/// over-exclusion side, matching the asymmetry `owner::keys_of` already
/// documents: broad over-exclusion is annoying, a missed suppression is an
/// echo. Suppressing a *set* of daemon PIDs is the real answer if a
/// multi-daemon host ever turns up; it is not v1, and it is recorded rather
/// than silently approximated.
pub fn resolve(candidates: &BTreeSet<u32>, comm_of: impl Fn(u32) -> Option<String>) -> Option<u32> {
let mut found = None;
for &pid in candidates {
if validate(pid, comm_of(pid).as_deref()).is_some() {
if found.is_some() {
return None;
}
found = Some(pid);
}
}
found
}
/// Both stages composed, for callers that can probe on demand.
///
/// The model keeps them separate — it recomputes the candidate set as Clients
/// churn and only re-probes PIDs entering it — so this is a convenience for
/// tests and for the fully-resolved path.
pub fn derive(clients: &[ClientSnapshot], comm_of: impl Fn(u32) -> Option<String>) -> Option<u32> {
resolve(&candidates(clients), comm_of)
}
File diff suppressed because it is too large Load Diff
+65
View File
@@ -36,6 +36,10 @@ pub struct Graph {
/// (GStreamer opens one per stream) pass clients explicitly instead.
client_by_app: BTreeMap<u32, GlobalId>,
client_by_module: BTreeMap<u64, GlobalId>,
/// Native (non-Pulse-emulated) clients, whose `pipewire.sec.pid` is the
/// app's **own** pid rather than pipewire-pulse's. See
/// [`Graph::native_client_node`].
native_client_by_app: BTreeMap<u32, GlobalId>,
session_client: Option<GlobalId>,
}
@@ -84,6 +88,35 @@ impl Graph {
id
}
/// A **native PipeWire** client's stream: `client.id` on the node, **no
/// `application.process.id`**, and the app's real pid only on the Client
/// as `pipewire.sec.pid`.
///
/// ⚠️ This is what an ordinary app actually looks like when it does not go
/// through pipewire-pulse — measured for mpv on its default ao and for
/// peerspeak's own playback stream. [`Graph::app_node`] models the
/// Pulse-emulated shape, where the pid is on the node and the Client's
/// `sec_pid` is the *daemon's*; both shapes are live on this host, and
/// only this one exercises key 4's Client fallback (round 10, R10-3).
pub fn native_client_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
let client = match self.native_client_by_app.get(&pid) {
Some(id) => *id,
None => {
let id = self.client(Some(pid));
self.native_client_by_app.insert(pid, id);
id
}
};
self.node(
name,
role,
NodeProps {
client_id: Some(client),
..NodeProps::default()
},
)
}
/// An ordinary application stream: its own client, its own PID.
pub fn app_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
@@ -152,11 +185,43 @@ impl Graph {
self.node(name, role, app(client, pid))
}
/// A peerspeak-owned node carrying **both** ownership carriers, as a
/// live one does. `name` gets the real `node.name` prefix so the fixture
/// cannot pass on the property alone.
pub fn peerspeak_node(&mut self, name: &str, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
let name = format!("{}{name}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX);
self.node(&name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
}
/// Both ownership carriers on a node of **any** role — an impostor, or a
/// producer-side tagging bug. Only [`MediaRole::StreamOutput`] makes it a
/// taint root (round 10, R10-1); every other role must be ignored, and
/// these are the fixtures that prove it.
pub fn peerspeak_tagged_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
let name = format!("{}{name}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX);
self.node(&name, role, peerspeak_owned(client, pid))
}
/// Carrier 1 alone: the `peerspeak.owned` property present, the
/// `node.name` prefix absent. What the engine sees for a node it had to
/// bind to observe (v3.5 §6.7).
pub fn peerspeak_node_prop_only(&mut self, name: &str, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
self.node(name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
}
/// Carrier 2 alone: the `node.name` prefix present, the property absent
/// — indistinguishable from an ordinary app in every other respect.
/// This is the case that survives the F1 observation defect, and the
/// reason round 8 added a second carrier at all.
pub fn peerspeak_node_name_only(&mut self, role: &str, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
let name = format!("{}{role}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX);
self.node(&name, MediaRole::StreamOutput, app(client, pid))
}
pub fn node(&mut self, name: &str, role: MediaRole, props: NodeProps) -> NodeRef {
let id = self.id();
self.node_with_id(name, role, id, props)
+234 -39
View File
@@ -100,8 +100,11 @@
pub mod owner;
pub mod snapshot;
// `pub` so the phase-5 audit's pure tests can drive the auditor with the same
// graph builder the taint fixtures use — one fixture vocabulary, so an audit
// test and a taint test describing the same topology cannot drift apart.
#[cfg(test)]
mod fixture;
pub mod fixture;
#[cfg(test)]
mod tests;
@@ -120,6 +123,44 @@ pub const CAPTURE_SINK_PREFIX: &str = "pixelpass_capture_";
/// what `pulse.module.id` is for (v3.4 §5.2 correction 4).
pub const ECHO_CANCEL_GROUP_PREFIX: &str = "echo-cancel-";
/// Ownership carrier 1: the node property peerspeak sets on everything it
/// plays (v3.5 §5.1). Read at the observer boundary, which is the only place
/// that touches raw property names — see [`super::observer`].
///
/// ⚠️ **Cross-repo wire contract.** peerspeak emits this; it does not depend
/// on this crate, nor this crate on it. The values are pinned in
/// `tests/fixtures/ownership-tag-contract.txt`, committed byte-identical in
/// both repos, and asserted by [`tests::ownership_carriers_match_the_cross_repo_fixture`].
/// The producer's matching constants live in peerspeak
/// `src/audio/ownership.rs`. Changing either is a both-repos-same-session
/// change that invalidates the phase 5 matrix.
pub const PEERSPEAK_OWNED_PROP: &str = "peerspeak.owned";
/// The value peerspeak emits for [`PEERSPEAK_OWNED_PROP`], and the **only**
/// value this consumer reads as owned.
///
/// ⚠️ This doc used to say the opposite — that any truthy value counted, on
/// the theory that treating an unexpected value as "owned" is the fail-closed
/// direction. R10-4 removed that leniency and the round-10 review caught the
/// prose surviving it here and in the shared fixture. The theory is wrong:
/// leniency buys false-positive *exclusion*, not safety, and it let any
/// process suppress a rival application's audio from the share with a
/// property it did not have to spell right. Fail-closed on this feature is
/// about **ancestry** — an unresolvable graph is not eligible — not about
/// parsing. The matching lives in the observer's `peerspeak_owned`, which is
/// deliberately *not* the lenient `truthy` used for PipeWire's own booleans.
pub const PEERSPEAK_OWNED_VALUE: &str = "1";
/// Ownership carrier 2: a `node.name` prefix (v3.5 §5.1, round 8).
///
/// Matched as a **union** with [`PEERSPEAK_OWNED_PROP`] — either one makes a
/// node peerspeak-owned. Two carriers because a property is invisible to the
/// registry `global` event and recoverable only by binding the node (v3.5
/// §6.7), which is precisely how the phase-5 gate failed; this one is
/// announced directly. A union is also the fail-closed direction: a missed
/// tag leaks call audio into the share, a spurious one only over-excludes.
pub const PEERSPEAK_OWNED_NODE_PREFIX: &str = "peerspeak_owned_";
/// Why a node is tainted or excluded. Stable machine-readable codes: this
/// value is the phase 5 audit output, the phase 6 status event, and the
/// eventual answer to "why isn't this app being shared?".
@@ -375,39 +416,36 @@ pub fn evaluate(
ctx: &ExclusionCtx,
prior: &StickyState,
) -> (Decisions, StickyState) {
let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid);
let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid);
// Built once and shared: it carries the Client → `pipewire.sec.pid` index
// that key 4 falls back to (round 10, R10-3), so the components and the
// key index must be derived from the *same* one or they would disagree
// about which nodes are bounded.
let owner_ctx = owner::OwnerCtx::new(snapshot, ctx.pipewire_pulse_pid);
let components = OwnerComponents::build(snapshot, &owner_ctx);
let keys = owner::OwnerKeyIndex::build(snapshot, &owner_ctx);
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
seed_local_roots(snapshot, ctx, &mut taint);
seed_sticky(
// Pass 1 — the fail-closed view. Every decision is made from this one, so
// "we could not see" counts as taint.
let (taint, sticky_serials) = compute_taint(
snapshot,
ctx,
&keys,
prior,
&components,
&mut taint,
&mut sticky_serials,
prior,
Uncertainty::FailsClosed,
);
// Monotone fixpoint: every step only adds taint, or lowers a node's
// reason priority, both of which are bounded. Link propagation and the
// owner bridge feed each other — a bridged output leg has downstream
// links, and a downstream monitor reader bridges to its own siblings —
// so neither can be run once.
let edges = downstream_edges(snapshot, &mut taint);
loop {
let mut changed = false;
changed |= propagate_links(&edges.edges, &mut taint);
changed |= propagate_owner_bridge(&keys, &components, &edges, &mut taint);
changed |= propagate_unresolved_owner(snapshot, &keys, &edges, &mut taint);
if !changed {
break;
}
}
let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials);
// Pass 2 — the evidence-only view, and the only thing sticky state is
// ever built from (see [`Uncertainty`]).
let (evidence, _) = compute_taint(
snapshot,
ctx,
&keys,
&components,
prior,
Uncertainty::Ignored,
);
// ⚠️ Readiness gates **retirement only**, never addition (Codex rounds
// 1 and 2, which caught the two halves of this in turn). An object
// missing from an untrustworthy snapshot has not been observed to
@@ -416,10 +454,105 @@ pub fn evaluate(
// *observed* during a not-ready epoch is real — a reader can consume
// and buffer the call and then vanish before readiness — so discarding
// additions was the same defect pointing the other way.
let next_sticky = build_sticky(snapshot, &keys, &components, &taint, prior, ctx.graph_ready);
let next_sticky = build_sticky(
snapshot,
&keys,
&components,
&evidence,
prior,
ctx.graph_ready,
);
(decisions, next_sticky)
}
/// Whether a pass treats "we could not see" as taint.
///
/// **Both passes exist because stickiness is a claim about history, and
/// uncertainty is not history.** A node tainted only because the graph was
/// mid-enumeration has had nothing observed about it; remembering that as
/// taint forever is over-exclusion with no evidence behind it, and phase 3r's
/// bind-everything observer makes the window it happens in systematically
/// wide (every node is withheld until its bind resolves, so any link observed
/// across that gap raises [`Reason::UnresolvedAncestry`] on its input side).
/// Measured on a live desktop: a hardware sink acquired a permanent sticky
/// taint at every startup, from one link seen while its output node was still
/// unbound.
///
/// Retiring by *reason code* is not enough, because uncertainty launders
/// itself: an unresolved node propagates [`Reason::TaintedUpstream`] to its
/// downstream, and that reason is indistinguishable from real contamination
/// once recorded. So the split is by **provenance** — the sticky pass never
/// raises an uncertainty root at all, and nothing derived from one can reach
/// it. Decisions are unaffected: they are made from the fail-closed pass,
/// which is unchanged.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Uncertainty {
/// Unresolved ancestry and an unbounded tainted reader are taint
/// (v3.4 §6.1, §6.1.1, §6.1.4).
FailsClosed,
/// Only positively observed contamination counts.
Ignored,
}
/// One taint fixpoint over the snapshot. The `uncertainty` mode decides
/// whether absence of evidence is treated as evidence of contamination.
fn compute_taint(
snapshot: &GraphSnapshot,
ctx: &ExclusionCtx,
keys: &owner::OwnerKeyIndex,
components: &OwnerComponents,
prior: &StickyState,
uncertainty: Uncertainty,
) -> (BTreeMap<Serial, Reason>, BTreeSet<Serial>) {
let fails_closed = uncertainty == Uncertainty::FailsClosed;
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
seed_local_roots(snapshot, ctx, &mut taint);
if fails_closed {
for serial in ambiguous_id_nodes(snapshot) {
raise(&mut taint, serial, Reason::UnresolvedAncestry);
}
}
seed_sticky(
snapshot,
keys,
prior,
components,
&mut taint,
&mut sticky_serials,
);
// Edges are built identically in both passes — receiver status is a
// topological fact and must not depend on the mode, or the owner bridge
// would see two different graphs.
let mut unresolved_input: BTreeSet<Serial> = BTreeSet::new();
let edges = downstream_edges(snapshot, &mut unresolved_input);
if fails_closed {
for serial in unresolved_input {
raise(&mut taint, serial, Reason::UnresolvedAncestry);
}
}
// Monotone fixpoint: every step only adds taint, or lowers a node's
// reason priority, both of which are bounded. Link propagation and the
// owner bridge feed each other — a bridged output leg has downstream
// links, and a downstream monitor reader bridges to its own siblings —
// so neither can be run once.
loop {
let mut changed = false;
changed |= propagate_links(&edges.edges, &mut taint);
changed |= propagate_owner_bridge(keys, components, &edges, &mut taint);
if fails_closed {
changed |= propagate_unresolved_owner(snapshot, keys, &edges, &mut taint);
}
if !changed {
break;
}
}
(taint, sticky_serials)
}
/// Roots that are visible on the node itself.
fn seed_local_roots(
snapshot: &GraphSnapshot,
@@ -430,16 +563,73 @@ fn seed_local_roots(
if let Some(reason) = local_root_reason(node, ctx) {
raise(taint, node.serial, reason);
}
// A node whose own global id is ambiguous cannot be the reliable
// endpoint of any link, so its ancestry is unresolvable.
if snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous) {
raise(taint, node.serial, Reason::UnresolvedAncestry);
}
}
}
/// Nodes whose own global id is ambiguous: they cannot be the reliable
/// endpoint of any link, so their ancestry is unresolvable. Uncertainty, not
/// evidence — see [`Uncertainty`].
fn ambiguous_id_nodes(snapshot: &GraphSnapshot) -> BTreeSet<Serial> {
snapshot
.nodes()
.filter(|node| snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous))
.map(|node| node.serial)
.collect()
}
/// Does this node carry either ownership carrier? **Tag presence only** — it
/// deliberately says nothing about whether the tag is honoured, which is
/// `local_root_reason`'s business (round 10 restricts that to producers).
/// Split out so the "is it tagged?" and "does the tag count?" questions can
/// be tested, and reported, independently.
pub fn is_peerspeak_tagged(node: &NodeSnapshot) -> bool {
node.props.peerspeak_owned
|| node
.name
.as_deref()
.is_some_and(|name| name.starts_with(PEERSPEAK_OWNED_NODE_PREFIX))
}
/// Nodes carrying an ownership carrier that `local_root_reason` **ignored**
/// because the node is not a producer (round 10, R10-1). Ascending by serial.
///
/// Purely diagnostic — nothing in the engine consumes it. It exists because
/// R10-1 turns a formerly load-bearing tag into a no-op, and a silently
/// ignored tag has exactly two causes, both of which someone wants to know
/// about: peerspeak tagging a node it should not (a producer-side bug this
/// would otherwise hide), or another process impersonating the tag (the F2
/// attack, now defanged but still worth seeing).
pub fn misplaced_ownership_tags(snapshot: &GraphSnapshot) -> Vec<&NodeSnapshot> {
let mut tagged: Vec<&NodeSnapshot> = snapshot
.nodes()
.filter(|node| node.role != MediaRole::StreamOutput && is_peerspeak_tagged(node))
.collect();
tagged.sort_by_key(|node| node.serial);
tagged
}
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
if node.props.peerspeak_owned {
// The two ownership carriers, as a union (v3.5 §5.1). Kept here rather
// than folded together at the observer boundary so that the union is a
// pure, directly-testable rule: an adapter that collapsed both into the
// one `peerspeak_owned` bool would make each carrier untestable alone,
// which is exactly how phase 3r's row 1 nearly gated nothing.
//
// ⚠️ **Producer roles only** (round 10, R10-1). Neither carrier is a
// security boundary — both are strings any unprivileged process can put
// on its own node — so an unrestricted root is a denial of the whole
// feature: an unlinked `Stream/Input/Audio` named `peerspeak_owned_x`
// is a tainted *reader* with no owner bound to it, which fails every
// candidate closed machine-wide (Codex phase-1 F2, reproduced live).
// Restricting the root to `Stream/Output/Audio` costs nothing real —
// peerspeak only ever tags playback streams — and the attack needs the
// impostor to be a plausible playback node instead, which taints only
// its own descendants. The AEC's virtual sink/source is unaffected: it
// roots on [`Reason::AecIdentity`] below, by module id, not by this tag.
// A tag on a non-producer falls through: ignored for taint, but not
// nothing — it is either a peerspeak bug or an impostor, and
// [`misplaced_ownership_tags`] surfaces it so neither is silent.
if is_peerspeak_tagged(node) && node.role == MediaRole::StreamOutput {
return Some(Reason::PeerspeakOwned);
}
if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id)
@@ -560,7 +750,7 @@ fn nodes_of_client(
/// `output node → input nodes`, resolving snapshot-local ids. An endpoint
/// that does not resolve taints the *other* end as unresolved ancestry when
/// that other end is the input side — we cannot know what is feeding it.
fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reason>) -> Edges {
fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet<Serial>) -> Edges {
let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new();
let mut receivers: BTreeSet<Serial> = BTreeSet::new();
for link in snapshot.links() {
@@ -572,8 +762,10 @@ fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reaso
receivers.insert(to);
}
(_, Some(IdLookup::Unique(to))) => {
// Something feeds this node and we cannot say what.
raise(taint, to, Reason::UnresolvedAncestry);
// Something feeds this node and we cannot say what. Reported
// rather than raised here, because whether "cannot say" is
// taint depends on which pass is running ([`Uncertainty`]).
unresolved_input.insert(to);
receivers.insert(to);
}
(_, Some(IdLookup::Ambiguous)) => {
@@ -712,7 +904,10 @@ fn propagate_owner_bridge(
/// unknown than one we can** (Codex round 3 — the mirror image of the
/// round-1 case):
///
/// - A *bounded* tainted reader has a strong key or a usable PID, so its
/// - A *bounded* tainted reader has a strong key, or a usable PID **backed by
/// a resolved Client** (F11-1 — a node's self-claimed
/// `application.process.id` no longer bounds anything on its own; see
/// [`owner::owner_is_bounded`]), so its
/// siblings are exactly the output legs sharing that key. Any output leg
/// that is *itself* bounded by a **different** key is provably a different
/// owner and stays eligible; only unbounded output legs are its possible
+270 -29
View File
@@ -67,10 +67,87 @@
//! Grouping is **transitive** (union-find). That is the fail-closed
//! direction: bigger owner components mean more taint, never less.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use super::snapshot::{GlobalId, GraphSnapshot, NodeSnapshot, Serial};
/// Everything owner-key derivation needs from outside a single node.
///
/// Introduced by round 10 (R10-3). Before it, `keys_of` read only node
/// properties, and key 4 was therefore available **only** to nodes carrying
/// `application.process.id` — which native PipeWire clients do not. mpv on its
/// default ao, and peerspeak's own playback stream, expose nothing but
/// `client.id`, so both were *unbounded*, and the moment any tainted reader
/// existed anywhere, `propagate_unresolved_owner` excluded every one of them.
/// Measured: an untagged mpv went from eligible (alone) to `unresolved-owner`
/// the instant peerspeak played audio. That is "native-PipeWire apps are never
/// shareable", which is not a feature.
///
/// The missing pid is not missing at all — it is one hop away, on the node's
/// **Client**, as `pipewire.sec.pid`, and already in the snapshot.
pub struct OwnerCtx {
pub pipewire_pulse_pid: Option<u32>,
/// `client.id` → that Client's `pipewire.sec.pid`.
///
/// Clients whose global id is **ambiguous** (two live objects claiming it,
/// i.e. the observer missed a removal) are deliberately absent: resolving
/// an ambiguous id to a pid would attribute a node to whichever Client won
/// a coin toss, and inventing an owner key is the one direction that can
/// *reduce* taint. Absent ⇒ unbounded ⇒ fails closed, as before.
client_pids: BTreeMap<GlobalId, u32>,
}
impl OwnerCtx {
pub fn new(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
let mut client_pids: BTreeMap<GlobalId, u32> = BTreeMap::new();
// ⚠️ Tracked separately from `client_pids`, and that is the point: a
// Client with no `sec_pid` still *claims* its id. Detecting duplicates
// by looking in the pid map would let a pid-less first claimant leave
// no trace, so the next Client claiming the same id would look unique
// and its pid would be used — resolving an ambiguous id, which is the
// one guess this guard exists to refuse. Pid-less Clients are ordinary
// (the session manager's is one).
let mut seen: BTreeSet<GlobalId> = BTreeSet::new();
for client in snapshot.clients() {
if !seen.insert(client.id) {
// Two Clients claiming one id: drop it entirely rather than
// pick. See the field docs.
client_pids.remove(&client.id);
continue;
}
if let Some(pid) = client.sec_pid {
client_pids.insert(client.id, pid);
}
}
Self {
pipewire_pulse_pid,
client_pids,
}
}
/// The `pipewire.sec.pid` of this node's Client, if it has one and that
/// Client's id is unambiguous.
fn client_pid(&self, node: &NodeSnapshot) -> Option<u32> {
self.client_pids.get(&node.props.client_id?).copied()
}
/// Does this node have **protected provenance** — an unambiguous Client
/// yielding `Some(pipewire.sec.pid)`?
///
/// ⚠️ Read **before** the pipewire-pulse suppression in [`keys_of`], and
/// that ordering is the whole rule (F11-1, below). A Pulse-emulated app's
/// Client resolves to the daemon's PID; the value is then omitted from the
/// bridge keys as too coarse to *group* on, but it is still a protected
/// `pipewire.*` answer to "who is this", so the app keeps its provenance.
///
/// ❌ Not "a unique Client object exists". A unique Client with
/// `sec_pid = None` satisfies that and carries no protected identity at
/// all, which is exactly the hole [`owner_is_bounded`] closes.
fn client_is_resolved(&self, node: &NodeSnapshot) -> bool {
self.client_pid(node).is_some()
}
}
/// Which key bridged two legs. Ordered strongest first; the `Ord` derive is
/// load-bearing for "report the strongest shared key".
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
@@ -84,6 +161,16 @@ pub enum OwnerKey {
impl OwnerKey {
/// Stable, machine-readable — this ends up in the phase 5 audit output
/// and the phase 6 status event.
///
/// ⚠️ **Known imprecision, deliberately not fixed here.** `ProcessId` now
/// covers two sources — the node's `application.process.id` and its
/// Client's `pipewire.sec.pid` (see [`keys_of`]) — so a bridge reported as
/// `application.process.id` may in fact have resolved on the Client's
/// protected pid. Pre-existing since R10-3 made the Client a fallback, and
/// widened by the review's finding 1 making it a union. Splitting it would
/// add a code to a set that is explicitly a stable contract for the audit
/// output and the "why isn't this app being shared?" answer, so it wants
/// its own decision rather than a drive-by.
pub fn code(self) -> &'static str {
match self {
Self::LinkGroup => "node.link-group",
@@ -106,7 +193,7 @@ enum KeyValue {
/// A key that is present but unusable (the pipewire-pulse PID; a coarse key
/// on a device node) is **absent** here — that is the whole mechanism of the
/// two exceptions.
fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKey, KeyValue)> {
fn keys_of(node: &NodeSnapshot, ctx: &OwnerCtx) -> Vec<(OwnerKey, KeyValue)> {
let mut out = Vec::new();
if let Some(group) = &node.props.link_group {
out.push((OwnerKey::LinkGroup, KeyValue::Text(group.clone())));
@@ -122,14 +209,48 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKe
if let Some(client) = node.props.client_id {
out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0))));
}
if let Some(pid) = node.props.process_id {
// Exception 1. Note the fail-closed asymmetry when the daemon PID is
// unknown (`None`): the exception does *not* fire, key 4 applies to
// everything, and Pulse modules fuse into one owner. That is broad
// over-exclusion — annoying and safe — which is the direction v3.4
// §6.1.2's failure-mode paragraph asks for.
if Some(pid) != pipewire_pulse_pid {
out.push((OwnerKey::ProcessId, KeyValue::Num(u64::from(pid))));
// Key 4, from the node **and** from its Client (round 10, R10-3; made a
// union rather than a fallback by the round-10 review, finding 1).
//
// ⚠️ **A union, not `node.or_else(client)`, and the difference is a leak.**
// The node's `application.process.id` is client-controlled and optional;
// the Client's `pipewire.sec.pid` is `pipewire.*`, protected, and the only
// one that can carry a soundness argument (the same reason
// `propagate_unresolved_owner` sweeps everything for an unbounded reader).
// Letting the node's value *replace* the Client's meant one process using
// two Clients could escape the bridge entirely: its tainted reader reports
// a bogus node pid, its output leg omits the node pid and falls back to
// the Client's real one, the two legs are bounded by different values, so
// they neither bridge nor trip the unbounded sweep — and the output stays
// eligible while re-emitting the call. Carrying both values costs nothing
// and closes it: a leg that presents *either* value bridges.
//
// ⚠️ **Exception 1 applies to each value independently, and that is the
// whole risk here.** Measured on this host: 15 unrelated Clients share
// `sec_pid` 2528, which is pipewire-pulse's own — every Pulse-emulated app
// has one. Suppressing it per value is what keeps the union from fusing
// all fifteen into a single owner while still keeping each app's real
// per-app pid. For the common Pulse shape (node pid = the app's, Client
// `sec_pid` = the daemon's) the union therefore reduces to exactly the
// node's pid, as before.
//
// Note the fail-closed asymmetry when the daemon PID is unknown (`None`):
// the exception does *not* fire, key 4 applies to everything, and Pulse
// modules fuse into one owner. That is broad over-exclusion — annoying and
// safe — which is the direction v3.4 §6.1.2's failure-mode paragraph asks
// for.
for pid in [node.props.process_id, ctx.client_pid(node)]
.into_iter()
.flatten()
{
if Some(pid) == ctx.pipewire_pulse_pid {
continue;
}
let key = (OwnerKey::ProcessId, KeyValue::Num(u64::from(pid)));
// The two agree far more often than not; a duplicate entry would be
// harmless but would make the audit's key list read oddly.
if !out.contains(&key) {
out.push(key);
}
}
out
@@ -151,10 +272,120 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKe
/// nothing else relates them. Its sibling output leg cannot be found, so
/// the engine must fail closed rather than declare it clean
/// (v3.4 §6.1.1, final paragraph).
pub fn owner_is_bounded(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> bool {
keys_of(node, pipewire_pulse_pid)
.iter()
.any(|(key, _)| *key != OwnerKey::ClientId)
///
/// # F11-1 — CLOSED. A self-claimed PID is not provenance
///
/// **The rule, implemented below:** a strong key (`node.link-group`,
/// `pulse.module.id`) bounds an owner on its own; **key 4 bounds an owner only
/// when the node's Client resolves** — see [`OwnerCtx::client_is_resolved`].
/// A node whose Client cannot be resolved at all is *unbounded*, whatever
/// `application.process.id` it puts on itself.
///
/// The five Client cases, which is the matrix this needed (two of them are
/// indistinguishable under the wrong reading of "resolves"):
///
/// | Client | node's own pid | bounded? | why |
/// | --- | --- | --- | --- |
/// | **absent** | claimed | **no** | nothing corroborates the claim |
/// | **ambiguous** (two Clients, one id) | claimed | **no** | "we do not know who owns this" must not be papered over |
/// | **unique but pid-less** | claimed | **no** | a Client object is not an identity; `sec_pid` is |
/// | **resolved-native** (`sec_pid` = the app's) | absent | **yes** | protected pid, and it *is* key 4 |
/// | **resolved-to-pipewire-pulse** | claimed | **yes** | protected provenance; the daemon pid is suppressed as a *grouping* key only |
///
/// The last row is what keeps this from being the blunt fix. Applying
/// "self-claims are not sound" without the provenance test unbounds every
/// Pulse-emulated app — their Client's `sec_pid` is the daemon's and
/// suppressed, so the node's own claim is their only per-app identity — which
/// re-triggers the §6.1.1 mass over-exclusion the whole design exists to avoid
/// and empties the eligible half of the §5.1 matrix.
///
/// **Cost, measured on the live graph** (2026-07-26): **zero**. The
/// before- and after-binaries audited the *same* graph simultaneously — both
/// are read-only observers, which is the only way to A/B a partition without
/// churn between runs — with a tagged producer feeding the default sink,
/// `parec` on its monitor as a real tainted reader (so the sweep was armed,
/// not merely present in the code), and Firefox, `aplay` and `pacat` as
/// bystanders. **181 records each, the same 14 distinct decision states, none
/// exclusive to either side, no `unresolved-owner` on either.** The eligible
/// half stayed non-empty throughout: native (`aplay`), Pulse-emulated
/// (`pacat`) and Firefox all eligible. O5 is unmoved: identical p50 (15 µs)
/// and busy fraction (0.0012), and the after-binary's worst per-record
/// recompute was *lower* (217 µs vs 243 µs — noise, same debug build, same
/// concurrent load).
///
/// Why it costs nothing here: every real app on this box is either native
/// (Client `sec_pid` = its own pid) or Pulse-emulated (Client `sec_pid` = the
/// daemon's), and **both resolve**. Sweeping all 18 live nodes for the
/// predicate's inputs directly, the only unresolved-Client nodes were
/// `Dummy-Driver` and `Freewheel-Driver`, which carry no pid key to lose;
/// session-manager device nodes are unresolved too (their Client is pid-less)
/// but exception 2 already strips key 4 from them. That is the answer the
/// deferral was waiting for: the rule bites exactly the anomalous shapes, and
/// this host has none.
///
/// ## The leak it closes (round 11 review, finding 1)
///
/// Round 10 made key 4 a union of the node's
/// `application.process.id` and its Client's `pipewire.sec.pid`, and the claim
/// that this was "strictly additive" was too strong: the same key list also
/// feeds *this* predicate, so adding a value can move a node from unbounded to
/// bounded, and `propagate_unresolved_owner`'s global sweep is triggered by an
/// **un**bounded tainted reader. Concretely:
///
/// 1. A tainted reader's node claims the pipewire-pulse PID while its Client
/// holds a real protected PID `A`. Under `or_else` the node's value won and
/// exception 1 suppressed it, leaving the reader unbounded; under the union
/// it is bounded by `A`.
/// 2. Its process's output leg uses a second Client whose id is **ambiguous**
/// (the observer missed a removal), so no protected PID is available — but
/// the leg claims a bogus `application.process.id` `B`, which bounds it.
/// 3. Neither the bridge nor the sweep fires, and the output stays eligible
/// while re-emitting the call.
///
/// Step 2 is now unbounded ⇒ the sweep fires ⇒ the leg is excluded. Note it
/// could not leak *yet* when it was filed — `evaluate()` is reached only by the
/// dry-run audit, which creates no links — and that is why the fix waited for
/// the §5.1 measurement instead of guessing at its cost.
///
/// ## What this is deliberately NOT
///
/// It is not a claim that `application.process.id` is now unused: it still
/// bridges (a self-claim is fine as *evidence that two legs are related* —
/// the fail-closed direction), and a resolved-Client node is still bounded by
/// whichever key-4 value survives suppression. Only *boundedness* — the
/// permission to say "I can enumerate this owner's other legs, so a
/// differently-keyed output is provably someone else" — now demands a
/// `pipewire.*` answer to "who is this".
///
/// ⚠️ Bridging must keep using the **full** union, so boundedness is carried
/// separately from the key set in [`OwnerKeyIndex`] rather than being
/// re-derived from it.
pub fn owner_is_bounded(node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
bounded_by(&keys_of(node, ctx), node, ctx)
}
/// [`owner_is_bounded`]'s rule, over an already-computed key list.
///
/// The single implementation: [`OwnerKeyIndex::build`] has the keys in hand and
/// must not recompute them, and two copies of a predicate this load-bearing is
/// how the two spellings drift apart.
fn bounded_by(keys: &[(OwnerKey, KeyValue)], node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
let mut has_process_key = false;
for (key, _) in keys {
match key {
// Strong keys are per-instance and name the sibling set directly.
OwnerKey::LinkGroup | OwnerKey::PulseModuleId => return true,
OwnerKey::ProcessId => has_process_key = true,
// Never: one process can present two `client.id`s (the measured
// GStreamer refutation, above).
OwnerKey::ClientId => {}
}
}
// F11-1. The key may be the node's own claim, the Client's protected pid,
// or both — `keys_of` does not record which, and it does not need to: a
// resolved Client is provenance for the node *whatever* value key 4 ends
// up carrying, and without one there is no protected identity to stand on.
has_process_key && ctx.client_is_resolved(node)
}
/// Owner keys computed once per snapshot.
@@ -165,16 +396,27 @@ pub fn owner_is_bounded(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) ->
#[derive(Debug, Default)]
pub struct OwnerKeyIndex {
keys: BTreeMap<Serial, Vec<(OwnerKey, KeyValue)>>,
/// Nodes whose owner is positively bounded — see [`owner_is_bounded`].
///
/// ⚠️ **Stored, not derived from `keys`.** Since F11-1 the predicate needs
/// the node's Client as well as its key list, and the two answers are
/// deliberately different: the full union still bridges, while a
/// self-claimed pid no longer bounds.
bounded: BTreeSet<Serial>,
}
impl OwnerKeyIndex {
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
Self {
keys: snapshot
.nodes()
.map(|node| (node.serial, keys_of(node, pipewire_pulse_pid)))
.collect(),
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
let mut keys = BTreeMap::new();
let mut bounded = BTreeSet::new();
for node in snapshot.nodes() {
let node_keys = keys_of(node, ctx);
if bounded_by(&node_keys, node, ctx) {
bounded.insert(node.serial);
}
keys.insert(node.serial, node_keys);
}
Self { keys, bounded }
}
/// The strongest key these two nodes share directly, if any.
@@ -238,11 +480,10 @@ impl OwnerKeyIndex {
})
}
/// See [`owner_is_bounded`].
/// See [`owner_is_bounded`]. A node outside this snapshot is unbounded,
/// which is the fail-closed answer.
pub fn is_bounded(&self, serial: Serial) -> bool {
self.keys
.get(&serial)
.is_some_and(|keys| keys.iter().any(|(key, _)| *key != OwnerKey::ClientId))
self.bounded.contains(&serial)
}
}
@@ -252,10 +493,10 @@ impl OwnerKeyIndex {
pub fn strongest_shared_key(
a: &NodeSnapshot,
b: &NodeSnapshot,
pipewire_pulse_pid: Option<u32>,
ctx: &OwnerCtx,
) -> Option<OwnerKey> {
let a_keys = keys_of(a, pipewire_pulse_pid);
let b_keys = keys_of(b, pipewire_pulse_pid);
let a_keys = keys_of(a, ctx);
let b_keys = keys_of(b, ctx);
// `keys_of` yields strongest-first, so the first match is the strongest.
a_keys.iter().find_map(|(key, value)| {
b_keys
@@ -279,7 +520,7 @@ pub struct OwnerComponents {
}
impl OwnerComponents {
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
let serials: Vec<Serial> = snapshot.nodes().map(|n| n.serial).collect();
let index: BTreeMap<Serial, usize> =
serials.iter().enumerate().map(|(i, s)| (*s, i)).collect();
@@ -290,7 +531,7 @@ impl OwnerComponents {
let mut buckets: BTreeMap<(OwnerKey, KeyValue), Vec<usize>> = BTreeMap::new();
for node in snapshot.nodes() {
let slot = index[&node.serial];
for (key, value) in keys_of(node, pipewire_pulse_pid) {
for (key, value) in keys_of(node, ctx) {
buckets.entry((key, value)).or_default().push(slot);
}
}
+25 -2
View File
@@ -87,6 +87,20 @@ impl MediaRole {
pub fn is_candidate(self) -> bool {
matches!(self, Self::StreamOutput)
}
/// Stable machine-readable code for the audit output. Not the raw
/// `media.class`: `Other` has no single one, and the audit's codes are a
/// contract with the matrix, not with PipeWire.
pub fn code(self) -> &'static str {
match self {
Self::StreamOutput => "stream-output",
Self::StreamInput => "stream-input",
Self::Sink => "sink",
Self::Source => "source",
Self::Duplex => "duplex",
Self::Other => "other",
}
}
}
/// The subset of node properties the engine actually reasons about.
@@ -97,8 +111,17 @@ impl MediaRole {
/// on this feature means "not tainted".
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct NodeProps {
/// `peerspeak.owned` is present and truthy (v3.4 §5.1). A correctness
/// mechanism, explicitly *not* a security boundary.
/// `peerspeak.owned` is present and **exactly**
/// [`super::PEERSPEAK_OWNED_VALUE`] (v3.4 §5.1, tightened by round 10's
/// R10-4 — it is not "present and truthy", and the round-10 review found
/// this doc still saying so). A correctness mechanism, explicitly *not* a
/// security boundary.
///
/// ⚠️ **Ownership carrier 1 of 2, so this being `false` does not mean
/// "not peerspeak's".** Carrier 2 is the [`NodeSnapshot::name`] prefix
/// [`super::PEERSPEAK_OWNED_NODE_PREFIX`], matched as a union in
/// `local_root_reason`. Read that function, not this field, to answer
/// "is this node owned?".
pub peerspeak_owned: bool,
/// `pulse.module.id`, parsed as `u64` — never `u32`, per v3.4 §5.2's
/// parse-defensively note and the phase 0a truncation bug.
+890 -4
View File
@@ -15,7 +15,7 @@
use std::collections::BTreeSet;
use super::fixture::{Graph, NodeRef, PULSE_PID, app};
use super::owner::{OwnerKey, strongest_shared_key};
use super::owner::{OwnerCtx, OwnerKey, strongest_shared_key};
use super::snapshot::{MediaRole, NodeProps, PortDirection, Serial};
use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate};
@@ -176,6 +176,217 @@ fn peerspeak_tagged_nodes_are_excluded_and_plain_apps_are_not() {
assert_tainted(&decisions, sink, "tainted-upstream");
}
/// Each ownership carrier must work **alone** (v3.5 §5.1).
///
/// ⚠️ The phase-3r lesson, applied deliberately: a gate that asserts a value
/// two sources can satisfy gates neither. `peerspeak_tagged_nodes_…` above
/// uses nodes carrying both carriers, so it would keep passing if either
/// were deleted. These are the rows that actually pin them.
#[test]
fn either_ownership_carrier_alone_taints_the_node() {
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
// Carrier 1: the property, on a node whose name says nothing.
let prop_only = graph.peerspeak_node_prop_only("some-playback-stream", 7);
// Carrier 2: the name prefix, property absent — the F1 case.
let name_only = graph.peerspeak_node_name_only("mpv", 31_284);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
for node in [prop_only, name_only, firefox] {
graph.link(node, sink);
}
assert_partition(
&run(&graph, &ctx()),
&[("firefox", firefox)],
&[
("prop_only", prop_only, "peerspeak-owned"),
("name_only", name_only, "peerspeak-owned"),
],
);
}
/// **R10-1, the F2 fix.** Neither carrier is a security boundary — both are
/// strings any unprivileged process can set on its own node — so the tag is
/// honoured only on `Stream/Output/Audio`, the one role peerspeak ever tags.
///
/// Without the restriction, a tagged `Stream/Input/Audio` **with no links at
/// all** is a tainted *reader* (`receivers` includes nodes by role, no link
/// required), and an unbounded one, so `propagate_unresolved_owner` fails
/// every candidate on the machine closed. That is a whole-feature denial from
/// an unprivileged process, reproduced live during the phase-1 review.
#[test]
fn an_ownership_tag_on_a_non_producer_is_not_a_taint_root() {
for role in [
MediaRole::StreamInput,
MediaRole::Sink,
MediaRole::Source,
MediaRole::Duplex,
MediaRole::Other,
] {
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
graph.link(firefox, sink);
// Deliberately unlinked: the F2 shape needs no edges whatsoever.
let impostor = graph.peerspeak_tagged_node("rogue", role, 4_242);
let decisions = run(&graph, &ctx());
assert_untainted(&decisions, impostor);
assert!(
decisions.taint.is_empty(),
"{role:?} impostor tainted something: {:?}",
decisions.taint.keys().collect::<Vec<_>>()
);
// The whole point: the eligible half stays non-empty.
assert_partition(&decisions, &[("firefox", firefox)], &[]);
}
}
/// **The live F2 reproduction, verbatim.** The measured impostor was an
/// *unbounded* reader — `client.id` present, `application.process.id` absent
/// — which is what turns "one bogus tainted node" into "nothing on this
/// machine is shareable": `propagate_unresolved_owner` cannot prove any
/// candidate independent of a reader it cannot attribute to an owner.
///
/// Measured before the fix: `BASELINE eligible=1 excluded=[]` →
/// `WITH IMPOSTOR eligible=0 excluded=[firefox → unresolved-owner]`.
///
/// Distinct from the row above, which uses a *bounded* impostor and so would
/// still pass if only the cheap half of the fix were present.
#[test]
fn an_unbounded_tagged_impostor_cannot_exclude_a_bystander_app() {
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
let mpv = graph.app_node("mpv", MediaRole::StreamOutput, 31_284);
for node in [firefox, mpv] {
graph.link(node, sink);
}
let baseline = run(&graph, &ctx());
assert_partition(&baseline, &[("firefox", firefox), ("mpv", mpv)], &[]);
// Both carriers, no pid, no links — everything an unprivileged process
// can arrange for itself in one `pw-cli` invocation.
let rogue_client = graph.client(Some(PULSE_PID));
let impostor = graph.node(
&format!("{}rogue_4242", super::PEERSPEAK_OWNED_NODE_PREFIX),
MediaRole::StreamInput,
NodeProps {
peerspeak_owned: true,
client_id: Some(rogue_client),
..NodeProps::default()
},
);
let decisions = run(&graph, &ctx());
assert_untainted(&decisions, impostor);
assert_partition(&decisions, &[("firefox", firefox), ("mpv", mpv)], &[]);
}
/// A tag that R10-1 ignores is still reported, so that neither a peerspeak
/// tagging bug nor an impersonation attempt is silent.
#[test]
fn ignored_ownership_tags_are_surfaced_for_diagnostics() {
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("call", 7);
graph.link(call, sink);
let impostor = graph.peerspeak_tagged_node("rogue", MediaRole::StreamInput, 4_242);
let snapshot = graph.build();
let misplaced: Vec<Serial> = super::misplaced_ownership_tags(&snapshot)
.iter()
.map(|node| node.serial)
.collect();
// Exactly the ignored one: the honoured producer is not "misplaced".
assert_eq!(misplaced, vec![impostor.serial]);
assert_ne!(impostor.serial, call.serial);
}
/// The prefix is a **prefix**, not a substring: an unrelated app must not be
/// excluded because the literal appears somewhere in its name. Over-exclusion
/// is the safe direction, but it is still wrong, and the phase-5 gate now
/// asserts exact partitions in both halves.
#[test]
fn the_owned_prefix_matches_only_at_the_start_of_node_name() {
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
let impostor = graph.app_node(
&format!("recorder-of-{}stuff", super::PEERSPEAK_OWNED_NODE_PREFIX),
MediaRole::StreamOutput,
11_114,
);
graph.link(impostor, sink);
assert_partition(&run(&graph, &ctx()), &[("impostor", impostor)], &[]);
}
/// The consumer half of the cross-repo contract test (impl plan §3
/// requirement 2). peerspeak runs the mirror of this against a byte-identical
/// copy of the same file, and asserts the environment a real child `Command`
/// would carry produces exactly these literals.
///
/// This proves the two repos agree on the *literals*. That pixelpass actually
/// *listens* is proven by the two carrier tests above, and against the live
/// graph by the phase 5 dry-run.
#[test]
fn ownership_carriers_match_the_cross_repo_fixture() {
const FIXTURE: &str = include_str!("../../../tests/fixtures/ownership-tag-contract.txt");
let pinned: Vec<(&str, &str)> = FIXTURE
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| line.split_once('=').expect("fixture line is key=value"))
.collect();
// ⚠️ Refuse a duplicated key rather than resolving it (Codex phase-1
// review, finding 3). This side takes the first match and peerspeak's
// took the last, so a duplicate in a byte-identical file could leave both
// repos green having selected *different* contracts.
for (index, (key, _)) in pinned.iter().enumerate() {
assert!(
!pinned[..index].iter().any(|(seen, _)| seen == key),
"fixture defines {key:?} twice; the two repos would disagree on which wins"
);
}
let get = |key: &str| -> &str {
pinned
.iter()
.find(|(k, _)| *k == key)
.unwrap_or_else(|| panic!("fixture has no key {key:?}"))
.1
};
assert_eq!(super::PEERSPEAK_OWNED_PROP, get("prop_key"));
assert_eq!(super::PEERSPEAK_OWNED_NODE_PREFIX, get("node_name_prefix"));
// ⚠️ **Equality, and that is now the whole rule**: carrier 1 is matched
// exactly, not as "anything but false/0" (round 10, R10-4). This assert
// used to be followed by a weaker `value != "false" && value != "0"`
// check, which described a leniency that no longer exists — the round-10
// review's finding 6, and a real trap: a future producer reading the old
// fixture prose could emit "true" and silently lose this carrier.
//
// That this consumer actually *listens* to the fixture's value, through
// the production observer wiring rather than a helper, is asserted by
// `observer::adapter::tests::the_fixture_value_is_the_only_owned_spelling`.
assert_eq!(super::PEERSPEAK_OWNED_VALUE, get("prop_value"));
// And the fixture's own worked example must be one this engine excludes,
// through carrier 2, exactly as written in the shared file.
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
let example = graph.app_node(get("node_name_example"), MediaRole::StreamOutput, 31_284);
graph.link(example, sink);
assert_partition(
&run(&graph, &ctx()),
&[],
&[("example", example, "peerspeak-owned")],
);
}
#[test]
fn aec_identity_is_exact_equality_and_other_modules_stay_eligible() {
let mut graph = Graph::new();
@@ -507,8 +718,9 @@ fn owner_key_union_falls_through_a_present_but_unequal_key() {
snapshot.node(b.serial).unwrap(),
);
assert_ne!(a.props.client_id, b.props.client_id);
let owner_ctx = OwnerCtx::new(&snapshot, Some(PULSE_PID));
assert_eq!(
strongest_shared_key(a, b, Some(PULSE_PID)),
strongest_shared_key(a, b, &owner_ctx),
Some(OwnerKey::ProcessId)
);
}
@@ -519,11 +731,12 @@ fn the_strongest_shared_key_wins_when_several_match() {
let a = graph.group_node("a", MediaRole::StreamInput, "g", 500);
let b = graph.group_node("b", MediaRole::StreamOutput, "g", 500);
let snapshot = graph.build();
let owner_ctx = OwnerCtx::new(&snapshot, Some(PULSE_PID));
assert_eq!(
strongest_shared_key(
snapshot.node(a.serial).unwrap(),
snapshot.node(b.serial).unwrap(),
Some(PULSE_PID)
&owner_ctx
),
Some(OwnerKey::LinkGroup)
);
@@ -560,6 +773,541 @@ fn the_pipewire_pulse_pid_does_not_fuse_unrelated_modules() {
assert_untainted(&decisions, b_in);
}
/// **R10-3, the fix.** A native PipeWire client puts no
/// `application.process.id` on its node — only `client.id` — so before the
/// Client fallback it had no key 4, was therefore *unbounded*, and
/// `propagate_unresolved_owner` excluded it the moment any tainted reader
/// existed anywhere on the machine.
///
/// Measured live: an untagged mpv was eligible alone, and became
/// `unresolved-owner` the instant peerspeak played audio. Since peerspeak
/// playing audio is the only situation in which this feature runs at all, that
/// amounted to "native-PipeWire apps are never shareable".
#[test]
fn a_native_client_is_bounded_by_its_clients_sec_pid() {
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
// The tainted reader that arms the unresolved-owner arm. Bounded itself
// (a real pid), exactly as the live `sunshine` was — so this is the
// bounded-reader arm, not the keyless-reader one.
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
graph.link(hw, sunshine);
// mpv on its default ao: client.id only, pid on the Client.
let mpv = graph.native_client_node("mpv", MediaRole::StreamOutput, 31_284);
graph.link(mpv, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", mpv)],
&[("call", call, "peerspeak-owned")],
);
}
/// The fallback must bridge a native app's *own* legs, or it has bought
/// boundedness without buying correctness: an app that reads the call and
/// re-emits it on a second native node would be declared clean.
#[test]
fn the_sec_pid_fallback_still_bridges_a_native_apps_own_legs() {
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
// One native process, two nodes, no link between them — the forwarder
// shape, in the native flavour.
let leg_in = graph.native_client_node("forwarder-in", MediaRole::StreamInput, 50_000);
let leg_out = graph.native_client_node("forwarder-out", MediaRole::StreamOutput, 50_000);
graph.link(hw, leg_in);
let decisions = run(&graph, &ctx());
assert_tainted(&decisions, leg_out, "tainted-owner-bridge");
assert_partition(
&decisions,
&[],
&[
("call", call, "peerspeak-owned"),
("forwarder-out", leg_out, "tainted-owner-bridge"),
],
);
}
/// **The risk the fallback creates, and the guard on it.** Every
/// Pulse-emulated Client carries pipewire-pulse's own PID as `sec_pid` —
/// measured, 15 unrelated Clients sharing 2528 on this host. An unguarded
/// fallback would give all of them key 4 with the *same* value and fuse them
/// into one owner, so a single tainted Pulse app would exclude every other
/// Pulse app on the machine.
///
/// Exception 1 therefore applies to the fallback exactly as it does to the
/// node's own property. Without that, this row goes red.
#[test]
fn the_sec_pid_fallback_does_not_fuse_every_pulse_client() {
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
// Three unrelated Pulse-emulated apps, each on its own Client, none
// exposing a node-level pid — so each can only reach key 4 through its
// Client, whose sec_pid is the daemon's.
let pulse_app = |graph: &mut Graph, name: &str, role| {
let client = graph.client(Some(PULSE_PID));
graph.node(
name,
role,
NodeProps {
client_id: Some(client),
..NodeProps::default()
},
)
};
// One of them reads the tainted sink; the other two must not care.
let reader = pulse_app(&mut graph, "recorder", MediaRole::StreamInput);
graph.link(hw, reader);
let other_a = pulse_app(&mut graph, "player-a", MediaRole::StreamOutput);
let other_b = pulse_app(&mut graph, "player-b", MediaRole::StreamOutput);
let decisions = run(&graph, &ctx());
// They are unbounded (`client.id` alone never bounds an owner), so the
// fail-closed arm still excludes them — but as `unresolved-owner`, NOT as
// `tainted-owner-bridge`. That distinction is the whole assertion: a
// bridge reason here would mean the daemon pid had fused three unrelated
// applications into one owner, and unlike fail-closed exclusion, fusion
// does not go away when the apps are given real pids
// (`distinct_sec_pids_bound_each_native_app_separately` is that half).
assert_tainted(&decisions, other_a, "unresolved-owner");
assert_tainted(&decisions, other_b, "unresolved-owner");
for node in [other_a, other_b] {
assert_ne!(
decisions.taint.get(&node.serial).map(|e| e.reason.code()),
Some("tainted-owner-bridge"),
"the daemon pid must not bridge unrelated Pulse clients"
);
}
}
/// The same three apps, given **real per-app** `sec_pid`s: now the fallback
/// fires, all three are bounded, and only the one actually reading the call is
/// affected. This is the row that proves the guard above suppresses the daemon
/// pid *specifically* rather than disabling the fallback outright.
#[test]
fn distinct_sec_pids_bound_each_native_app_separately() {
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let reader = graph.native_client_node("recorder", MediaRole::StreamInput, 6_001);
graph.link(hw, reader);
let other_a = graph.native_client_node("player-a", MediaRole::StreamOutput, 6_002);
let other_b = graph.native_client_node("player-b", MediaRole::StreamOutput, 6_003);
assert_partition(
&run(&graph, &ctx()),
&[("player-a", other_a), ("player-b", other_b)],
&[("call", call, "peerspeak-owned")],
);
}
/// An **ambiguous** `client.id` — two live Clients claiming it, meaning the
/// observer missed a removal — must not yield a fallback pid. Inventing an
/// owner key is the one direction that can *reduce* taint, so resolving the
/// ambiguity by coin toss is the wrong kind of guess.
#[test]
fn an_ambiguous_client_id_yields_no_fallback_pid() {
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
graph.link(hw, sunshine);
// Two Clients, one id, distinct real pids.
let shared_id = graph.client(Some(6_010));
graph.client_with_id(shared_id, Some(6_011));
let app = graph.node(
"native-app",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(shared_id),
..NodeProps::default()
},
);
graph.link(app, hw);
// Unbounded ⇒ fails closed, exactly as before R10-3.
assert_partition(
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("native-app", app, "unresolved-owner"),
],
);
}
/// The ambiguity guard must not depend on the *first* Client claiming an id
/// having a `sec_pid`.
///
/// Found by auditing R10-3 rather than by a failing case: the first cut
/// detected a duplicate id by looking it up in the pid map, which is only
/// populated for Clients that carry a pid at all. A pid-less Client therefore
/// left no trace, and the next Client claiming the same id was treated as
/// unique — resolving an ambiguous id, which is exactly the guess the guard
/// exists to refuse. Pid-less Clients are ordinary here (`device_node`'s
/// session client is one), so this is reachable, not theoretical.
#[test]
fn a_pidless_first_client_still_makes_its_id_ambiguous() {
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
graph.link(hw, sunshine);
// First claimant has NO sec_pid; second has one.
let shared_id = graph.client(None);
graph.client_with_id(shared_id, Some(6_011));
let app = graph.node(
"native-app",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(shared_id),
..NodeProps::default()
},
);
graph.link(app, hw);
assert_partition(
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("native-app", app, "unresolved-owner"),
],
);
}
/// A process using **two** Clients cannot escape the bridge by presenting a
/// bogus pid on one leg and none on the other.
///
/// ⚠️ **This is the round-10 review's finding 1, and it was a real leak while
/// key 4 was `node.or_else(client)`.** The node's `application.process.id` is
/// client-controlled; the Client's `pipewire.sec.pid` is protected. Letting
/// the node's value *replace* the Client's meant the reader was bounded by
/// `12_345` and the output leg by `50_000`, so they shared no key, did not
/// bridge, and — both being bounded — neither tripped the unbounded sweep.
/// The output stayed eligible while re-emitting the call.
///
/// Carrying both values fixes it: the two legs share the Client pid.
///
/// Reachability, stated honestly: `evaluate()` today is reached only by the
/// dry-run audit, which creates no links, so this could not echo on this
/// branch. It becomes live the moment phase 6 consumes these decisions.
#[test]
fn one_process_with_two_clients_cannot_split_its_pid_to_escape_the_bridge() {
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
// One native process, two Clients, one protected pid.
let reader_client = graph.client(Some(50_000));
let output_client = graph.client(Some(50_000));
// Its reading leg claims a pid that is not its own.
let reader = graph.node(
"two-client-reader",
MediaRole::StreamInput,
NodeProps {
client_id: Some(reader_client),
process_id: Some(12_345),
..NodeProps::default()
},
);
graph.link(hw, reader);
// Its re-emitting leg claims no pid at all.
let output = graph.node(
"two-client-output",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(output_client),
process_id: None,
..NodeProps::default()
},
);
graph.link(output, hw);
// A genuinely unrelated app must survive, or "exclude everything" would
// pass this test — the §5.1 eligible-half rule.
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
graph.link(bystander, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("two-client-output", output, "tainted-owner-bridge"),
],
);
}
/// The node's own `application.process.id` is used even when its Client's
/// `sec_pid` is the daemon's — the single most common shape here, since a
/// Pulse-emulated node's pid is the app's while its Client's is
/// pipewire-pulse's.
///
/// ⚠️ Both values are now carried (round-10 review, finding 1), so this is no
/// longer "the node's wins" but "exception 1 is applied per value": the
/// daemon's `sec_pid` is dropped and the node's real pid is kept, leaving the
/// same single key as before.
#[test]
fn the_nodes_own_process_id_wins_over_its_clients() {
let mut graph = Graph::new();
// `app_node` is exactly that shape: node pid 11_114, Client sec_pid
// PULSE_PID. If the Client's won, exception 1 would suppress key 4 and
// this node would be unbounded.
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
graph.link(hw, sunshine);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
graph.link(firefox, hw);
assert_partition(
&run(&graph, &ctx()),
&[("firefox", firefox)],
&[("call", call, "peerspeak-owned")],
);
}
// ──────────────────────────────────────────────────────────────────────
// F11-1 — a self-claimed pid is not provenance: the five Client cases
// ──────────────────────────────────────────────────────────────────────
/// The scaffold every F11-1 row needs: peerspeak's call reaching the hardware
/// sink, a **bounded** tainted reader, and an ordinary bystander.
///
/// ⚠️ The reader must be *bounded* (`sunshine` carries a real pid). An
/// unbounded tainted reader trips `propagate_unresolved_owner`'s other tier,
/// which sweeps **every** output candidate on the box regardless of its own
/// keys — the three "unbounded" rows below would then pass without testing
/// anything. The bystander is the other half of that guard: it is bounded via
/// the ordinary Pulse shape, so an implementation that unbounded everything
/// fails every row instead of passing three of them.
///
/// Returns the graph, the hardware sink to hang nodes off, and the two nodes
/// every row must name in its partition.
fn armed_with_a_bounded_reader() -> (Graph, NodeRef, NodeRef, NodeRef) {
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
graph.link(hw, sunshine);
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
graph.link(bystander, hw);
(graph, hw, call, bystander)
}
/// Case 1 of 5 — **Client absent.** A node that names no Client at all has
/// nothing but its own word for who owns it, so it cannot be bounded.
#[test]
fn an_absent_client_leaves_a_self_claimed_pid_unbounded() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let orphan = graph.node(
"no-client",
MediaRole::StreamOutput,
NodeProps {
process_id: Some(70_001),
..NodeProps::default()
},
);
graph.link(orphan, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("no-client", orphan, "unresolved-owner"),
],
);
}
/// Case 2 of 5 — **Client ambiguous.** Two live Clients claim the id, so the
/// observer missed a removal and we do not know who owns this node. A
/// self-claimed pid must not paper over that: this is step 2 of the recorded
/// leak path, and before F11-1 the claim bounded the node and spared it.
#[test]
fn an_ambiguous_client_leaves_a_self_claimed_pid_unbounded() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let shared_id = graph.client(Some(70_010));
graph.client_with_id(shared_id, Some(70_011));
let app = graph.node(
"ambiguous-client",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(shared_id),
process_id: Some(70_012),
..NodeProps::default()
},
);
graph.link(app, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("ambiguous-client", app, "unresolved-owner"),
],
);
}
/// Case 3 of 5 — **Client unique but pid-less**, and *the row that decides
/// which rule is implemented*.
///
/// A unique Client object exists, so "resolved = a unique Client exists" would
/// call this node bounded — leaving the self-claimed-pid hole wide open under a
/// rule that looks like it closed it. `sec_pid` is what carries protected
/// identity, so `None` means unresolved, and pid-less Clients are ordinary
/// (the session manager's is one).
///
/// A two-case absent/resolved matrix skips this silently. That is why it is
/// written out.
#[test]
fn a_unique_but_pidless_client_leaves_a_self_claimed_pid_unbounded() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let pidless = graph.client(None);
let app = graph.node(
"pidless-client",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(pidless),
process_id: Some(70_020),
..NodeProps::default()
},
);
graph.link(app, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("pidless-client", app, "unresolved-owner"),
],
);
}
/// Case 4 of 5 — **Client resolved, native.** `pipewire.sec.pid` is the app's
/// own, so provenance and key 4 are the same value and the node is bounded
/// without claiming anything itself.
#[test]
fn a_resolved_native_client_bounds_its_node() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let mpv2 = graph.native_client_node("mpv-native", MediaRole::StreamOutput, 70_030);
graph.link(mpv2, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander), ("mpv-native", mpv2)],
&[("call", call, "peerspeak-owned")],
);
}
/// Case 5 of 5 — **Client resolved to pipewire-pulse.** The row that stops
/// this rule from being the blunt fix.
///
/// Every Pulse-emulated app looks like this: the Client's `sec_pid` is the
/// daemon's — suppressed as a *grouping* key, because it would fuse fifteen
/// unrelated apps — while the node's own `application.process.id` is the app's.
/// Provenance is read **before** that suppression, so the app keeps its bound
/// and stays eligible. Reading it after would unbound every Pulse app on the
/// box and empty the eligible half of the §5.1 matrix, which is the §6.1.1
/// catastrophe arriving through the boundedness door.
#[test]
fn a_client_resolving_to_pipewire_pulse_still_bounds_its_node() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 70_040);
graph.link(firefox, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander), ("firefox", firefox)],
&[("call", call, "peerspeak-owned")],
);
}
/// **The recorded leak path, end to end** (round 11 review, finding 1).
///
/// One process, two Clients. Its reading leg claims the daemon's pid — which
/// exception 1 suppresses — while its Client holds a real protected pid `A`, so
/// the union bounds the reader by `A` and the *unbounded-reader* tier never
/// arms. Its re-emitting leg sits on a second Client whose id is **ambiguous**
/// (one of the two claimants even holds `A`, so this is not "the guess would
/// have been wrong" — it is "a guess is not evidence"), and claims a pid of its
/// own. The two legs share no key, so the bridge does not fire either.
///
/// Before F11-1 the self-claim bounded the output leg, both tiers stayed quiet,
/// and it re-emitted the call while eligible. Now the leg is unbounded, the
/// bounded-reader tier sweeps it, and `mpv` shows the sweep is still targeted.
#[test]
fn a_self_claimed_pid_cannot_spare_an_output_leg_the_bridge_cannot_reach() {
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let reader_client = graph.client(Some(80_000));
let reader = graph.node(
"forwarder-in",
MediaRole::StreamInput,
NodeProps {
client_id: Some(reader_client),
process_id: Some(PULSE_PID),
..NodeProps::default()
},
);
graph.link(hw, reader);
let ambiguous = graph.client(Some(80_000));
graph.client_with_id(ambiguous, Some(80_001));
let output = graph.node(
"forwarder-out",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(ambiguous),
process_id: Some(80_002),
..NodeProps::default()
},
);
graph.link(output, hw);
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
graph.link(bystander, hw);
// `mpv` staying eligible is what proves the reader is bounded: an
// unbounded tainted reader sweeps **every** output candidate, `mpv`
// included, and this row would then be testing the wrong tier.
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("forwarder-out", output, "unresolved-owner"),
],
);
}
#[test]
fn an_unknown_pipewire_pulse_pid_over_excludes_rather_than_leaks() {
// v3.4 §6.1.2's failure-mode paragraph: if pixelpass cannot identify
@@ -820,6 +1568,137 @@ fn an_ambiguous_recycled_global_id_fails_closed() {
);
}
// ──────────────────────────────────────────────────────────────────────
// Uncertainty is not history — it never enters sticky state
// (round 9, from a live phase-5 audit run; see `Uncertainty` in mod.rs)
// ──────────────────────────────────────────────────────────────────────
#[test]
fn unresolved_ancestry_does_not_survive_being_resolved() {
// Measured live on a desktop: a link is observed while its output node is
// still unbound, the input side fails closed — correctly — and then that
// fail-closed mark became *sticky*, so a hardware sink stayed excluded for
// the process lifetime even after the node resolved and turned out to be
// an ordinary game. Phase 3r's bind-everything observer widens that window
// to every node, so this must clear.
let mut graph = Graph::new();
let ghost = graph.dangling_id();
let client = graph.client_of_app(6000);
let victim = graph.node("victim-in", MediaRole::StreamInput, app(client, 6000));
let sibling = graph.node("victim-out", MediaRole::StreamOutput, app(client, 6000));
graph.link_ids(ghost, victim.id);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
let c = ctx();
// While the ancestry is genuinely unresolved, the decision is unchanged:
// fail closed, both the victim and its sibling excluded.
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
assert_partition(
&first,
&[("firefox", firefox)],
&[("victim-out", sibling, "tainted-owner-bridge")],
);
assert_tainted(&first, victim, "unresolved-ancestry");
// The node behind that id turns up — nothing tainted, it was simply not
// observed yet. The uncertainty is gone, so nothing may remain of it.
let late_client = graph.client_of_app(7100);
let resolved = graph.node_with_id(
"was-unbound",
MediaRole::StreamOutput,
ghost,
app(late_client, 7100),
);
let (second, _) = evaluate(&graph.build(), &c, &sticky);
assert_partition(
&second,
&[
("firefox", firefox),
("victim-out", sibling),
("was-unbound", resolved),
],
&[],
);
}
#[test]
fn uncertainty_laundered_into_downstream_taint_is_not_sticky_either() {
// Retiring by reason *code* would not be enough: an unresolved node
// propagates `tainted-upstream`, which is indistinguishable from real
// contamination once recorded. The split has to be by provenance, so a
// node two hops from the uncertainty must clear too.
let mut graph = Graph::new();
let ghost = graph.dangling_id();
let forwarder_client = graph.client_of_app(6100);
let forwarder_in = graph.node(
"fwd-in",
MediaRole::StreamInput,
app(forwarder_client, 6100),
);
let forwarder_out = graph.node(
"fwd-out",
MediaRole::StreamOutput,
app(forwarder_client, 6100),
);
let downstream_client = graph.client_of_app(6200);
let downstream = graph.node("downstream", MediaRole::Sink, app(downstream_client, 6200));
let downstream_leg = graph.node(
"downstream-out",
MediaRole::StreamOutput,
app(downstream_client, 6200),
);
graph.link_ids(ghost, forwarder_in.id);
graph.link(forwarder_out, downstream);
let c = ctx();
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
assert_tainted(&first, forwarder_in, "unresolved-ancestry");
assert_tainted(&first, downstream, "tainted-upstream");
assert!(
first.candidates[&downstream_leg.serial].reason().is_some(),
"while the ancestry is unresolved the downstream owner is excluded too"
);
let late_client = graph.client_of_app(7200);
graph.node_with_id(
"was-unbound",
MediaRole::StreamOutput,
ghost,
app(late_client, 7200),
);
let (second, _) = evaluate(&graph.build(), &c, &sticky);
assert_eq!(
second.candidates[&downstream_leg.serial].reason(),
None,
"nothing derived from the uncertainty may outlive it"
);
assert_eq!(
second.candidates[&forwarder_out.serial].reason(),
None,
"including the unresolved node's own owner siblings"
);
}
#[test]
fn real_taint_is_still_sticky_when_its_topology_goes_away() {
// The other half of the same rule, stated positively: *evidence* is
// history and must survive. This is the guard on the change above — if
// provenance splitting ever leaks into the evidence path, peerspeak's own
// audio starts escaping.
let (graph, call, rec_in, rec_out, firefox) = sticky_scene();
let c = ctx();
let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
let (second, _) = evaluate(&graph.build_without(&[rec_in]), &c, &sticky);
assert_partition(
&second,
&[("firefox", firefox)],
&[
("call", call, "peerspeak-owned"),
("rec-out", rec_out, "tainted-owner-bridge"),
],
);
}
// ──────────────────────────────────────────────────────────────────────
// Stickiness and lifetime-awareness (v3.4 §6.1.3)
// ──────────────────────────────────────────────────────────────────────
@@ -1804,5 +2683,12 @@ fn an_ambiguous_client_id_remembers_every_claimant_for_stickiness() {
client_members >= 2,
"both ambiguous-id clients should be remembered: {sticky:#?}"
);
let _ = (ClientSnapshot { serial: Serial(0), id: GlobalId(0), sec_pid: None }, firefox);
let _ = (
ClientSnapshot {
serial: Serial(0),
id: GlobalId(0),
sec_pid: None,
},
firefox,
);
}
+8 -1
View File
@@ -49,7 +49,14 @@ async fn main() -> Result<()> {
pipewire::init();
if cli.repair {
return repair::run().await;
return repair::run(cli.repair_legacy_untagged).await;
}
// Read-only diagnostic: observe the graph, report what the audio-exclusion
// engine concludes, create nothing. Placed before the host/viewer dispatch
// because it is neither — it shares no screen and connects to no peer.
if cli.audit_audio {
return host::audit::run::run_standalone().await;
}
if cli.reconfigure {
-236
View File
@@ -1,236 +0,0 @@
//! `--repair`: clean up null-sinks and loopbacks left behind by a crashed
//! pixelpass host. Identifies orphans by the `pixelpass_capture_<pid>`
//! name pattern + dead-PID check, then unloads paired loopbacks first
//! (mirrors `Routing::shutdown`'s order so PipeWire doesn't leave zombie
//! links). Live PIDs — including this process and any other running
//! pixelpass — are left alone.
use anyhow::{Context, Result, bail};
use std::collections::HashSet;
use std::path::Path;
use std::process::Command;
const SINK_NAME_PREFIX: &str = "pixelpass_capture_";
pub async fn run() -> Result<()> {
let modules = list_modules().context("failed to list pactl modules")?;
let mut dead_sinks: Vec<OrphanSink> = Vec::new();
let mut dead_pids: HashSet<u32> = HashSet::new();
let mut live_skipped: u32 = 0;
for m in &modules {
if m.name != "module-null-sink" {
continue;
}
let Some(sink_name) = extract_kv(&m.args, "sink_name") else {
continue;
};
let Some(pid_str) = sink_name.strip_prefix(SINK_NAME_PREFIX) else {
continue;
};
let Ok(pid) = pid_str.parse::<u32>() else {
continue;
};
if is_pid_alive(pid) {
live_skipped += 1;
continue;
}
dead_pids.insert(pid);
dead_sinks.push(OrphanSink {
id: m.id,
sink_name: sink_name.to_string(),
pid,
});
}
let mut dead_loopbacks: Vec<u32> = Vec::new();
for m in &modules {
if m.name != "module-loopback" {
continue;
}
// A pixelpass loopback references a capture sink either as its
// destination (`sink=pixelpass_capture_<pid>` — the default→null
// mirror) or as its source (`source=pixelpass_capture_<pid>.monitor`
// — the local monitor that lets the sharer hear the app). Match both.
let Some(pid) = loopback_capture_pid(&m.args) else {
continue;
};
if dead_pids.contains(&pid) {
dead_loopbacks.push(m.id);
}
}
if dead_sinks.is_empty() && dead_loopbacks.is_empty() {
if live_skipped > 0 {
println!(
"[pixelpass] --repair: nothing to clean up ({live_skipped} live pixelpass host(s) left alone)."
);
} else {
println!("[pixelpass] --repair: nothing to clean up.");
}
return Ok(());
}
let mut unloaded = 0u32;
let mut failed = 0u32;
for id in &dead_loopbacks {
match unload_module(*id) {
Ok(()) => {
println!("[pixelpass] --repair: unloaded loopback module #{id}");
unloaded += 1;
}
Err(e) => {
eprintln!("[pixelpass] --repair: failed to unload loopback #{id}: {e:#}");
failed += 1;
}
}
}
for orphan in &dead_sinks {
match unload_module(orphan.id) {
Ok(()) => {
println!(
"[pixelpass] --repair: unloaded {} (orphaned from pid {})",
orphan.sink_name, orphan.pid
);
unloaded += 1;
}
Err(e) => {
eprintln!(
"[pixelpass] --repair: failed to unload {} (#{}): {e:#}",
orphan.sink_name, orphan.id
);
failed += 1;
}
}
}
if live_skipped > 0 {
println!("[pixelpass] --repair: left {live_skipped} live pixelpass host(s) alone.");
}
if failed > 0 {
bail!("--repair: {failed} module(s) failed to unload (see errors above)");
}
println!("[pixelpass] --repair: cleaned up {unloaded} module(s).");
Ok(())
}
struct Module {
id: u32,
name: String,
args: String,
}
struct OrphanSink {
id: u32,
sink_name: String,
pid: u32,
}
fn list_modules() -> Result<Vec<Module>> {
let output = Command::new("pactl")
.args(["list", "short", "modules"])
.output()
.context("failed to run `pactl list short modules`")?;
if !output.status.success() {
bail!(
"pactl list short modules failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let text = String::from_utf8(output.stdout).context("pactl returned non-UTF-8")?;
let mut modules = Vec::new();
// `pactl list short modules` is tab-separated, but some modules have
// multi-line `{ ... }` argument blocks that wrap onto continuation
// lines starting with whitespace. The wrap lines never parse as a
// u32 ID, so the simple per-line + parse-id filter is robust.
for line in text.lines() {
let mut parts = line.splitn(4, '\t');
let Some(id_str) = parts.next() else { continue };
let Ok(id) = id_str.parse::<u32>() else {
continue;
};
let Some(name) = parts.next() else { continue };
let args = parts.next().unwrap_or("").to_string();
modules.push(Module {
id,
name: name.to_string(),
args,
});
}
Ok(modules)
}
/// The `pixelpass_capture_<pid>` PID a loopback references, whether the capture
/// sink is its destination (`sink=pixelpass_capture_<pid>`) or its source
/// (`source=pixelpass_capture_<pid>.monitor`). `None` for unrelated loopbacks.
fn loopback_capture_pid(args: &str) -> Option<u32> {
let from_sink = extract_kv(args, "sink").and_then(|v| v.strip_prefix(SINK_NAME_PREFIX));
let from_source = extract_kv(args, "source")
.and_then(|v| v.strip_prefix(SINK_NAME_PREFIX))
.and_then(|rest| rest.strip_suffix(".monitor"));
from_sink
.or(from_source)
.and_then(|pid| pid.parse::<u32>().ok())
}
fn extract_kv<'a>(args: &'a str, key: &str) -> Option<&'a str> {
for token in args.split_whitespace() {
if let Some(rest) = token.strip_prefix(key)
&& let Some(value) = rest.strip_prefix('=')
{
return Some(value);
}
}
None
}
fn is_pid_alive(pid: u32) -> bool {
Path::new(&format!("/proc/{pid}")).exists()
}
fn unload_module(id: u32) -> Result<()> {
let output = Command::new("pactl")
.arg("unload-module")
.arg(id.to_string())
.output()
.context("failed to run pactl unload-module")?;
if !output.status.success() {
bail!(
"pactl unload-module #{id}: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loopback_pid_matches_default_null_mirror_by_sink() {
// The default→null loopback: capture sink is the destination.
let args = "source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_4242 latency_msec=20";
assert_eq!(loopback_capture_pid(args), Some(4242));
}
#[test]
fn loopback_pid_matches_local_monitor_by_source() {
// The local monitor: capture sink's monitor is the source, and the
// destination is the real default sink (not a pixelpass name).
let args = "source=pixelpass_capture_4242.monitor sink=@DEFAULT_SINK@ latency_msec=20";
assert_eq!(loopback_capture_pid(args), Some(4242));
}
#[test]
fn loopback_pid_ignores_unrelated_loopback() {
assert_eq!(
loopback_capture_pid("source=alsa_output.pci.monitor sink=some_other_sink"),
None
);
}
}
+297
View File
@@ -0,0 +1,297 @@
//! Structured Pulse introspection: the observation and destruction layer for
//! `--repair`.
//!
//! # Why this replaced parsing `pactl`
//!
//! Three separate defects, all of them consequences of reading a human-oriented
//! text format rather than the protocol:
//!
//! 1. **Record boundaries were unprovable.** `pactl list short modules` prints a
//! module's argument raw into a tab-and-newline-delimited format with no
//! escaping. A *genuine* module whose argument contains a newline — say
//! `…latency_msec=20\nremix=false`, and `remix` is a real loopback option —
//! renders a first line that reads byte-exactly like one of our canonical
//! forms, with the remainder dropped as an unparseable continuation. No index
//! is forged, so no duplicate-index check can see it: repair would classify and
//! unload a module it had never actually seen in full. A tab in the same
//! position instead hides a sink reference, which is worse, because the gate
//! that protects a still-referenced sink then cannot see the reference.
//! 2. **Index and argument could be mis-paired.** The one listing that carries the
//! exact argument (`-f json`) carries **no index** at all on pactl 17, and the
//! one that carries the index cannot carry the argument faithfully. Combining
//! them by position is unsound whenever module names repeat: another client
//! loading one module and unloading another between the two calls leaves the
//! counts and names aligned while every argument has shifted by one.
//! 3. **Locality was a guess.** `PULSE_SERVER` is a *fallback list*, so
//! `unix:/missing tcp:remote:4713` passes any "starts with unix:" test and then
//! connects to another machine — where our local pids mean nothing and a live
//! remote host's modules look dead.
//!
//! `pa_module_info` carries index, name and argument together in one structured
//! record, so (1) and (2) cannot arise. `pa_context_is_local()` answers (3) about
//! the connection that actually got established rather than about a string we
//! hoped described it. And because unloading goes back through the *same*
//! connection, there is no window in which listing and destruction could disagree
//! about which server they are talking to.
//!
//! # What is deliberately not here
//!
//! No decisions. This module observes and destroys; every judgement about what may
//! be destroyed lives in [`super::plan`], which is pure and needs no Pulse server
//! to test. The one policy this layer owns is *refusing to talk to the wrong
//! server at all*.
use anyhow::{Context as _, Result, bail};
use libpulse_binding::callbacks::ListResult;
use libpulse_binding::context::{Context, FlagSet as ContextFlagSet, State as ContextState};
use libpulse_binding::mainloop::standard::{IterateResult, Mainloop};
use libpulse_binding::operation::{Operation, State as OperationState};
use libpulse_binding::proplist::{Proplist, properties};
use std::cell::RefCell;
use std::rc::Rc;
use std::time::{Duration, Instant};
use super::plan::ModuleObservation;
/// How long to wait for the connection to reach `Ready`. A one-shot CLI must not
/// hang on an unresponsive server; failing closed here costs the user a re-run.
const CONNECT_BUDGET: Duration = Duration::from_secs(3);
/// How long any single introspection request may take.
///
/// ⚠️ On timeout the `Operation` wrapper is dropped while still running. In
/// libpulse-binding 2.30.1 that only unrefs the C operation — the boxed callback
/// and the `Rc`s it captured leak until the context cancels the operation at
/// disconnect. That is bounded and harmless *here*, because `--repair` is a
/// one-shot process that exits immediately afterwards, and it cannot become a
/// use-after-free (the closure owns its clones, and the context clears callbacks
/// before the mainloop is touched). **It would not be acceptable in the long-lived
/// host**, so this module must not be reused for host-side loading until that
/// binding bug is fixed or worked around; `op.cancel()` does not help.
const REQUEST_BUDGET: Duration = Duration::from_secs(3);
/// How long to sleep between mainloop iterations while waiting. Non-blocking
/// iteration plus a short sleep keeps the deadline enforceable, which
/// `iterate(true)` would not.
const POLL_INTERVAL: Duration = Duration::from_millis(2);
/// A live, verified-local connection to the Pulse server.
///
/// Both listing and unloading run through this one connection, so everything
/// repair sees and everything it destroys provably belong to the same server.
///
/// ⚠️ **Field order is load-bearing, and this was not theoretical.** Rust drops
/// fields in declaration order, and the context's teardown frees IO events that
/// live *in* the mainloop. With `mainloop` declared first, `--repair` did its work
/// correctly and then died on the way out:
///
/// ```text
/// Assertion '!e->dead' failed at ../pulseaudio/src/pulse/mainloop.c:207,
/// function mainloop_io_free(). Aborting.
/// ```
///
/// SIGABRT, a core dump, and exit 134 — so a completely successful repair reported
/// failure to its caller. This is the same invariant phase 0b's
/// `ScreenshareTeardown` exists for, met again one layer down.
///
/// Rather than leave that resting on where the fields happen to be written, [`Drop`]
/// **explicitly** takes and drops the context first, so the ordering survives a
/// future reorder of this struct. The declaration order below is still correct, and
/// now it is also not load-bearing.
pub struct PulseSession {
/// `Option` only so that `Drop` can `take()` it and destroy it *before* the
/// mainloop. Always `Some` for the whole of the session's usable life.
context: Option<Context>,
mainloop: Mainloop,
}
impl Drop for PulseSession {
fn drop(&mut self) {
// Disconnect, then destroy the context while the mainloop it registered its
// IO events with is still alive. The mainloop then drops after us.
//
// Nothing is drained afterwards on purpose. An earlier version iterated the
// mainloop a few times here to "let teardown settle", which was a ritual
// rather than a barrier: a fixed number of non-blocking polls cannot
// guarantee that any particular event became ready. It is also unnecessary —
// PulseAudio's context unlink cancels outstanding operations and removes the
// context's socket machinery synchronously, so by the time `drop(context)`
// returns there is no obligation left for the mainloop to service.
if let Some(mut context) = self.context.take() {
context.disconnect();
drop(context);
}
}
}
impl PulseSession {
/// The live context. Infallible in practice: only `Drop` ever clears it, and
/// nothing can call this afterwards.
fn context(&mut self) -> &mut Context {
self.context
.as_mut()
.expect("the context is only taken during Drop")
}
/// Connect, wait for readiness, and refuse anything but a local server.
pub fn connect() -> Result<Self> {
let mut proplist = Proplist::new().context("could not allocate a Pulse proplist")?;
// `set_str` fails only on an invalid key, and these keys are constants.
let _ = proplist.set_str(properties::APPLICATION_NAME, "pixelpass --repair");
let _ = proplist.set_str(properties::APPLICATION_ID, "xyz.pixelpass.repair");
let mut mainloop = Mainloop::new().context("could not create a Pulse mainloop")?;
let mut context = Context::new_with_proplist(&mainloop, "pixelpass --repair", &proplist)
.context("could not create a Pulse context")?;
context
.connect(None, ContextFlagSet::NOFLAGS, None)
.context("could not connect to the Pulse server")?;
let deadline = Instant::now() + CONNECT_BUDGET;
loop {
iterate_once(&mut mainloop)?;
match context.get_state() {
ContextState::Ready => break,
ContextState::Failed => {
bail!("the Pulse server refused the connection");
}
ContextState::Terminated => {
bail!("the Pulse connection terminated before it was ready");
}
_ => {
if Instant::now() >= deadline {
bail!(
"the Pulse server did not become ready within {:?}",
CONNECT_BUDGET
);
}
std::thread::sleep(POLL_INTERVAL);
}
}
}
// The connection is established, so this is about the server we actually
// reached — not about what a server string appeared to promise. A remote
// server's module table belongs to another machine's processes, where our
// pids mean nothing, so repair must not touch it.
match context.is_local() {
Some(true) => {}
Some(false) => bail!(
"connected to a REMOTE Pulse server; --repair only ever operates on the local \
server, because it decides what to unload from local process liveness"
),
None => bail!(
"could not determine whether the Pulse server is local; refusing to unload \
anything"
),
}
Ok(Self {
context: Some(context),
mainloop,
})
}
/// Every loaded module, with its exact argument.
pub fn list_modules(&mut self) -> Result<Vec<ModuleObservation>> {
// `Rc<RefCell<…>>` because the callback is owned by the C library and may
// be invoked many times before the operation completes.
let collected: Rc<RefCell<Vec<ModuleObservation>>> = Rc::new(RefCell::new(Vec::new()));
let failed: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
let sink = Rc::clone(&collected);
let error_flag = Rc::clone(&failed);
let op = self
.context()
.introspect()
.get_module_info_list(move |result| match result {
ListResult::Item(info) => {
// A module with no name is not one we can identify, and an
// argumentless module is simply one loaded without arguments.
let name = info.name.as_deref().unwrap_or_default();
let args = info.argument.as_deref().unwrap_or_default();
sink.borrow_mut()
.push(ModuleObservation::new(info.index, name, args));
}
ListResult::End => {}
ListResult::Error => *error_flag.borrow_mut() = true,
});
self.run_to_completion(op, "list modules")?;
if *failed.borrow() {
bail!("the Pulse server returned an error while listing modules");
}
let modules = collected.borrow().clone();
// Impossible per the protocol — an index identifies one module — so this is
// a sanity check on external input, not a safety boundary. It fails closed
// because an ambiguous index is one we could unload wrongly.
for (i, module) in modules.iter().enumerate() {
if modules[..i].iter().any(|earlier| earlier.id == module.id) {
bail!(
"the Pulse server reported module index #{} twice; refusing to unload \
anything",
module.id
);
}
}
Ok(modules)
}
/// Unload one module, over the same connection it was observed on.
pub fn unload_module(&mut self, index: u32) -> Result<()> {
let succeeded: Rc<RefCell<Option<bool>>> = Rc::new(RefCell::new(None));
let outcome = Rc::clone(&succeeded);
let op = self
.context()
.introspect()
.unload_module(index, move |success| *outcome.borrow_mut() = Some(success));
self.run_to_completion(op, "unload module")?;
match *succeeded.borrow() {
Some(true) => Ok(()),
Some(false) => bail!("the Pulse server rejected unloading module #{index}"),
// The operation completed without the callback running, which we cannot
// read as success.
None => bail!("no result was reported for unloading module #{index}"),
}
}
/// Drive the mainloop until `op` finishes, or the budget expires.
fn run_to_completion<T: ?Sized>(&mut self, op: Operation<T>, what: &str) -> Result<()> {
let deadline = Instant::now() + REQUEST_BUDGET;
loop {
iterate_once(&mut self.mainloop)?;
match op.get_state() {
OperationState::Done => return Ok(()),
OperationState::Cancelled => {
bail!("the Pulse server cancelled the request to {what}");
}
OperationState::Running => {
// A connection that dies mid-request would otherwise be waited
// out to the full budget.
match self.context().get_state() {
ContextState::Ready => {}
state => {
bail!("the Pulse connection became {state:?} while trying to {what}")
}
}
if Instant::now() >= deadline {
bail!("the Pulse server did not {what} within {REQUEST_BUDGET:?}");
}
std::thread::sleep(POLL_INTERVAL);
}
}
}
}
}
/// One non-blocking mainloop iteration, with quit and error surfaced as errors.
fn iterate_once(mainloop: &mut Mainloop) -> Result<()> {
match mainloop.iterate(false) {
IterateResult::Success(_) => Ok(()),
IterateResult::Quit(code) => {
bail!("the Pulse mainloop quit unexpectedly (code {})", code.0)
}
IterateResult::Err(e) => Err(e).context("the Pulse mainloop failed"),
}
}
+553
View File
@@ -0,0 +1,553 @@
//! `--repair`: clean up the Pulse modules left behind by a crashed pixelpass
//! host.
//!
//! All of the judgement lives in [`plan`], which is pure. What remains here is
//! I/O plus the two rules that cannot be expressed in a plan:
//!
//! - **Re-verify immediately before destroying anything.** Pulse module indices
//! are reused verbatim, and a host can die (or come back) between the snapshot
//! and the unload, so the plan is treated as evidence that expires — never as a
//! licence.
//! - **Gate the sink on what is still attached to it**, not on the plan's ordering
//! having succeeded. An unload can fail or be skipped, and a loopback can appear
//! after the plan was made.
//!
//! Repair never touches native PipeWire nodes. Since phase 0c the capture sink is
//! connection-owned and removes itself when its host dies, so there is nothing
//! there for repair to do and no safe way for it to help.
//!
//! # Where the observations come from
//!
//! Structured Pulse introspection over one verified-local connection — see
//! [`introspect`], which also documents the three defects that parsing `pactl`'s
//! text output turned out to have. Listing *and* unloading both go through that
//! same connection.
pub mod introspect;
pub mod plan;
use anyhow::{Context, Result, bail};
use std::path::Path;
use introspect::PulseSession;
use plan::{Fingerprint, Liveness, Shape};
pub async fn run(clean_untagged: bool) -> Result<()> {
let liveness = LivenessProbe::new();
if let Some(reason) = liveness.degraded_reason() {
// Scoped deliberately: modules carrying a token that matches this machine,
// boot and pid namespace are still cleaned, because the token establishes
// what these signals can only guess at. Saying "refusing to unload
// anything" here would be false in exactly the container-recovery case the
// token was added for.
eprintln!(
"[pixelpass] --repair: cannot independently determine process liveness \
({reason}); modules WITHOUT an ownership token will be left alone."
);
}
let local = local_identity().context("could not establish this process's own identity")?;
let policy = plan::Policy {
local,
untagged: if clean_untagged {
plan::UntaggedPolicy::CleanByPidAlone
} else {
plan::UntaggedPolicy::Refuse
},
};
if clean_untagged {
eprintln!(
"[pixelpass] --repair: --repair-legacy-untagged given; untagged modules will be \
judged by process id ALONE. That is only safe on the machine and in the pid \
namespace that ran the crashed host."
);
}
let mut pulse = PulseSession::connect().context("could not observe the Pulse module table")?;
let modules = pulse
.list_modules()
.context("could not list Pulse modules")?;
// Say so loudly when something names our sinks but matches no shape we know:
// that is either a third party using our names, or a newer pixelpass whose
// modules this build cannot recognise. The second is how repair would go
// silently blind, so it never gets inferred from a clean exit.
let unrecognised = plan::unrecognised_pixelpass_modules(&modules);
if !unrecognised.is_empty() {
eprintln!(
"[pixelpass] --repair: {} module(s) name a pixelpass capture sink but do not match \
any shape this build knows; they are being LEFT ALONE:",
unrecognised.len()
);
for obs in &unrecognised {
eprintln!(
"[pixelpass] --repair: #{} {} {}",
obs.id, obs.name, obs.args
);
}
}
let planned = plan::plan(&modules, &policy, |pid, attribution| {
liveness_for(&liveness, attribution, pid)
});
// Ours by shape, but carrying no proof of whose pid they name. Never unloaded by
// default — listed, so an explicit legacy run has something to look at first.
if !planned.untagged.is_empty() {
eprintln!(
"[pixelpass] --repair: {} module(s) are pixelpass's but carry no ownership token, so \
the process id in their name cannot be attributed to this machine or pid namespace. \
LEFT ALONE. Re-run with --repair-legacy-untagged to clean them by pid alone:",
planned.untagged.len()
);
for fp in &planned.untagged {
eprintln!(
"[pixelpass] --repair: #{} {} (claims pid {})",
fp.id,
fp.shape.label(),
fp.pid
);
}
}
// Tokened, but the token belongs to another machine, boot or namespace.
if !planned.foreign.is_empty() {
eprintln!(
"[pixelpass] --repair: {} module(s) belong to another machine, boot or pid namespace; \
their process ids mean nothing here. LEFT ALONE:",
planned.foreign.len()
);
for fp in &planned.foreign {
eprintln!(
"[pixelpass] --repair: #{} {} (claims pid {})",
fp.id,
fp.shape.label(),
fp.pid
);
}
}
if planned.is_empty() {
let mut held = Vec::new();
if !planned.live_pids.is_empty() {
held.push(format!(
"{} live pixelpass host(s)",
planned.live_pids.len()
));
}
if !planned.unknown_pids.is_empty() {
held.push(format!(
"{} pid(s) of undeterminable liveness",
planned.unknown_pids.len()
));
}
if held.is_empty() {
println!("[pixelpass] --repair: nothing to clean up.");
} else {
println!(
"[pixelpass] --repair: nothing to clean up ({} left alone).",
held.join(", ")
);
}
return Ok(());
}
let mut unloaded = 0u32;
let mut skipped = 0u32;
let mut failed = 0u32;
for fp in &planned.unload {
// ORDER MATTERS, and it is the opposite of what reads naturally.
//
// Liveness is asked FIRST, and the fresh snapshot is taken AFTER it. The
// tempting order — verify the module, then check liveness, then unload —
// leaves the dangerous window wide open: between `kill` returning ESRCH and
// the unload, this process can be descheduled long enough for the planned
// module to vanish, a new host to inherit both the pid and the module index,
// and its differently-nonced arguments to occupy that index. Nothing would
// re-read those arguments, so the reused index gets unloaded.
//
// Asking liveness first and re-verifying the fingerprint after it means a
// replacement arriving in that window is caught by the argument comparison,
// and only the irreducible snapshot-to-unload interval remains.
match attributed_liveness(&liveness, fp) {
Liveness::Dead => {}
Liveness::Alive => {
println!(
"[pixelpass] --repair: pid {} is alive again; leaving module #{} alone",
fp.pid, fp.id
);
skipped += 1;
continue;
}
Liveness::Unknown => {
eprintln!(
"[pixelpass] --repair: pid {}'s liveness became undeterminable; \
leaving module #{} alone",
fp.pid, fp.id
);
skipped += 1;
continue;
}
}
// Fresh snapshot per action, taken after the liveness answer. Deliberately
// not hoisted out of the loop: each unload changes the module table, and the
// point is to decide against the table as it is *now*.
let current = pulse
.list_modules()
.context("could not re-list Pulse modules")?;
let Some(obs) = current.iter().find(|m| m.id == fp.id) else {
println!(
"[pixelpass] --repair: module #{} is already gone; skipping",
fp.id
);
skipped += 1;
continue;
};
if !fp.still_matches(obs) {
// The index now names something else, or the same module's
// arguments changed. Either way we no longer know what we would be
// destroying, so we do not destroy it.
eprintln!(
"[pixelpass] --repair: module #{} no longer matches what was planned \
(index reused?); refusing to unload it",
fp.id
);
skipped += 1;
continue;
}
// The sink goes last in the plan, but "last" is not the same as "nothing
// is attached any more": a loopback unload may have failed or been
// skipped, or a new one may have arrived since. Ask the fresh snapshot.
if fp.shape == Shape::LegacyCaptureSink
&& let Some(holder) = plan::sink_still_referenced(&current, fp.pid, fp.id)
{
eprintln!(
"[pixelpass] --repair: module #{} (capture sink for pid {}) is still referenced \
by module #{}; leaving the sink loaded",
fp.id, fp.pid, holder
);
skipped += 1;
continue;
}
match pulse.unload_module(fp.id) {
Ok(()) => {
println!("[pixelpass] --repair: {}", describe(fp));
unloaded += 1;
}
Err(e) => {
eprintln!("[pixelpass] --repair: failed to unload #{}: {e:#}", fp.id);
failed += 1;
}
}
}
if !planned.live_pids.is_empty() {
println!(
"[pixelpass] --repair: left {} live pixelpass host(s) alone.",
planned.live_pids.len()
);
}
if !planned.unknown_pids.is_empty() {
println!(
"[pixelpass] --repair: left {} pid(s) alone whose liveness could not be determined.",
planned.unknown_pids.len()
);
}
if skipped > 0 {
// Deliberately not "changed under us": a skip can also mean the module is
// still referenced, or its owner's liveness stopped being decidable. The
// per-module reason was printed above.
println!("[pixelpass] --repair: skipped {skipped} module(s) (reasons above).");
}
if failed > 0 {
bail!("--repair: {failed} module(s) failed to unload (see errors above)");
}
println!("[pixelpass] --repair: cleaned up {unloaded} module(s).");
Ok(())
}
fn describe(fp: &Fingerprint) -> String {
format!(
"unloaded {} #{} (orphaned from pid {})",
fp.shape.label(),
fp.id,
fp.pid
)
}
/// Ask liveness with the module's *attribution* in hand.
///
/// A module whose token matches this machine, boot and pid namespace has already
/// proven that its pid is a number meaningful here — that is the token's entire
/// job. Running such a module through the probe's degradation checks would defeat
/// it in exactly the situation it exists for: a host crashing inside a container
/// leaves a token that matches perfectly, while a container marker or a multi-entry
/// `NSpid` makes the probe answer `Unknown` for everything, so token-qualified
/// repair would do nothing precisely where it is now safe.
///
/// The degradation signals therefore guard only the *untagged* path, where a bare
/// pid is all there is and those signals are the only protection left.
fn liveness_for(probe: &LivenessProbe, attribution: plan::Attribution, pid: u32) -> Liveness {
match attribution {
plan::Attribution::Tokened => probe.of_attributed(pid),
plan::Attribution::Untagged => probe.of(pid),
}
}
/// The same rule, for a fingerprint at execution time.
fn attributed_liveness(probe: &LivenessProbe, fp: &Fingerprint) -> Liveness {
let attribution = match fp.owner {
Some(_) => plan::Attribution::Tokened,
None => plan::Attribution::Untagged,
};
liveness_for(probe, attribution, fp.pid)
}
// ──────────────────────────────────────────────────────────────────────
// Identity
// ──────────────────────────────────────────────────────────────────────
/// This process's machine, boot and pid-namespace identity.
///
/// Read from the kernel and the system, never guessed: without all three, a token
/// cannot be compared and no module can be attributed. Dashes are stripped so every
/// component is safe inside a single unquoted Pulse property value.
pub fn local_identity() -> Result<plan::LocalIdentity> {
let machine = read_identity_file("/etc/machine-id")
.or_else(|_| read_identity_file("/var/lib/dbus/machine-id"))
.context("could not read a machine id")?;
let boot = read_identity_file("/proc/sys/kernel/random/boot_id")
.context("could not read the boot id")?;
let pid_ns = pid_namespace_id().context("could not read this process's pid namespace")?;
Ok(plan::LocalIdentity {
machine,
boot,
pid_ns,
})
}
fn read_identity_file(path: &str) -> Result<String> {
let raw = std::fs::read_to_string(path).with_context(|| format!("could not read {path}"))?;
let cleaned: String = raw
.trim()
.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect();
if cleaned.is_empty() {
bail!("{path} held no usable identity");
}
Ok(cleaned)
}
/// The inode of `/proc/self/ns/pid` — the kernel's identity for a pid namespace.
///
/// This is the value that makes a pid meaningful: two processes in different pid
/// namespaces can hold the same number, and only this distinguishes them.
fn pid_namespace_id() -> Result<u64> {
use std::os::unix::fs::MetadataExt;
let meta =
std::fs::metadata("/proc/self/ns/pid").context("could not stat /proc/self/ns/pid")?;
Ok(meta.ino())
}
// ──────────────────────────────────────────────────────────────────────
// Liveness
// ──────────────────────────────────────────────────────────────────────
/// Answers "is this pid still around", and knows when it must refuse to answer.
///
/// **An error is not an absence.** `Path::exists()` folds permission errors and a
/// missing `/proc` into `false`, which here would read as "dead, go ahead and
/// unload". Liveness is asked with `kill(pid, 0)` instead, where `EPERM` *proves*
/// existence.
///
/// # The limit of what this can prove, stated rather than papered over
///
/// A pid can be alive and invisible. Inside a pid namespace — a container, a
/// distrobox — `/proc/self` is perfectly visible while every process in the
/// *parent* namespace is not, and `hidepid` has the same self-visible,
/// others-invisible shape. Repair in such a place can reach the host's Pulse
/// socket, see a live host's modules, get `ESRCH` for its pid and unload a running
/// host's audio.
///
/// The signals below are **negative** ones: they detect *some* cases where pid
/// numbers cannot be trusted, and every one of them fails closed. What they cannot
/// do is prove the converse. `NSpid` reports this process's pid in each namespace
/// that its procfs can see, and its leftmost value is relative to the pid namespace
/// that mounted that procfs — so a nested namespace with its own `/proc` reports a
/// single entry quite legitimately. `NSpid > 1` therefore means "definitely
/// nested", while `NSpid == 1` means only "not detectably nested".
///
/// Closing that properly needs the module itself to carry an owner token (machine
/// and boot identity plus pid-namespace identity) written at load time, with
/// token-less modules treated as `Unknown`. That changes what pixelpass writes into
/// the graph and how far back `--repair` can clean up, so it is a design decision
/// recorded in the impl plan rather than guessed at here.
struct LivenessProbe {
/// `None` when no signal says pid numbers are untrustworthy; `Some(reason)`
/// when every answer must be [`Liveness::Unknown`].
degraded: Option<String>,
}
impl LivenessProbe {
fn new() -> Self {
Self {
degraded: Self::detect_degradation(),
}
}
fn detect_degradation() -> Option<String> {
// Locality is deliberately NOT checked here. `PULSE_SERVER` is a fallback
// *list*, so `unix:/missing tcp:remote:4713` starts with "unix:" and still
// connects to another machine, and a remote server can be selected by client
// configuration with the variable unset entirely. The authoritative answer
// comes from `pa_context_is_local()` on the connection that actually got
// established — see `introspect::PulseSession::connect`.
match std::fs::read_to_string("/proc/self/status") {
Ok(status) => {
let nspid = status
.lines()
.find_map(|line| line.strip_prefix("NSpid:"))
.map(|rest| rest.split_whitespace().count());
match nspid {
Some(n) if n > 1 => {
return Some(format!(
"this process is in a nested pid namespace (NSpid has {n} entries), \
so pids in module names may belong to processes it cannot see"
));
}
// NB: a single entry is not proof of the initial namespace — see
// the type's doc comment. It only means nothing detected it.
// A kernel too old to report NSpid cannot rule nesting out.
None => {
return Some(
"/proc/self/status does not report NSpid, so pid-namespace identity \
cannot be established"
.to_string(),
);
}
Some(_) => {}
}
}
Err(e) => return Some(format!("/proc/self/status could not be read: {e}")),
}
// Belt and braces: container runtimes that leave a marker.
for marker in ["/run/.containerenv", "/.dockerenv"] {
if Path::new(marker).try_exists().unwrap_or(false) {
return Some(format!("{marker} exists, so this is a container"));
}
}
None
}
fn degraded_reason(&self) -> Option<&str> {
self.degraded.as_deref()
}
/// Liveness for a pid this process has **no** independent reason to trust —
/// an untagged module. Here the degradation signals are the only protection.
fn of(&self, pid: u32) -> Liveness {
if self.degraded.is_some() {
return Liveness::Unknown;
}
self.of_attributed(pid)
}
/// Liveness for a pid already proven to belong to this machine, boot and pid
/// namespace by an [`plan::OwnerToken`].
///
/// The degradation checks are deliberately skipped: they exist to guess at
/// whether a bare pid is meaningful, and here that is not a guess any more.
fn of_attributed(&self, pid: u32) -> Liveness {
// `kill(0, …)` signals our whole process group and a negative pid signals
// another group, so neither may ever reach `kill`. Neither is a pid we
// could have written into a sink name anyway.
if pid == 0 || pid > i32::MAX as u32 {
return Liveness::Unknown;
}
match nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid as i32), None) {
Ok(()) => Liveness::Alive,
// The process exists; we merely may not signal it.
Err(nix::errno::Errno::EPERM) => Liveness::Alive,
Err(nix::errno::Errno::ESRCH) => Liveness::Dead,
Err(_) => Liveness::Unknown,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// The `pactl`-text parser that used to live here is gone, and so are its
// tests: `introspect` gets index, name and argument as structured fields, so
// there is no format left to mis-parse. What replaced those tests is the live
// field gate, since the remaining risk is in talking to the server, which no
// unit test can exercise. The decisions all live in `plan`, which is pure and
// tested there.
/// The probe must never say `Dead` when it cannot see the whole pid space, and
/// must never ask `kill` about a pid that would signal something other than one
/// process.
#[test]
fn a_degraded_probe_never_reports_dead() {
let degraded = LivenessProbe {
degraded: Some("test".to_string()),
};
assert_eq!(degraded.of(1), Liveness::Unknown);
assert_eq!(degraded.of(u32::MAX), Liveness::Unknown);
let probe = LivenessProbe::new();
// Our own pid is alive by construction — unless this test itself runs
// somewhere the probe must abstain, which is exactly the other branch.
let me = std::process::id();
match probe.degraded_reason() {
None => assert_eq!(probe.of(me), Liveness::Alive),
Some(_) => assert_eq!(probe.of(me), Liveness::Unknown),
}
// `kill(0, …)` would signal our whole process group, and a pid past
// `i32::MAX` cannot be expressed to `kill` at all.
assert_eq!(probe.of(0), Liveness::Unknown);
assert_eq!(probe.of(u32::MAX), Liveness::Unknown);
}
/// A token proves the pid is meaningful here, so the probe's namespace
/// guesswork must not veto it. Without this, a host crashing inside a
/// container leaves a perfectly matching token while a container marker makes
/// every answer `Unknown` — and token-qualified repair does nothing in exactly
/// the situation the token was built for.
///
/// This runs against a *degraded* probe deliberately: on an ordinary desktop
/// the two paths agree, so a test using the real probe's state would pass
/// whether or not the distinction exists.
#[test]
fn a_token_beats_the_degradation_signals_but_a_bare_pid_does_not() {
let degraded = LivenessProbe {
degraded: Some("pretending to be in a container".to_string()),
};
let me = std::process::id();
assert_eq!(
degraded.of_attributed(me),
Liveness::Alive,
"an attributed pid must still be answered when the probe is degraded"
);
assert_eq!(
degraded.of(me),
Liveness::Unknown,
"a bare pid must not be, since the signals are all it has"
);
// And the routing between them, which is what the caller actually uses.
assert_eq!(
liveness_for(&degraded, plan::Attribution::Tokened, me),
Liveness::Alive
);
assert_eq!(
liveness_for(&degraded, plan::Attribution::Untagged, me),
Liveness::Unknown
);
}
}
+1470
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
# Screenshare audio exclusion — ownership tagging wire contract.
#
# peerspeak PRODUCES these carriers on every audio node it owns; pixelpass
# CONSUMES them as the primary taint root of the exclusion engine. Neither
# repo depends on the other, so this file is the contract: it is committed
# byte-identical in both, and each repo has a test that asserts its own named
# constants (and, on the producer side, the environment a real child Command
# would carry) match these values exactly.
#
# peerspeak/tests/fixtures/ownership-tag-contract.txt
# pixelpass/tests/fixtures/ownership-tag-contract.txt
#
# Pinned by peerspeak docs/screenshare-audio-exclusion-impl-plan.md §3 and
# docs/screenshare-audio-exclusion-plan.md §5.1 (v3.5). Changing a value here
# is a cross-repo breaking change: both repos must land in the same session,
# and the phase 5 matrix must be re-run.
#
# Two carriers, matched as a UNION — a node is peerspeak-owned if EITHER
# matches. Round 8 added the second because a property is invisible to the
# PipeWire registry `global` event and readable only via a node bind, so the
# primary taint root must not rest on one observation mechanism alone.
# Carrier 1 — a node property, matched EXACTLY: `prop_value` below is the
# ONLY spelling the consumer reads as owned. A producer emitting "true", "yes"
# or "" is NOT owned on this carrier, and only carrier 2 would still catch it.
#
# ⚠️ This wording is load-bearing and it CHANGED in round 10. The consumer
# used to accept any value other than "false"/"0", on the theory that leniency
# over-excludes and is therefore safe. It is not: leniency buys false-positive
# exclusion, and it let any process suppress a rival application's audio from
# the share with a property it did not even have to spell right. Fail-closed
# on this feature is about ANCESTRY — an unresolvable graph is not eligible —
# not about parsing.
prop_key=peerspeak.owned
prop_value=1
# Carrier 2 — a `node.name` prefix, announced by the registry without a bind.
# `node.description` is deliberately NOT touched, so mixers still show "mpv".
# Only the prefix is matched; the rest of the name is for diagnostics.
node_name_prefix=peerspeak_owned_
node_name_format=peerspeak_owned_<role>_<pid>
node_name_example=peerspeak_owned_mpv_31284