Merge phase 1: ownership tagging (SPA-JSON carriers via libspa)

peerspeak-side half of phase 1 of the screenshare audio-exclusion work: every
node peerspeak owns carries two registry-visible ownership carriers, so the
taint engine has a primary root that survives the registry's filtered global
event (design v3.5 section 6.7).

Reviewed by Codex over rounds 10-12; all findings verified and dispositioned.
Round 12's F12-1 (rfind('}') spliced carriers inside a trailing comment, a
fail-open) and F12-2 (depth ceiling taken from the consumer,
pw_properties_update_string, not from the spa-json-dump grammar) are fixed and
live-verified through the real ALSA plugin.

623 lib tests green, fmt clean, clippy clean.
This commit is contained in:
2026-07-26 02:14:25 -04:00
8 changed files with 1869 additions and 6 deletions
+50
View File
@@ -328,4 +328,54 @@ mod tests {
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
assert_eq!(seek_target(2.0, total), total);
}
/// **The fourth playback path's exit gate (round 10, R10-2).** Drives a
/// real [`ClipPlayer`] — the same object the app uses for chat clips, peer
/// music and the local playlist — and asserts the node it puts on the
/// graph carries both ownership carriers.
///
/// This path was untagged through all of phase 1, which is a real echo:
/// B broadcasts music, A tunes in, A shares their desktop, B hears their
/// own track. It was missed because phase 1 worked from the impl plan's
/// list of three playback sites and that list was incomplete — so this
/// gate drives the *player*, not the tagging helper.
///
/// ⚠️ **Run alone**: it sets a process-wide environment variable, which is
/// only sound single-threaded. In production `main` does this before
/// anything is spawned; a test binary has no such guarantee, hence
/// `--test-threads=1`.
///
/// `cargo test --lib -- --ignored --test-threads=1 clip_player_node`
#[test]
#[ignore = "live: requires a running PipeWire daemon and pw-dump; run with --test-threads=1"]
fn clip_player_node_carries_both_ownership_carriers() {
use crate::audio::ownership::{self, live_test};
// SAFETY: `--test-threads=1` is documented above and in the ignore
// reason; this is the same call `main` makes, exercised for real
// rather than reimplemented, so the gate cannot pass against a
// formatter that production never uses.
unsafe { ownership::tag_this_process_alsa_audio() };
let (player, _status) = ClipPlayer::new(1.0);
// Six seconds of silence: long enough for the poll, inaudible.
player.play([0u8; 32], live_test::silent_wav(6));
let prefix = live_test::expected_prefix(ownership::CLIP_ROLE);
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
player.stop();
let (name, owned) = found.unwrap_or_else(|| {
panic!("no live clip-player 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"
);
}
}
+4
View File
@@ -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
File diff suppressed because it is too large Load Diff
+75
View File
@@ -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::<Vec<i16>>();
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.
+16
View File
@@ -3,6 +3,22 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// Tag the audio we play through ALSA (rodio's `ClipPlayer`: chat clips,
// peer music, local playlist tracks) so the screen-share exclusion engine
// can recognise it as ours and refuse to fan it back to the far end.
//
// First statement in the program, and that is load-bearing: this sets an
// environment variable, which is only sound while the process is still
// single-threaded, and PipeWire's ALSA plugin reads it when a stream is
// opened. See `audio::ownership::tag_this_process_alsa_audio`.
//
// SAFETY: nothing has been spawned yet, so no thread can be reading the
// environment concurrently.
#[cfg(target_os = "linux")]
unsafe {
peerspeak::audio::ownership::tag_this_process_alsa_audio()
};
if let Err(e) = peerspeak::app::run_gui() {
eprintln!("Error running GUI: {:?}", e);
}
+56 -3
View File
@@ -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,20 @@ 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.
// Measured on this host: all three fallbacks tag correctly, `aplay`
// included — it reaches the graph through PipeWire's ALSA plugin,
// which honours `PIPEWIRE_PROPS` like any other client.
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 +323,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.
+54 -3
View File
@@ -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<Child>
// 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,
+42
View File
@@ -0,0 +1,42 @@
# Screenshare audio exclusion — ownership tagging wire contract.
#
# peerspeak PRODUCES these carriers on every audio node it owns; pixelpass
# CONSUMES them as the primary taint root of the exclusion engine. Neither
# repo depends on the other, so this file is the contract: it is committed
# byte-identical in both, and each repo has a test that asserts its own named
# constants (and, on the producer side, the environment a real child Command
# would carry) match these values exactly.
#
# peerspeak/tests/fixtures/ownership-tag-contract.txt
# pixelpass/tests/fixtures/ownership-tag-contract.txt
#
# Pinned by peerspeak docs/screenshare-audio-exclusion-impl-plan.md §3 and
# docs/screenshare-audio-exclusion-plan.md §5.1 (v3.5). Changing a value here
# is a cross-repo breaking change: both repos must land in the same session,
# and the phase 5 matrix must be re-run.
#
# Two carriers, matched as a UNION — a node is peerspeak-owned if EITHER
# matches. Round 8 added the second because a property is invisible to the
# PipeWire registry `global` event and readable only via a node bind, so the
# primary taint root must not rest on one observation mechanism alone.
# Carrier 1 — a node property, matched EXACTLY: `prop_value` below is the
# ONLY spelling the consumer reads as owned. A producer emitting "true", "yes"
# or "" is NOT owned on this carrier, and only carrier 2 would still catch it.
#
# ⚠️ This wording is load-bearing and it CHANGED in round 10. The consumer
# used to accept any value other than "false"/"0", on the theory that leniency
# over-excludes and is therefore safe. It is not: leniency buys false-positive
# exclusion, and it let any process suppress a rival application's audio from
# the share with a property it did not even have to spell right. Fail-closed
# on this feature is about ANCESTRY — an unresolvable graph is not eligible —
# not about parsing.
prop_key=peerspeak.owned
prop_value=1
# Carrier 2 — a `node.name` prefix, announced by the registry without a bind.
# `node.description` is deliberately NOT touched, so mixers still show "mpv".
# Only the prefix is matched; the rest of the name is for diagnostics.
node_name_prefix=peerspeak_owned_
node_name_format=peerspeak_owned_<role>_<pid>
node_name_example=peerspeak_owned_mpv_31284