Author SHA1 Message Date
mollusk 911f0521f8 build(nix): pin the Rust toolchain to 1.97.1 via rust-overlay
nixpkgs 26.05 ships rustc 1.95.0, but this crate was developed and verified on
1.97.1 (what CachyOS had, installed 2026-07-17). Taking the compiler from
oxalica/rust-overlay decouples "which Rust the project targets" from "which
release the audio stack came from", so a nixpkgs bump can no longer move the
compiler under the lint gate as a side effect.

Chosen over rustup, which would also have worked here (nix-ld is enabled, so
its prebuilt binaries run) and would have let one rust-toolchain.toml cover the
packaging distroboxes too. The deciding factor is purity: rustup records
nothing in flake.lock, so a fresh clone or darp5 would resolve whatever it
fetched that day. rust-overlay gives the same exact-version control with the
choice pinned in the lock.

`.default` is the rustup "default" profile — rustc, cargo, rust-std, rustfmt
and clippy — so those are no longer listed individually. No windows-gnu target
here: pixelpass is Linux-only, unlike peerspeak.

Verified on 1.97.1: 256 tests pass, fmt clean, and `cargo clippy --all-targets
-- -D warnings` is clean.
2026-08-07 14:03:25 -04:00
mollusk 9ad55c19de build(nix): add a devShell so pixelpass builds on NixOS
The repo assumed a distro with a system-wide Rust and system-wide GStreamer,
which is exactly what NixOS does not provide. This adds a flake devShell
carrying the whole dependency surface:

- Build: rustc/cargo/clippy/rustfmt, pkg-config, and clang — pipewire-sys,
  libspa-sys and libpulse-sys all generate bindings with bindgen, which needs
  a real libclang via LIBCLANG_PATH rather than just clang on PATH.
- Link: pipewire, libpulseaudio, and libxcb. The libxcb one is not obvious:
  x11rb is declared `default-features = false` here, but Cargo unifies
  features across the graph and arboard pulls x11rb with `libxcb` on, so the
  final link really does need -lxcb.
- Runtime: GStreamer is driven as a SUBPROCESS, not linked, so the tools and
  their plugin search path are provided here too. NixOS keeps every plugin in
  its own store path, so gst-launch-1.0 finds them only through
  GST_PLUGIN_SYSTEM_PATH_1_0 — without it the `gst-inspect-1.0 --exists
  pipewiresrc` preflight fails even with the plugins installed.

nixpkgs is pinned to nixos-26.05, the same channel the hosts run, so the
client libraries match the PipeWire daemon and PulseAudio server they talk to.

Verified: 256 tests pass, clippy clean, and `--doctor` reports all checks
passing (capture, encode, mux/audio, viewer, relay).
2026-08-07 13:46:05 -04:00
molluskandClaude Opus 5 347462cca7 Merge 0c step 1: --repair learns ownership, and stops parsing pactl
Nine commits, seven adversarial review rounds. The starting point was a real
defect — after 0c the capture sink is connection-owned, so a dead host leaves
loopbacks with no `module-null-sink` to trace its pid from, and discovery went
blind rather than getting smaller. Everything after that was the review finding
that the fix's foundations were softer than they looked.

What landed:

- Discovery derives candidate pids independently from all three module shapes,
  A/B-proven on the live graph against the old binary.
- Recognition is exact-form only, and the matcher's templates are generated from
  the loader's own renderer, so the two cannot drift; anything naming our sinks
  that matches no known form is reported rather than silently ignored.
- Observation and unloading go through libpulse introspection over one
  verified-local connection. `pactl`'s text output cannot carry this: a genuine
  module whose argument contains a newline renders a first line that is
  byte-exactly canonical (field-confirmed, no adversary needed), the JSON listing
  carries no module index at all, and `PULSE_SERVER` is a fallback list that never
  proved locality.
- A pid is not an owner. Every module carries a machine/boot/pid-namespace token,
  and repair asks about a pid only when all three match — otherwise the module is
  reported and its pid is never even looked up. Untagged modules from older builds
  are refused by default, behind `--repair-legacy-untagged`.
- A plan is not a licence, and neither is ordering: fingerprints are re-verified
  against a fresh snapshot per action, the sink unload is gated on nothing still
  referencing it, and liveness runs before the snapshot so a replacement arriving
  in that window is caught.

Verified beyond the unit suite: 256 tests, the phase-5 audit re-run with and
without tokens to prove the new property is inert to the taint engine, and four
live field gates covering orphan removal, the reference gate, and the token's
three cases.

Two lessons this merge is worth remembering for:

- The live field test found what unit tests structurally could not — including a
  drop-order bug that made a completely successful repair exit 134, which is phase
  0b's invariant one layer down.
