Author SHA1 Message Date
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
12 changed files with 3190 additions and 408 deletions
+32
View File
@@ -198,6 +198,17 @@ pub struct TaintRow {
pub sticky: bool,
}
/// 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 +238,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 {
@@ -433,6 +454,16 @@ fn build_body(
})
.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 +475,7 @@ fn build_body(
eligible_count,
candidates,
taint,
ignored_ownership_tags,
}
}
+68 -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,11 +518,12 @@ 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"),
]
);
@@ -494,11 +556,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 +764,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
+625 -70
View File
@@ -4,12 +4,15 @@
//! 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};
@@ -104,14 +107,24 @@ 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 {
@@ -142,12 +155,13 @@ impl ObserverState {
}
}
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 {
@@ -160,11 +174,19 @@ 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.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 +205,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 +320,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 +334,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 +425,7 @@ fn run_observer(
}
};
state_for_global.borrow_mut().add(
serial,
id,
RegEvent::PortAdded(PortSnapshot {
serial,
@@ -381,6 +450,7 @@ fn run_observer(
return;
};
state_for_global.borrow_mut().add(
serial,
id,
RegEvent::ClientAdded(ClientSnapshot {
serial,
@@ -392,9 +462,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 +543,7 @@ fn run_observer(
};
let endpoints = link_endpoints_from_props(props);
state_for_global.borrow_mut().add(
serial,
id,
RegEvent::LinkAdded {
serial,
@@ -456,24 +590,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 +649,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 +746,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 +979,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 {
+330 -127
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,7 +90,7 @@ use crate::host::taint::snapshot::{
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
PortSnapshot, Serial,
};
use classify::{Classification, DeviceClaim};
use classify::{Classification, DeviceClaim, DeviceProps};
use std::collections::{BTreeMap, VecDeque};
/// A monotonic millisecond clock value, supplied by the adapter via
@@ -67,14 +98,17 @@ use std::collections::{BTreeMap, VecDeque};
/// [`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,21 +379,22 @@ 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()
}
@@ -310,102 +407,120 @@ impl RegistryModel {
pulse_pid::candidate(&clients)
}
/// Fold one observation into the model.
pub fn apply(&mut self, event: RegEvent) {
/// 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 +538,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 +564,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 +576,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,13 +669,34 @@ 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).
@@ -529,9 +707,34 @@ impl RegistryModel {
}
/// 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(),
+821 -109
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)
+226 -37
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)) => {
+190 -19
View File
@@ -67,10 +67,71 @@
//! 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()
}
}
/// 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 +145,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 +177,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 +193,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,8 +256,74 @@ 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)
///
/// # 🔴 OPEN, phase-6 blocking — the key union can *reduce* taint here
///
/// **Round 11 review, finding 1. Verified correct; deliberately not fixed in
/// that round.** 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.
///
/// It cannot leak today: `evaluate()` is reached only by the dry-run audit,
/// which creates no links. It becomes live when phase 6 consumes eligibility.
///
/// **Why it is not fixed yet.** The principled repair is provenance: a
/// self-claimed `application.process.id` is not a *sound* bound, only the
/// protected keys are. But applying that bluntly makes every Pulse-emulated
/// app unbounded — 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 is built to avoid, and would
/// make the eligible half of the §5.1 matrix empty.
///
/// The targeted rule that closes the path above without that cost: **a node
/// whose Client cannot be resolved at all must not be bounded by its own
/// self-claimed PID.** An ambiguous Client already means "we do not know who
/// owns this", and a self-claim must not paper over it; a Pulse app's Client
/// *is* resolved (to the daemon's PID, then suppressed), so it keeps its
/// bound. Implementing it needs `OwnerCtx` to distinguish "resolved" from
/// "absent", and `OwnerKeyIndex` to carry boundedness separately from the key
/// set, since bridging must keep using the full union.
///
/// ⚠️ Do this **with the §5.1 matrix data in hand**, not before: the whole
/// question is how much over-exclusion the rule actually causes on a real
/// graph, and that is measurable rather than arguable.
///
/// ## Round 12 — the deferral holds, and "resolved" has a trap in it
///
/// Codex re-examined this and agreed the deferral is defensible while
/// `evaluate()` is audit-only, and that the rule above closes the recorded path
/// without unbounding normal Pulse-emulated apps — **but only under one
/// reading of "resolves"**, and the wrong reading reintroduces the hole:
///
/// - ✅ "Resolved" must mean **an unambiguous Client that yields
/// `Some(pipewire.sec.pid)`**, taken *before* the pipewire-pulse suppression
/// step. A Pulse app then still has the daemon's protected PID as
/// provenance, even though that value is omitted from the bridge keys, so it
/// stays bounded and the eligible half survives.
/// - ❌ **Do not** implement it as "a unique Client object exists". A unique
/// Client with `sec_pid = None` would satisfy that test while providing no
/// protected identity at all, leaving exactly the self-claimed-PID hole this
/// rule is meant to close.
///
/// So the matrix needs five Client cases, not two: **absent**, **ambiguous**,
/// **unique but pid-less**, **resolved-native**, and
/// **resolved-to-pipewire-pulse**. The third is the one that distinguishes the
/// two readings, and it is the row a two-case matrix would silently skip.
pub fn owner_is_bounded(node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
keys_of(node, ctx)
.iter()
.any(|(key, _)| *key != OwnerKey::ClientId)
}
@@ -168,11 +339,11 @@ pub struct OwnerKeyIndex {
}
impl OwnerKeyIndex {
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
Self {
keys: snapshot
.nodes()
.map(|node| (node.serial, keys_of(node, pipewire_pulse_pid)))
.map(|node| (node.serial, keys_of(node, ctx)))
.collect(),
}
}
@@ -252,10 +423,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 +450,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 +461,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.
+662 -3
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,321 @@ 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")],
);
}
#[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 +1348,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)
// ──────────────────────────────────────────────────────────────────────
+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