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(-1.0, total), Duration::ZERO);
|
||||||
assert_eq!(seek_target(2.0, total), total);
|
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 gate;
|
||||||
pub mod limiter;
|
pub mod limiter;
|
||||||
pub mod multitrack;
|
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;
|
pub mod pan;
|
||||||
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
// 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
|
// 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 crate::audio::{AudioBackend, AudioError};
|
||||||
use pipewire as pw;
|
use pipewire as pw;
|
||||||
use pw::{properties::properties, spa};
|
use pw::{properties::properties, spa};
|
||||||
@@ -371,6 +372,11 @@ fn run_playback(
|
|||||||
mainloop_clone.quit();
|
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! {
|
let mut props = properties! {
|
||||||
*pw::keys::MEDIA_TYPE => "Audio",
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||||
@@ -379,6 +385,19 @@ fn run_playback(
|
|||||||
// buffer — the real fix is the explicit Buffers param below — but it
|
// buffer — the real fix is the explicit Buffers param below — but it
|
||||||
// expresses the intended quantum for any node that honours it.
|
// expresses the intended quantum for any node that honours it.
|
||||||
*pw::keys::NODE_LATENCY => "1024/48000",
|
*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 {
|
if let Some(target) = target_node {
|
||||||
props.insert("node.target", target);
|
props.insert("node.target", target);
|
||||||
@@ -637,6 +656,62 @@ mod tests {
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::{sync::mpsc, thread};
|
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]
|
#[test]
|
||||||
fn requested_in_range_is_honored() {
|
fn requested_in_range_is_honored() {
|
||||||
// The graph's requested quantum is produced verbatim when it fits.
|
// The graph's requested quantum is produced verbatim when it fits.
|
||||||
|
|||||||
+16
@@ -3,6 +3,22 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
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() {
|
if let Err(e) = peerspeak::app::run_gui() {
|
||||||
eprintln!("Error running GUI: {:?}", e);
|
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
|
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
||||||
//! missing chime should never disrupt a call.
|
//! missing chime should never disrupt a call.
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
use crate::audio::ownership;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
@@ -264,12 +266,20 @@ fn escape_powershell_single_quoted(s: &str) -> String {
|
|||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
fn spawn_player(path: &Path) {
|
fn spawn_player(path: &Path) {
|
||||||
for player in ["pw-play", "paplay", "aplay"] {
|
for player in ["pw-play", "paplay", "aplay"] {
|
||||||
let started = Command::new(player)
|
let mut command = Command::new(player);
|
||||||
|
command
|
||||||
.arg(path)
|
.arg(path)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null());
|
||||||
.status();
|
// 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
|
// `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
|
// 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.
|
// the loop either way — we don't want to double-play through fallbacks.
|
||||||
@@ -313,6 +323,49 @@ mod tests {
|
|||||||
dir
|
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]
|
#[test]
|
||||||
fn test_should_play_truth_table() {
|
fn test_should_play_truth_table() {
|
||||||
// Plays only when BOTH the master and the per-sound flag are on.
|
// 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::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
|
|
||||||
|
use crate::audio::ownership;
|
||||||
|
|
||||||
pub mod livesync;
|
pub mod livesync;
|
||||||
|
|
||||||
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
|
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
|
// 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.
|
// fallback from the preferred player to the other one is visible too.
|
||||||
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
|
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
|
||||||
Command::new(bin)
|
let mut command = Command::new(bin);
|
||||||
|
command
|
||||||
.args(args)
|
.args(args)
|
||||||
.arg(url)
|
.arg(url)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.kill_on_drop(false)
|
.kill_on_drop(false);
|
||||||
.spawn()
|
// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn viewer_args_guard_neutralizes_flag_like_ticket() {
|
fn viewer_args_guard_neutralizes_flag_like_ticket() {
|
||||||
// A malicious "ticket" that looks like a flag must end up positional,
|
// 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