//! 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__`. 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 { 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 { 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_init( &mut outer, text.as_ptr().cast::(), 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_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 { 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)> { 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)> { 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 { 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 { 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__"); // 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::().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 = 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::>() .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 = 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)) ); } }