From bcf1343a552f35ee97de47145c274d9451b16461 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sat, 25 Jul 2026 19:19:25 -0400 Subject: [PATCH] phase 1: tag every audio node peerspeak owns, on both carriers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero behaviour change. This is what makes the screenshare exclusion engine able to see us at all (plan §5.1, impl plan §3): pixelpass must refuse to fan out our own playback, and until now it had no way to recognise it. Two carriers, matched by pixelpass as a union — `peerspeak.owned=1` and a `node.name` prefix `peerspeak_owned__`. Round 8 added the second after the phase-5 audit found a node property is invisible to the PipeWire registry `global` event and recoverable only by binding the node; the prefix is announced directly. A union is also the fail-closed direction: a missed tag leaks call audio into a share, a spurious one only over-excludes. Three tagging sites, all three verified live on this host: - native call playback → props on the stream dict - screenshare mpv/VLC → PULSE_PROP + PIPEWIRE_PROPS on the child - notification chimes → same, on pw-play/paplay The literals are a cross-repo wire contract, so they appear once here as named constants and are pinned in a fixture committed byte-identical in both repos (tests/fixtures/ownership-tag-contract.txt). The contract test is black-box: it builds a real child `Command` and reads back the environment it would carry, rather than testing our own formatter. Three live `#[ignore]`d exit-gate tests drive the real call sites and poll `pw-dump` for the resulting node — the plan requires the tag be shown landing on a live node, not just in the env. All three mutation-verified (drop either carrier, or the role, and the matching gate fails). Measured while verifying: mpv, VLC, pw-play and paplay all honour `node.name` from those env vars. The native stream set neither `application.name` nor a description, so a mixer fell back to `node.name` — which the tag turns into an internal identifier. Added an explicit `node.description = "PeerSpeak"` there, which keeps the plan's rule (the prefix must not reach `node.description`) while preserving its intent: mixers stay readable. Co-Authored-By: Claude Opus 5 --- src/audio/mod.rs | 4 + src/audio/ownership.rs | 380 ++++++++++++++++++++++ src/audio/pipewire_impl.rs | 75 +++++ src/notify.rs | 57 +++- src/screenshare/mod.rs | 57 +++- tests/fixtures/ownership-tag-contract.txt | 34 ++ 6 files changed, 601 insertions(+), 6 deletions(-) create mode 100644 src/audio/ownership.rs create mode 100644 tests/fixtures/ownership-tag-contract.txt diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 6bca441..9a1e9ac 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -65,6 +65,10 @@ pub mod eq; pub mod gate; pub mod limiter; pub mod multitrack; +// The cross-repo ownership tag (plan §5.1). Platform-neutral on purpose: the +// carriers only matter on PipeWire, but the literals are a wire contract and +// their test must run on every platform so a rename can't pass CI elsewhere. +pub mod ownership; pub mod pan; // Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and // pure, so it builds (and its tests run) everywhere even though only the cpal diff --git a/src/audio/ownership.rs b/src/audio/ownership.rs new file mode 100644 index 0000000..a698ba5 --- /dev/null +++ b/src/audio/ownership.rs @@ -0,0 +1,380 @@ +//! 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 treats anything other than `false`/`0` as +/// truthy (fail-closed), but we always emit exactly this. +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"; + +/// 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. +/// +/// Both environment variables are set unconditionally, because we cannot know +/// from here which audio API the child will pick (mpv chooses at runtime, and +/// VLC and `paplay` differ from `pw-play`). Setting the one the child ignores +/// costs nothing. +/// +/// Any inherited value of either variable is **replaced**, not appended: the +/// tag is a correctness mechanism, and a half-merged property string that +/// fails to parse would drop it silently. +pub fn tag_child(command: &mut Command, role: &str) { + let node_name = owned_node_name(role); + command.env(PULSE_PROP_ENV, pulse_prop_value(&node_name)); + command.env(PIPEWIRE_PROPS_ENV, pipewire_props_value(&node_name)); +} + +/// 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"); + + fn fixture() -> BTreeMap { + 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:?}")); + (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}" + ); + } + + #[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)) + ); + } +} diff --git a/src/audio/pipewire_impl.rs b/src/audio/pipewire_impl.rs index c3cd319..d5b04c0 100644 --- a/src/audio/pipewire_impl.rs +++ b/src/audio/pipewire_impl.rs @@ -1,3 +1,4 @@ +use crate::audio::ownership; use crate::audio::{AudioBackend, AudioError}; use pipewire as pw; use pw::{properties::properties, spa}; @@ -371,6 +372,11 @@ fn run_playback( mainloop_clone.quit(); }); + // Ownership tag, both carriers (`crate::audio::ownership`, plan §5.1). + // This is the node that carries the far end's voice, so it is the single + // most important thing for pixelpass to refuse to fan out: sharing it + // would send the call back to the person already speaking on it. + let owned_node_name = ownership::owned_node_name(ownership::NATIVE_PLAYBACK_ROLE); let mut props = properties! { *pw::keys::MEDIA_TYPE => "Audio", *pw::keys::MEDIA_CATEGORY => "Playback", @@ -379,6 +385,19 @@ fn run_playback( // buffer — the real fix is the explicit Buffers param below — but it // expresses the intended quantum for any node that honours it. *pw::keys::NODE_LATENCY => "1024/48000", + ownership::OWNED_PROP_KEY => ownership::OWNED_PROP_VALUE, + // Set explicitly rather than relying on the stream name passed to + // `StreamBox::new` below: props win over that name, and this one has + // to be exact. + *pw::keys::NODE_NAME => owned_node_name.as_str(), + // Measured: this stream sets neither `application.name` nor a + // description, so a mixer falls back to `node.name` — which the line + // above just turned into an internal identifier. The plan's rule is + // that the ownership prefix must not reach `node.description`; a + // human label there is what keeps that rule's *intent* (mixers stay + // readable) true for our own stream, exactly as mpv's own + // description does for the spawned players. + *pw::keys::NODE_DESCRIPTION => "PeerSpeak", }; if let Some(target) = target_node { props.insert("node.target", target); @@ -637,6 +656,62 @@ mod tests { use std::time::Duration; use std::{sync::mpsc, thread}; + /// Phase-1 exit gate, native-playback half (impl plan §3): the stream + /// that carries the far end's voice appears on the graph with **both** + /// ownership carriers, and still with the `Communication` media role. + /// + /// The third and most important of the three tagged paths — this is the + /// node whose audio, if fanned out, would send the call back to whoever + /// is speaking on it. + /// + /// Feeds silence, so the gate is inaudible. Live: needs PipeWire and + /// `pw-dump`. `cargo test --lib -- --ignored native_playback` + #[test] + #[ignore = "live: requires a running PipeWire daemon and pw-dump"] + fn native_playback_node_carries_both_ownership_carriers() { + use crate::audio::ownership::{self, live_test}; + use crate::audio::{AudioBackend, PLAYBACK_TARGET_SAMPLES}; + + let backend = super::PipeWireBackend::new(); + let (tx, rx) = mpsc::channel::>(); + let ring_fill = Arc::new(AtomicUsize::new(0)); + backend + .start_playback(rx, None, ring_fill.clone()) + .expect("playback starts"); + + // Keep the ring fed so the node stays live for the whole poll; the + // stream is created on connect, but a starved one is not a fair test + // of what a real call looks like on the graph. + let feeder = thread::spawn(move || { + let silence = vec![0i16; 960 * 2]; + for _ in 0..300 { + if ring_fill.load(Ordering::Relaxed) < PLAYBACK_TARGET_SAMPLES + && tx.send(silence.clone()).is_err() + { + return; + } + thread::sleep(Duration::from_millis(20)); + } + }); + + let prefix = live_test::expected_prefix(ownership::NATIVE_PLAYBACK_ROLE); + let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5)); + let _ = backend.stop(); + let _ = feeder.join(); + + let (name, owned) = + found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s")); + assert!( + name.starts_with(ownership::OWNED_NODE_NAME_PREFIX), + "{name}" + ); + assert_eq!( + owned.as_deref(), + Some(ownership::OWNED_PROP_VALUE), + "carrier 1 must be on the live node, not just carrier 2" + ); + } + #[test] fn requested_in_range_is_honored() { // The graph's requested quantum is produced verbatim when it fits. diff --git a/src/notify.rs b/src/notify.rs index 1d7adcd..b88021f 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -10,6 +10,8 @@ //! leaves a zombie. Any failure (no player, no audio) is silent by design — a //! missing chime should never disrupt a call. +#[cfg(not(windows))] +use crate::audio::ownership; use std::collections::HashMap; use std::fs::OpenOptions; use std::io::Write; @@ -264,12 +266,18 @@ fn escape_powershell_single_quoted(s: &str) -> String { #[cfg(not(windows))] fn spawn_player(path: &Path) { for player in ["pw-play", "paplay", "aplay"] { - let started = Command::new(player) + let mut command = Command::new(player); + command .arg(path) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); + .stderr(Stdio::null()); + // Ownership tag (plan §5.1). A chime is short, but it is still our + // audio on the default sink, and an untagged one is an unowned root + // the exclusion engine would have to reason about from scratch. + // `aplay` talks to ALSA and ignores both variables; harmless. + ownership::tag_child(&mut command, ownership::NOTIFICATION_ROLE); + let started = command.status(); // `status()` errors only if the player binary isn't present; on a real // playback error it still returns (non-zero), so a started player ends // the loop either way — we don't want to double-play through fallbacks. @@ -313,6 +321,49 @@ mod tests { dir } + /// Phase-1 exit gate, notification half (impl plan §3): a chime peerspeak + /// actually plays produces a live PipeWire node carrying **both** + /// ownership carriers. + /// + /// ⚠️ Deliberately drives `play()`, not `tag_child()`. The unit test in + /// `audio::ownership` proves the environment is built correctly; only a + /// live run proves this module *uses* it and that the audio stack honours + /// it end to end. The chime is silent (a zero-filled WAV), so running it + /// never makes noise. + /// + /// Live: needs a running PipeWire daemon, `pw-play`/`paplay` and + /// `pw-dump`. `cargo test --lib -- --ignored notification_chime` + #[test] + #[ignore = "live: requires a running PipeWire daemon and pw-dump"] + #[cfg(not(windows))] + fn notification_chime_node_carries_both_ownership_carriers() { + use crate::audio::ownership::{self, live_test}; + + let dir = temp_wav_dir("ownership"); + let path = dir.join("silence.wav"); + std::fs::write(&path, live_test::silent_wav(6)).unwrap(); + + set_enabled(true); + set_sound_enabled(Sound::PeerJoin, true); + play(Sound::PeerJoin, Some(path.to_str().unwrap())); + + let prefix = live_test::expected_prefix(ownership::NOTIFICATION_ROLE); + let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5)); + std::fs::remove_dir_all(&dir).ok(); + + let (name, owned) = + found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s")); + assert!( + name.starts_with(ownership::OWNED_NODE_NAME_PREFIX), + "{name}" + ); + assert_eq!( + owned.as_deref(), + Some(ownership::OWNED_PROP_VALUE), + "carrier 1 must be on the live node too, not just carrier 2" + ); + } + #[test] fn test_should_play_truth_table() { // Plays only when BOTH the master and the per-sound flag are on. diff --git a/src/screenshare/mod.rs b/src/screenshare/mod.rs index 2b7a247..839a6fe 100644 --- a/src/screenshare/mod.rs +++ b/src/screenshare/mod.rs @@ -21,6 +21,8 @@ use std::time::Duration; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::{Child, Command}; +use crate::audio::ownership; + pub mod livesync; use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality}; @@ -765,20 +767,69 @@ fn spawn_player(bin: &str, args: &[String], url: &str) -> std::io::Result // and is not needed to verify the flags. Logged on each attempt, so a // fallback from the preferred player to the other one is visible too. crate::log_msg(&format!("player spawn: {bin} {}", args.join(" "))); - Command::new(bin) + let mut command = Command::new(bin); + command .args(args) .arg(url) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .kill_on_drop(false) - .spawn() + .kill_on_drop(false); + // Ownership tag (plan §5.1): this player is playing the *incoming* + // screenshare's audio, so it is exactly what must not be fanned back out + // if this machine also starts sharing. The role is the player binary, so + // a `pw-dump` during a field test names which one produced the node. + ownership::tag_child(command.as_std_mut(), bin); + command.spawn() } #[cfg(test)] mod tests { use super::*; + /// Phase-1 exit gate, player half (impl plan §3): the mpv peerspeak + /// actually spawns produces a live node carrying **both** ownership + /// carriers, tagged with the player's own name as the role. + /// + /// ⚠️ Drives the real [`spawn_player`], for the same reason the notify + /// gate does: the plan requires the tag to be shown "landing on a live + /// mpv node, not just in the env". Plays a silent WAV, so it is quiet. + /// + /// Live: needs PipeWire, `mpv` and `pw-dump`. + /// `cargo test --lib -- --ignored spawned_player` + #[tokio::test] + #[ignore = "live: requires a running PipeWire daemon, mpv and pw-dump"] + async fn spawned_player_node_carries_both_ownership_carriers() { + use crate::audio::ownership::live_test; + + let dir = std::env::temp_dir().join(format!("peerspeak-playertest-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("silence.wav"); + std::fs::write(&path, live_test::silent_wav(6)).unwrap(); + + let mut child = spawn_player( + "mpv", + &["--no-video".to_string(), "--really-quiet".to_string()], + path.to_str().unwrap(), + ) + .expect("mpv spawns"); + + // The role is the player binary, so this also pins that the call site + // passes `bin` and not a fixed literal. + let prefix = live_test::expected_prefix("mpv"); + let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5)); + let _ = child.kill().await; + std::fs::remove_dir_all(&dir).ok(); + + let (name, owned) = + found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s")); + assert!( + name.starts_with(ownership::OWNED_NODE_NAME_PREFIX), + "{name}" + ); + assert_eq!(owned.as_deref(), Some(ownership::OWNED_PROP_VALUE)); + } + #[test] fn viewer_args_guard_neutralizes_flag_like_ticket() { // A malicious "ticket" that looks like a flag must end up positional, diff --git a/tests/fixtures/ownership-tag-contract.txt b/tests/fixtures/ownership-tag-contract.txt new file mode 100644 index 0000000..471c413 --- /dev/null +++ b/tests/fixtures/ownership-tag-contract.txt @@ -0,0 +1,34 @@ +# Screenshare audio exclusion — ownership tagging wire contract. +# +# peerspeak PRODUCES these carriers on every audio node it owns; pixelpass +# CONSUMES them as the primary taint root of the exclusion engine. Neither +# repo depends on the other, so this file is the contract: it is committed +# byte-identical in both, and each repo has a test that asserts its own named +# constants (and, on the producer side, the environment a real child Command +# would carry) match these values exactly. +# +# peerspeak/tests/fixtures/ownership-tag-contract.txt +# pixelpass/tests/fixtures/ownership-tag-contract.txt +# +# Pinned by peerspeak docs/screenshare-audio-exclusion-impl-plan.md §3 and +# docs/screenshare-audio-exclusion-plan.md §5.1 (v3.5). Changing a value here +# is a cross-repo breaking change: both repos must land in the same session, +# and the phase 5 matrix must be re-run. +# +# Two carriers, matched as a UNION — a node is peerspeak-owned if EITHER +# matches. Round 8 added the second because a property is invisible to the +# PipeWire registry `global` event and readable only via a node bind, so the +# primary taint root must not rest on one observation mechanism alone. + +# Carrier 1 — a node property. The consumer treats any value other than +# "false"/"0" as truthy, which is the fail-closed direction; the producer +# always emits exactly this value. +prop_key=peerspeak.owned +prop_value=1 + +# Carrier 2 — a `node.name` prefix, announced by the registry without a bind. +# `node.description` is deliberately NOT touched, so mixers still show "mpv". +# Only the prefix is matched; the rest of the name is for diagnostics. +node_name_prefix=peerspeak_owned_ +node_name_format=peerspeak_owned__ +node_name_example=peerspeak_owned_mpv_31284