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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user