Files
peerspeak/src/audio/ownership.rs
T
molluskandClaude Opus 5 21ba633825 audio/ownership: validate inherited SPA-JSON with libspa, not a scanner
Round 11 review, findings 2, 3 and 4.

The round-10 fix replaced a brace check with a hand-written scanner. That was
the wrong shape: a second implementation of someone else's grammar drifts in
both directions at once, and measured against `spa-json-dump -s` on this host
it did.

It ACCEPTED `{ "foo" = { garbage } }` (only brackets were balanced, contents
never validated), `{ "a" = "\é" }`, `{ "a" = é }` and `{ "a" = foo\bar }`.
Merging into those put an invalid pair before our carriers, so the daemon
stops at it and drops both -- recreating the exact fail-open the round-10 fix
existed to close. Its own test even pinned `"\é"` as a valid token.

It REJECTED `{ target.object, "my-sink" }`, `{ key == "value" }` and
CR-terminated comments, all valid -- so a user with one of those in their
environment silently lost their routing policy to an overwrite. That half
affects a running Linux user.

Now libspa's own parser validates, and the merge splices into the validated
text instead of re-emitting parsed pairs. Splicing preserves the user's bytes
exactly, which also answers the review's point that re-quoting a bare key can
invent a different one (`foo\bar` -> a string with a \b escape). Three
measured properties make the splice safe -- the last `}` is the object's, a
validated object's brace is never mid-comment, and commas are pure separators
-- and the result is validated again before it is returned.

Mutation testing then deleted the rest: every pairing and recursion check I
had written turned out to be redundant, because spa_json_next already errors
on `{ garbage }` and on nested garbage, and skips containers rather than
descending. ~60 lines of my own grammar logic removed. What remains is gated
by a new differential test against `spa-json-dump -s` over a 27-value corpus
-- the check whose absence caused this round. It found a real disagreement on
its first run (a bare document, which we reject by design, not by accident).

One mutation HUNG rather than failed: dropping the `length < 0` check makes
libspa report the same error without advancing, spinning forever. Kept, now
labelled load-bearing for termination, with a token-count bound beside it.

Finding 4: the ordering test took the first textual match of `fn main`, so a
raw-string decoy above the real function satisfied it while the real one
spawned a thread first. Now requires each of the three anchors to be unique.
Mutation-verified with the review's own decoy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:08:17 -04:00

1379 lines
61 KiB
Rust

