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>
1379 lines
61 KiB
Rust
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))
|
|
);
|
|
}
|
|
}
|