- Every fix round in this branch contained a defect the next review caught. The
  design held; the execution shell kept slipping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:41:33 -04:00
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
25 changed files with 6457 additions and 756 deletions
+6
View File
@@ -1 +1,7 @@
/target
# Nix: the symlink `nix build` drops, and direnv's local cache. flake.nix and
# flake.lock ARE tracked — the lock is what pins the toolchain.
/result
/result-*
/.direnv/
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"
Generated
+48
View File
@@ -0,0 +1,48 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1785989512,
"narHash": "sha256-HFQhkQcl5D1hUNoen3SGHCSFCt2Bg6uP+HgbrnA3InQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "445d861c6d31b4af0c79d8d4be2331f762a361d7",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1786076960,
"narHash": "sha256-jfR6OhwurCKn1tREyfOcK/Omxf1Q/DzDDFbnEr1mBLs=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "57a23bfaf4f7017267294b161175db1e32eb1c85",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+147
View File
@@ -0,0 +1,147 @@
{
description = "PixelPass P2P screen sharing CLI over iroh";
inputs = {
# Same channel the hosts run (nixos-config tracks nixos-26.05). The capture
# path talks to the live PipeWire daemon and the system PulseAudio server,
# so the client libraries here should come from the same release the server
# did.
nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05";
# The Rust toolchain is pinned SEPARATELY from the system libraries, so a
# nixpkgs bump cannot move the compiler under the lint gate. nixpkgs 26.05
# ships 1.95.0; this crate was developed and verified on 1.97.1, and
# peerspeak — the sibling project this one is built against — has a clippy
# lint that differs between exactly those two versions. Keeping both repos
# on one pinned compiler means a check that passes here passes there.
#
# This is the reproducible alternative to rustup: the same exact-version
# control, but recorded in flake.lock, so a fresh clone resolves the
# identical toolchain rather than whatever rustup fetches that day.
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{ nixpkgs, rust-overlay, ... }:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
# Matches what CachyOS shipped (rust 1:1.97.1-1) and what peerspeak pins.
# `default` is the rustup "default" profile — rustc, cargo, rust-std,
# rustfmt and clippy — so those are NOT listed separately below. No
# windows-gnu target here: pixelpass is Linux-only (portal/PipeWire/X11
# capture), unlike peerspeak which has a Windows port.
rustToolchain = pkgs.rust-bin.stable."1.97.1".default;
# GStreamer is driven as a SUBPROCESS (gst-launch-1.0 / gst-inspect-1.0),
# not linked — there is no gstreamer-sys in Cargo.lock. So these are PATH
# dependencies at runtime rather than build inputs, and `deps.rs` refuses
# to start a share if any are missing.
gstPlugins = with pkgs; [
gst_all_1.gstreamer # gst-launch-1.0 / gst-inspect-1.0
gst_all_1.gst-plugins-base # videoscale (quality-preset downscale)
gst_all_1.gst-plugins-good # pulsesrc, ximagesrc
gst_all_1.gst-plugins-bad # h264parse, mpegtsmux, aacparse, vah264enc
gst_all_1.gst-plugins-ugly # x264enc (software-encode fallback)
gst_all_1.gst-libav # avenc_aac
pipewire # pipewiresrc (Wayland capture; ships in this pkg)
];
# Opened with dlopen by the optional `--gui` front end (eframe/egui_glow/
# winit/glutin), never linked. Harmless for the default headless build.
guiRuntimeLibs = with pkgs; [
libGL
libxkbcommon
wayland
libx11
libxcursor
libxrandr
libxi
];
in
{
devShells.${system}.default = pkgs.mkShell {
nativeBuildInputs =
[ rustToolchain ]
++ (with pkgs; [
# Debian packaging (`cargo deb --no-build`). Build the binary
# inside a Debian/Ubuntu distrobox first so it links that distro's
# glibc — see the packaging notes in Cargo.toml.
cargo-deb
pkg-config
# pipewire-sys, libspa-sys and libpulse-sys all generate bindings
# with bindgen, which needs a real libclang at build time.
clang
])
++ gstPlugins
++ [
# The rest of what `deps::check_host_binaries` looks for.
pkgs.pulseaudio # `pactl` (PipeWire stays the actual audio server)
pkgs.mpv # the viewer-side player
pkgs.xwininfo # the `--window` click-picker on X11
# `--doctor` shells out to vainfo to confirm the VA-API H.264
# ENCODE entrypoint really exists. Without it the report can only
# say "vah264enc and a render node are present" and has to leave
# hardware encode unconfirmed — which matters, because a GPU
# missing that entrypoint produces no video at all under the
# default encoder rather than failing loudly.
pkgs.libva-utils
];
buildInputs =
with pkgs;
[
pipewire # pipewire-sys + libspa-sys
libpulseaudio # libpulse-sys: --repair reads/unloads Pulse modules
# x11rb is declared `default-features = false` here, which by itself
# is pure Rust — but Cargo unifies features across the graph, and
# arboard pulls x11rb with its `libxcb` feature on. That drags in
# as-raw-xcb-connection and makes the final link need -lxcb. It is a
# real link-time dependency of the binary, not an optional extra.
libxcb
]
++ guiRuntimeLibs;
# bindgen finds libclang through this variable specifically — having
# clang on PATH is not sufficient.
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
# NixOS keeps every GStreamer plugin in its own store path, so
# gst-launch-1.0 discovers them ONLY through this search path. Without
# it, pixelpass's `gst-inspect-1.0 --exists pipewiresrc` preflight fails
# even though the plugins are installed. Same reasoning as the
# GST_PLUGIN_SYSTEM_PATH_1_0 block in nixos-config hosts/darp5.
GST_PLUGIN_SYSTEM_PATH_1_0 = pkgs.lib.makeSearchPathOutput "lib" "lib/gstreamer-1.0" gstPlugins;
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath guiRuntimeLibs;
# Only greet an interactive shell. shellHook also runs under
# `nix develop --command …`, where printing this would interleave the
# banner with the command's own output (and corrupt it outright for
# anything whose stdout is parsed, such as pixelpass's `--output json`).
shellHook = ''
if [ -t 1 ]; then
echo "pixelpass rustc $(rustc --version | cut -d' ' -f2) / cargo $(cargo --version | cut -d' ' -f2)"
echo " cargo build --release headless build (what peerspeak spawns)"
echo " cargo build --release --features gui with the egui front end"
echo " cargo test unit + integration tests"
echo " ./target/debug/pixelpass --doctor verify this machine can host"
echo
echo "GStreamer, pactl, mpv and xwininfo are on PATH in this shell, so"
echo "capture works here without a system rebuild."
fi
'';
};
};
}
+12
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
+51 -24
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() {
+68 -5
View File
@@ -74,8 +74,9 @@ 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, StickyState, evaluate};
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
@@ -181,6 +182,16 @@ pub struct AuditRow {
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,
@@ -195,9 +206,36 @@ 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
@@ -227,6 +265,16 @@ pub struct AuditBody {
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 {
@@ -403,11 +451,13 @@ fn build_body(
// 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, sticky) = match decision.eligibility {
Eligibility::NotEligible { reason, sticky } => (false, Some(reason.code()), sticky),
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()), false),
None => (true, None, false),
Some(gate) => (false, Some(gate.code()), None, false),
None => (true, None, None, false),
},
};
AuditRow {
@@ -415,6 +465,7 @@ fn build_body(
name: decision.name.clone(),
eligible,
reason,
owner_key,
sticky,
}
})
@@ -429,10 +480,21 @@ fn build_body(
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),
@@ -444,6 +506,7 @@ fn build_body(
eligible_count,
candidates,
taint,
ignored_ownership_tags,
}
}
+115 -4
View File
@@ -22,9 +22,17 @@ use super::metrics::{BUCKET_LABELS, Metrics, QUEUE_THRESHOLD_US, Sample};
use super::*;
use crate::host::aec::AecConfig;
use crate::host::observer::{EventKind, Readiness};
use crate::host::taint::PEERSPEAK_OWNED_NODE_PREFIX;
use crate::host::taint::fixture::{self, Graph, NodeRef};
use crate::host::taint::snapshot::{GraphSnapshot, MediaRole};
/// The `node.name` a [`Graph::peerspeak_node`] fixture produces. Built from
/// the same constant the engine matches on, so these audit rows report the
/// name shape a live peerspeak node actually has (v3.5 §5.1, carrier 2).
fn owned_name(role: &str, pid: u32) -> String {
format!("{PEERSPEAK_OWNED_NODE_PREFIX}{role}_{pid}")
}
const AEC_MODULE: u64 = 7;
const TIMEOUT: Millis = 5_000;
@@ -117,6 +125,58 @@ fn the_record_carries_the_complete_candidate_universe() {
assert_eq!(outcome.record.body.excluded_count, 0);
}
/// **R10-1's diagnostic reaches the record.** The engine deliberately ignores
/// an ownership carrier on a non-producer, which means the fix removes an
/// exclusion — so the only way an operator learns a tag was seen and dropped is
/// this field. A matrix row that silently grew an impostor would otherwise read
/// as a clean pass.
#[test]
fn an_ignored_ownership_tag_is_reported_without_excluding_anything() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
let impostor = graph.peerspeak_tagged_node("rogue", MediaRole::StreamInput, 4_242);
let projection = ready(graph.build());
let body = observe(&mut auditor_off(), &projection, 0).record.body;
// The bystander is untouched — the point of the fix.
let (eligible, excluded) = partition(&body);
assert_eq!(eligible, vec!["music"]);
assert!(excluded.is_empty(), "unexpected exclusions: {excluded:?}");
assert!(body.taint.is_empty(), "unexpected taint: {:?}", body.taint);
// ...but the tag is not silent, and the row names the role it appeared on.
assert_eq!(body.ignored_ownership_tags.len(), 1);
let row = &body.ignored_ownership_tags[0];
assert_eq!(row.serial, impostor.serial.0);
assert_eq!(row.role, "stream-input");
assert_eq!(
row.name.as_deref(),
Some(owned_name("rogue", 4_242).as_str())
);
}
/// The common path stays quiet: a correctly tagged peerspeak producer is
/// honoured as a taint root and is *not* reported as a misplaced tag. Without
/// this, a diagnostic that fired on every normal run would be worthless.
#[test]
fn a_correctly_tagged_producer_is_not_reported_as_misplaced() {
let mut graph = Graph::new();
let sink = graph.device_node("speakers", MediaRole::Sink);
let call = graph.peerspeak_node("call", 200);
graph.link(call, sink);
let projection = ready(graph.build());
let body = observe(&mut auditor_off(), &projection, 0).record.body;
assert_eq!(body.excluded_count, 1);
assert!(
body.ignored_ownership_tags.is_empty(),
"honoured tag reported as misplaced: {:?}",
body.ignored_ownership_tags
);
}
/// The fail-closed default asserted at the boundary (impl plan §4, phase 2's
/// "one addition"): nothing in, nothing eligible — and, just as importantly, no
/// panic and no invented row.
@@ -284,13 +344,14 @@ fn a_shut_gate_preserves_the_engines_own_reasons() {
.body;
let (_, excluded) = partition(&body);
let playback = owned_name("peerspeak-playback", 200);
assert!(!body.fan_out_permitted);
assert_eq!(
excluded,
vec![
("music", "aec-validating"),
// Tagged, so it keeps the reason that actually applies to it.
("peerspeak-playback", "peerspeak-owned"),
(playback.as_str(), "peerspeak-owned"),
]
);
}
@@ -457,16 +518,64 @@ fn row_1_owner_bridge_forwarder_with_an_untainted_control() {
.body;
let (eligible, excluded) = partition(&body);
let call_name = owned_name("peerspeak-call", 200);
assert_eq!(eligible, vec!["clean-loopback-playback"]);
assert_eq!(
excluded,
vec![
("peerspeak-call", "peerspeak-owned"),
(call_name.as_str(), "peerspeak-owned"),
("tainted-loopback-playback", "tainted-owner-bridge"),
]
);
}
/// §5.1 row 1's other half: the record must **name the key** the bridge
/// resolved on, not merely say "owner bridge".
///
/// Without this the row is unassertable from the record: `Reason::code`
/// collapses `TaintedOwnerBridge { key }` to one string, so an exclusion that
/// arrived by an incidental link walk and one that arrived across a named owner
/// key are indistinguishable — and the row exists precisely to tell them apart.
/// The fixture's forwarder legs are joined by `pulse.module.id`, so that is the
/// key that must be reported.
#[test]
fn row_1_names_the_owner_key_the_bridge_resolved_on() {
let mut graph = Graph::new();
let call = graph.peerspeak_node("peerspeak-call", 200);
let sink = graph.module_node("tainted-null-sink", MediaRole::Sink, 30);
graph.link(call, sink);
let capture = graph.module_node("tainted-loopback-capture", MediaRole::StreamInput, 30);
let _playback = graph.module_node("tainted-loopback-playback", MediaRole::StreamOutput, 30);
graph.link(sink, capture);
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
.record
.body;
let playback = body
.candidates
.iter()
.find(|row| row.name.as_deref() == Some("tainted-loopback-playback"))
.expect("the forwarder's playback leg is a candidate");
assert_eq!(playback.reason, Some("tainted-owner-bridge"));
assert_eq!(
playback.owner_key,
Some("pulse.module.id"),
"the bridge key must be named in the record, not collapsed into the reason code"
);
// And it stays absent everywhere it would be a false diagnosis: the tag
// exclusion is not a bridge at all.
let call_name = owned_name("peerspeak-call", 200);
let tagged = body
.candidates
.iter()
.find(|row| row.name.as_deref() == Some(call_name.as_str()))
.expect("the tagged call playback is a candidate");
assert_eq!(tagged.reason, Some("peerspeak-owned"));
assert_eq!(tagged.owner_key, None);
}
/// §5.1 row 3: two Pulse modules, one tainted input. **The other module's output
/// must be eligible** — this is the row that makes a wrong pipewire-pulse-PID
/// fusion observable, because fusing all Pulse-created nodes into one owner
@@ -494,11 +603,12 @@ fn row_3_one_tainted_module_does_not_taint_the_other() {
.body;
let (eligible, excluded) = partition(&body);
let call_name = owned_name("peerspeak-call", 200);
assert_eq!(eligible, vec!["module-b-playback"]);
assert_eq!(
excluded,
vec![
("peerspeak-call", "peerspeak-owned"),
(call_name.as_str(), "peerspeak-owned"),
("module-a-playback", "tainted-owner-bridge"),
]
);
@@ -701,7 +811,8 @@ fn the_taint_view_covers_non_candidate_roles() {
tainted.contains(&("null-sink", "tainted-upstream")),
"the sink is not a candidate but its taint is what explains the row: {tainted:?}"
);
assert!(tainted.contains(&("peerspeak-call", "peerspeak-owned")));
let call_name = owned_name("peerspeak-call", 200);
assert!(tainted.contains(&(call_name.as_str(), "peerspeak-owned")));
}
/// A record must serialise to a single line. Newlines inside a JSON Lines
+643 -77
View File
@@ -4,16 +4,19 @@
//! callbacks into [`RegEvent`]s, and publishes the latest [`Projection`] for
//! consumers running outside the PipeWire thread.
use super::classify::DeviceClaim;
use super::{EventKind, LinkEndpoints, NodeObservation, Projection, RegEvent, RegistryModel};
use super::classify::{DeviceClaim, DeviceProps};
use super::{
EventKind, LinkEndpoints, NodeObservation, Outcome, Projection, RegEvent, RegistryModel,
};
use crate::host::audio::parse_object_serial;
use crate::host::taint::snapshot::{
ClientSnapshot, GlobalId, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
};
use crate::host::taint::{PEERSPEAK_OWNED_PROP, PEERSPEAK_OWNED_VALUE};
use anyhow::{Context, Result};
use pipewire::{self as pw, types::ObjectType};
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, VecDeque};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
@@ -104,20 +107,32 @@ impl Drop for RegistryObserverHandle {
}
}
struct BoundLink {
_proxy: pw::link::Link,
_listener: pw::link::LinkListener,
enum BoundProxy {
Node {
_listener: pw::node::NodeListener,
_proxy: pw::node::Node,
},
Device {
_listener: pw::device::DeviceListener,
_proxy: pw::device::Device,
},
Link {
_listener: pw::link::LinkListener,
_proxy: pw::link::Link,
},
}
#[derive(Default)]
struct LiveGlobal {
bound_link: Option<BoundLink>,
serial: Serial,
bound_proxy: Option<BoundProxy>,
}
struct ObserverState {
model: RegistryModel,
latest: Arc<Mutex<Option<Projection>>>,
last_candidate: Option<u32>,
/// The pulse-PID candidate set as of the last probe, so only PIDs entering
/// it are read from `/proc`.
last_candidates: BTreeSet<u32>,
live_globals: BTreeMap<GlobalId, VecDeque<LiveGlobal>>,
sink: Option<Box<dyn ProjectionSink>>,
started_at: Instant,
@@ -135,24 +150,32 @@ impl ObserverState {
Self {
model: RegistryModel::new(0, READINESS_TIMEOUT_MILLIS),
latest,
last_candidate: None,
last_candidates: BTreeSet::new(),
live_globals: BTreeMap::new(),
sink,
started_at,
}
}
fn apply(&mut self, event: RegEvent) {
fn apply(&mut self, event: RegEvent) -> Outcome {
// Taken before the model consumes the event: the sink is told what kind
// of observation produced the projection, and deriving that from the
// event itself is what stops the two from ever disagreeing.
let kind = event.kind();
self.model.apply(event);
let event_outcome = self.model.apply(event);
let mut outcome = event_outcome;
let candidate = self.model.pulse_pid_candidate();
if candidate != self.last_candidate {
self.last_candidate = candidate;
if let Some(pid) = candidate {
// Round 10: a *set* of candidates, because repetition across Clients
// turned out not to identify pipewire-pulse (see `pulse_pid`'s module
// docs — WirePlumber repeats a PID too, which made the old single
// candidate permanently ambiguous on this host).
let candidates = self.model.pulse_pid_candidates();
if candidates != self.last_candidates {
// Only PIDs *entering* the set are probed. A PID that left and came
// back is "entering" again and so is re-probed, which is what keeps
// the PID-reuse guard honest rather than answering from a cached
// `comm` for a number that now belongs to someone else.
for &pid in candidates.difference(&self.last_candidates) {
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm"))
.ok()
.map(|comm| comm.trim_end_matches(['\r', '\n']).to_string());
@@ -160,11 +183,21 @@ impl ObserverState {
// one registry event still yields exactly one sink call — the
// no-coalescing contract cuts both ways, and a *duplicated*
// observation would make the O5 event rate a fiction.
self.model.apply(RegEvent::ProcCommProbed { pid, comm });
if self.model.apply(RegEvent::ProcCommProbed { pid, comm }) == Outcome::Applied {
outcome = Outcome::Applied;
}
}
self.model.retain_probed_comms(&candidates);
self.last_candidates = candidates;
}
self.publish(kind);
// v3.5 §6.7 decision 2: a projection the model proved identical is not
// published. Only the model can make that claim soundly, which is why
// it is [`Outcome`] and not a diff of two snapshots here.
if outcome == Outcome::Applied {
self.publish(kind);
}
event_outcome
}
fn publish(&mut self, kind: EventKind) {
@@ -183,40 +216,65 @@ impl ObserverState {
}
/// Record the global's id and apply its add event as one step, so the
/// bound-link FIFO stays provably lockstep with the model's own `live_ids`
/// bound-proxy FIFO stays provably lockstep with the model's own `live_ids`
/// index. Recording only on *applied* adds (never on unknown object types
/// or globals dropped for a missing serial) is what keeps the two id
/// queues the same length per id — otherwise a phantom slot ahead of a
/// bound Link would be popped on removal, leaking that Link's proxy.
fn add(&mut self, id: GlobalId, event: RegEvent) {
self.live_globals
.entry(id)
.or_default()
.push_back(LiveGlobal::default());
self.apply(event);
/// queues the same length per id — otherwise a phantom slot could pop
/// another generation's proxy after an id is recycled.
fn add(&mut self, serial: Serial, id: GlobalId, event: RegEvent) {
if self.apply(event) == Outcome::Applied {
self.live_globals
.entry(id)
.or_default()
.push_back(LiveGlobal {
serial,
bound_proxy: None,
});
}
}
fn attach_bound_link(&mut self, id: GlobalId, bound_link: BoundLink) {
let Some(global) = self.live_globals.get_mut(&id).and_then(VecDeque::back_mut) else {
/// Return a proxy that could not be attached so its listener is dropped
/// after the caller releases the `RefCell` borrow.
fn attach_bound_proxy(
&mut self,
id: GlobalId,
serial: Serial,
bound_proxy: BoundProxy,
) -> Option<BoundProxy> {
let Some(global) = self
.live_globals
.get_mut(&id)
.and_then(|globals| globals.iter_mut().find(|global| global.serial == serial))
else {
tracing::warn!(
global_id = id.0,
"registry observer: link bind completed without a live global slot"
serial = serial.0,
"registry observer: bind completed without a live global slot"
);
return;
return Some(bound_proxy);
};
global.bound_link = Some(bound_link);
if global.bound_proxy.is_some() {
tracing::warn!(
global_id = id.0,
serial = serial.0,
"registry observer: live global slot already has a bound proxy"
);
return Some(bound_proxy);
}
global.bound_proxy = Some(bound_proxy);
None
}
fn remove_global(&mut self, id: GlobalId) -> Option<BoundLink> {
let (bound_link, empty) = {
fn remove_global(&mut self, id: GlobalId) -> Option<BoundProxy> {
let (bound_proxy, empty) = {
let globals = self.live_globals.get_mut(&id)?;
let bound_link = globals.pop_front().and_then(|global| global.bound_link);
(bound_link, globals.is_empty())
let bound_proxy = globals.pop_front().and_then(|global| global.bound_proxy);
(bound_proxy, globals.is_empty())
};
if empty {
self.live_globals.remove(&id);
}
bound_link
bound_proxy
}
}
@@ -273,6 +331,9 @@ fn run_observer(
match obj.type_ {
ObjectType::Node => {
// ⚠️ v3.5 §6.7: the global is an INDEX. Only `object.serial`
// is read here; every property the engine reasons about
// comes from the bind's `info` (phase 3r).
let Some(props) = obj.props.as_ref() else {
tracing::warn!(
node_id = obj.id,
@@ -284,41 +345,59 @@ fn run_observer(
else {
return;
};
let node_props = NodeProps {
peerspeak_owned: truthy(props.get("peerspeak.owned")),
pulse_module_id: props
.get("pulse.module.id")
.and_then(|value| value.parse::<u64>().ok()),
link_group: props.get("node.link-group").map(str::to_owned),
client_id: props
.get("client.id")
.and_then(|value| value.parse::<u32>().ok())
.map(GlobalId),
process_id: props
.get("application.process.id")
.and_then(|value| value.parse::<u32>().ok()),
passthrough: truthy(props.get("node.passthrough")),
session_device: false,
};
let observation = NodeObservation {
state_for_global.borrow_mut().add(
serial,
id,
name: props.get("node.name").map(str::to_owned),
role: MediaRole::parse(props.get("media.class")),
props: node_props,
device_claim: DeviceClaim {
device_id: props
.get("device.id")
.and_then(|value| value.parse::<u32>().ok())
.map(GlobalId),
device_api: props.get("device.api").map(str::to_owned),
factory_name: props.get("factory.name").map(str::to_owned),
alsa_driver_name: props.get("alsa.driver_name").map(str::to_owned),
},
RegEvent::NodeAdded { serial, id },
);
let Some(registry) = registry_weak.upgrade() else {
return;
};
state_for_global
.borrow_mut()
.add(id, RegEvent::NodeAdded(observation));
let node: pw::node::Node = match registry.bind(obj) {
Ok(node) => node,
Err(e) => {
tracing::warn!(
node_id = obj.id,
"registry observer: failed to bind Node for properties: {e}"
);
return;
}
};
// This bit only recognizes the initial callback for the
// change-mask fast path. Admission vs update remains
// entirely the model's decision.
let first_info = Cell::new(true);
let state_for_info = Rc::downgrade(&state_for_global);
let listener = node
.add_listener_local()
.info(move |info| {
let Some(props) = info.props() else {
return;
};
let first = first_info.replace(false);
if !first
&& !info.change_mask().contains(pw::node::NodeChangeMask::PROPS)
{
return;
}
if let Some(state) = state_for_info.upgrade() {
state.borrow_mut().apply(RegEvent::NodeInfo {
serial,
observation: node_observation_from_props(props),
});
}
})
.register();
let unattached = state_for_global.borrow_mut().attach_bound_proxy(
id,
serial,
BoundProxy::Node {
_listener: listener,
_proxy: node,
},
);
drop(unattached);
}
ObjectType::Port => {
let Some(props) = obj.props.as_ref() else {
@@ -357,6 +436,7 @@ fn run_observer(
}
};
state_for_global.borrow_mut().add(
serial,
id,
RegEvent::PortAdded(PortSnapshot {
serial,
@@ -381,6 +461,7 @@ fn run_observer(
return;
};
state_for_global.borrow_mut().add(
serial,
id,
RegEvent::ClientAdded(ClientSnapshot {
serial,
@@ -392,9 +473,72 @@ fn run_observer(
);
}
ObjectType::Device => {
state_for_global
.borrow_mut()
.add(id, RegEvent::DeviceAdded { id });
// Index only, exactly as for a Node: `device.api` and
// `alsa.driver_name` live on the bind's `info` (v3.5 §6.7
// decision 4), not here.
let Some(props) = obj.props.as_ref() else {
tracing::warn!(
device_id = obj.id,
"registry observer: Device has no properties; dropping"
);
return;
};
let Some(serial) = parse_serial(obj.id, "Device", props.get("object.serial"))
else {
return;
};
state_for_global.borrow_mut().add(
serial,
id,
RegEvent::DeviceAdded { serial, id },
);
let Some(registry) = registry_weak.upgrade() else {
return;
};
let device: pw::device::Device = match registry.bind(obj) {
Ok(device) => device,
Err(e) => {
tracing::warn!(
device_id = obj.id,
"registry observer: failed to bind Device for properties: {e}"
);
return;
}
};
let first_info = Cell::new(true);
let state_for_info = Rc::downgrade(&state_for_global);
let listener = device
.add_listener_local()
.info(move |info| {
let Some(props) = info.props() else {
return;
};
let first = first_info.replace(false);
if !first
&& !info
.change_mask()
.contains(pw::device::DeviceChangeMask::PROPS)
{
return;
}
if let Some(state) = state_for_info.upgrade() {
state.borrow_mut().apply(RegEvent::DeviceInfo {
serial,
props: device_props_from_props(props),
});
}
})
.register();
let unattached = state_for_global.borrow_mut().attach_bound_proxy(
id,
serial,
BoundProxy::Device {
_listener: listener,
_proxy: device,
},
);
drop(unattached);
}
ObjectType::Link => {
let Some(props) = obj.props.as_ref() else {
@@ -410,6 +554,7 @@ fn run_observer(
};
let endpoints = link_endpoints_from_props(props);
state_for_global.borrow_mut().add(
serial,
id,
RegEvent::LinkAdded {
serial,
@@ -456,24 +601,26 @@ fn run_observer(
}
})
.register();
state_for_global.borrow_mut().attach_bound_link(
let unattached = state_for_global.borrow_mut().attach_bound_proxy(
id,
BoundLink {
_proxy: link,
serial,
BoundProxy::Link {
_listener: listener,
_proxy: link,
},
);
drop(unattached);
}
_ => {}
}
})
.global_remove(move |id| {
let id = GlobalId(id);
let bound_link = state_for_remove.borrow_mut().remove_global(id);
let bound_proxy = state_for_remove.borrow_mut().remove_global(id);
state_for_remove
.borrow_mut()
.apply(RegEvent::Removed { id });
drop(bound_link);
drop(bound_proxy);
})
.register();
@@ -513,10 +660,77 @@ fn parse_serial(id: u32, kind: &str, raw: Option<&str>) -> Option<Serial> {
}
}
/// Lenient boolean for PipeWire's own `bool`-ish properties
/// (`port.exclusive`, `port.monitor`, `node.passthrough`), whose spelling
/// varies by producer. Leniency is the fail-closed direction *for these*:
/// each one, when true, causes exclusion.
fn truthy(value: Option<&str>) -> bool {
value.is_some_and(|value| value != "false" && value != "0")
}
/// The ownership carrier is matched **exactly**, not leniently (round 10,
/// R10-4).
///
/// It is tempting to reuse [`truthy`] here on the grounds that treating an
/// unexpected value as "owned" over-excludes and is therefore safe. That
/// argument does not hold: leniency buys false-positive *exclusion*, not
/// safety. Under `truthy`, `peerspeak.owned=""` and `peerspeak.owned=false `
/// (trailing space) both mean owned, so any process can suppress a rival's
/// audio from the share with a property it does not even have to spell right.
///
/// 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, so there is no real value to be lenient
/// about. And a missed tag is not silent: carrier 2 is a union with this one,
/// so a garbled property still leaves the `node.name` prefix.
fn peerspeak_owned(value: Option<&str>) -> bool {
value == Some(PEERSPEAK_OWNED_VALUE)
}
fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObservation {
NodeObservation {
name: props.get("node.name").map(str::to_string),
role: MediaRole::parse(props.get("media.class")),
props: NodeProps {
// Carrier 1 only. Carrier 2 (the `node.name` prefix) is matched
// in the engine off `NodeObservation::name` above, so each
// carrier stays independently testable — see
// [`crate::host::taint::PEERSPEAK_OWNED_NODE_PREFIX`].
peerspeak_owned: peerspeak_owned(props.get(PEERSPEAK_OWNED_PROP)),
pulse_module_id: props
.get("pulse.module.id")
.and_then(|value| value.parse::<u64>().ok()),
link_group: props.get("node.link-group").map(str::to_string),
client_id: props
.get("client.id")
.and_then(|value| value.parse::<u32>().ok())
.map(GlobalId),
process_id: props
.get("application.process.id")
.and_then(|value| value.parse::<u32>().ok()),
passthrough: truthy(props.get("node.passthrough")),
session_device: false,
},
device_claim: DeviceClaim {
device_id: props
.get("device.id")
.and_then(|value| value.parse::<u32>().ok())
.map(GlobalId),
device_api: props.get("device.api").map(str::to_string),
factory_name: props.get("factory.name").map(str::to_string),
alsa_driver_name: props.get("alsa.driver_name").map(str::to_string),
},
}
}
fn device_props_from_props(props: &pw::spa::utils::dict::DictRef) -> DeviceProps {
DeviceProps {
device_api: props.get("device.api").map(str::to_string),
alsa_driver_name: props.get("alsa.driver_name").map(str::to_string),
}
}
fn link_endpoints_from_props(props: &pw::spa::utils::dict::DictRef) -> Option<LinkEndpoints> {
let output_node = props.get("link.output.node")?.parse::<u32>().ok()?;
let input_node = props.get("link.input.node")?.parse::<u32>().ok()?;
@@ -543,6 +757,165 @@ mod tests {
use super::*;
use std::process::Command;
/// **R10-4.** The ownership carrier is matched exactly; the lenient
/// [`truthy`] spelling is wrong for it.
///
/// Under `truthy`, every value in `denied` below meant "peerspeak owns
/// this" — including the empty string and a `false` with a trailing space
/// — so any process could suppress a rival application's audio from the
/// share with a property it did not have to spell correctly. Leniency here
/// buys false-positive exclusion, not safety.
#[test]
fn the_ownership_carrier_is_matched_exactly_not_leniently() {
assert!(peerspeak_owned(Some(PEERSPEAK_OWNED_VALUE)));
let denied = [
None,
Some(""),
Some("false"),
Some("0"),
Some("false "),
Some("true"),
Some("yes"),
Some("1 "),
Some(" 1"),
Some("01"),
Some("2"),
];
for value in denied {
assert!(
!peerspeak_owned(value),
"{value:?} must not read as peerspeak-owned"
);
}
}
/// The same property, asserted through the **production wiring** rather
/// than the helper.
///
/// ⚠️ **This is the gate; the one above is a unit test of a private
/// function** (round 10 review, finding 4). Mutating
/// [`node_observation_from_props`] back to `truthy(props.get(…))` left the
/// helper test green, because it calls [`peerspeak_owned`] directly and
/// the only live case it shares with production — exact `"1"` — passes
/// under both implementations. That is precisely the "a gate satisfiable
/// by two sources gates neither" failure that bit the `main.rs` wiring
/// guard and phase 3r row 1.
///
/// So: build a real `pw_properties` dictionary, push it through the same
/// function the registry callback calls, and assert the resulting
/// [`NodeProps::peerspeak_owned`] for every spelling.
#[test]
fn the_production_wiring_reads_the_ownership_carrier_exactly() {
pw::init();
// (property value, must be read as peerspeak-owned)
let spellings = [
(Some(PEERSPEAK_OWNED_VALUE), true),
(None, false),
(Some(""), false),
(Some("false"), false),
(Some("0"), false),
(Some("false "), false),
(Some("true"), false),
(Some("yes"), false),
(Some("1 "), false),
(Some(" 1"), false),
(Some("01"), false),
(Some("2"), false),
];
for (value, expected) in spellings {
let mut props = pw::properties::PropertiesBox::new();
// A realistic node, so the rest of the parse runs too: this is the
// shape peerspeak's own tagged playback arrives in.
props.insert("media.class", "Stream/Output/Audio");
props.insert("node.name", "probe");
props.insert("client.id", "42");
if let Some(value) = value {
props.insert(PEERSPEAK_OWNED_PROP, value);
}
let observation = node_observation_from_props(props.dict());
assert_eq!(
observation.props.peerspeak_owned, expected,
"{PEERSPEAK_OWNED_PROP}={value:?} through the real adapter"
);
// The surrounding parse must still work, or a green result above
// could just mean the whole dictionary was dropped.
assert_eq!(observation.role, MediaRole::StreamOutput);
assert_eq!(observation.name.as_deref(), Some("probe"));
assert_eq!(observation.props.client_id, Some(GlobalId(42)));
}
}
/// The cross-repo fixture's `prop_value` is the only spelling this
/// consumer treats as owned — asserted through the production wiring.
///
/// The taint module's `ownership_carriers_match_the_cross_repo_fixture`
/// proves the two repos agree on the *literal*. That is not the same as
/// proving the shipping observer reads it, which is the half the round-10
/// review's finding 6 was about: a future producer following the fixture
/// needs the file to describe what the code does, and only a test that
/// runs the code can keep those two honest.
#[test]
fn the_fixture_value_is_the_only_owned_spelling() {
const FIXTURE: &str = include_str!("../../../tests/fixtures/ownership-tag-contract.txt");
pw::init();
let pinned = FIXTURE
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| line.split_once('=').expect("fixture line is key=value"));
let mut prop_key = None;
let mut prop_value = None;
for (key, value) in pinned {
match key {
"prop_key" => prop_key = Some(value),
"prop_value" => prop_value = Some(value),
_ => {}
}
}
let prop_key = prop_key.expect("fixture defines prop_key");
let prop_value = prop_value.expect("fixture defines prop_value");
let observe = |value: &str| {
let mut props = pw::properties::PropertiesBox::new();
props.insert("media.class", "Stream/Output/Audio");
props.insert(prop_key, value);
node_observation_from_props(props.dict())
.props
.peerspeak_owned
};
assert!(
observe(prop_value),
"the fixture's own {prop_key}={prop_value} must read as owned"
);
// The spellings the fixture explicitly says are NOT owned.
for denied in ["true", "yes", ""] {
assert!(
!observe(denied),
"{prop_key}={denied:?} must not read as owned; the fixture says so"
);
}
}
/// The other three boolean properties keep the lenient spelling, and that
/// is deliberate rather than an oversight: each is PipeWire's own, each
/// varies by producer, and each causes *exclusion* when true — so reading
/// an unrecognised value as true is genuinely the safe direction for them.
#[test]
fn pipewires_own_boolean_props_stay_lenient() {
assert!(truthy(Some("true")));
assert!(truthy(Some("1")));
assert!(truthy(Some("")));
assert!(!truthy(Some("false")));
assert!(!truthy(Some("0")));
assert!(!truthy(None));
}
struct PactlModule {
id: Option<u32>,
}
@@ -617,6 +990,199 @@ mod tests {
.any(|node| node.name.as_deref() == Some(name))
}
/// Phase 3r exit-gate row 1, the Device half — and the reason it needs its
/// own test.
///
/// `live_bound_properties_recover_node_and_device_inputs` asserts
/// `session_device`, which the classifier grants on a **union**:
/// `device.api` and `alsa.driver_name` may come from the bound Device *or*
/// from the node's own copies. On this host (WirePlumber 0.5.15 ≥ 0.5.13)
/// the session manager *does* copy both onto ALSA nodes, so that assertion
/// passes through the node fallback and would keep passing if the Device
/// bind delivered nothing at all — leaving v3.5 §6.7 decision 4, the whole
/// authoritative path, ungated on the machine we develop on.
///
/// So assert the Device side directly: bind every Device global and require
/// that at least one ALSA card announces **both** keys on its `info` props.
/// A failure here means the fix for the phase-3 review's owed finding (a
/// real card over-excluded on installs that do not copy `alsa.*` onto the
/// node) rests on nothing.
#[test]
#[ignore = "needs live pipewire"]
fn live_device_bind_carries_api_and_driver_name() {
pw::init();
let main_loop = pw::main_loop::MainLoopRc::new(None).expect("pw main loop");
let context = pw::context::ContextRc::new(&main_loop, None).expect("pw context");
let core = context.connect_rc(None).expect("pw core connect");
let registry = core.get_registry_rc().expect("pw registry");
// Devices bound off the registry, each holding its proxy + listener so
// the callback lives long enough to fire, exactly as the adapter does.
let bound: Rc<RefCell<Vec<(pw::device::Device, pw::device::DeviceListener)>>> =
Rc::new(RefCell::new(Vec::new()));
let observed: Rc<RefCell<Vec<DeviceProps>>> = Rc::new(RefCell::new(Vec::new()));
let bound_for_global = Rc::clone(&bound);
let observed_for_global = Rc::clone(&observed);
let registry_weak = registry.downgrade();
let _listener = registry
.add_listener_local()
.global(move |obj| {
if obj.type_ != ObjectType::Device {
return;
}
let Some(registry) = registry_weak.upgrade() else {
return;
};
let Ok(device) = registry.bind::<pw::device::Device, _>(obj) else {
return;
};
let observed_for_info = Rc::clone(&observed_for_global);
let listener = device
.add_listener_local()
.info(move |info| {
if let Some(props) = info.props() {
observed_for_info
.borrow_mut()
.push(device_props_from_props(props));
}
})
.register();
bound_for_global.borrow_mut().push((device, listener));
})
.register();
// Two seconds is the same budget the observer gives its own binds.
let main_loop_for_timer = main_loop.clone();
let timer = main_loop
.loop_()
.add_timer(move |_| main_loop_for_timer.quit());
timer
.update_timer(Some(Duration::from_secs(2)), None)
.into_result()
.expect("arm the test deadline");
main_loop.run();
let observed = observed.borrow();
assert!(
!observed.is_empty(),
"no Device delivered info props at all — the Device bind path is dead"
);
assert!(
observed.iter().any(|props| {
props.device_api.as_deref() == Some("alsa") && props.alsa_driver_name.is_some()
}),
"no bound Device carried both device.api=alsa and alsa.driver_name; \
observed: {observed:?}"
);
}
// Phase 3r exit-gate row 1: failure means the observation boundary regressed.
#[test]
#[ignore = "needs live pipewire"]
fn live_bound_properties_recover_node_and_device_inputs() {
pw::init();
let observer = RegistryObserverHandle::spawn().expect("observer thread must spawn");
wait_for(&observer, |projection| projection.graph_ready);
let unique = format!("pixelpass_observer_props_test_{}", std::process::id());
let capture_name = format!("{unique}_capture");
let playback_name = format!("{unique}_playback");
let null_sink = PactlModule::load(
"module-null-sink",
&[
format!("sink_name={unique}"),
// The value peerspeak actually emits, not merely a truthy one:
// this row is the live proof that carrier 1 survives the bind,
// and the sink's name deliberately does *not* carry the
// `peerspeak_owned_` prefix, so carrier 2 cannot stand in for
// it here.
format!(
"sink_properties={PEERSPEAK_OWNED_PROP}={} node.passthrough=true",
crate::host::taint::PEERSPEAK_OWNED_VALUE
),
],
);
let null_sink_id = null_sink.id.expect("null-sink module must have an id");
let loopback = PactlModule::load(
"module-loopback",
&[
format!("source={unique}.monitor"),
format!("sink={unique}"),
format!("source_output_properties=node.name={capture_name}"),
format!("sink_input_properties=node.name={playback_name}"),
],
);
let projection = wait_for(&observer, |projection| {
projection.graph_ready
&& has_node(projection, &unique)
&& has_node(projection, &capture_name)
&& has_node(projection, &playback_name)
});
let tagged_sink = projection
.snapshot
.nodes()
.find(|node| node.name.as_deref() == Some(&unique))
.expect("tagged null sink must be projected");
assert!(tagged_sink.props.peerspeak_owned);
assert!(tagged_sink.props.passthrough);
assert_eq!(
tagged_sink.props.pulse_module_id,
Some(u64::from(null_sink_id))
);
let capture = projection
.snapshot
.nodes()
.find(|node| node.name.as_deref() == Some(&capture_name))
.expect("loopback capture leg must be projected");
let playback = projection
.snapshot
.nodes()
.find(|node| node.name.as_deref() == Some(&playback_name))
.expect("loopback playback leg must be projected");
let capture_group = capture
.props
.link_group
.as_ref()
.expect("loopback capture leg must carry node.link-group");
let playback_group = playback
.props
.link_group
.as_ref()
.expect("loopback playback leg must carry node.link-group");
assert_eq!(capture_group, playback_group);
assert!(
projection
.snapshot
.nodes()
.any(|node| node.props.process_id.is_some()),
"at least one projected node must carry application.process.id"
);
let session_device = projection.snapshot.nodes().find(|node| {
node.props.session_device
&& (node
.name
.as_deref()
.is_some_and(|name| name.contains("alsa"))
|| matches!(node.role, MediaRole::Sink | MediaRole::Source))
});
assert!(
session_device.is_some(),
"a named ALSA or Audio/Sink/Audio/Source node must classify as a session device"
);
assert!(projection.graph_ready);
loopback.unload();
null_sink.unload();
wait_for(&observer, |projection| {
!has_node(projection, &unique)
&& !has_node(projection, &capture_name)
&& !has_node(projection, &playback_name)
});
}
#[test]
#[ignore = "needs live pipewire"]
fn live_topology_diff_tracks_null_sink_and_loopback() {
+104 -37
View File
@@ -18,11 +18,21 @@
//! 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 observed. A node that claims
//! a `device.id` we have not yet resolved 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).
//! - 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;
@@ -54,6 +64,11 @@ const HARDWARE_PCM_FACTORIES: &[&str] = &[
"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
@@ -67,8 +82,9 @@ const HARDWARE_PCM_FACTORIES: &[&str] = &[
/// does not couple playback to capture, so it is not a loopback hazard.
const NON_TERMINAL_ALSA_DRIVERS: &[&str] = &["snd_aloop"];
/// The three node properties the classifier reads, exactly as the adapter
/// parsed them off the Node global. Kept separate from
/// 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.
@@ -78,10 +94,12 @@ pub struct DeviceClaim {
/// `Stream/*` nodes, which is exactly why their absence means "not a
/// device", not "unknown".
pub device_id: Option<GlobalId>,
/// `device.api` — 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.
/// `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`.
@@ -92,8 +110,27 @@ pub struct DeviceClaim {
/// 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`]).
/// May be absent on non-ALSA backends or on version pairings that do not
/// copy `alsa.*` onto the node.
/// 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>,
}
@@ -102,9 +139,10 @@ pub struct DeviceClaim {
pub enum Classification {
/// No `device.id` — a `Stream/*` node. Admit with `session_device=false`.
NotADevice,
/// A `device.id` is claimed but the backing Device has not been resolved
/// yet. **Withhold the node and keep the readiness epoch not-ready**;
/// re-classify when the Device is observed.
/// 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`.
@@ -115,42 +153,71 @@ pub enum Classification {
NotSessionDevice,
}
/// Classify a node's device claim.
/// Classify a node's device claim against its backing Device.
///
/// `device_resolved` is whether [`DeviceClaim::device_id`] has been observed
/// as a Device global; it is only consulted when a `device_id` is present.
/// Pure: the model supplies `device_resolved` from its resolved-Device set,
/// and the I/O of *binding* the Device lives in the adapter.
pub fn classify(claim: &DeviceClaim, device_resolved: bool) -> Classification {
/// `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;
};
if !device_resolved {
// Backed by a Device we have not seen — the one case that blocks
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). `alsa.driver_name` is not copied onto the
// node on every PipeWire/WirePlumber version pairing (PipeWire ≥1.2.6
// stopped overwriting node props with card props; WirePlumber only began
// copying `alsa.*` onto nodes in 0.5.13), so a *missing* value must not be
// read as "not a loopback" — that is exactly the hole an `snd_aloop` node
// without the property would slip through. A real card whose node lacks
// the driver is instead over-excluded (keeps its owner keys — safe);
// recovering `session_device` for it needs reading the driver from the
// backing Device global, which is owed to a later round.
let driver_ok = claim
// (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()
.is_some_and(|d| !NON_TERMINAL_ALSA_DRIVERS.contains(&d));
let is_hardware_pcm = claim.device_api.is_some() && on_factory_allowlist && driver_ok;
.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 {
+352 -136
View File
@@ -1,12 +1,34 @@
//! The registry observer's **pure core** (impl plan §4, phase 3).
//! 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, Link/Device 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.
//! 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:
//!
@@ -14,18 +36,20 @@
//! 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).
//! [`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 devices.** A node claiming a `device.id`
//! whose Device we have not observed is held out of the snapshot entirely
//! rather than admitted with a provisional `session_device` (see
//! [`classify`]).
//! - **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`]).
//!
//! **Two accepted limitations (Codex phase-3 review, findings 3 and 4), both
//! low-reachability, owed to a later hardening round:**
//! **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
@@ -45,6 +69,13 @@
//! 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.
@@ -59,22 +90,25 @@ use crate::host::taint::snapshot::{
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
PortSnapshot, Serial,
};
use classify::{Classification, DeviceClaim};
use std::collections::{BTreeMap, VecDeque};
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 as observed off the registry, before `session_device` has been
/// decided. The adapter fills [`NodeProps`] with everything it can parse and
/// leaves `session_device` at its `false` default; the model overwrites it
/// from the [`classify`] result once the backing Device (if any) is resolved.
/// 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 serial: Serial,
pub id: GlobalId,
pub name: Option<String>,
pub role: MediaRole,
pub props: NodeProps,
@@ -97,19 +131,39 @@ pub struct LinkEndpoints {
/// model consumes them in [`RegistryModel::apply`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RegEvent {
/// A Node global appeared. Admitted immediately unless it claims an
/// unresolved Device (then withheld — see [`classify`]).
NodeAdded(NodeObservation),
/// 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. Resolves any nodes withheld on its id.
DeviceAdded { id: GlobalId },
/// 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,
@@ -143,7 +197,7 @@ pub enum RegEvent {
/// 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 link resolution, a `/proc`
/// 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
@@ -169,16 +223,39 @@ impl RegEvent {
}
}
/// 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. A Node
/// slot's serial may live in either the admitted or the withheld map.
/// 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,
Device(Serial),
}
/// The readiness epoch. A one-time transition out of [`Readiness::Waiting`];
@@ -220,25 +297,45 @@ pub struct Projection {
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 {
// Admitted objects, keyed by their never-recycled serial.
nodes: BTreeMap<Serial, NodeSnapshot>,
/// **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>,
/// Nodes held out of the snapshot pending their Device's resolution.
withheld: BTreeMap<Serial, NodeObservation>,
/// Links whose endpoints the adapter is still binding; the id is kept so
/// removal and resolution can find them.
pending_links: BTreeMap<Serial, GlobalId>,
/// Live Device global ids, ref-counted so a recycled id is only
/// considered resolved while a Device actually holds it.
resolved_devices: BTreeMap<GlobalId, usize>,
/// 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>>,
@@ -259,12 +356,11 @@ impl RegistryModel {
pub fn new(now: Millis, timeout: Millis) -> Self {
Self {
nodes: BTreeMap::new(),
devices: BTreeMap::new(),
ports: BTreeMap::new(),
links: BTreeMap::new(),
clients: BTreeMap::new(),
withheld: BTreeMap::new(),
pending_links: BTreeMap::new(),
resolved_devices: BTreeMap::new(),
live_ids: BTreeMap::new(),
probed_comm: BTreeMap::new(),
server_synced: false,
@@ -283,129 +379,161 @@ impl RegistryModel {
///
/// 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 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 an
/// unresolved Link is unresolved ancestry, so `graph_ready` must drop back
/// to false whenever one is pending — even after the initial epoch.
/// 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 Link bind causes; the observer's job is to report the truth.
/// 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 candidate the adapter should be probing (`None` = no
/// repeated `sec_pid`, nothing to probe). Exposed so the adapter re-probes
/// only when the candidate changes.
pub fn pulse_pid_candidate(&self) -> Option<u32> {
/// 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::candidate(&clients)
pulse_pid::candidates(&clients)
}
/// Fold one observation into the model.
pub fn apply(&mut self, event: RegEvent) {
/// 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(obs) => self.on_node_added(obs),
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 { id } => self.on_device_added(id),
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),
} => {
self.on_link_added(serial, id, endpoints);
Outcome::Applied
}
RegEvent::LinkEndpointsResolved { serial, endpoints } => {
self.on_link_resolved(serial, endpoints)
}
RegEvent::ProcCommProbed { pid, comm } => {
self.probed_comm.insert(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
}
}
}
fn on_node_added(&mut self, obs: NodeObservation) {
self.push_id(obs.id, Slot::Node(obs.serial));
let resolved = obs
.device_claim
.device_id
.is_some_and(|id| self.device_resolved(id));
match classify::classify(&obs.device_claim, resolved) {
Classification::Withhold { .. } => {
self.withheld.insert(obs.serial, obs);
}
Classification::SessionDevice => self.admit_node(obs, true),
Classification::NotADevice | Classification::NotSessionDevice => {
self.admit_node(obs, false)
}
/// 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;
}
// Withholding a node adds an obligation; admitting one can never
// complete readiness on its own, but re-check is cheap and keeps the
// invariant local.
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 admit_node(&mut self, obs: NodeObservation, session_device: bool) {
let mut props = obs.props;
props.session_device = session_device;
self.nodes.insert(
obs.serial,
NodeSnapshot {
serial: obs.serial,
id: obs.id,
name: obs.name,
role: obs.role,
props,
},
);
}
fn on_device_added(&mut self, id: GlobalId) {
self.push_id(id, Slot::Device);
*self.resolved_devices.entry(id).or_insert(0) += 1;
// Admit every node that was withheld waiting on exactly this Device.
let ready: Vec<Serial> = self
.withheld
.iter()
.filter(|(_, obs)| obs.device_claim.device_id == Some(id))
.map(|(&serial, _)| serial)
.collect();
for serial in ready {
if let Some(obs) = self.withheld.remove(&serial) {
// Resolved now, so classify yields a terminal answer, never
// Withhold again.
let session_device = matches!(
classify::classify(&obs.device_claim, true),
Classification::SessionDevice
);
self.admit_node(obs, session_device);
}
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>) {
@@ -423,20 +551,23 @@ impl RegistryModel {
self.maybe_complete();
}
fn on_link_resolved(&mut self, serial: Serial, endpoints: LinkEndpoints) {
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) {
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;
return Outcome::Suppressed;
};
// Oldest generation first — the id may be shared during a
// missed-removal window.
@@ -446,10 +577,7 @@ impl RegistryModel {
}
match slot {
Some(Slot::Node(serial)) => {
if self.nodes.remove(&serial).is_none() {
// Was still withheld — drop the obligation.
self.withheld.remove(&serial);
}
self.nodes.remove(&serial);
}
Some(Slot::Port(serial)) => {
self.ports.remove(&serial);
@@ -461,35 +589,77 @@ impl RegistryModel {
Some(Slot::Client(serial)) => {
self.clients.remove(&serial);
}
Some(Slot::Device) => {
if let Some(count) = self.resolved_devices.get_mut(&id) {
*count -= 1;
if *count == 0 {
self.resolved_devices.remove(&id);
}
}
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 (a withheld node or pending
// link vanished before it resolved).
// 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);
}
fn device_resolved(&self, id: GlobalId) -> bool {
self.resolved_devices.get(&id).is_some_and(|&n| n > 0)
/// 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 withheld on an unresolved Device, no Link awaiting its bind.
/// no node awaiting its bind, no node withheld on an unresolved Device,
/// no Link awaiting its bind.
fn obligations_outstanding(&self) -> bool {
!self.withheld.is_empty() || !self.pending_links.is_empty()
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
@@ -512,26 +682,72 @@ impl RegistryModel {
if now >= self.deadline {
self.readiness = Readiness::TimedOut;
tracing::warn!(
withheld = self.withheld.len(),
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> {
let candidate = self.pulse_pid_candidate()?;
let comm = self.probed_comm.get(&candidate).and_then(|c| c.as_deref());
pulse_pid::validate(candidate, comm)
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(
self.nodes.values().cloned().collect(),
nodes,
self.ports.values().cloned().collect(),
self.links.values().cloned().collect(),
self.clients.values().cloned().collect(),
+82 -49
View File
@@ -11,20 +11,49 @@
//! The derivation is split into two pure stages so the I/O — reading
//! `/proc/<pid>/comm` — stays in the adapter:
//!
//! 1. [`candidate`] finds the PID that *looks* like pulse from the graph
//! alone: the `pipewire.sec.pid` value shared across multiple Clients.
//! Native PipeWire clients carry their own distinct PID; only the
//! Pulse shim repeats one value, so a repeated value is the signal.
//! 2. [`validate`] confirms that candidate against the `comm` the adapter
//! read from `/proc`. This is what closes **PID reuse**: a recycled PID
//! that coincidentally repeats in the graph is rejected because
//! `/proc/<pid>/comm` now names a different process.
//! 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 at either stage — no repeated value, two repeated values,
//! the property missing, `/proc` gone, a `comm` mismatch — yields `None`.
//! 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::BTreeMap;
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 —
@@ -32,40 +61,19 @@ use std::collections::BTreeMap;
/// recycled PID belonging to e.g. `pipewire-pulseX`.
const PULSE_COMM: &str = "pipewire-pulse";
/// Stage 1: the PID that looks like pipewire-pulse from the client graph.
/// Stage 1: every PID worth probing — the distinct `pipewire.sec.pid` values
/// the Clients present.
///
/// Returns `Some(pid)` only when **exactly one** `pipewire.sec.pid` value is
/// shared by two or more clients. Rationale, matched to the failure matrix:
///
/// - **consistent** — one value repeats, the rest (native clients) are
/// distinct ⇒ that value.
/// - **inconsistent** — two or more values each repeat ⇒ we cannot tell which
/// is pulse ⇒ `None`.
/// - **missing property** — the Pulse clients carry no `sec_pid` ⇒ nothing
/// repeats ⇒ `None`.
///
/// A count threshold of two is deliberate: a single client carrying a PID is
/// indistinguishable from a lone native app, and pulse always mints many.
pub fn candidate(clients: &[ClientSnapshot]) -> Option<u32> {
let mut counts: BTreeMap<u32, usize> = BTreeMap::new();
for client in clients {
if let Some(pid) = client.sec_pid {
*counts.entry(pid).or_insert(0) += 1;
}
}
// Every PID seen on 2+ clients is a pulse candidate. If there is exactly
// one such PID we trust it; zero or several ⇒ fail closed.
let mut repeated = counts.iter().filter(|&(_, &n)| n >= 2).map(|(&pid, _)| pid);
let first = repeated.next()?;
if repeated.next().is_some() {
// Ambiguous: more than one value repeats.
return None;
}
Some(first)
/// 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 the candidate against the `comm` read from
/// 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
@@ -79,11 +87,36 @@ pub fn validate(candidate: u32, comm: Option<&str>) -> Option<u32> {
}
}
/// The two stages composed, for callers that already hold the probed `comm`.
/// The model keeps them separate (it recomputes the candidate as clients
/// churn, and only re-probes when the candidate *changes*), 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> {
let candidate = candidate(clients)?;
validate(candidate, comm_of(candidate).as_deref())
/// 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)
}
+937 -114
View File
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)
+230 -38
View File
@@ -123,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?".
@@ -378,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
@@ -419,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,
@@ -433,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)
@@ -563,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() {
@@ -575,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)) => {
@@ -715,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,
);
}
+1 -1
View File
@@ -49,7 +49,7 @@ async fn main() -> Result<()> {
pipewire::init();
if cli.repair {
return repair::run().await;
return repair::run(cli.repair_legacy_untagged).await;
}
// Read-only diagnostic: observe the graph, report what the audio-exclusion
-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