Files
peerspeak/src/audio/ownership.rs
T
molluskandClaude Opus 5 bcf1343a55 phase 1: tag every audio node peerspeak owns, on both carriers
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_<role>_<pid>`. 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 <noreply@anthropic.com>
2026-07-25 19:19:25 -04:00

381 lines
15 KiB
Rust

//! 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_<role>_<pid>`.
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<String>)> {
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<String>)> {
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<u8> {
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<String, String> {
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_<role>_<pid>");
// 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::<u32>().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<String, String> = 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))
);
}
}