diff --git a/src/host/audit/tests.rs b/src/host/audit/tests.rs index ce254df..473a6b3 100644 --- a/src/host/audit/tests.rs +++ b/src/host/audit/tests.rs @@ -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 diff --git a/src/host/observer/adapter.rs b/src/host/observer/adapter.rs index f0e31ee..ef6302e 100644 --- a/src/host/observer/adapter.rs +++ b/src/host/observer/adapter.rs @@ -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::().ok()), diff --git a/src/host/taint/fixture.rs b/src/host/taint/fixture.rs index ec3f2bd..4ae652f 100644 --- a/src/host/taint/fixture.rs +++ b/src/host/taint/fixture.rs @@ -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) diff --git a/src/host/taint/mod.rs b/src/host/taint/mod.rs index 9ee8b9c..4c4e9b4 100644 --- a/src/host/taint/mod.rs +++ b/src/host/taint/mod.rs @@ -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 { } fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option { - 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) diff --git a/src/host/taint/snapshot.rs b/src/host/taint/snapshot.rs index 88c3417..29b02f5 100644 --- a/src/host/taint/snapshot.rs +++ b/src/host/taint/snapshot.rs @@ -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. diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs index d6bde50..0c4faa2 100644 --- a/src/host/taint/tests.rs +++ b/src/host/taint/tests.rs @@ -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(); diff --git a/tests/fixtures/ownership-tag-contract.txt b/tests/fixtures/ownership-tag-contract.txt new file mode 100644 index 0000000..471c413 --- /dev/null +++ b/tests/fixtures/ownership-tag-contract.txt @@ -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__ +node_name_example=peerspeak_owned_mpv_31284