//! Ownership tagging for every audio node peerspeak puts on the graph.
//!
//! Zero behaviour change on its own. Its whole purpose is to make peerspeak's
//! own playback *identifiable from outside the process*, so that pixelpass's
//! screenshare audio exclusion engine can refuse to fan our own call audio
//! back into the stream the far end is already hearing (the echo it exists to
//! prevent). See `docs/screenshare-audio-exclusion-plan.md` §5.1 and
//! `docs/screenshare-audio-exclusion-impl-plan.md` §3.
//!
//! # The wire contract
//!
//! The literals below are a **cross-repo contract**, not local naming.
//! pixelpass matches on them and does not depend on this crate (nor this
//! crate on it) — the coupling is the PipeWire graph itself. The values are
//! committed in both repos as `tests/fixtures/ownership-tag-contract.txt`,
//! and [`contract_matches_fixture`](tests::contract_matches_fixture) asserts
//! these constants against that file. pixelpass's matching constants are in
//! `src/host/taint/mod.rs` (`PEERSPEAK_OWNED_PROP`,
//! `PEERSPEAK_OWNED_NODE_PREFIX`), asserted against its own copy of the same
//! fixture.
//!
//! # Why two carriers
//!
//! A node is owned if **either** carrier matches — a union, which is the
//! fail-closed direction (a missed tag means our audio gets shared; a
//! spurious tag only over-excludes). Round 8 of the design added the second
//! carrier after the phase-5 audit found that a node *property* is invisible
//! to the PipeWire registry's `global` event and recoverable only by binding
//! the node, while `node.name` is announced directly. The primary taint root
//! should not rest on a single observation mechanism.
//!
//! `node.description` is deliberately left alone, so mixers still show "mpv"
//! rather than an internal identifier.
//!
//! # Known limitation (accepted for v1)
//!
//! The child environment is inherited by *grandchildren*, so a player that
//! spawns its own audio helper tags it too. That over-excludes rather than
//! leaks, and is the accepted v1 limitation recorded in plan §5.1.
use std::process::Command;
/// Carrier 1, key. Set on every peerspeak-owned node. Read by pixelpass only
/// after binding the node — see the module docs.
pub const OWNED_PROP_KEY: &str = "peerspeak.owned";
/// Carrier 1, value.
///
/// ⚠️ **pixelpass matches this EXACTLY** (its round-10 R10-4). An earlier
/// version of this comment said it accepted anything other than `false`/`0`;
/// that leniency was removed, because it let any process suppress a rival
/// app's audio from the share with a property it did not have to spell right.
/// Emitting any other value here now means the node is not owned on carrier 1
/// — only carrier 2's `node.name` prefix would still catch it.
pub const OWNED_PROP_VALUE: &str = "1";
/// Carrier 2. The `node.name` prefix pixelpass matches; the remainder of the
/// name is diagnostic only. Format: `peerspeak_owned_<role>_<pid>`.
pub const OWNED_NODE_NAME_PREFIX: &str = "peerspeak_owned_";
/// The environment variable through which a PulseAudio-API client (mpv/VLC on
/// `--ao=pulse`, `paplay`) receives extra stream properties.
pub const PULSE_PROP_ENV: &str = "PULSE_PROP";
/// The environment variable through which a PipeWire-native client
/// (`pw-play`, mpv on `--ao=pipewire`) receives extra stream properties.
pub const PIPEWIRE_PROPS_ENV: &str = "PIPEWIRE_PROPS";
/// The environment variable read **only by PipeWire's ALSA plugin**, and so
/// the one that reaches audio played through ALSA — which on Linux is how
/// `rodio`/`cpal` reach the graph (round 10, R10-2).
///
/// This is the *in-process* carrier: unlike [`PULSE_PROP_ENV`] and
/// [`PIPEWIRE_PROPS_ENV`], which are set per-child on a [`Command`], this one
/// is set process-wide in `main` because the audio it tags is played by a
/// library inside this process, with no fork to hang properties on.
///
/// **Measured 2026-07-25**, with `PIPEWIRE_PROPS` and `PULSE_PROP` explicitly
/// unset, to establish that it is surgical enough to set process-wide:
///
/// - `aplay` (ALSA plugin) ⇒ node carries **both** carriers. ✅
/// - `pw-play` (PipeWire-native) ⇒ `node.name=pw-play`, `peerspeak.owned`
/// absent. ✅ So our own native call-playback and capture streams, which go
/// through `pipewire_impl`, are **not** affected by this variable; they keep
/// being tagged explicitly at their own site.
/// - `arecord` (ALSA plugin, capture) ⇒ **is** tagged, on a
/// `Stream/Input/Audio`. ⚠️ Not surgical in the *role* dimension. Harmless
/// only because pixelpass honours the carriers on `Stream/Output/Audio`
/// alone (R10-1) — which is why R10-1 lands first. Nothing in peerspeak
/// captures via ALSA on Linux today (the Linux backend is native PipeWire;
/// `cpal` is the Windows backend), so this is a guard against a future
/// caller, not a live condition.
pub const PIPEWIRE_ALSA_ENV: &str = "PIPEWIRE_ALSA";
/// Role of in-process audio played through `rodio`: received chat clips, peer
/// music, and locally chosen playlist tracks (round 10, R10-2).
///
/// # Policy: peerspeak's own playlist audio is never screen-shareable
///
/// **All three are tagged, and for the local playlist that is a deliberate
/// policy rather than a consequence** (round 10 review, finding 2).
///
/// ⚠️ An earlier version of this comment justified it by claiming a local
/// track is "already being broadcast to peers on the same keypress", so
/// sharing it again would send the far end two copies. **That claim is false
/// in the default configuration** and Codex was right to call it: shared
/// listening is opt-in, `Config::music_broadcast` defaults to `false` (pinned
/// by `config::tests::test_input_output_volume_fields`),
/// `app::play_music_index` starts local playback unconditionally, and
/// `app::broadcast_track` returns immediately when
/// `can_broadcast_music` is false. With broadcasting off there is no second
/// copy and no defect — only a track the user chose that they cannot put in
/// the share.
///
/// The policy stands anyway, on cost rather than on that argument:
///
/// - The two carriers reach `rodio` through [`PIPEWIRE_ALSA_ENV`], which is
/// **process-wide**. `clip_player` and `music_player` are two `ClipPlayer`
/// instances in one process (`app::AppState`), so no value of that variable
/// can tag one and not the other, and `set_var` cannot be flipped at
/// runtime once threads exist.
/// - Exempting the playlist therefore means giving it a separately taggable
/// stream — a helper process, or rebuilding `ClipPlayer` on peerspeak's own
/// PipeWire backend — for a case with a one-step workaround.
/// - Tagging is **not** optional for the other two roles: received clips and
/// peer music are the far end's own audio, and re-sharing them is the exact
/// echo this feature exists to prevent.
///
/// So: a user who wants music inside the screen share plays it in any other
/// application, which peerspeak never tags. When shared listening *is* on,
/// the original two-copies argument does apply, and the policy is simply
/// right for that case too.
pub const CLIP_ROLE: &str = "clip";
/// Role of the native call-playback stream — the node carrying the far end's
/// voice.
pub const NATIVE_PLAYBACK_ROLE: &str = "call";
/// Role of a notification-sound player (`pw-play`/`paplay`/`aplay`).
pub const NOTIFICATION_ROLE: &str = "notify";
/// Build the `node.name` for a node this process owns.
///
/// The pid is **peerspeak's own**, not the child's: for a spawned player we
/// set the name before the fork, so the child's pid does not exist yet — and
/// the owning process is the more useful thing to record anyway.
pub fn owned_node_name(role: &str) -> String {
format!(
"{OWNED_NODE_NAME_PREFIX}{}_{}",
sanitize_role(role),
std::process::id()
)
}
/// Keep a role to `[a-z0-9-]`, lowercased, so the generated `node.name` is a
/// single shell-safe, whitespace-free token.
///
/// Not defensive theatre: [`PULSE_PROP_ENV`] is a space-separated
/// `key=value` encoding, so a role containing a space would silently split
/// into a second bogus property. Every current caller passes a literal, and
/// this keeps that true no matter who calls next.
fn sanitize_role(role: &str) -> String {
let cleaned: String = role
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let trimmed = cleaned.trim_matches('-');
if trimmed.is_empty() {
"child".to_string()
} else {
trimmed.to_string()
}
}
/// The `PULSE_PROP` value carrying both carriers.
fn pulse_prop_value(node_name: &str) -> String {
format!("{OWNED_PROP_KEY}={OWNED_PROP_VALUE} node.name={node_name}")
}
/// The `PIPEWIRE_PROPS` value carrying both carriers.
///
/// SPA-JSON; keys are quoted because `peerspeak.owned` contains a dot.
fn pipewire_props_value(node_name: &str) -> String {
format!("{{ \"{OWNED_PROP_KEY}\" = \"{OWNED_PROP_VALUE}\", \"node.name\" = \"{node_name}\" }}")
}
/// Tag a child process that will play audio, so its resulting node carries
/// both ownership carriers.
///
/// All three environment variables are set unconditionally, because we cannot
/// know from here which audio API the child will pick: mpv chooses at runtime,
/// VLC and `paplay` differ from `pw-play`, and either may be configured for
/// ALSA output. Setting the ones the child ignores costs nothing.
///
/// An inherited value is **merged** rather than replaced (round 10, R10-5).
/// All three can legitimately carry a user's own routing policy —
/// `media.role`, a target sink — and clobbering it changes where the user's
/// audio goes as a side effect of a tagging mechanism that is supposed to be
/// behaviourally invisible. An inherited value that does not match the
/// expected shape is logged and overwritten: a half-merged string that fails
/// to parse would drop the tag silently, which is the one outcome worse than
/// losing the user's routing preference.
///
/// ✅ **Our pairs go last, and last wins — measured 2026-07-25**, not assumed.
/// `PIPEWIRE_PROPS='{ "node.name" = "theirs_first", "media.role" = "music",
/// "node.name" = "ours_last" }'` on `pw-play` produced `node.name=ours_last`
/// with `media.role` preserved, and the `PULSE_PROP` equivalent on `paplay`
/// did the same. So a user who already sets `node.name` cannot silently
/// untag us, and their other keys survive.
///
/// ⚠️ Reachability, measured 2026-07-25: none of the three is set anywhere in
/// this user's environment or configuration, so this is a correctness
/// property with no live consumer today.
pub fn tag_child(command: &mut Command, role: &str) {
let node_name = owned_node_name(role);
command.env(
PULSE_PROP_ENV,
merge_pulse_prop(inherited(PULSE_PROP_ENV).as_deref(), &node_name),
);
command.env(
PIPEWIRE_PROPS_ENV,
merge_pipewire_props(
inherited(PIPEWIRE_PROPS_ENV).as_deref(),
&node_name,
PIPEWIRE_PROPS_ENV,
),
);
// The ALSA carrier takes the same SPA-JSON grammar and is set here too, so
// a child configured for ALSA output — which neither of the other two
// variables reaches — is tagged at all, and with *its own* role.
//
// Merged from the inherited `PIPEWIRE_ALSA`, which by now holds this
// process's own `clip` tag from [`tag_this_process_alsa_audio`] plus
// whatever the user set. The duplicate `node.name` that produces is
// resolved in our favour by the measured last-wins rule above, so the
// child gets its own role rather than inheriting `clip`.
command.env(
PIPEWIRE_ALSA_ENV,
merge_pipewire_props(
inherited(PIPEWIRE_ALSA_ENV).as_deref(),
&node_name,
PIPEWIRE_ALSA_ENV,
),
);
}
/// This process's value for `key`, which is what a child would inherit.
/// Non-UTF-8 reads as absent — we cannot merge into bytes we cannot parse, and
/// the overwrite path is where that belongs.
fn inherited(key: &str) -> Option<String> {
match std::env::var(key) {
Ok(value) if !value.trim().is_empty() => Some(value),
Ok(_) => None,
Err(std::env::VarError::NotPresent) => None,
Err(std::env::VarError::NotUnicode(_)) => {
crate::log_msg(&format!(
"ownership: inherited {key} is not valid UTF-8; overwriting it with the ownership tag"
));
None
}
}
}
/// `PULSE_PROP` is whitespace-separated `key=value`, so merging is appending.
///
/// The shape check is every token containing `=`. A token without one is not
/// something we can append to safely — PulseAudio's parser would take the
/// whole string as malformed and we would lose the tag with it.
fn merge_pulse_prop(inherited: Option<&str>, node_name: &str) -> String {
let ours = pulse_prop_value(node_name);
let Some(existing) = inherited else {
return ours;
};
if existing.split_whitespace().all(|pair| pair.contains('=')) {
format!("{} {ours}", existing.trim())
} else {
crate::log_msg(&format!(
"ownership: inherited {PULSE_PROP_ENV} is not space-separated key=value \
({existing:?}); overwriting it with the ownership tag"
));
ours
}
}
/// The diagnostic for an inherited value we refuse to merge into.
///
/// A pure function so a test can assert the variable name is the one that was
/// actually malformed; as an inline `format!` inside the logging call it was
/// ungated, and it was wrong — it always said `PIPEWIRE_PROPS`, including for
/// `PIPEWIRE_ALSA`.
fn malformed_object_warning(var: &str, existing: &str) -> String {
format!(
"ownership: inherited {var} is not a well-formed SPA-JSON object \
({existing:?}); overwriting it with the ownership tag"
)
}
/// Validate a SPA-JSON **object** with **libspa's own parser** — the same code
/// the daemon runs — and return how many tokens it holds.
///
/// The count is only ever compared against zero, to tell an empty object from
/// one with something worth preserving, so tokens rather than pairs is fine.
///
/// ⚠️ **This was a hand-written scanner for one round, and that is worth
/// recording** (round 11 review, findings 2 and 3). Checking the outer braces
/// was not a validity check; neither is checking that brackets balance.
/// Measured against `spa-json-dump -s` on this host, the hand-written version
/// disagreed with the real grammar in **both** directions:
///
/// - It **accepted** `{ "foo" = { garbage } }`, `{ "a" = "\é" }` (invalid
/// escape), `{ "a" = é }` (non-ASCII bare token) and `{ "a" = foo\bar }`.
/// Merging into those put an invalid pair *before* our carriers, so the
/// daemon would stop at it and drop both — recreating the exact fail-open
/// the fix existed to close.
/// - It **rejected** `{ target.object, "my-sink" }` (a comma between key and
/// value is ignored by SPA-JSON), `{ key == "value" }`, and CR-terminated
/// comments, all of which are valid — so a user with one of those in their
/// environment silently lost their routing policy to an overwrite.
///
/// A second implementation of someone else's grammar is a liability in both
/// directions at once. Using theirs makes the disagreement impossible.
///
/// `None` for anything that is not one complete, well-formed object, including
/// trailing text after it.
#[cfg(target_os = "linux")]
fn spa_object_tokens(text: &str) -> Option<usize> {
use pipewire::spa::sys as spa;
// SAFETY: the iterators borrow `text` and never outlive this call, and
// every pointer libspa hands back points into `text`. No slice is taken —
// only counts and libspa's own predicates — so there is no span
// arithmetic to get wrong.
unsafe {
let mut outer = std::mem::zeroed::<spa::spa_json>();
spa::spa_json_init(
&mut outer,
text.as_ptr().cast::<std::os::raw::c_char>(),
text.len(),
);
// The whole input must be exactly one object.
let mut token: *const std::os::raw::c_char = std::ptr::null();
let length = spa::spa_json_next(&mut outer, &mut token);
if length <= 0 || spa::spa_json_is_object(token, length) == 0 {
return None;
}
// ⚠️ **Nothing here re-implements the grammar, and that is the whole
// point.** An earlier cut walked the object itself, checking key/value
// pairing and recursing into nested containers. Mutation testing then
// showed every one of those checks was redundant: `spa_json_next`
// already returns an error for `{ garbage }`, for
// `{ "foo" = { garbage } }`, and for an unterminated string, and it
// *skips* a nested container rather than descending into it — so a
// flat token walk is both correct and complete. Deleting the
// hand-written half is what makes "use their grammar, not ours"
// actually true.
let mut object = std::mem::zeroed::<spa::spa_json>();
spa::spa_json_enter(&mut outer, &mut object);
let mut tokens = 0;
loop {
let mut token: *const std::os::raw::c_char = std::ptr::null();
let length = spa::spa_json_next(&mut object, &mut token);
if length == 0 {
break;
}
// ⚠️ **Load-bearing for termination, not just correctness.**
// Mutation-tested: with this removed, libspa keeps reporting the
// same error without advancing the cursor and the loop spins
// forever — the test run hung rather than failed, which is a much
// worse way to find out.
if length < 0 {
return None;
}
tokens += 1;
// Belt and braces on the same hazard: every token consumes at
// least one byte, so a token count past the input length means the
// iterator is not advancing and we must not trust it.
if tokens > text.len() {
return None;
}
}
// Nothing may follow the object.
let mut trailing: *const std::os::raw::c_char = std::ptr::null();
if spa::spa_json_next(&mut outer, &mut trailing) != 0 {
return None;
}
Some(tokens)
}
}
/// Off Linux there is nothing to merge into: `PIPEWIRE_PROPS` and
/// `PIPEWIRE_ALSA` are PipeWire's own and libspa is a Linux-only dependency,
/// so refusing to validate makes [`merge_pipewire_props`] overwrite — the
/// right answer on a platform where the variable has no reader.
///
/// The module stays platform-neutral on purpose (see `audio::mod`): the
/// literals are a wire contract and their test must run everywhere.
#[cfg(not(target_os = "linux"))]
fn spa_object_tokens(_text: &str) -> Option<usize> {
None
}
/// `PIPEWIRE_PROPS` and `PIPEWIRE_ALSA` are SPA-JSON objects, so merging is
/// inserting our two pairs before the closing brace of a **validated** object.
///
/// Anything that is not a complete, well-formed object — a bare `key = value`
/// list, a truncated object, an unterminated string, a key with no value, an
/// invalid escape — is logged and overwritten rather than guessed at.
///
/// Three properties make the splice safe, and all three depend on the object
/// having been validated first:
///
/// 1. **The last `}` is the object's own.** Nothing may follow the object, so
/// the final brace closes it.
/// 2. **Our pairs cannot land inside a comment.** A `#` comment runs to the
/// end of its line, so a validated object's closing brace is always
/// reachable, and the text immediately before it is never mid-comment.
/// Only spaces and tabs are trimmed before appending — never a newline —
/// so a value ending in `# note\n ` keeps its line break.
/// 3. **A leading comma is always legal.** Measured: SPA-JSON treats commas
/// as pure separators, accepting `{ "a" = "b",, "c" = "d" }` and even
/// `{ target.object, "my-sink" }`.
///
/// And then the result is **validated again before it is returned**. If the
/// splice ever produces something libspa will not parse, we overwrite instead:
/// a lost routing preference is recoverable, a lost carrier is an echo.
///
/// ⚠️ Honest note on that last check and on `spa_object_tokens`'s
/// "must be an object" test: **mutation testing cannot currently distinguish
/// either of them.** Removing the re-validation breaks nothing because the
/// first validation already rejects every input we have; removing the
/// object test breaks nothing because a non-object has no `}` to splice
/// before. They are deliberate belt-and-braces on the one code path where
/// being wrong is a silent echo, not load-bearing logic — and saying so is
/// better than leaving a future reader to assume a test is watching them.
///
/// `var` names the variable being merged, and is a parameter rather than a
/// constant because both variables take this grammar: the warning used to say
/// `PIPEWIRE_PROPS` even when `PIPEWIRE_ALSA` was the malformed one.
fn merge_pipewire_props(inherited: Option<&str>, node_name: &str, var: &str) -> String {
let ours = pipewire_props_value(node_name);
let Some(existing) = inherited else {
return ours;
};
let overwrite = || {
crate::log_msg(&malformed_object_warning(var, existing));
ours.clone()
};
// An empty object carries nothing worth preserving.
let Some(tokens) = spa_object_tokens(existing) else {
return overwrite();
};
if tokens == 0 {
return ours;
}
let trimmed = existing.trim_end();
let Some(closer) = trimmed.rfind('}') else {
return overwrite();
};
// A trailing separator of the user's would make ours a double comma. That
// is legal SPA-JSON (measured) but it reads like a bug, so drop it. It
// cannot be a comma inside a comment: a comment runs to end of line, so an
// object whose closing brace followed one on the same line would not have
// validated.
let kept = trimmed[..closer]
.trim_end_matches([' ', '\t'])
.trim_end_matches(',');
let merged = format!(
"{kept}, \"{OWNED_PROP_KEY}\" = \"{OWNED_PROP_VALUE}\", \
\"node.name\" = \"{node_name}\" }}"
);
// Never emit a value we cannot parse back.
if spa_object_tokens(&merged).is_none() {
return overwrite();
}
merged
}
/// Tag the audio this process plays **through ALSA** — in practice everything
/// `rodio` plays, which is [`crate::audio::clip_player::ClipPlayer`]: received
/// chat clips, peer music, and local playlist tracks.
///
/// `ClipPlayer` opens a `rodio` default sink, which on Linux reaches the graph
/// through PipeWire's ALSA plugin. It is a genuine fourth playback path and it
/// was untagged until round 10, which is a real echo: B broadcasts music, A
/// tunes in, A shares their desktop, and B hears their own track played back
/// at them. Found by Codex's phase-1 review; confirmed live as
/// `alsa_playback.peerspeak-…` with no ownership properties at all.
///
/// # Why an environment variable and not a property on the stream
///
/// `rodio` exposes no way to set PipeWire node properties — it is an ALSA
/// consumer several layers down. The alternative fix is to rebuild `ClipPlayer`
/// on peerspeak's own PipeWire backend, which is a large change for an
/// identical outcome. [`PIPEWIRE_ALSA_ENV`] reaches exactly the ALSA path and
/// nothing else (measured — see the constant).
///
/// # Must be called before any thread starts
///
/// `std::env::set_var` is `unsafe` in edition 2024 because a concurrent
/// `getenv` in another thread is a data race. The only safe moment is the top
/// of `main`, before anything is spawned; that is also correct on the merits,
/// since the plugin reads the variable when a stream is opened.
///
/// An inherited value is **merged**, on the same reasoning as [`tag_child`]'s
/// (R10-5): a user's `PIPEWIRE_ALSA` can carry routing policy, and this
/// function is not entitled to move their audio as a side effect of tagging
/// ours. Ours goes last and last wins (measured — see [`tag_child`]).
///
/// # Safety
///
/// The caller must guarantee no other thread exists in this process.
pub unsafe fn tag_this_process_alsa_audio() {
let node_name = owned_node_name(CLIP_ROLE);
let value = merge_pipewire_props(
inherited(PIPEWIRE_ALSA_ENV).as_deref(),
&node_name,
PIPEWIRE_ALSA_ENV,
);
// SAFETY: the caller's obligation, discharged by calling this at the top
// of `main` before any thread is spawned.
unsafe { std::env::set_var(PIPEWIRE_ALSA_ENV, value) };
}
/// Live-test support for the phase-1 exit gate, shared by the two modules
/// that spawn tagged players (`notify`, `screenshare`).
///
/// Lives here rather than in either test module because the gate is one
/// property — "the carriers reach a live node" — asked of two call sites, and
/// two copies of the poll would drift.
#[cfg(all(test, not(windows)))]
pub mod live_test {
use std::process::Command;
use std::time::{Duration, Instant};
/// Poll `pw-dump` for a `Stream/Output/Audio` node whose `node.name`
/// starts with `prefix`; returns its name and `peerspeak.owned` value.
///
/// Polls rather than sleeping once because node creation races the
/// player process starting up: a fixed sleep either flakes or is slow.
pub fn poll_for_owned_node(
prefix: &str,
timeout: Duration,
) -> Option<(String, Option<String>)> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if let Some(found) = scan_once(prefix) {
return Some(found);
}
std::thread::sleep(Duration::from_millis(100));
}
None
}
fn scan_once(prefix: &str) -> Option<(String, Option<String>)> {
let out = Command::new("pw-dump").output().ok()?;
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
for object in dump.as_array()? {
let props = &object["info"]["props"];
// `continue`, never `?`: most objects in a dump are not nodes and
// have no `node.name` at all, and propagating that as `None`
// would abandon the scan at the first one.
let Some(name) = props["node.name"].as_str() else {
continue;
};
if !name.starts_with(prefix) {
continue;
}
// PipeWire may hand the property back as a JSON string or a
// number depending on how the client set it; both are the same
// property, and reading only one would make the assertion
// accidentally depend on the producer's encoding.
let owned = props[super::OWNED_PROP_KEY]
.as_str()
.map(str::to_string)
.or_else(|| props[super::OWNED_PROP_KEY].as_i64().map(|n| n.to_string()));
return Some((name.to_string(), owned));
}
None
}
/// A zero-filled 48 kHz stereo s16 WAV of `seconds` length — long enough
/// for the poll to catch the node, silent so the gate never makes noise.
pub fn silent_wav(seconds: u32) -> Vec<u8> {
let data_len = 48_000 * seconds * 4;
let mut wav = Vec::with_capacity(44 + data_len as usize);
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&(36 + data_len).to_le_bytes());
wav.extend_from_slice(b"WAVEfmt ");
wav.extend_from_slice(&16u32.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes()); // PCM
wav.extend_from_slice(&2u16.to_le_bytes()); // stereo
wav.extend_from_slice(&48_000u32.to_le_bytes());
wav.extend_from_slice(&(48_000u32 * 4).to_le_bytes()); // byte rate
wav.extend_from_slice(&4u16.to_le_bytes()); // block align
wav.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
wav.extend_from_slice(b"data");
wav.extend_from_slice(&data_len.to_le_bytes());
wav.resize(44 + data_len as usize, 0);
wav
}
/// The `node.name` prefix a node tagged by *this* process with `role`
/// must carry. Narrower than [`super::OWNED_NODE_NAME_PREFIX`] on
/// purpose: it pins the match to this test run, so a stray tagged node
/// left by anything else cannot make a gate pass.
pub fn expected_prefix(role: &str) -> String {
format!(
"{}{role}_{}",
super::OWNED_NODE_NAME_PREFIX,
std::process::id()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
/// The contract file, byte-identical to pixelpass's copy.
const FIXTURE: &str = include_str!("../../tests/fixtures/ownership-tag-contract.txt");
/// ⚠️ **Rejects duplicate keys, and that is the point** (Codex phase-1
/// review, finding 3). This side collected into a map, so a duplicate
/// silently took the *last* value; pixelpass's half searches a list and
/// takes the *first*. A byte-identical fixture with a duplicated key
/// could therefore leave both suites green while the two repos had
/// selected different contracts — the exact drift this file exists to
/// prevent. Both sides now refuse the ambiguity instead of resolving it.
fn fixture() -> BTreeMap<String, String> {
let mut seen: Vec<&str> = Vec::new();
FIXTURE
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| {
let (key, value) = line
.split_once('=')
.unwrap_or_else(|| panic!("fixture line is not key=value: {line:?}"));
assert!(
!seen.contains(&key),
"fixture defines {key:?} twice; the two repos would disagree on which wins"
);
seen.push(key);
(key.to_string(), value.to_string())
})
.collect()
}
fn fixture_get(key: &str) -> String {
fixture()
.get(key)
.unwrap_or_else(|| panic!("fixture has no key {key:?}"))
.clone()
}
/// Half of the cross-repo contract test: our constants are exactly what
/// the shared fixture pins. pixelpass runs the mirror of this against its
/// own byte-identical copy.
#[test]
fn contract_matches_fixture() {
assert_eq!(OWNED_PROP_KEY, fixture_get("prop_key"));
assert_eq!(OWNED_PROP_VALUE, fixture_get("prop_value"));
assert_eq!(OWNED_NODE_NAME_PREFIX, fixture_get("node_name_prefix"));
}
/// The fixture's documented format and example must agree with the
/// generator, so a future reader can trust the example.
#[test]
fn fixture_example_is_well_formed() {
let example = fixture_get("node_name_example");
assert!(example.starts_with(&fixture_get("node_name_prefix")));
let format = fixture_get("node_name_format");
assert_eq!(format, "peerspeak_owned_<role>_<pid>");
// Same shape our generator emits: prefix, role, pid.
let generated = owned_node_name("mpv");
let suffix = generated
.strip_prefix(OWNED_NODE_NAME_PREFIX)
.expect("generated name carries the prefix");
let (role, pid) = suffix.split_once('_').expect("role_pid suffix");
assert_eq!(role, "mpv");
assert_eq!(pid.parse::<u32>().unwrap(), std::process::id());
}
/// The black-box half: build a real `Command` exactly as a call site
/// does, read back the environment it *would* carry across the fork, and
/// assert the property string pixelpass matches on is present in it.
///
/// Deliberately inspects the `Command`, not our own formatter — a helper
/// that returns the right string but is wired into the wrong variable
/// would pass a formatter test and fail in the field.
#[test]
fn tagged_child_command_carries_both_carriers() {
let mut command = Command::new("mpv");
tag_child(&mut command, "mpv");
let env: BTreeMap<String, String> = command
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().into_owned(),
value
.expect("tagging sets values, never removals")
.to_string_lossy()
.into_owned(),
)
})
.collect();
let expected_name = owned_node_name("mpv");
let prop = format!("{}={}", fixture_get("prop_key"), fixture_get("prop_value"));
let pulse = env.get(PULSE_PROP_ENV).expect("PULSE_PROP set");
assert!(
pulse.contains(&prop),
"PULSE_PROP carries carrier 1: {pulse}"
);
assert!(
pulse.contains(&format!("node.name={expected_name}")),
"PULSE_PROP carries carrier 2: {pulse}"
);
let pw = env.get(PIPEWIRE_PROPS_ENV).expect("PIPEWIRE_PROPS set");
assert!(
pw.contains(&format!(
"\"{}\" = \"{}\"",
fixture_get("prop_key"),
fixture_get("prop_value")
)),
"PIPEWIRE_PROPS carries carrier 1: {pw}"
);
assert!(
pw.contains(&format!("\"node.name\" = \"{expected_name}\"")),
"PIPEWIRE_PROPS carries carrier 2: {pw}"
);
assert!(
pw.starts_with('{') && pw.ends_with('}'),
"PIPEWIRE_PROPS is a SPA-JSON object: {pw}"
);
}
/// R10-2's in-process carrier, checked as a *value*, not by setting the
/// variable: mutating the process environment from a test races every
/// other test in the binary. That the value reaches `PIPEWIRE_ALSA` at all
/// is what the live gate below proves, and only a live gate can.
#[test]
fn the_alsa_carrier_value_carries_both_carriers() {
let name = owned_node_name(CLIP_ROLE);
let value = pipewire_props_value(&name);
assert_eq!(PIPEWIRE_ALSA_ENV, "PIPEWIRE_ALSA");
assert!(
value.contains(&format!(
"\"{}\" = \"{}\"",
fixture_get("prop_key"),
fixture_get("prop_value")
)),
"carrier 1: {value}"
);
assert!(
value.contains(&format!("\"node.name\" = \"{name}\"")),
"carrier 2: {value}"
);
assert!(name.starts_with(&fixture_get("node_name_prefix")));
// The role is a distinct token, so an audit can tell clip audio from
// the call stream and from a spawned player.
assert!(name.contains("_clip_"), "role token present: {name}");
}
/// The in-process carrier only works if `main` actually sets it, and the
/// live gate cannot prove that half: a test binary has no `main`, so it
/// must call the tagger itself. That leaves exactly one way to regress —
/// delete the line from `main` — and this is the cheapest thing that
/// catches it.
///
/// A source-text assertion is crude. It is also the *only* check available
/// short of driving the real GUI binary, and phase 1's miss was precisely
/// a call site nobody verified existed.
///
/// ⚠️ **The check is "first statement in `main`", not "before
/// `run_gui`"** (round 10 review, finding 5). Ordering against `run_gui`
/// proved far less than the safety obligation needs: inserting a
/// long-lived `std::thread::spawn` above the tag still satisfied it, while
/// making the `set_var` a data race — the exact thing the `unsafe` block's
/// SAFETY comment claims cannot happen. `set_var` is sound only while the
/// process is single-threaded, so the property to gate is that **nothing
/// executable precedes the call**, which is checkable and is what `main`
/// actually does today.
#[test]
fn main_tags_this_process_before_anything_starts() {
const MAIN: &str = include_str!("../main.rs");
// ⚠️ Comments are stripped first, and that is not tidiness. The first
// version of this test searched the raw source and **passed against a
// `main` with the call deleted**, because the explanatory comment
// above it still named the function. A gate satisfiable by two sources
// gates neither (the phase-3r lesson); here the prose was the second
// source. Caught by mutation, which is the only reason it is not still
// green and worthless.
let code: String = MAIN
.lines()
.map(|line| line.split("//").next().unwrap_or(""))
.collect::<Vec<_>>()
.join("\n");
let call = code
.find("tag_this_process_alsa_audio()")
.expect("main must call tag_this_process_alsa_audio (round 10, R10-2)");
let body = code
.find("fn main()")
.and_then(|start| code[start..].find('{').map(|open| start + open + 1))
.expect("main has a body");
// Back up over the module path the call is written with, so the
// statement's own `peerspeak::audio::ownership::` is not mistaken for
// code preceding it. Stepping by `len_utf8` rather than by one byte
// keeps the slice below on a character boundary whatever precedes it.
let call_start = code[..call]
.char_indices()
.rev()
.find(|(_, c)| !(c.is_alphanumeric() || *c == '_' || *c == ':'))
.map_or(0, |(index, c)| index + c.len_utf8());
assert!(
call_start > body,
"the call must be inside main, not above it"
);
// Everything between `main`'s opening brace and the call, with the
// syntax that cannot *run* removed: attributes, the `unsafe` keyword,
// block punctuation, whitespace. Anything left is a statement that
// executes before the tag — which is what must not exist.
let mut before = code[body..call_start].to_string();
while let Some(start) = before.find("#[") {
let end = before[start..]
.find(']')
.map(|offset| start + offset + 1)
.expect("an attribute closes");
before.replace_range(start..end, "");
}
let residue: String = before
.replace("unsafe", "")
.chars()
.filter(|c| !c.is_whitespace() && !matches!(c, '{' | '}'))
.collect();
assert!(
residue.is_empty(),
"nothing may execute before the tag; found {residue:?} in main"
);
// And it must still be the real `main` of a program that starts the
// GUI, or the check above is asserting about the wrong function.
let gui = code
.find("run_gui")
.expect("main runs the GUI; this test's ordering check assumes it");
assert!(
call < gui,
"the tag must be set before the GUI starts any thread"
);
// ⚠️ **Every search above takes the FIRST textual match, so the whole
// check is only as good as there being one candidate** (round 11
// review, finding 4). A raw-string decoy above the real function —
// `const DECOY: &str = r#" fn main() { unsafe { …tag…(); run_gui(); } "#;`
// — would satisfy all three searches while the real `main` spawned a
// thread before the tag. Requiring uniqueness kills that class without
// pulling in a parser: if a second `fn main` or a second call ever
// appears, this fails and asks a human to look.
assert_eq!(
code.matches("fn main").count(),
1,
"exactly one `fn main` in main.rs; the ordering check reads the first match"
);
assert_eq!(
code.matches("tag_this_process_alsa_audio").count(),
1,
"exactly one tag call; a second would make the ordering check ambiguous"
);
assert_eq!(
code.matches("run_gui").count(),
1,
"exactly one run_gui; a second would make the ordering check ambiguous"
);
}
/// R10-5: a user's inherited `PULSE_PROP` survives, and our pairs go last
/// so they win any duplicate key.
///
/// Tested on the merge functions rather than through `tag_child`, because
/// `tag_child` reads the *process* environment and a test that set it
/// would race every other test in the binary.
#[test]
fn pulse_prop_merges_rather_than_clobbering() {
let name = owned_node_name("mpv");
let merged = merge_pulse_prop(Some("media.role=music target.object=my-sink"), &name);
assert!(
merged.contains("media.role=music") && merged.contains("target.object=my-sink"),
"the user's routing policy survives: {merged}"
);
assert!(merged.contains(&format!("{OWNED_PROP_KEY}={OWNED_PROP_VALUE}")));
assert!(merged.contains(&format!("node.name={name}")));
// Still parses as whitespace-separated key=value, with ours last.
let pairs: Vec<(&str, &str)> = merged
.split_whitespace()
.map(|pair| pair.split_once('=').expect("key=value"))
.collect();
assert_eq!(pairs.len(), 4, "{merged}");
assert_eq!(pairs[pairs.len() - 1].0, "node.name");
}
/// Ours must win a key the inherited value already sets, or a user with
/// `node.name` in their environment would silently untag us.
#[test]
fn our_pulse_prop_pairs_win_a_duplicate_key() {
let name = owned_node_name("mpv");
let merged = merge_pulse_prop(Some("node.name=theirs"), &name);
let last_name = merged
.split_whitespace()
.filter_map(|pair| pair.split_once('='))
.rfind(|(key, _)| *key == "node.name")
.expect("a node.name pair");
assert_eq!(last_name.1, name, "ours must come last: {merged}");
}
#[test]
fn a_malformed_pulse_prop_is_overwritten_not_half_merged() {
let name = owned_node_name("mpv");
// No `=` in the token: not something we can safely append to.
let merged = merge_pulse_prop(Some("garbage-without-equals"), &name);
assert_eq!(merged, pulse_prop_value(&name));
assert!(!merged.contains("garbage"));
}
#[test]
fn pipewire_props_merges_inside_the_spa_json_object() {
let name = owned_node_name("vlc");
let merged = merge_pipewire_props(
Some("{ \"media.role\" = \"music\" }"),
&name,
PIPEWIRE_PROPS_ENV,
);
assert!(merged.starts_with('{') && merged.ends_with('}'), "{merged}");
assert!(merged.contains("\"media.role\" = \"music\""), "{merged}");
assert!(merged.contains(&format!("\"{OWNED_PROP_KEY}\" = \"{OWNED_PROP_VALUE}\"")));
assert!(merged.contains(&format!("\"node.name\" = \"{name}\"")));
// Exactly one object, not two concatenated.
assert_eq!(merged.matches('{').count(), 1, "{merged}");
assert_eq!(merged.matches('}').count(), 1, "{merged}");
}
/// A trailing comma in the inherited object must not become a double
/// comma, which SPA-JSON would reject — taking our tag down with it.
#[test]
fn pipewire_props_merge_does_not_double_a_trailing_comma() {
let name = owned_node_name("vlc");
let merged = merge_pipewire_props(
Some("{ \"media.role\" = \"music\", }"),
&name,
PIPEWIRE_PROPS_ENV,
);
assert!(!merged.contains(",,"), "{merged}");
assert!(!merged.contains(", ,"), "{merged}");
assert!(merged.contains("\"media.role\" = \"music\""), "{merged}");
}
#[test]
fn an_empty_or_malformed_pipewire_props_yields_our_value_alone() {
let name = owned_node_name("vlc");
assert_eq!(
merge_pipewire_props(Some("{ }"), &name, PIPEWIRE_PROPS_ENV),
pipewire_props_value(&name)
);
// Not an object: overwritten rather than guessed at.
assert_eq!(
merge_pipewire_props(Some("media.role = music"), &name, PIPEWIRE_PROPS_ENV),
pipewire_props_value(&name)
);
assert_eq!(
merge_pipewire_props(None, &name, PIPEWIRE_PROPS_ENV),
pipewire_props_value(&name)
);
}
/// **Round 10 review, finding 3 — the brace check was not a validity
/// check, and the gap is a measured fail-open.**
///
/// Every value below is brace-wrapped, so the old rule spliced our pairs
/// into it and produced an object the daemon does not accept. ⚠️ The live
/// failure is worse than a rejection: measured 2026-07-25, `PIPEWIRE_ALSA`
/// set to the old merge of `{ garbage }` gave a real `aplay` node
/// `node.name=alsa_playback.aplay`, **no** `peerspeak.owned`, and a junk
/// property `garbage = "peerspeak.owned"` — the lenient parser ate our key
/// as their value and stopped, losing *both* carriers on a live
/// `Stream/Output/Audio` node. Each of these must overwrite instead.
#[test]
fn a_brace_wrapped_but_malformed_pipewire_props_is_overwritten() {
let name = owned_node_name("vlc");
let malformed = [
// The measured case: a key with no value.
"{ garbage }",
// A key with no value, followed by real pairs.
"{ garbage, \"media.role\" = \"music\" }",
// Unterminated string.
"{ \"media.role\" = \"music }",
// Broken nested container.
"{ \"channels\" = [ 1, 2 }",
// Mismatched closer.
"{ \"channels\" = [ 1, 2 } ]",
// Truncated object that still ends in a brace, via nesting.
"{ \"a\" = { \"b\" = 1 }",
// A trailing comment would swallow anything appended after it.
"{ \"media.role\" = \"music\" # note }",
// Two objects concatenated: splicing would have tagged only the
// second one.
"{ \"a\" = 1 } { \"b\" = 2 }",
// A dangling assignment.
"{ \"media.role\" = }",
// ⚠️ Round 11 finding 2's decisive case: garbage *inside* a nested
// container. The hand-written scanner only balanced brackets, so
// it accepted these and spliced our carriers in after an invalid
// pair — which the daemon stops at, dropping both.
"{ \"foo\" = { garbage } }",
"{ \"foo\" = [ { garbage } ] }",
"{ \"foo\" = { \"bar\" = { garbage } } }",
// Invalid escapes and non-ASCII bare tokens, also accepted before.
"{ \"a\" = \"\\é\" }",
"{ \"a\" = é }",
"{ \"a\" = foo\\bar }",
];
for value in malformed {
assert_eq!(
merge_pipewire_props(Some(value), &name, PIPEWIRE_PROPS_ENV),
pipewire_props_value(&name),
"{value:?} must be overwritten, not merged into"
);
}
}
/// The other half of finding 3: values that *are* well-formed must survive,
/// or the fix would be "overwrite everything" wearing a parser as a
/// disguise.
///
/// ⚠️ **The user's text is preserved byte-for-byte, not re-encoded.** An
/// earlier version parsed into pairs and re-emitted them, which re-quoted
/// bare keys — and round 11 pointed out that re-quoting a token the parser
/// wrongly accepted can invent a *different* key (`foo\bar` becoming a
/// string with a `\b` escape). Splicing into validated text cannot change
/// the meaning of anything it does not touch.
///
/// Every input here is one `spa-json-dump -s` accepts; the last three were
/// specifically rejected by the hand-written scanner this replaced.
#[test]
fn a_well_formed_pipewire_props_survives_the_merge() {
let name = owned_node_name("vlc");
let cases = [
// Bare key, bare value — kept bare, exactly as written.
("{ media.role = music }", "media.role = music"),
// Colon separator.
("{ \"media.role\": \"music\" }", "\"media.role\": \"music\""),
// No separator at all — legal SPA-JSON.
("{ \"media.role\" \"music\" }", "\"media.role\" \"music\""),
// Nested array and object.
(
"{ \"audio.position\" = [ FL, FR ] }",
"\"audio.position\" = [ FL, FR ]",
),
("{ \"props\" = { \"a\" = 1 } }", "\"props\" = { \"a\" = 1 }"),
// ⚠️ An ODD number of array elements. This is the case that
// distinguishes recursive validation from a flat token walk: a
// flat walk pairs FL with FR, then finds RC with no partner and
// wrongly rejects a valid object. Found by mutation.
(
"{ \"audio.position\" = [ FL, FR, RC ] }",
"\"audio.position\" = [ FL, FR, RC ]",
),
// A brace inside a string is not structure.
(
"{ \"node.name\" = \"weird}name\" }",
"\"node.name\" = \"weird}name\"",
),
// A leading comment line.
(
"# mine\n{ \"media.role\" = \"music\" }",
"\"media.role\" = \"music\"",
),
// ⚠️ Round 11 finding 3 — the hand-written scanner REJECTED these
// three valid objects, overwriting the user's routing policy.
(
"{ target.object, \"my-sink\" }",
"target.object, \"my-sink\"",
),
("{ key == \"value\" }", "key == \"value\""),
("{ \"a\" = \"b\" # note\r }", "\"a\" = \"b\""),
];
for (input, preserved) in cases {
let merged = merge_pipewire_props(Some(input), &name, PIPEWIRE_PROPS_ENV);
assert!(
merged.contains(preserved),
"{input:?} must keep {preserved:?}: {merged}"
);
assert!(
merged.contains(&format!("\"{OWNED_PROP_KEY}\" = \"{OWNED_PROP_VALUE}\"")),
"{input:?} must still carry the tag: {merged}"
);
assert!(
merged.contains(&format!("\"node.name\" = \"{name}\"")),
"{input:?} must still carry the name: {merged}"
);
// Ours goes last, so it wins any duplicate key.
let ours = merged
.rfind("\"node.name\" = ")
.expect("a node.name pair in the merged value");
assert!(merged[ours..].contains(&name), "ours last: {merged}");
}
}
/// ✅ **The exact bytes below were verified against the live daemon**
/// (2026-07-25, and the third re-verified 2026-07-26 after round 11
/// changed what it emits): each was set as `PIPEWIRE_ALSA` for a real
/// `aplay`, and `pw-dump` showed a `Stream/Output/Audio` node carrying
/// `peerspeak.owned = 1`, the expected `node.name`, **and** the user's own
/// properties (`media.role = music`, `audio.position = [ FL, FR ]`).
/// `spa-json-dump -s` accepts all three. A comma-between-key-and-value and
/// a nested-object case were confirmed live in the same run.
///
/// Pinned byte-for-byte rather than asserted with `contains`, because
/// what the live run establishes is that *this string* works — a
/// reformatted equivalent is a different experiment, and the failure mode
/// this whole finding is about was a value that looked fine and parsed
/// wrong.
#[test]
fn the_merged_value_is_byte_for_byte_what_was_verified_live() {
assert_eq!(
merge_pipewire_props(
Some("{ \"media.role\" = \"music\" }"),
"f3_rebuilt_probe",
PIPEWIRE_ALSA_ENV
),
"{ \"media.role\" = \"music\", \"peerspeak.owned\" = \"1\", \
\"node.name\" = \"f3_rebuilt_probe\" }"
);
// The malformed case collapses to our value alone.
assert_eq!(
merge_pipewire_props(
Some("{ garbage }"),
"f3_overwritten_probe",
PIPEWIRE_ALSA_ENV
),
"{ \"peerspeak.owned\" = \"1\", \"node.name\" = \"f3_overwritten_probe\" }"
);
// A bare key, a bare value and a nested array all survive verbatim —
// the bare key stays bare, which is the round-11 correction.
assert_eq!(
merge_pipewire_props(
Some("{ media.role = music, \"audio.position\" = [ FL, FR ] }"),
"f3_exotic_probe",
PIPEWIRE_ALSA_ENV
),
"{ media.role = music, \"audio.position\" = [ FL, FR ], \
\"peerspeak.owned\" = \"1\", \"node.name\" = \"f3_exotic_probe\" }"
);
}
/// **Differential: our acceptance set must equal `spa-json-dump -s`'s.**
///
/// This is the check whose absence caused round 11. The previous validator
/// was a hand-written scanner, and nothing compared it to the real grammar
/// — so it drifted in both directions at once and neither the unit tests
/// nor the 20,000-input property test noticed, because both used *it* as
/// their oracle.
///
/// Now that the validator is libspa itself this should be true by
/// construction, which is exactly why it is worth asserting: if it ever
/// fails, the construction is not what we think it is.
///
/// `#[ignore]` because it shells out to a PipeWire tool that CI need not
/// have; it is part of the phase 5 matrix procedure.
#[test]
#[ignore = "needs spa-json-dump"]
fn our_validator_agrees_with_spa_json_dump() {
let corpus = [
// Valid.
"{ \"a\" = \"b\" }",
"{ }",
"{ media.role = music }",
"{ \"media.role\": \"music\" }",
"{ \"media.role\" \"music\" }",
"{ target.object, \"my-sink\" }",
"{ key == \"value\" }",
"{ \"a\" = \"b\",, \"c\" = \"d\" }",
"{ \"a\" = \"b\", }",
"{ \"audio.position\" = [ FL, FR ] }",
"{ \"props\" = { \"a\" = 1 } }",
"{ \"node.name\" = \"weird}name\" }",
"# mine\n{ \"media.role\" = \"music\" }",
"{ \"a\" = \"b\" # note\r }",
// Invalid.
"{ garbage }",
"{ garbage, \"media.role\" = \"music\" }",
"{ \"media.role\" = \"music }",
"{ \"channels\" = [ 1, 2 }",
"{ \"channels\" = [ 1, 2 } ]",
"{ \"a\" = { \"b\" = 1 }",
"{ \"media.role\" = \"music\" # note }",
"{ \"a\" = 1 } { \"b\" = 2 }",
"{ \"media.role\" = }",
"{ \"foo\" = { garbage } }",
"{ \"a\" = \"\\é\" }",
"{ \"a\" = é }",
"{ \"a\" = foo\\bar }",
];
// ⚠️ The corpus is deliberately all *objects*. `spa-json-dump` accepts
// any SPA-JSON document, so a bare `media.role = music` passes there
// and is rejected here — not a disagreement about the grammar but our
// own additional requirement, since there is no object to splice into.
// The first run of this test flagged exactly that, which is the point
// of having it. `an_empty_or_malformed_pipewire_props_yields_our_value_alone`
// pins the bare-list case.
for value in corpus {
let mut child = std::process::Command::new("spa-json-dump")
.arg("-s")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spa-json-dump must be installed for this test");
{
use std::io::Write;
child
.stdin
.as_mut()
.expect("piped stdin")
.write_all(value.as_bytes())
.expect("write to spa-json-dump");
}
let theirs = child.wait().expect("spa-json-dump exits").success();
let ours = spa_object_tokens(value).is_some();
assert_eq!(
ours, theirs,
"disagreement on {value:?}: ours={ours}, spa-json-dump={theirs}"
);
}
}
/// The warning names the variable that was actually malformed. It used to
/// always say `PIPEWIRE_PROPS`, including when `tag_child` merged
/// `PIPEWIRE_ALSA` (round 10 review, finding 3's second half).
#[test]
fn the_malformed_warning_names_the_variable_it_was_given() {
// Both variables take the same grammar and go through the same merge,
// so the only thing distinguishing their diagnostics is this name
// being threaded through rather than hard-coded — which it was.
assert_ne!(PIPEWIRE_PROPS_ENV, PIPEWIRE_ALSA_ENV);
let alsa = malformed_object_warning(PIPEWIRE_ALSA_ENV, "{ garbage }");
assert!(alsa.contains(PIPEWIRE_ALSA_ENV), "{alsa}");
assert!(!alsa.contains(PIPEWIRE_PROPS_ENV), "{alsa}");
let props = malformed_object_warning(PIPEWIRE_PROPS_ENV, "{ garbage }");
assert!(props.contains(PIPEWIRE_PROPS_ENV), "{props}");
// The offending value is quoted into the message, so the user can see
// what was dropped rather than only that something was.
assert!(props.contains("garbage"), "{props}");
}
/// A spawned player configured for ALSA output is reached by neither
/// `PULSE_PROP` nor `PIPEWIRE_PROPS`. `tag_child` therefore sets the ALSA
/// carrier too — with the *child's* role, so it does not inherit this
/// process's `clip` tag from `tag_this_process_alsa_audio`.
#[test]
fn tagged_child_command_also_carries_the_alsa_carrier_with_its_own_role() {
let mut command = Command::new("mpv");
tag_child(&mut command, "mpv");
let env: BTreeMap<String, String> = command
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().into_owned(),
value
.expect("tagging sets values, never removals")
.to_string_lossy()
.into_owned(),
)
})
.collect();
let alsa = env.get(PIPEWIRE_ALSA_ENV).expect("PIPEWIRE_ALSA set");
assert!(
alsa.contains(&format!("\"node.name\" = \"{}\"", owned_node_name("mpv"))),
"the child's own role: {alsa}"
);
assert!(alsa.contains(&format!("\"{OWNED_PROP_KEY}\" = \"{OWNED_PROP_VALUE}\"")));
}
/// ⚠️ In production `main` has already set `PIPEWIRE_ALSA` to this
/// process's `clip` tag by the time a player is spawned, so `tag_child`
/// merges into *that*, not into nothing. The test binary has no such
/// value, so the row above never exercises the real shape — this one does,
/// on the merge function directly.
///
/// The child's `node.name` must come last, because last wins (measured):
/// otherwise a spawned mpv would report itself as `clip` and the audit
/// could not tell the incoming screenshare's audio from a chat clip.
#[test]
fn a_childs_role_overrides_the_inherited_clip_tag_in_the_alsa_carrier() {
let clip = pipewire_props_value(&owned_node_name(CLIP_ROLE));
let child = owned_node_name("mpv");
let merged = merge_pipewire_props(Some(&clip), &child, PIPEWIRE_ALSA_ENV);
let last_name = merged
.rfind("\"node.name\" = ")
.expect("a node.name pair in the merged value");
assert!(
merged[last_name..].contains(&child),
"the child's role must be the last node.name: {merged}"
);
// Both are still peerspeak-owned, so the duplicate is harmless.
assert!(merged.contains(&format!("\"{OWNED_PROP_KEY}\" = \"{OWNED_PROP_VALUE}\"")));
assert!(merged.starts_with('{') && merged.ends_with('}'), "{merged}");
assert_eq!(merged.matches('{').count(), 1, "one object only: {merged}");
}
#[test]
fn every_generated_name_matches_the_prefix_pixelpass_looks_for() {
for role in ["call", "mpv", "vlc", "notify"] {
let name = owned_node_name(role);
assert!(
name.starts_with(OWNED_NODE_NAME_PREFIX),
"{name} must match the prefix"
);
}
}
#[test]
fn role_is_reduced_to_a_single_safe_token() {
// A space would split PULSE_PROP into a bogus second property.
assert_eq!(sanitize_role("my player"), "my-player");
assert_eq!(sanitize_role("MPV"), "mpv");
assert_eq!(sanitize_role("vlc/../.."), "vlc");
assert_eq!(sanitize_role(""), "child");
assert_eq!(sanitize_role("---"), "child");
assert!(!owned_node_name("my player").contains(' '));
}
#[test]
fn pulse_prop_pairs_are_whitespace_separated_and_parse_back() {
let value = pulse_prop_value(&owned_node_name("notify"));
let pairs: BTreeMap<&str, &str> = value
.split_whitespace()
.map(|pair| pair.split_once('=').expect("key=value"))
.collect();
assert_eq!(pairs.len(), 2, "exactly two properties: {value}");
assert_eq!(pairs.get(OWNED_PROP_KEY), Some(&OWNED_PROP_VALUE));
assert!(
pairs
.get("node.name")
.is_some_and(|name| name.starts_with(OWNED_NODE_NAME_PREFIX))
);
}
}