host/taint: match peerspeak's second ownership carrier
The consumer half of phase 1 (plan §5.1, impl plan §3). The engine's tag root becomes a union: `peerspeak.owned` truthy OR `node.name` starting with `peerspeak_owned_`. Round 8 added the second carrier because a node property is invisible to the registry `global` event and recoverable only by binding the node — which is exactly how the phase-5 gate failed — while `node.name` is announced directly. The union lives in `local_root_reason`, not in the adapter. Folding both into the one `peerspeak_owned` bool at the observation boundary would make each carrier untestable alone, which is the phase-3r lesson: a gate asserting a value two sources can satisfy gates neither. The existing `peerspeak_tagged_nodes_…` fixture now carries both carriers, so it would keep passing if either were deleted; two new tests pin them individually, and a third pins that the prefix matches only at the start of a name. Both literals are now named constants — they are a cross-repo wire contract with peerspeak, not local naming — and asserted against tests/fixtures/ownership-tag-contract.txt, committed byte-identical in both repos. That test also runs the fixture's own worked example name through the engine, so the shared file cannot document a value this side does not actually exclude. Five mutations verified: drop either carrier, loosen `starts_with` to `contains`, or rename either constant, and exactly the intended test fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+16
-4
@@ -22,9 +22,17 @@ use super::metrics::{BUCKET_LABELS, Metrics, QUEUE_THRESHOLD_US, Sample};
|
||||
use super::*;
|
||||
use crate::host::aec::AecConfig;
|
||||
use crate::host::observer::{EventKind, Readiness};
|
||||
use crate::host::taint::PEERSPEAK_OWNED_NODE_PREFIX;
|
||||
use crate::host::taint::fixture::{self, Graph, NodeRef};
|
||||
use crate::host::taint::snapshot::{GraphSnapshot, MediaRole};
|
||||
|
||||
/// The `node.name` a [`Graph::peerspeak_node`] fixture produces. Built from
|
||||
/// the same constant the engine matches on, so these audit rows report the
|
||||
/// name shape a live peerspeak node actually has (v3.5 §5.1, carrier 2).
|
||||
fn owned_name(role: &str, pid: u32) -> String {
|
||||
format!("{PEERSPEAK_OWNED_NODE_PREFIX}{role}_{pid}")
|
||||
}
|
||||
|
||||
const AEC_MODULE: u64 = 7;
|
||||
const TIMEOUT: Millis = 5_000;
|
||||
|
||||
@@ -284,13 +292,14 @@ fn a_shut_gate_preserves_the_engines_own_reasons() {
|
||||
.body;
|
||||
let (_, excluded) = partition(&body);
|
||||
|
||||
let playback = owned_name("peerspeak-playback", 200);
|
||||
assert!(!body.fan_out_permitted);
|
||||
assert_eq!(
|
||||
excluded,
|
||||
vec![
|
||||
("music", "aec-validating"),
|
||||
// Tagged, so it keeps the reason that actually applies to it.
|
||||
("peerspeak-playback", "peerspeak-owned"),
|
||||
(playback.as_str(), "peerspeak-owned"),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -457,11 +466,12 @@ fn row_1_owner_bridge_forwarder_with_an_untainted_control() {
|
||||
.body;
|
||||
let (eligible, excluded) = partition(&body);
|
||||
|
||||
let call_name = owned_name("peerspeak-call", 200);
|
||||
assert_eq!(eligible, vec!["clean-loopback-playback"]);
|
||||
assert_eq!(
|
||||
excluded,
|
||||
vec![
|
||||
("peerspeak-call", "peerspeak-owned"),
|
||||
(call_name.as_str(), "peerspeak-owned"),
|
||||
("tainted-loopback-playback", "tainted-owner-bridge"),
|
||||
]
|
||||
);
|
||||
@@ -494,11 +504,12 @@ fn row_3_one_tainted_module_does_not_taint_the_other() {
|
||||
.body;
|
||||
let (eligible, excluded) = partition(&body);
|
||||
|
||||
let call_name = owned_name("peerspeak-call", 200);
|
||||
assert_eq!(eligible, vec!["module-b-playback"]);
|
||||
assert_eq!(
|
||||
excluded,
|
||||
vec![
|
||||
("peerspeak-call", "peerspeak-owned"),
|
||||
(call_name.as_str(), "peerspeak-owned"),
|
||||
("module-a-playback", "tainted-owner-bridge"),
|
||||
]
|
||||
);
|
||||
@@ -701,7 +712,8 @@ fn the_taint_view_covers_non_candidate_roles() {
|
||||
tainted.contains(&("null-sink", "tainted-upstream")),
|
||||
"the sink is not a candidate but its taint is what explains the row: {tainted:?}"
|
||||
);
|
||||
assert!(tainted.contains(&("peerspeak-call", "peerspeak-owned")));
|
||||
let call_name = owned_name("peerspeak-call", 200);
|
||||
assert!(tainted.contains(&(call_name.as_str(), "peerspeak-owned")));
|
||||
}
|
||||
|
||||
/// A record must serialise to a single line. Newlines inside a JSON Lines
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::{
|
||||
EventKind, LinkEndpoints, NodeObservation, Outcome, Projection, RegEvent, RegistryModel,
|
||||
};
|
||||
use crate::host::audio::parse_object_serial;
|
||||
use crate::host::taint::PEERSPEAK_OWNED_PROP;
|
||||
use crate::host::taint::snapshot::{
|
||||
ClientSnapshot, GlobalId, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
|
||||
};
|
||||
@@ -657,7 +658,11 @@ fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObs
|
||||
name: props.get("node.name").map(str::to_string),
|
||||
role: MediaRole::parse(props.get("media.class")),
|
||||
props: NodeProps {
|
||||
peerspeak_owned: truthy(props.get("peerspeak.owned")),
|
||||
// Carrier 1 only. Carrier 2 (the `node.name` prefix) is matched
|
||||
// in the engine off `NodeObservation::name` above, so each
|
||||
// carrier stays independently testable — see
|
||||
// [`crate::host::taint::PEERSPEAK_OWNED_NODE_PREFIX`].
|
||||
peerspeak_owned: truthy(props.get(PEERSPEAK_OWNED_PROP)),
|
||||
pulse_module_id: props
|
||||
.get("pulse.module.id")
|
||||
.and_then(|value| value.parse::<u64>().ok()),
|
||||
|
||||
@@ -152,11 +152,33 @@ impl Graph {
|
||||
self.node(name, role, app(client, pid))
|
||||
}
|
||||
|
||||
/// A peerspeak-owned node carrying **both** ownership carriers, as a
|
||||
/// live one does. `name` gets the real `node.name` prefix so the fixture
|
||||
/// cannot pass on the property alone.
|
||||
pub fn peerspeak_node(&mut self, name: &str, pid: u32) -> NodeRef {
|
||||
let client = self.client_of_app(pid);
|
||||
let name = format!("{}{name}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX);
|
||||
self.node(&name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
|
||||
}
|
||||
|
||||
/// Carrier 1 alone: the `peerspeak.owned` property present, the
|
||||
/// `node.name` prefix absent. What the engine sees for a node it had to
|
||||
/// bind to observe (v3.5 §6.7).
|
||||
pub fn peerspeak_node_prop_only(&mut self, name: &str, pid: u32) -> NodeRef {
|
||||
let client = self.client_of_app(pid);
|
||||
self.node(name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
|
||||
}
|
||||
|
||||
/// Carrier 2 alone: the `node.name` prefix present, the property absent
|
||||
/// — indistinguishable from an ordinary app in every other respect.
|
||||
/// This is the case that survives the F1 observation defect, and the
|
||||
/// reason round 8 added a second carrier at all.
|
||||
pub fn peerspeak_node_name_only(&mut self, role: &str, pid: u32) -> NodeRef {
|
||||
let client = self.client_of_app(pid);
|
||||
let name = format!("{}{role}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX);
|
||||
self.node(&name, MediaRole::StreamOutput, app(client, pid))
|
||||
}
|
||||
|
||||
pub fn node(&mut self, name: &str, role: MediaRole, props: NodeProps) -> NodeRef {
|
||||
let id = self.id();
|
||||
self.node_with_id(name, role, id, props)
|
||||
|
||||
+34
-1
@@ -123,6 +123,29 @@ pub const CAPTURE_SINK_PREFIX: &str = "pixelpass_capture_";
|
||||
/// what `pulse.module.id` is for (v3.4 §5.2 correction 4).
|
||||
pub const ECHO_CANCEL_GROUP_PREFIX: &str = "echo-cancel-";
|
||||
|
||||
/// Ownership carrier 1: the node property peerspeak sets on everything it
|
||||
/// plays (v3.5 §5.1). Read at the observer boundary, which is the only place
|
||||
/// that touches raw property names — see [`super::observer`].
|
||||
///
|
||||
/// ⚠️ **Cross-repo wire contract.** peerspeak emits this; it does not depend
|
||||
/// on this crate, nor this crate on it. The values are pinned in
|
||||
/// `tests/fixtures/ownership-tag-contract.txt`, committed byte-identical in
|
||||
/// both repos, and asserted by [`tests::ownership_carriers_match_the_cross_repo_fixture`].
|
||||
/// The producer's matching constants live in peerspeak
|
||||
/// `src/audio/ownership.rs`. Changing either is a both-repos-same-session
|
||||
/// change that invalidates the phase 5 matrix.
|
||||
pub const PEERSPEAK_OWNED_PROP: &str = "peerspeak.owned";
|
||||
|
||||
/// Ownership carrier 2: a `node.name` prefix (v3.5 §5.1, round 8).
|
||||
///
|
||||
/// Matched as a **union** with [`PEERSPEAK_OWNED_PROP`] — either one makes a
|
||||
/// node peerspeak-owned. Two carriers because a property is invisible to the
|
||||
/// registry `global` event and recoverable only by binding the node (v3.5
|
||||
/// §6.7), which is precisely how the phase-5 gate failed; this one is
|
||||
/// announced directly. A union is also the fail-closed direction: a missed
|
||||
/// tag leaks call audio into the share, a spurious one only over-excludes.
|
||||
pub const PEERSPEAK_OWNED_NODE_PREFIX: &str = "peerspeak_owned_";
|
||||
|
||||
/// Why a node is tainted or excluded. Stable machine-readable codes: this
|
||||
/// value is the phase 5 audit output, the phase 6 status event, and the
|
||||
/// eventual answer to "why isn't this app being shared?".
|
||||
@@ -535,7 +558,17 @@ fn ambiguous_id_nodes(snapshot: &GraphSnapshot) -> BTreeSet<Serial> {
|
||||
}
|
||||
|
||||
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
|
||||
if node.props.peerspeak_owned {
|
||||
// The two ownership carriers, as a union (v3.5 §5.1). Kept here rather
|
||||
// than folded together at the observer boundary so that the union is a
|
||||
// pure, directly-testable rule: an adapter that collapsed both into the
|
||||
// one `peerspeak_owned` bool would make each carrier untestable alone,
|
||||
// which is exactly how phase 3r's row 1 nearly gated nothing.
|
||||
if node.props.peerspeak_owned
|
||||
|| node
|
||||
.name
|
||||
.as_deref()
|
||||
.is_some_and(|name| name.starts_with(PEERSPEAK_OWNED_NODE_PREFIX))
|
||||
{
|
||||
return Some(Reason::PeerspeakOwned);
|
||||
}
|
||||
if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id)
|
||||
|
||||
@@ -99,6 +99,12 @@ impl MediaRole {
|
||||
pub struct NodeProps {
|
||||
/// `peerspeak.owned` is present and truthy (v3.4 §5.1). A correctness
|
||||
/// mechanism, explicitly *not* a security boundary.
|
||||
///
|
||||
/// ⚠️ **Ownership carrier 1 of 2, so this being `false` does not mean
|
||||
/// "not peerspeak's".** Carrier 2 is the [`NodeSnapshot::name`] prefix
|
||||
/// [`super::PEERSPEAK_OWNED_NODE_PREFIX`], matched as a union in
|
||||
/// `local_root_reason`. Read that function, not this field, to answer
|
||||
/// "is this node owned?".
|
||||
pub peerspeak_owned: bool,
|
||||
/// `pulse.module.id`, parsed as `u64` — never `u32`, per v3.4 §5.2's
|
||||
/// parse-defensively note and the phase 0a truncation bug.
|
||||
|
||||
@@ -176,6 +176,104 @@ fn peerspeak_tagged_nodes_are_excluded_and_plain_apps_are_not() {
|
||||
assert_tainted(&decisions, sink, "tainted-upstream");
|
||||
}
|
||||
|
||||
/// Each ownership carrier must work **alone** (v3.5 §5.1).
|
||||
///
|
||||
/// ⚠️ The phase-3r lesson, applied deliberately: a gate that asserts a value
|
||||
/// two sources can satisfy gates neither. `peerspeak_tagged_nodes_…` above
|
||||
/// uses nodes carrying both carriers, so it would keep passing if either
|
||||
/// were deleted. These are the rows that actually pin them.
|
||||
#[test]
|
||||
fn either_ownership_carrier_alone_taints_the_node() {
|
||||
let mut graph = Graph::new();
|
||||
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||
// Carrier 1: the property, on a node whose name says nothing.
|
||||
let prop_only = graph.peerspeak_node_prop_only("some-playback-stream", 7);
|
||||
// Carrier 2: the name prefix, property absent — the F1 case.
|
||||
let name_only = graph.peerspeak_node_name_only("mpv", 31_284);
|
||||
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
|
||||
for node in [prop_only, name_only, firefox] {
|
||||
graph.link(node, sink);
|
||||
}
|
||||
|
||||
assert_partition(
|
||||
&run(&graph, &ctx()),
|
||||
&[("firefox", firefox)],
|
||||
&[
|
||||
("prop_only", prop_only, "peerspeak-owned"),
|
||||
("name_only", name_only, "peerspeak-owned"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The prefix is a **prefix**, not a substring: an unrelated app must not be
|
||||
/// excluded because the literal appears somewhere in its name. Over-exclusion
|
||||
/// is the safe direction, but it is still wrong, and the phase-5 gate now
|
||||
/// asserts exact partitions in both halves.
|
||||
#[test]
|
||||
fn the_owned_prefix_matches_only_at_the_start_of_node_name() {
|
||||
let mut graph = Graph::new();
|
||||
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||
let impostor = graph.app_node(
|
||||
&format!("recorder-of-{}stuff", super::PEERSPEAK_OWNED_NODE_PREFIX),
|
||||
MediaRole::StreamOutput,
|
||||
11_114,
|
||||
);
|
||||
graph.link(impostor, sink);
|
||||
|
||||
assert_partition(&run(&graph, &ctx()), &[("impostor", impostor)], &[]);
|
||||
}
|
||||
|
||||
/// The consumer half of the cross-repo contract test (impl plan §3
|
||||
/// requirement 2). peerspeak runs the mirror of this against a byte-identical
|
||||
/// copy of the same file, and asserts the environment a real child `Command`
|
||||
/// would carry produces exactly these literals.
|
||||
///
|
||||
/// This proves the two repos agree on the *literals*. That pixelpass actually
|
||||
/// *listens* is proven by the two carrier tests above, and against the live
|
||||
/// graph by the phase 5 dry-run.
|
||||
#[test]
|
||||
fn ownership_carriers_match_the_cross_repo_fixture() {
|
||||
const FIXTURE: &str = include_str!("../../../tests/fixtures/ownership-tag-contract.txt");
|
||||
|
||||
let pinned: Vec<(&str, &str)> = FIXTURE
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.map(|line| line.split_once('=').expect("fixture line is key=value"))
|
||||
.collect();
|
||||
let get = |key: &str| -> &str {
|
||||
pinned
|
||||
.iter()
|
||||
.find(|(k, _)| *k == key)
|
||||
.unwrap_or_else(|| panic!("fixture has no key {key:?}"))
|
||||
.1
|
||||
};
|
||||
|
||||
assert_eq!(super::PEERSPEAK_OWNED_PROP, get("prop_key"));
|
||||
assert_eq!(super::PEERSPEAK_OWNED_NODE_PREFIX, get("node_name_prefix"));
|
||||
|
||||
// The value the producer pins must be one this consumer reads as truthy.
|
||||
// `truthy` lives at the observer boundary; assert the property of it that
|
||||
// matters here rather than reaching across modules for the function.
|
||||
let value = get("prop_value");
|
||||
assert!(
|
||||
value != "false" && value != "0",
|
||||
"pinned prop value {value:?} would read as untruthy"
|
||||
);
|
||||
|
||||
// And the fixture's own worked example must be one this engine excludes,
|
||||
// through carrier 2, exactly as written in the shared file.
|
||||
let mut graph = Graph::new();
|
||||
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||
let example = graph.app_node(get("node_name_example"), MediaRole::StreamOutput, 31_284);
|
||||
graph.link(example, sink);
|
||||
assert_partition(
|
||||
&run(&graph, &ctx()),
|
||||
&[],
|
||||
&[("example", example, "peerspeak-owned")],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aec_identity_is_exact_equality_and_other_modules_stay_eligible() {
|
||||
let mut graph = Graph::new();
|
||||
|
||||
+34
@@ -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_<role>_<pid>
|
||||
node_name_example=peerspeak_owned_mpv_31284
|
||||
Reference in New Issue
Block a user