Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8b8b78b09 | ||
|
|
289016d071 | ||
|
|
4b2b192601 | ||
|
|
993befdedd | ||
|
|
abaf5d9c10 | ||
|
|
67f4ff931e | ||
|
|
295a575b15 | ||
|
|
bf5f2508b8 | ||
|
|
45ca5057f8 | ||
|
|
8b41f64e12 | ||
|
|
1b01847c66 | ||
|
|
0af0124e17 | ||
|
|
e34289fdb5 | ||
|
|
471b8221ff | ||
|
|
64f98990c8 | ||
|
|
306b601490 | ||
|
|
b3d71724ae | ||
|
|
a1ac7ea8d5 |
@@ -198,6 +198,17 @@ pub struct TaintRow {
|
|||||||
pub sticky: bool,
|
pub sticky: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A node carrying a peerspeak ownership carrier on a role the engine does not
|
||||||
|
/// honour it on (round 10, R10-1). `role` is the point of the row: it says
|
||||||
|
/// which non-producer role the tag turned up on, which is what distinguishes a
|
||||||
|
/// producer-side bug from an impersonation attempt.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct IgnoredTagRow {
|
||||||
|
pub serial: u64,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub role: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
/// The decision content of one recompute — everything except which recompute it
|
/// The decision content of one recompute — everything except which recompute it
|
||||||
/// was. Split out from [`AuditRecord`] so "did anything actually change?" is a
|
/// was. Split out from [`AuditRecord`] so "did anything actually change?" is a
|
||||||
/// derived `==` rather than a hand-maintained field comparison that a later
|
/// derived `==` rather than a hand-maintained field comparison that a later
|
||||||
@@ -227,6 +238,16 @@ pub struct AuditBody {
|
|||||||
pub excluded_count: usize,
|
pub excluded_count: usize,
|
||||||
/// Taint across all node roles, ascending by serial.
|
/// Taint across all node roles, ascending by serial.
|
||||||
pub taint: Vec<TaintRow>,
|
pub taint: Vec<TaintRow>,
|
||||||
|
/// Nodes carrying a peerspeak ownership carrier that the engine
|
||||||
|
/// **ignored** because they are not `Stream/Output/Audio` (round 10,
|
||||||
|
/// R10-1). Normally empty; a non-empty list means either peerspeak is
|
||||||
|
/// tagging something it should not, or a process is impersonating the
|
||||||
|
/// tag. Neither is an exclusion, and neither should be silent.
|
||||||
|
///
|
||||||
|
/// Omitted from the JSONL when empty, so it costs nothing on the common
|
||||||
|
/// path and is impossible to miss when it is not.
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub ignored_ownership_tags: Vec<IgnoredTagRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuditBody {
|
impl AuditBody {
|
||||||
@@ -433,6 +454,16 @@ fn build_body(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let ignored_ownership_tags: Vec<IgnoredTagRow> =
|
||||||
|
crate::host::taint::misplaced_ownership_tags(&projection.snapshot)
|
||||||
|
.into_iter()
|
||||||
|
.map(|node| IgnoredTagRow {
|
||||||
|
serial: node.serial.0,
|
||||||
|
name: node.name.clone(),
|
||||||
|
role: node.role.code(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
AuditBody {
|
AuditBody {
|
||||||
graph_ready: projection.graph_ready,
|
graph_ready: projection.graph_ready,
|
||||||
epoch: readiness_code(projection.readiness),
|
epoch: readiness_code(projection.readiness),
|
||||||
@@ -444,6 +475,7 @@ fn build_body(
|
|||||||
eligible_count,
|
eligible_count,
|
||||||
candidates,
|
candidates,
|
||||||
taint,
|
taint,
|
||||||
|
ignored_ownership_tags,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+68
-4
@@ -22,9 +22,17 @@ use super::metrics::{BUCKET_LABELS, Metrics, QUEUE_THRESHOLD_US, Sample};
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::host::aec::AecConfig;
|
use crate::host::aec::AecConfig;
|
||||||
use crate::host::observer::{EventKind, Readiness};
|
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::fixture::{self, Graph, NodeRef};
|
||||||
use crate::host::taint::snapshot::{GraphSnapshot, MediaRole};
|
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 AEC_MODULE: u64 = 7;
|
||||||
const TIMEOUT: Millis = 5_000;
|
const TIMEOUT: Millis = 5_000;
|
||||||
|
|
||||||
@@ -117,6 +125,58 @@ fn the_record_carries_the_complete_candidate_universe() {
|
|||||||
assert_eq!(outcome.record.body.excluded_count, 0);
|
assert_eq!(outcome.record.body.excluded_count, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **R10-1's diagnostic reaches the record.** The engine deliberately ignores
|
||||||
|
/// an ownership carrier on a non-producer, which means the fix removes an
|
||||||
|
/// exclusion — so the only way an operator learns a tag was seen and dropped is
|
||||||
|
/// this field. A matrix row that silently grew an impostor would otherwise read
|
||||||
|
/// as a clean pass.
|
||||||
|
#[test]
|
||||||
|
fn an_ignored_ownership_tag_is_reported_without_excluding_anything() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||||
|
let impostor = graph.peerspeak_tagged_node("rogue", MediaRole::StreamInput, 4_242);
|
||||||
|
let projection = ready(graph.build());
|
||||||
|
|
||||||
|
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
||||||
|
|
||||||
|
// The bystander is untouched — the point of the fix.
|
||||||
|
let (eligible, excluded) = partition(&body);
|
||||||
|
assert_eq!(eligible, vec!["music"]);
|
||||||
|
assert!(excluded.is_empty(), "unexpected exclusions: {excluded:?}");
|
||||||
|
assert!(body.taint.is_empty(), "unexpected taint: {:?}", body.taint);
|
||||||
|
|
||||||
|
// ...but the tag is not silent, and the row names the role it appeared on.
|
||||||
|
assert_eq!(body.ignored_ownership_tags.len(), 1);
|
||||||
|
let row = &body.ignored_ownership_tags[0];
|
||||||
|
assert_eq!(row.serial, impostor.serial.0);
|
||||||
|
assert_eq!(row.role, "stream-input");
|
||||||
|
assert_eq!(
|
||||||
|
row.name.as_deref(),
|
||||||
|
Some(owned_name("rogue", 4_242).as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The common path stays quiet: a correctly tagged peerspeak producer is
|
||||||
|
/// honoured as a taint root and is *not* reported as a misplaced tag. Without
|
||||||
|
/// this, a diagnostic that fired on every normal run would be worthless.
|
||||||
|
#[test]
|
||||||
|
fn a_correctly_tagged_producer_is_not_reported_as_misplaced() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let sink = graph.device_node("speakers", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("call", 200);
|
||||||
|
graph.link(call, sink);
|
||||||
|
let projection = ready(graph.build());
|
||||||
|
|
||||||
|
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
||||||
|
|
||||||
|
assert_eq!(body.excluded_count, 1);
|
||||||
|
assert!(
|
||||||
|
body.ignored_ownership_tags.is_empty(),
|
||||||
|
"honoured tag reported as misplaced: {:?}",
|
||||||
|
body.ignored_ownership_tags
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The fail-closed default asserted at the boundary (impl plan §4, phase 2's
|
/// The fail-closed default asserted at the boundary (impl plan §4, phase 2's
|
||||||
/// "one addition"): nothing in, nothing eligible — and, just as importantly, no
|
/// "one addition"): nothing in, nothing eligible — and, just as importantly, no
|
||||||
/// panic and no invented row.
|
/// panic and no invented row.
|
||||||
@@ -284,13 +344,14 @@ fn a_shut_gate_preserves_the_engines_own_reasons() {
|
|||||||
.body;
|
.body;
|
||||||
let (_, excluded) = partition(&body);
|
let (_, excluded) = partition(&body);
|
||||||
|
|
||||||
|
let playback = owned_name("peerspeak-playback", 200);
|
||||||
assert!(!body.fan_out_permitted);
|
assert!(!body.fan_out_permitted);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
excluded,
|
excluded,
|
||||||
vec![
|
vec![
|
||||||
("music", "aec-validating"),
|
("music", "aec-validating"),
|
||||||
// Tagged, so it keeps the reason that actually applies to it.
|
// Tagged, so it keeps the reason that actually applies to it.
|
||||||
("peerspeak-playback", "peerspeak-owned"),
|
(playback.as_str(), "peerspeak-owned"),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -457,11 +518,12 @@ fn row_1_owner_bridge_forwarder_with_an_untainted_control() {
|
|||||||
.body;
|
.body;
|
||||||
let (eligible, excluded) = partition(&body);
|
let (eligible, excluded) = partition(&body);
|
||||||
|
|
||||||
|
let call_name = owned_name("peerspeak-call", 200);
|
||||||
assert_eq!(eligible, vec!["clean-loopback-playback"]);
|
assert_eq!(eligible, vec!["clean-loopback-playback"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
excluded,
|
excluded,
|
||||||
vec![
|
vec![
|
||||||
("peerspeak-call", "peerspeak-owned"),
|
(call_name.as_str(), "peerspeak-owned"),
|
||||||
("tainted-loopback-playback", "tainted-owner-bridge"),
|
("tainted-loopback-playback", "tainted-owner-bridge"),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -494,11 +556,12 @@ fn row_3_one_tainted_module_does_not_taint_the_other() {
|
|||||||
.body;
|
.body;
|
||||||
let (eligible, excluded) = partition(&body);
|
let (eligible, excluded) = partition(&body);
|
||||||
|
|
||||||
|
let call_name = owned_name("peerspeak-call", 200);
|
||||||
assert_eq!(eligible, vec!["module-b-playback"]);
|
assert_eq!(eligible, vec!["module-b-playback"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
excluded,
|
excluded,
|
||||||
vec![
|
vec![
|
||||||
("peerspeak-call", "peerspeak-owned"),
|
(call_name.as_str(), "peerspeak-owned"),
|
||||||
("module-a-playback", "tainted-owner-bridge"),
|
("module-a-playback", "tainted-owner-bridge"),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -701,7 +764,8 @@ fn the_taint_view_covers_non_candidate_roles() {
|
|||||||
tainted.contains(&("null-sink", "tainted-upstream")),
|
tainted.contains(&("null-sink", "tainted-upstream")),
|
||||||
"the sink is not a candidate but its taint is what explains the row: {tainted:?}"
|
"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
|
/// A record must serialise to a single line. Newlines inside a JSON Lines
|
||||||
|
|||||||
+625
-70
@@ -4,12 +4,15 @@
|
|||||||
//! callbacks into [`RegEvent`]s, and publishes the latest [`Projection`] for
|
//! callbacks into [`RegEvent`]s, and publishes the latest [`Projection`] for
|
||||||
//! consumers running outside the PipeWire thread.
|
//! consumers running outside the PipeWire thread.
|
||||||
|
|
||||||
use super::classify::DeviceClaim;
|
use super::classify::{DeviceClaim, DeviceProps};
|
||||||
use super::{EventKind, LinkEndpoints, NodeObservation, Projection, RegEvent, RegistryModel};
|
use super::{
|
||||||
|
EventKind, LinkEndpoints, NodeObservation, Outcome, Projection, RegEvent, RegistryModel,
|
||||||
|
};
|
||||||
use crate::host::audio::parse_object_serial;
|
use crate::host::audio::parse_object_serial;
|
||||||
use crate::host::taint::snapshot::{
|
use crate::host::taint::snapshot::{
|
||||||
ClientSnapshot, GlobalId, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
|
ClientSnapshot, GlobalId, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
|
||||||
};
|
};
|
||||||
|
use crate::host::taint::{PEERSPEAK_OWNED_PROP, PEERSPEAK_OWNED_VALUE};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use pipewire::{self as pw, types::ObjectType};
|
use pipewire::{self as pw, types::ObjectType};
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
@@ -104,14 +107,24 @@ impl Drop for RegistryObserverHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BoundLink {
|
enum BoundProxy {
|
||||||
_proxy: pw::link::Link,
|
Node {
|
||||||
_listener: pw::link::LinkListener,
|
_listener: pw::node::NodeListener,
|
||||||
|
_proxy: pw::node::Node,
|
||||||
|
},
|
||||||
|
Device {
|
||||||
|
_listener: pw::device::DeviceListener,
|
||||||
|
_proxy: pw::device::Device,
|
||||||
|
},
|
||||||
|
Link {
|
||||||
|
_listener: pw::link::LinkListener,
|
||||||
|
_proxy: pw::link::Link,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct LiveGlobal {
|
struct LiveGlobal {
|
||||||
bound_link: Option<BoundLink>,
|
serial: Serial,
|
||||||
|
bound_proxy: Option<BoundProxy>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ObserverState {
|
struct ObserverState {
|
||||||
@@ -142,12 +155,13 @@ impl ObserverState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply(&mut self, event: RegEvent) {
|
fn apply(&mut self, event: RegEvent) -> Outcome {
|
||||||
// Taken before the model consumes the event: the sink is told what kind
|
// Taken before the model consumes the event: the sink is told what kind
|
||||||
// of observation produced the projection, and deriving that from the
|
// of observation produced the projection, and deriving that from the
|
||||||
// event itself is what stops the two from ever disagreeing.
|
// event itself is what stops the two from ever disagreeing.
|
||||||
let kind = event.kind();
|
let kind = event.kind();
|
||||||
self.model.apply(event);
|
let event_outcome = self.model.apply(event);
|
||||||
|
let mut outcome = event_outcome;
|
||||||
|
|
||||||
let candidate = self.model.pulse_pid_candidate();
|
let candidate = self.model.pulse_pid_candidate();
|
||||||
if candidate != self.last_candidate {
|
if candidate != self.last_candidate {
|
||||||
@@ -160,11 +174,19 @@ impl ObserverState {
|
|||||||
// one registry event still yields exactly one sink call — the
|
// one registry event still yields exactly one sink call — the
|
||||||
// no-coalescing contract cuts both ways, and a *duplicated*
|
// no-coalescing contract cuts both ways, and a *duplicated*
|
||||||
// observation would make the O5 event rate a fiction.
|
// observation would make the O5 event rate a fiction.
|
||||||
self.model.apply(RegEvent::ProcCommProbed { pid, comm });
|
if self.model.apply(RegEvent::ProcCommProbed { pid, comm }) == Outcome::Applied {
|
||||||
|
outcome = Outcome::Applied;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.publish(kind);
|
// v3.5 §6.7 decision 2: a projection the model proved identical is not
|
||||||
|
// published. Only the model can make that claim soundly, which is why
|
||||||
|
// it is [`Outcome`] and not a diff of two snapshots here.
|
||||||
|
if outcome == Outcome::Applied {
|
||||||
|
self.publish(kind);
|
||||||
|
}
|
||||||
|
event_outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
fn publish(&mut self, kind: EventKind) {
|
fn publish(&mut self, kind: EventKind) {
|
||||||
@@ -183,40 +205,65 @@ impl ObserverState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Record the global's id and apply its add event as one step, so the
|
/// Record the global's id and apply its add event as one step, so the
|
||||||
/// bound-link FIFO stays provably lockstep with the model's own `live_ids`
|
/// bound-proxy FIFO stays provably lockstep with the model's own `live_ids`
|
||||||
/// index. Recording only on *applied* adds (never on unknown object types
|
/// index. Recording only on *applied* adds (never on unknown object types
|
||||||
/// or globals dropped for a missing serial) is what keeps the two id
|
/// or globals dropped for a missing serial) is what keeps the two id
|
||||||
/// queues the same length per id — otherwise a phantom slot ahead of a
|
/// queues the same length per id — otherwise a phantom slot could pop
|
||||||
/// bound Link would be popped on removal, leaking that Link's proxy.
|
/// another generation's proxy after an id is recycled.
|
||||||
fn add(&mut self, id: GlobalId, event: RegEvent) {
|
fn add(&mut self, serial: Serial, id: GlobalId, event: RegEvent) {
|
||||||
self.live_globals
|
if self.apply(event) == Outcome::Applied {
|
||||||
.entry(id)
|
self.live_globals
|
||||||
.or_default()
|
.entry(id)
|
||||||
.push_back(LiveGlobal::default());
|
.or_default()
|
||||||
self.apply(event);
|
.push_back(LiveGlobal {
|
||||||
|
serial,
|
||||||
|
bound_proxy: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn attach_bound_link(&mut self, id: GlobalId, bound_link: BoundLink) {
|
/// Return a proxy that could not be attached so its listener is dropped
|
||||||
let Some(global) = self.live_globals.get_mut(&id).and_then(VecDeque::back_mut) else {
|
/// after the caller releases the `RefCell` borrow.
|
||||||
|
fn attach_bound_proxy(
|
||||||
|
&mut self,
|
||||||
|
id: GlobalId,
|
||||||
|
serial: Serial,
|
||||||
|
bound_proxy: BoundProxy,
|
||||||
|
) -> Option<BoundProxy> {
|
||||||
|
let Some(global) = self
|
||||||
|
.live_globals
|
||||||
|
.get_mut(&id)
|
||||||
|
.and_then(|globals| globals.iter_mut().find(|global| global.serial == serial))
|
||||||
|
else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
global_id = id.0,
|
global_id = id.0,
|
||||||
"registry observer: link bind completed without a live global slot"
|
serial = serial.0,
|
||||||
|
"registry observer: bind completed without a live global slot"
|
||||||
);
|
);
|
||||||
return;
|
return Some(bound_proxy);
|
||||||
};
|
};
|
||||||
global.bound_link = Some(bound_link);
|
if global.bound_proxy.is_some() {
|
||||||
|
tracing::warn!(
|
||||||
|
global_id = id.0,
|
||||||
|
serial = serial.0,
|
||||||
|
"registry observer: live global slot already has a bound proxy"
|
||||||
|
);
|
||||||
|
return Some(bound_proxy);
|
||||||
|
}
|
||||||
|
global.bound_proxy = Some(bound_proxy);
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_global(&mut self, id: GlobalId) -> Option<BoundLink> {
|
fn remove_global(&mut self, id: GlobalId) -> Option<BoundProxy> {
|
||||||
let (bound_link, empty) = {
|
let (bound_proxy, empty) = {
|
||||||
let globals = self.live_globals.get_mut(&id)?;
|
let globals = self.live_globals.get_mut(&id)?;
|
||||||
let bound_link = globals.pop_front().and_then(|global| global.bound_link);
|
let bound_proxy = globals.pop_front().and_then(|global| global.bound_proxy);
|
||||||
(bound_link, globals.is_empty())
|
(bound_proxy, globals.is_empty())
|
||||||
};
|
};
|
||||||
if empty {
|
if empty {
|
||||||
self.live_globals.remove(&id);
|
self.live_globals.remove(&id);
|
||||||
}
|
}
|
||||||
bound_link
|
bound_proxy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +320,9 @@ fn run_observer(
|
|||||||
|
|
||||||
match obj.type_ {
|
match obj.type_ {
|
||||||
ObjectType::Node => {
|
ObjectType::Node => {
|
||||||
|
// ⚠️ v3.5 §6.7: the global is an INDEX. Only `object.serial`
|
||||||
|
// is read here; every property the engine reasons about
|
||||||
|
// comes from the bind's `info` (phase 3r).
|
||||||
let Some(props) = obj.props.as_ref() else {
|
let Some(props) = obj.props.as_ref() else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
node_id = obj.id,
|
node_id = obj.id,
|
||||||
@@ -284,41 +334,59 @@ fn run_observer(
|
|||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let node_props = NodeProps {
|
state_for_global.borrow_mut().add(
|
||||||
peerspeak_owned: truthy(props.get("peerspeak.owned")),
|
|
||||||
pulse_module_id: props
|
|
||||||
.get("pulse.module.id")
|
|
||||||
.and_then(|value| value.parse::<u64>().ok()),
|
|
||||||
link_group: props.get("node.link-group").map(str::to_owned),
|
|
||||||
client_id: props
|
|
||||||
.get("client.id")
|
|
||||||
.and_then(|value| value.parse::<u32>().ok())
|
|
||||||
.map(GlobalId),
|
|
||||||
process_id: props
|
|
||||||
.get("application.process.id")
|
|
||||||
.and_then(|value| value.parse::<u32>().ok()),
|
|
||||||
passthrough: truthy(props.get("node.passthrough")),
|
|
||||||
session_device: false,
|
|
||||||
};
|
|
||||||
let observation = NodeObservation {
|
|
||||||
serial,
|
serial,
|
||||||
id,
|
id,
|
||||||
name: props.get("node.name").map(str::to_owned),
|
RegEvent::NodeAdded { serial, id },
|
||||||
role: MediaRole::parse(props.get("media.class")),
|
);
|
||||||
props: node_props,
|
|
||||||
device_claim: DeviceClaim {
|
let Some(registry) = registry_weak.upgrade() else {
|
||||||
device_id: props
|
return;
|
||||||
.get("device.id")
|
|
||||||
.and_then(|value| value.parse::<u32>().ok())
|
|
||||||
.map(GlobalId),
|
|
||||||
device_api: props.get("device.api").map(str::to_owned),
|
|
||||||
factory_name: props.get("factory.name").map(str::to_owned),
|
|
||||||
alsa_driver_name: props.get("alsa.driver_name").map(str::to_owned),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
state_for_global
|
let node: pw::node::Node = match registry.bind(obj) {
|
||||||
.borrow_mut()
|
Ok(node) => node,
|
||||||
.add(id, RegEvent::NodeAdded(observation));
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
node_id = obj.id,
|
||||||
|
"registry observer: failed to bind Node for properties: {e}"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// This bit only recognizes the initial callback for the
|
||||||
|
// change-mask fast path. Admission vs update remains
|
||||||
|
// entirely the model's decision.
|
||||||
|
let first_info = Cell::new(true);
|
||||||
|
let state_for_info = Rc::downgrade(&state_for_global);
|
||||||
|
let listener = node
|
||||||
|
.add_listener_local()
|
||||||
|
.info(move |info| {
|
||||||
|
let Some(props) = info.props() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let first = first_info.replace(false);
|
||||||
|
if !first
|
||||||
|
&& !info.change_mask().contains(pw::node::NodeChangeMask::PROPS)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(state) = state_for_info.upgrade() {
|
||||||
|
state.borrow_mut().apply(RegEvent::NodeInfo {
|
||||||
|
serial,
|
||||||
|
observation: node_observation_from_props(props),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.register();
|
||||||
|
let unattached = state_for_global.borrow_mut().attach_bound_proxy(
|
||||||
|
id,
|
||||||
|
serial,
|
||||||
|
BoundProxy::Node {
|
||||||
|
_listener: listener,
|
||||||
|
_proxy: node,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
drop(unattached);
|
||||||
}
|
}
|
||||||
ObjectType::Port => {
|
ObjectType::Port => {
|
||||||
let Some(props) = obj.props.as_ref() else {
|
let Some(props) = obj.props.as_ref() else {
|
||||||
@@ -357,6 +425,7 @@ fn run_observer(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
state_for_global.borrow_mut().add(
|
state_for_global.borrow_mut().add(
|
||||||
|
serial,
|
||||||
id,
|
id,
|
||||||
RegEvent::PortAdded(PortSnapshot {
|
RegEvent::PortAdded(PortSnapshot {
|
||||||
serial,
|
serial,
|
||||||
@@ -381,6 +450,7 @@ fn run_observer(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
state_for_global.borrow_mut().add(
|
state_for_global.borrow_mut().add(
|
||||||
|
serial,
|
||||||
id,
|
id,
|
||||||
RegEvent::ClientAdded(ClientSnapshot {
|
RegEvent::ClientAdded(ClientSnapshot {
|
||||||
serial,
|
serial,
|
||||||
@@ -392,9 +462,72 @@ fn run_observer(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
ObjectType::Device => {
|
ObjectType::Device => {
|
||||||
state_for_global
|
// Index only, exactly as for a Node: `device.api` and
|
||||||
.borrow_mut()
|
// `alsa.driver_name` live on the bind's `info` (v3.5 §6.7
|
||||||
.add(id, RegEvent::DeviceAdded { id });
|
// decision 4), not here.
|
||||||
|
let Some(props) = obj.props.as_ref() else {
|
||||||
|
tracing::warn!(
|
||||||
|
device_id = obj.id,
|
||||||
|
"registry observer: Device has no properties; dropping"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(serial) = parse_serial(obj.id, "Device", props.get("object.serial"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
state_for_global.borrow_mut().add(
|
||||||
|
serial,
|
||||||
|
id,
|
||||||
|
RegEvent::DeviceAdded { serial, id },
|
||||||
|
);
|
||||||
|
|
||||||
|
let Some(registry) = registry_weak.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let device: pw::device::Device = match registry.bind(obj) {
|
||||||
|
Ok(device) => device,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
device_id = obj.id,
|
||||||
|
"registry observer: failed to bind Device for properties: {e}"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let first_info = Cell::new(true);
|
||||||
|
let state_for_info = Rc::downgrade(&state_for_global);
|
||||||
|
let listener = device
|
||||||
|
.add_listener_local()
|
||||||
|
.info(move |info| {
|
||||||
|
let Some(props) = info.props() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let first = first_info.replace(false);
|
||||||
|
if !first
|
||||||
|
&& !info
|
||||||
|
.change_mask()
|
||||||
|
.contains(pw::device::DeviceChangeMask::PROPS)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(state) = state_for_info.upgrade() {
|
||||||
|
state.borrow_mut().apply(RegEvent::DeviceInfo {
|
||||||
|
serial,
|
||||||
|
props: device_props_from_props(props),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.register();
|
||||||
|
let unattached = state_for_global.borrow_mut().attach_bound_proxy(
|
||||||
|
id,
|
||||||
|
serial,
|
||||||
|
BoundProxy::Device {
|
||||||
|
_listener: listener,
|
||||||
|
_proxy: device,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
drop(unattached);
|
||||||
}
|
}
|
||||||
ObjectType::Link => {
|
ObjectType::Link => {
|
||||||
let Some(props) = obj.props.as_ref() else {
|
let Some(props) = obj.props.as_ref() else {
|
||||||
@@ -410,6 +543,7 @@ fn run_observer(
|
|||||||
};
|
};
|
||||||
let endpoints = link_endpoints_from_props(props);
|
let endpoints = link_endpoints_from_props(props);
|
||||||
state_for_global.borrow_mut().add(
|
state_for_global.borrow_mut().add(
|
||||||
|
serial,
|
||||||
id,
|
id,
|
||||||
RegEvent::LinkAdded {
|
RegEvent::LinkAdded {
|
||||||
serial,
|
serial,
|
||||||
@@ -456,24 +590,26 @@ fn run_observer(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.register();
|
.register();
|
||||||
state_for_global.borrow_mut().attach_bound_link(
|
let unattached = state_for_global.borrow_mut().attach_bound_proxy(
|
||||||
id,
|
id,
|
||||||
BoundLink {
|
serial,
|
||||||
_proxy: link,
|
BoundProxy::Link {
|
||||||
_listener: listener,
|
_listener: listener,
|
||||||
|
_proxy: link,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
drop(unattached);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.global_remove(move |id| {
|
.global_remove(move |id| {
|
||||||
let id = GlobalId(id);
|
let id = GlobalId(id);
|
||||||
let bound_link = state_for_remove.borrow_mut().remove_global(id);
|
let bound_proxy = state_for_remove.borrow_mut().remove_global(id);
|
||||||
state_for_remove
|
state_for_remove
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.apply(RegEvent::Removed { id });
|
.apply(RegEvent::Removed { id });
|
||||||
drop(bound_link);
|
drop(bound_proxy);
|
||||||
})
|
})
|
||||||
.register();
|
.register();
|
||||||
|
|
||||||
@@ -513,10 +649,77 @@ fn parse_serial(id: u32, kind: &str, raw: Option<&str>) -> Option<Serial> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lenient boolean for PipeWire's own `bool`-ish properties
|
||||||
|
/// (`port.exclusive`, `port.monitor`, `node.passthrough`), whose spelling
|
||||||
|
/// varies by producer. Leniency is the fail-closed direction *for these*:
|
||||||
|
/// each one, when true, causes exclusion.
|
||||||
fn truthy(value: Option<&str>) -> bool {
|
fn truthy(value: Option<&str>) -> bool {
|
||||||
value.is_some_and(|value| value != "false" && value != "0")
|
value.is_some_and(|value| value != "false" && value != "0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The ownership carrier is matched **exactly**, not leniently (round 10,
|
||||||
|
/// R10-4).
|
||||||
|
///
|
||||||
|
/// It is tempting to reuse [`truthy`] here on the grounds that treating an
|
||||||
|
/// unexpected value as "owned" over-excludes and is therefore safe. That
|
||||||
|
/// argument does not hold: leniency buys false-positive *exclusion*, not
|
||||||
|
/// safety. Under `truthy`, `peerspeak.owned=""` and `peerspeak.owned=false `
|
||||||
|
/// (trailing space) both mean owned, so any process can suppress a rival's
|
||||||
|
/// audio from the share with a property it does not even have to spell right.
|
||||||
|
///
|
||||||
|
/// Fail-closed on this feature is about **ancestry** — an unresolvable graph
|
||||||
|
/// is not eligible — not about parsing. The producer emits exactly
|
||||||
|
/// [`PEERSPEAK_OWNED_VALUE`] at all three of its sites and is pinned to it by
|
||||||
|
/// the shared cross-repo fixture, so there is no real value to be lenient
|
||||||
|
/// about. And a missed tag is not silent: carrier 2 is a union with this one,
|
||||||
|
/// so a garbled property still leaves the `node.name` prefix.
|
||||||
|
fn peerspeak_owned(value: Option<&str>) -> bool {
|
||||||
|
value == Some(PEERSPEAK_OWNED_VALUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObservation {
|
||||||
|
NodeObservation {
|
||||||
|
name: props.get("node.name").map(str::to_string),
|
||||||
|
role: MediaRole::parse(props.get("media.class")),
|
||||||
|
props: NodeProps {
|
||||||
|
// 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: peerspeak_owned(props.get(PEERSPEAK_OWNED_PROP)),
|
||||||
|
pulse_module_id: props
|
||||||
|
.get("pulse.module.id")
|
||||||
|
.and_then(|value| value.parse::<u64>().ok()),
|
||||||
|
link_group: props.get("node.link-group").map(str::to_string),
|
||||||
|
client_id: props
|
||||||
|
.get("client.id")
|
||||||
|
.and_then(|value| value.parse::<u32>().ok())
|
||||||
|
.map(GlobalId),
|
||||||
|
process_id: props
|
||||||
|
.get("application.process.id")
|
||||||
|
.and_then(|value| value.parse::<u32>().ok()),
|
||||||
|
passthrough: truthy(props.get("node.passthrough")),
|
||||||
|
session_device: false,
|
||||||
|
},
|
||||||
|
device_claim: DeviceClaim {
|
||||||
|
device_id: props
|
||||||
|
.get("device.id")
|
||||||
|
.and_then(|value| value.parse::<u32>().ok())
|
||||||
|
.map(GlobalId),
|
||||||
|
device_api: props.get("device.api").map(str::to_string),
|
||||||
|
factory_name: props.get("factory.name").map(str::to_string),
|
||||||
|
alsa_driver_name: props.get("alsa.driver_name").map(str::to_string),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_props_from_props(props: &pw::spa::utils::dict::DictRef) -> DeviceProps {
|
||||||
|
DeviceProps {
|
||||||
|
device_api: props.get("device.api").map(str::to_string),
|
||||||
|
alsa_driver_name: props.get("alsa.driver_name").map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn link_endpoints_from_props(props: &pw::spa::utils::dict::DictRef) -> Option<LinkEndpoints> {
|
fn link_endpoints_from_props(props: &pw::spa::utils::dict::DictRef) -> Option<LinkEndpoints> {
|
||||||
let output_node = props.get("link.output.node")?.parse::<u32>().ok()?;
|
let output_node = props.get("link.output.node")?.parse::<u32>().ok()?;
|
||||||
let input_node = props.get("link.input.node")?.parse::<u32>().ok()?;
|
let input_node = props.get("link.input.node")?.parse::<u32>().ok()?;
|
||||||
@@ -543,6 +746,165 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// **R10-4.** The ownership carrier is matched exactly; the lenient
|
||||||
|
/// [`truthy`] spelling is wrong for it.
|
||||||
|
///
|
||||||
|
/// Under `truthy`, every value in `denied` below meant "peerspeak owns
|
||||||
|
/// this" — including the empty string and a `false` with a trailing space
|
||||||
|
/// — so any process could suppress a rival application's audio from the
|
||||||
|
/// share with a property it did not have to spell correctly. Leniency here
|
||||||
|
/// buys false-positive exclusion, not safety.
|
||||||
|
#[test]
|
||||||
|
fn the_ownership_carrier_is_matched_exactly_not_leniently() {
|
||||||
|
assert!(peerspeak_owned(Some(PEERSPEAK_OWNED_VALUE)));
|
||||||
|
|
||||||
|
let denied = [
|
||||||
|
None,
|
||||||
|
Some(""),
|
||||||
|
Some("false"),
|
||||||
|
Some("0"),
|
||||||
|
Some("false "),
|
||||||
|
Some("true"),
|
||||||
|
Some("yes"),
|
||||||
|
Some("1 "),
|
||||||
|
Some(" 1"),
|
||||||
|
Some("01"),
|
||||||
|
Some("2"),
|
||||||
|
];
|
||||||
|
for value in denied {
|
||||||
|
assert!(
|
||||||
|
!peerspeak_owned(value),
|
||||||
|
"{value:?} must not read as peerspeak-owned"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same property, asserted through the **production wiring** rather
|
||||||
|
/// than the helper.
|
||||||
|
///
|
||||||
|
/// ⚠️ **This is the gate; the one above is a unit test of a private
|
||||||
|
/// function** (round 10 review, finding 4). Mutating
|
||||||
|
/// [`node_observation_from_props`] back to `truthy(props.get(…))` left the
|
||||||
|
/// helper test green, because it calls [`peerspeak_owned`] directly and
|
||||||
|
/// the only live case it shares with production — exact `"1"` — passes
|
||||||
|
/// under both implementations. That is precisely the "a gate satisfiable
|
||||||
|
/// by two sources gates neither" failure that bit the `main.rs` wiring
|
||||||
|
/// guard and phase 3r row 1.
|
||||||
|
///
|
||||||
|
/// So: build a real `pw_properties` dictionary, push it through the same
|
||||||
|
/// function the registry callback calls, and assert the resulting
|
||||||
|
/// [`NodeProps::peerspeak_owned`] for every spelling.
|
||||||
|
#[test]
|
||||||
|
fn the_production_wiring_reads_the_ownership_carrier_exactly() {
|
||||||
|
pw::init();
|
||||||
|
|
||||||
|
// (property value, must be read as peerspeak-owned)
|
||||||
|
let spellings = [
|
||||||
|
(Some(PEERSPEAK_OWNED_VALUE), true),
|
||||||
|
(None, false),
|
||||||
|
(Some(""), false),
|
||||||
|
(Some("false"), false),
|
||||||
|
(Some("0"), false),
|
||||||
|
(Some("false "), false),
|
||||||
|
(Some("true"), false),
|
||||||
|
(Some("yes"), false),
|
||||||
|
(Some("1 "), false),
|
||||||
|
(Some(" 1"), false),
|
||||||
|
(Some("01"), false),
|
||||||
|
(Some("2"), false),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (value, expected) in spellings {
|
||||||
|
let mut props = pw::properties::PropertiesBox::new();
|
||||||
|
// A realistic node, so the rest of the parse runs too: this is the
|
||||||
|
// shape peerspeak's own tagged playback arrives in.
|
||||||
|
props.insert("media.class", "Stream/Output/Audio");
|
||||||
|
props.insert("node.name", "probe");
|
||||||
|
props.insert("client.id", "42");
|
||||||
|
if let Some(value) = value {
|
||||||
|
props.insert(PEERSPEAK_OWNED_PROP, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let observation = node_observation_from_props(props.dict());
|
||||||
|
assert_eq!(
|
||||||
|
observation.props.peerspeak_owned, expected,
|
||||||
|
"{PEERSPEAK_OWNED_PROP}={value:?} through the real adapter"
|
||||||
|
);
|
||||||
|
// The surrounding parse must still work, or a green result above
|
||||||
|
// could just mean the whole dictionary was dropped.
|
||||||
|
assert_eq!(observation.role, MediaRole::StreamOutput);
|
||||||
|
assert_eq!(observation.name.as_deref(), Some("probe"));
|
||||||
|
assert_eq!(observation.props.client_id, Some(GlobalId(42)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cross-repo fixture's `prop_value` is the only spelling this
|
||||||
|
/// consumer treats as owned — asserted through the production wiring.
|
||||||
|
///
|
||||||
|
/// The taint module's `ownership_carriers_match_the_cross_repo_fixture`
|
||||||
|
/// proves the two repos agree on the *literal*. That is not the same as
|
||||||
|
/// proving the shipping observer reads it, which is the half the round-10
|
||||||
|
/// review's finding 6 was about: a future producer following the fixture
|
||||||
|
/// needs the file to describe what the code does, and only a test that
|
||||||
|
/// runs the code can keep those two honest.
|
||||||
|
#[test]
|
||||||
|
fn the_fixture_value_is_the_only_owned_spelling() {
|
||||||
|
const FIXTURE: &str = include_str!("../../../tests/fixtures/ownership-tag-contract.txt");
|
||||||
|
pw::init();
|
||||||
|
|
||||||
|
let pinned = FIXTURE
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||||
|
.map(|line| line.split_once('=').expect("fixture line is key=value"));
|
||||||
|
let mut prop_key = None;
|
||||||
|
let mut prop_value = None;
|
||||||
|
for (key, value) in pinned {
|
||||||
|
match key {
|
||||||
|
"prop_key" => prop_key = Some(value),
|
||||||
|
"prop_value" => prop_value = Some(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let prop_key = prop_key.expect("fixture defines prop_key");
|
||||||
|
let prop_value = prop_value.expect("fixture defines prop_value");
|
||||||
|
|
||||||
|
let observe = |value: &str| {
|
||||||
|
let mut props = pw::properties::PropertiesBox::new();
|
||||||
|
props.insert("media.class", "Stream/Output/Audio");
|
||||||
|
props.insert(prop_key, value);
|
||||||
|
node_observation_from_props(props.dict())
|
||||||
|
.props
|
||||||
|
.peerspeak_owned
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
observe(prop_value),
|
||||||
|
"the fixture's own {prop_key}={prop_value} must read as owned"
|
||||||
|
);
|
||||||
|
// The spellings the fixture explicitly says are NOT owned.
|
||||||
|
for denied in ["true", "yes", ""] {
|
||||||
|
assert!(
|
||||||
|
!observe(denied),
|
||||||
|
"{prop_key}={denied:?} must not read as owned; the fixture says so"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other three boolean properties keep the lenient spelling, and that
|
||||||
|
/// is deliberate rather than an oversight: each is PipeWire's own, each
|
||||||
|
/// varies by producer, and each causes *exclusion* when true — so reading
|
||||||
|
/// an unrecognised value as true is genuinely the safe direction for them.
|
||||||
|
#[test]
|
||||||
|
fn pipewires_own_boolean_props_stay_lenient() {
|
||||||
|
assert!(truthy(Some("true")));
|
||||||
|
assert!(truthy(Some("1")));
|
||||||
|
assert!(truthy(Some("")));
|
||||||
|
assert!(!truthy(Some("false")));
|
||||||
|
assert!(!truthy(Some("0")));
|
||||||
|
assert!(!truthy(None));
|
||||||
|
}
|
||||||
|
|
||||||
struct PactlModule {
|
struct PactlModule {
|
||||||
id: Option<u32>,
|
id: Option<u32>,
|
||||||
}
|
}
|
||||||
@@ -617,6 +979,199 @@ mod tests {
|
|||||||
.any(|node| node.name.as_deref() == Some(name))
|
.any(|node| node.name.as_deref() == Some(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 3r exit-gate row 1, the Device half — and the reason it needs its
|
||||||
|
/// own test.
|
||||||
|
///
|
||||||
|
/// `live_bound_properties_recover_node_and_device_inputs` asserts
|
||||||
|
/// `session_device`, which the classifier grants on a **union**:
|
||||||
|
/// `device.api` and `alsa.driver_name` may come from the bound Device *or*
|
||||||
|
/// from the node's own copies. On this host (WirePlumber 0.5.15 ≥ 0.5.13)
|
||||||
|
/// the session manager *does* copy both onto ALSA nodes, so that assertion
|
||||||
|
/// passes through the node fallback and would keep passing if the Device
|
||||||
|
/// bind delivered nothing at all — leaving v3.5 §6.7 decision 4, the whole
|
||||||
|
/// authoritative path, ungated on the machine we develop on.
|
||||||
|
///
|
||||||
|
/// So assert the Device side directly: bind every Device global and require
|
||||||
|
/// that at least one ALSA card announces **both** keys on its `info` props.
|
||||||
|
/// A failure here means the fix for the phase-3 review's owed finding (a
|
||||||
|
/// real card over-excluded on installs that do not copy `alsa.*` onto the
|
||||||
|
/// node) rests on nothing.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs live pipewire"]
|
||||||
|
fn live_device_bind_carries_api_and_driver_name() {
|
||||||
|
pw::init();
|
||||||
|
let main_loop = pw::main_loop::MainLoopRc::new(None).expect("pw main loop");
|
||||||
|
let context = pw::context::ContextRc::new(&main_loop, None).expect("pw context");
|
||||||
|
let core = context.connect_rc(None).expect("pw core connect");
|
||||||
|
let registry = core.get_registry_rc().expect("pw registry");
|
||||||
|
|
||||||
|
// Devices bound off the registry, each holding its proxy + listener so
|
||||||
|
// the callback lives long enough to fire, exactly as the adapter does.
|
||||||
|
let bound: Rc<RefCell<Vec<(pw::device::Device, pw::device::DeviceListener)>>> =
|
||||||
|
Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let observed: Rc<RefCell<Vec<DeviceProps>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
|
||||||
|
let bound_for_global = Rc::clone(&bound);
|
||||||
|
let observed_for_global = Rc::clone(&observed);
|
||||||
|
let registry_weak = registry.downgrade();
|
||||||
|
let _listener = registry
|
||||||
|
.add_listener_local()
|
||||||
|
.global(move |obj| {
|
||||||
|
if obj.type_ != ObjectType::Device {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(registry) = registry_weak.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(device) = registry.bind::<pw::device::Device, _>(obj) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let observed_for_info = Rc::clone(&observed_for_global);
|
||||||
|
let listener = device
|
||||||
|
.add_listener_local()
|
||||||
|
.info(move |info| {
|
||||||
|
if let Some(props) = info.props() {
|
||||||
|
observed_for_info
|
||||||
|
.borrow_mut()
|
||||||
|
.push(device_props_from_props(props));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.register();
|
||||||
|
bound_for_global.borrow_mut().push((device, listener));
|
||||||
|
})
|
||||||
|
.register();
|
||||||
|
|
||||||
|
// Two seconds is the same budget the observer gives its own binds.
|
||||||
|
let main_loop_for_timer = main_loop.clone();
|
||||||
|
let timer = main_loop
|
||||||
|
.loop_()
|
||||||
|
.add_timer(move |_| main_loop_for_timer.quit());
|
||||||
|
timer
|
||||||
|
.update_timer(Some(Duration::from_secs(2)), None)
|
||||||
|
.into_result()
|
||||||
|
.expect("arm the test deadline");
|
||||||
|
main_loop.run();
|
||||||
|
|
||||||
|
let observed = observed.borrow();
|
||||||
|
assert!(
|
||||||
|
!observed.is_empty(),
|
||||||
|
"no Device delivered info props at all — the Device bind path is dead"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
observed.iter().any(|props| {
|
||||||
|
props.device_api.as_deref() == Some("alsa") && props.alsa_driver_name.is_some()
|
||||||
|
}),
|
||||||
|
"no bound Device carried both device.api=alsa and alsa.driver_name; \
|
||||||
|
observed: {observed:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3r exit-gate row 1: failure means the observation boundary regressed.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs live pipewire"]
|
||||||
|
fn live_bound_properties_recover_node_and_device_inputs() {
|
||||||
|
pw::init();
|
||||||
|
let observer = RegistryObserverHandle::spawn().expect("observer thread must spawn");
|
||||||
|
wait_for(&observer, |projection| projection.graph_ready);
|
||||||
|
|
||||||
|
let unique = format!("pixelpass_observer_props_test_{}", std::process::id());
|
||||||
|
let capture_name = format!("{unique}_capture");
|
||||||
|
let playback_name = format!("{unique}_playback");
|
||||||
|
let null_sink = PactlModule::load(
|
||||||
|
"module-null-sink",
|
||||||
|
&[
|
||||||
|
format!("sink_name={unique}"),
|
||||||
|
// The value peerspeak actually emits, not merely a truthy one:
|
||||||
|
// this row is the live proof that carrier 1 survives the bind,
|
||||||
|
// and the sink's name deliberately does *not* carry the
|
||||||
|
// `peerspeak_owned_` prefix, so carrier 2 cannot stand in for
|
||||||
|
// it here.
|
||||||
|
format!(
|
||||||
|
"sink_properties={PEERSPEAK_OWNED_PROP}={} node.passthrough=true",
|
||||||
|
crate::host::taint::PEERSPEAK_OWNED_VALUE
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let null_sink_id = null_sink.id.expect("null-sink module must have an id");
|
||||||
|
let loopback = PactlModule::load(
|
||||||
|
"module-loopback",
|
||||||
|
&[
|
||||||
|
format!("source={unique}.monitor"),
|
||||||
|
format!("sink={unique}"),
|
||||||
|
format!("source_output_properties=node.name={capture_name}"),
|
||||||
|
format!("sink_input_properties=node.name={playback_name}"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let projection = wait_for(&observer, |projection| {
|
||||||
|
projection.graph_ready
|
||||||
|
&& has_node(projection, &unique)
|
||||||
|
&& has_node(projection, &capture_name)
|
||||||
|
&& has_node(projection, &playback_name)
|
||||||
|
});
|
||||||
|
let tagged_sink = projection
|
||||||
|
.snapshot
|
||||||
|
.nodes()
|
||||||
|
.find(|node| node.name.as_deref() == Some(&unique))
|
||||||
|
.expect("tagged null sink must be projected");
|
||||||
|
assert!(tagged_sink.props.peerspeak_owned);
|
||||||
|
assert!(tagged_sink.props.passthrough);
|
||||||
|
assert_eq!(
|
||||||
|
tagged_sink.props.pulse_module_id,
|
||||||
|
Some(u64::from(null_sink_id))
|
||||||
|
);
|
||||||
|
|
||||||
|
let capture = projection
|
||||||
|
.snapshot
|
||||||
|
.nodes()
|
||||||
|
.find(|node| node.name.as_deref() == Some(&capture_name))
|
||||||
|
.expect("loopback capture leg must be projected");
|
||||||
|
let playback = projection
|
||||||
|
.snapshot
|
||||||
|
.nodes()
|
||||||
|
.find(|node| node.name.as_deref() == Some(&playback_name))
|
||||||
|
.expect("loopback playback leg must be projected");
|
||||||
|
let capture_group = capture
|
||||||
|
.props
|
||||||
|
.link_group
|
||||||
|
.as_ref()
|
||||||
|
.expect("loopback capture leg must carry node.link-group");
|
||||||
|
let playback_group = playback
|
||||||
|
.props
|
||||||
|
.link_group
|
||||||
|
.as_ref()
|
||||||
|
.expect("loopback playback leg must carry node.link-group");
|
||||||
|
assert_eq!(capture_group, playback_group);
|
||||||
|
assert!(
|
||||||
|
projection
|
||||||
|
.snapshot
|
||||||
|
.nodes()
|
||||||
|
.any(|node| node.props.process_id.is_some()),
|
||||||
|
"at least one projected node must carry application.process.id"
|
||||||
|
);
|
||||||
|
let session_device = projection.snapshot.nodes().find(|node| {
|
||||||
|
node.props.session_device
|
||||||
|
&& (node
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|name| name.contains("alsa"))
|
||||||
|
|| matches!(node.role, MediaRole::Sink | MediaRole::Source))
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
session_device.is_some(),
|
||||||
|
"a named ALSA or Audio/Sink/Audio/Source node must classify as a session device"
|
||||||
|
);
|
||||||
|
assert!(projection.graph_ready);
|
||||||
|
|
||||||
|
loopback.unload();
|
||||||
|
null_sink.unload();
|
||||||
|
wait_for(&observer, |projection| {
|
||||||
|
!has_node(projection, &unique)
|
||||||
|
&& !has_node(projection, &capture_name)
|
||||||
|
&& !has_node(projection, &playback_name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "needs live pipewire"]
|
#[ignore = "needs live pipewire"]
|
||||||
fn live_topology_diff_tracks_null_sink_and_loopback() {
|
fn live_topology_diff_tracks_null_sink_and_loopback() {
|
||||||
|
|||||||
+104
-37
@@ -18,11 +18,21 @@
|
|||||||
//! both. So the discriminator is `factory.name` on an **allowlist** of
|
//! both. So the discriminator is `factory.name` on an **allowlist** of
|
||||||
//! real hardware-PCM factories, never a substring or a denylist: an unknown
|
//! real hardware-PCM factories, never a substring or a denylist: an unknown
|
||||||
//! factory is not a device.
|
//! factory is not a device.
|
||||||
//! - The backing Device must actually have been observed. A node that claims
|
//! - The backing Device must actually have been **bound and resolved**. A node
|
||||||
//! a `device.id` we have not yet resolved is **withheld**, not admitted with
|
//! that claims a `device.id` whose Device's properties we do not hold is
|
||||||
//! a provisional `false` — a provisional `false` during the not-ready
|
//! **withheld**, not admitted with a provisional `false` — a provisional
|
||||||
//! window fuses sink and mic on the shared session client and that fusion
|
//! `false` during the not-ready window fuses sink and mic on the shared
|
||||||
//! can persist as sticky over-exclusion (round-3 finding 3).
|
//! session client and that fusion can persist as sticky over-exclusion
|
||||||
|
//! (round-3 finding 3).
|
||||||
|
//!
|
||||||
|
//! **Round 8 (v3.5 §6.7 decision 4): the Device is the authority on
|
||||||
|
//! `device.api` and `alsa.driver_name`.** Both are absent from the Node
|
||||||
|
//! *global* and both are present on the **bound Device**'s `info` props
|
||||||
|
//! (measured 2026-07-25). Reading them from the Device closes the phase-3
|
||||||
|
//! review's owed fix: on PipeWire ≥ 1.2.6 with WirePlumber < 0.5.13 the driver
|
||||||
|
//! name is not copied onto the node, and the fail-closed "absent driver ⇒ not
|
||||||
|
//! a session device" rule would over-exclude real sound cards. `factory.name`
|
||||||
|
//! exists only on the node, which is why the node bind is required regardless.
|
||||||
|
|
||||||
use crate::host::taint::snapshot::GlobalId;
|
use crate::host::taint::snapshot::GlobalId;
|
||||||
|
|
||||||
@@ -54,6 +64,11 @@ const HARDWARE_PCM_FACTORIES: &[&str] = &[
|
|||||||
"api.alsa.pcm.source",
|
"api.alsa.pcm.source",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// The `device.api` every entry in [`HARDWARE_PCM_FACTORIES`] belongs to.
|
||||||
|
/// A single value rather than a list, because the allowlist is ALSA-only;
|
||||||
|
/// this constant is the thing to change when that stops being true.
|
||||||
|
const HARDWARE_PCM_API: &str = "alsa";
|
||||||
|
|
||||||
/// ALSA drivers that expose a hardware-PCM `factory.name` but are **not**
|
/// ALSA drivers that expose a hardware-PCM `factory.name` but are **not**
|
||||||
/// passive terminals — audio written in reappears on their capture side
|
/// passive terminals — audio written in reappears on their capture side
|
||||||
/// through a path the PipeWire Link graph cannot see, so classifying them
|
/// through a path the PipeWire Link graph cannot see, so classifying them
|
||||||
@@ -67,8 +82,9 @@ const HARDWARE_PCM_FACTORIES: &[&str] = &[
|
|||||||
/// does not couple playback to capture, so it is not a loopback hazard.
|
/// does not couple playback to capture, so it is not a loopback hazard.
|
||||||
const NON_TERMINAL_ALSA_DRIVERS: &[&str] = &["snd_aloop"];
|
const NON_TERMINAL_ALSA_DRIVERS: &[&str] = &["snd_aloop"];
|
||||||
|
|
||||||
/// The three node properties the classifier reads, exactly as the adapter
|
/// The node-side properties the classifier reads, exactly as the adapter
|
||||||
/// parsed them off the Node global. Kept separate from
|
/// parsed them off the **bound Node's `info`** (never off the registry
|
||||||
|
/// global — v3.5 §6.7). Kept separate from
|
||||||
/// [`super::super::taint::snapshot::NodeProps`] because these feed the
|
/// [`super::super::taint::snapshot::NodeProps`] because these feed the
|
||||||
/// *decision* whose output is the `session_device` field — they are inputs,
|
/// *decision* whose output is the `session_device` field — they are inputs,
|
||||||
/// not part of the graph the engine reasons over.
|
/// not part of the graph the engine reasons over.
|
||||||
@@ -78,10 +94,12 @@ pub struct DeviceClaim {
|
|||||||
/// `Stream/*` nodes, which is exactly why their absence means "not a
|
/// `Stream/*` nodes, which is exactly why their absence means "not a
|
||||||
/// device", not "unknown".
|
/// device", not "unknown".
|
||||||
pub device_id: Option<GlobalId>,
|
pub device_id: Option<GlobalId>,
|
||||||
/// `device.api` — the access API of that Device (e.g. `alsa`, `bluez5`).
|
/// `device.api` **as copied onto the node**, when it is — the access API
|
||||||
/// Its mere presence is **not** sufficient (a card-associated filter has
|
/// of that Device (e.g. `alsa`, `bluez5`). Its mere presence is **not**
|
||||||
/// it too); required only as a corroborating signal alongside the factory
|
/// sufficient (a card-associated filter has it too); required only as a
|
||||||
/// allowlist.
|
/// corroborating signal alongside the factory allowlist. The
|
||||||
|
/// authoritative copy is [`DeviceProps::device_api`]; this is the
|
||||||
|
/// fallback.
|
||||||
pub device_api: Option<String>,
|
pub device_api: Option<String>,
|
||||||
/// `factory.name` — the discriminator. Only an allowlisted hardware-PCM
|
/// `factory.name` — the discriminator. Only an allowlisted hardware-PCM
|
||||||
/// factory earns `session_device`.
|
/// factory earns `session_device`.
|
||||||
@@ -92,8 +110,27 @@ pub struct DeviceClaim {
|
|||||||
/// shares the same factory. `session_device` requires this to be
|
/// shares the same factory. `session_device` requires this to be
|
||||||
/// **present and not** on [`NON_TERMINAL_ALSA_DRIVERS`]; a driver on the
|
/// **present and not** on [`NON_TERMINAL_ALSA_DRIVERS`]; a driver on the
|
||||||
/// denylist, or an absent value, both fail closed (see [`classify`]).
|
/// denylist, or an absent value, both fail closed (see [`classify`]).
|
||||||
/// May be absent on non-ALSA backends or on version pairings that do not
|
/// Frequently absent here — PipeWire ≥ 1.2.6 with WirePlumber < 0.5.13
|
||||||
/// copy `alsa.*` onto the node.
|
/// does not copy `alsa.*` onto the node — which is why the authoritative
|
||||||
|
/// copy is [`DeviceProps::alsa_driver_name`] and this is only the
|
||||||
|
/// fallback.
|
||||||
|
pub alsa_driver_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The **bound Device's** `info` properties — the authoritative half of the
|
||||||
|
/// `session_device` decision (v3.5 §6.7 decision 4).
|
||||||
|
///
|
||||||
|
/// Absent from the Device *registry global* exactly as the node's properties
|
||||||
|
/// are absent from the Node global; both are recovered by binding. A node
|
||||||
|
/// claiming a `device.id` is withheld until this struct exists for that
|
||||||
|
/// Device (see [`Classification::Withhold`]).
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct DeviceProps {
|
||||||
|
/// `device.api` on the Device — `alsa`, `bluez5`, `v4l2`, …
|
||||||
|
pub device_api: Option<String>,
|
||||||
|
/// `alsa.driver_name` on the Device — the kernel driver behind the card,
|
||||||
|
/// authoritative regardless of whether the session manager copied it onto
|
||||||
|
/// the node.
|
||||||
pub alsa_driver_name: Option<String>,
|
pub alsa_driver_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +139,10 @@ pub struct DeviceClaim {
|
|||||||
pub enum Classification {
|
pub enum Classification {
|
||||||
/// No `device.id` — a `Stream/*` node. Admit with `session_device=false`.
|
/// No `device.id` — a `Stream/*` node. Admit with `session_device=false`.
|
||||||
NotADevice,
|
NotADevice,
|
||||||
/// A `device.id` is claimed but the backing Device has not been resolved
|
/// A `device.id` is claimed but the backing Device's properties are not
|
||||||
/// yet. **Withhold the node and keep the readiness epoch not-ready**;
|
/// held: never observed, its bind still outstanding, or its global id
|
||||||
/// re-classify when the Device is observed.
|
/// ambiguously shared by two live Devices. **Withhold the node and keep
|
||||||
|
/// the readiness epoch not-ready**; re-classify when the Device resolves.
|
||||||
Withhold { device_id: GlobalId },
|
Withhold { device_id: GlobalId },
|
||||||
/// Positively a passive hardware terminal. Admit with
|
/// Positively a passive hardware terminal. Admit with
|
||||||
/// `session_device=true`.
|
/// `session_device=true`.
|
||||||
@@ -115,42 +153,71 @@ pub enum Classification {
|
|||||||
NotSessionDevice,
|
NotSessionDevice,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify a node's device claim.
|
/// Classify a node's device claim against its backing Device.
|
||||||
///
|
///
|
||||||
/// `device_resolved` is whether [`DeviceClaim::device_id`] has been observed
|
/// `device` is the bound Device's properties, and `None` means the claim is
|
||||||
/// as a Device global; it is only consulted when a `device_id` is present.
|
/// **unresolved** — never observed, bind outstanding, or an ambiguous
|
||||||
/// Pure: the model supplies `device_resolved` from its resolved-Device set,
|
/// recycled id. It is only consulted when a `device_id` is present. Pure: the
|
||||||
/// and the I/O of *binding* the Device lives in the adapter.
|
/// model looks the Device up, and the I/O of *binding* it lives in the
|
||||||
pub fn classify(claim: &DeviceClaim, device_resolved: bool) -> Classification {
|
/// adapter.
|
||||||
|
///
|
||||||
|
/// Where the two sides disagree the rule is deliberately asymmetric, and
|
||||||
|
/// safety picks the direction (v3.5 §6.7 decision 4):
|
||||||
|
///
|
||||||
|
/// - **Presence: the Device wins, the node is the fallback.** That is what
|
||||||
|
/// recovers a real card whose node was never given `alsa.driver_name`.
|
||||||
|
/// - **The denylist is a union.** If *either* side names a non-terminal
|
||||||
|
/// driver the node is not a session device. A disagreement here is not
|
||||||
|
/// expected on any measured configuration, and treating it as "the Device
|
||||||
|
/// says it is fine" would be the one reading that can leak.
|
||||||
|
pub fn classify(claim: &DeviceClaim, device: Option<&DeviceProps>) -> Classification {
|
||||||
let Some(device_id) = claim.device_id else {
|
let Some(device_id) = claim.device_id else {
|
||||||
// No backing Device: a stream. Not withheld, not a device.
|
// No backing Device: a stream. Not withheld, not a device.
|
||||||
return Classification::NotADevice;
|
return Classification::NotADevice;
|
||||||
};
|
};
|
||||||
if !device_resolved {
|
let Some(device) = device else {
|
||||||
// Backed by a Device we have not seen — the one case that blocks
|
// Backed by a Device we have not resolved — the one case that blocks
|
||||||
// readiness. A provisional answer here is the leak the contract
|
// readiness. A provisional answer here is the leak the contract
|
||||||
// forbids.
|
// forbids.
|
||||||
return Classification::Withhold { device_id };
|
return Classification::Withhold { device_id };
|
||||||
}
|
};
|
||||||
let on_factory_allowlist = claim
|
let on_factory_allowlist = claim
|
||||||
.factory_name
|
.factory_name
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|f| HARDWARE_PCM_FACTORIES.contains(&f));
|
.is_some_and(|f| HARDWARE_PCM_FACTORIES.contains(&f));
|
||||||
// A **present, non-denied** ALSA driver is required — absence fails closed
|
// A **present, non-denied** ALSA driver is required — absence fails closed
|
||||||
// (Codex phase-3 re-review). `alsa.driver_name` is not copied onto the
|
// (Codex phase-3 re-review). The factory allowlist cannot tell a real card
|
||||||
// node on every PipeWire/WirePlumber version pairing (PipeWire ≥1.2.6
|
// from `snd_aloop`, which presents the same `api.alsa.pcm.*` factory, so a
|
||||||
// stopped overwriting node props with card props; WirePlumber only began
|
// *missing* value must not be read as "not a loopback". Round 8 makes the
|
||||||
// copying `alsa.*` onto nodes in 0.5.13), so a *missing* value must not be
|
// bound Device the primary source, so a real card is no longer
|
||||||
// read as "not a loopback" — that is exactly the hole an `snd_aloop` node
|
// over-excluded merely because the session manager did not copy `alsa.*`
|
||||||
// without the property would slip through. A real card whose node lacks
|
// onto its node.
|
||||||
// the driver is instead over-excluded (keeps its owner keys — safe);
|
let driver = device
|
||||||
// recovering `session_device` for it needs reading the driver from the
|
|
||||||
// backing Device global, which is owed to a later round.
|
|
||||||
let driver_ok = claim
|
|
||||||
.alsa_driver_name
|
.alsa_driver_name
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|d| !NON_TERMINAL_ALSA_DRIVERS.contains(&d));
|
.or(claim.alsa_driver_name.as_deref());
|
||||||
let is_hardware_pcm = claim.device_api.is_some() && on_factory_allowlist && driver_ok;
|
let driver_denied = [
|
||||||
|
device.alsa_driver_name.as_deref(),
|
||||||
|
claim.alsa_driver_name.as_deref(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.any(|d| NON_TERMINAL_ALSA_DRIVERS.contains(&d));
|
||||||
|
let driver_ok = driver.is_some() && !driver_denied;
|
||||||
|
// The API must positively be the one the factory allowlist is written
|
||||||
|
// for, not merely present (Codex phase-3r review, finding 3). "Present"
|
||||||
|
// admitted `device.api=v4l2` alongside `factory.name=api.alsa.pcm.sink`
|
||||||
|
// — a contradiction no truthful configuration produces, which is exactly
|
||||||
|
// why it should be read as an observation gone wrong rather than as
|
||||||
|
// corroboration. Disagreement between the two sides fails closed for the
|
||||||
|
// same reason. ⚠️ Tied to [`HARDWARE_PCM_FACTORIES`] being ALSA-only:
|
||||||
|
// adding a BlueZ factory means allowing `bluez5` here too.
|
||||||
|
let api_ok = match (device.device_api.as_deref(), claim.device_api.as_deref()) {
|
||||||
|
(Some(from_device), Some(from_node)) if from_device != from_node => false,
|
||||||
|
(Some(api), _) | (None, Some(api)) => api == HARDWARE_PCM_API,
|
||||||
|
(None, None) => false,
|
||||||
|
};
|
||||||
|
let is_hardware_pcm = api_ok && on_factory_allowlist && driver_ok;
|
||||||
if is_hardware_pcm {
|
if is_hardware_pcm {
|
||||||
Classification::SessionDevice
|
Classification::SessionDevice
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+330
-127
@@ -1,12 +1,34 @@
|
|||||||
//! The registry observer's **pure core** (impl plan §4, phase 3).
|
//! The registry observer's **pure core** (impl plan §4, phases 3 and 3r).
|
||||||
//!
|
//!
|
||||||
//! This is my half of the phase-3 split: a reducer that folds a stream of
|
//! This is my half of the phase-3 split: a reducer that folds a stream of
|
||||||
//! typed [`RegEvent`]s into a live model of the PipeWire graph and projects
|
//! typed [`RegEvent`]s into a live model of the PipeWire graph and projects
|
||||||
//! the [`GraphSnapshot`] + context the taint engine (phase 2) consumes. **No
|
//! the [`GraphSnapshot`] + context the taint engine (phase 2) consumes. **No
|
||||||
//! PipeWire types appear here** — the I/O adapter (Codex's half) translates
|
//! PipeWire types appear here** — the I/O adapter (Codex's half) translates
|
||||||
//! live registry callbacks, Link/Device binds, `/proc` reads, and the
|
//! live registry callbacks, binds, `/proc` reads, and the `core.sync`/`done`
|
||||||
//! `core.sync`/`done` round-trip into these events and feeds them in. Every
|
//! round-trip into these events and feeds them in. Every test in this module
|
||||||
//! test in this module builds the event stream by hand.
|
//! builds the event stream by hand.
|
||||||
|
//!
|
||||||
|
//! ## 🔴 Round 8 (v3.5 §6.7): the global is an INDEX, not a source of truth
|
||||||
|
//!
|
||||||
|
//! Phase 3 shipped reading node properties off the registry `global` event.
|
||||||
|
//! The registry announces only a fixed 13-key subset for a Node, and **eight
|
||||||
|
//! properties this feature depends on are never among them** — they read as
|
||||||
|
//! absent rather than failing, so the engine was silently, permanently
|
||||||
|
//! starved of both its primary taint root and every strong owner key (the
|
||||||
|
//! phase-5 gate failure, F1/F2). The rule that replaces it:
|
||||||
|
//!
|
||||||
|
//! > A node's properties come from a **bind**, never from the global. The
|
||||||
|
//! > global tells us an object exists, its id and its serial. Everything
|
||||||
|
//! > else — including `node.name` and `media.class`, so there is exactly one
|
||||||
|
//! > source — arrives on [`RegEvent::NodeInfo`]. Same for `Device`
|
||||||
|
//! > ([`RegEvent::DeviceInfo`]).
|
||||||
|
//!
|
||||||
|
//! Consequences visible in this file: a Node is admitted to the snapshot
|
||||||
|
//! **only** once its `info` has arrived (until then it is withheld and is a
|
||||||
|
//! readiness obligation); a Device resolves a node's claim only once *its*
|
||||||
|
//! `info` has arrived; and `info` may fire again for the lifetime of the
|
||||||
|
//! object, so [`RegEvent::NodeInfo`] is both the first resolution and every
|
||||||
|
//! later property change (v3.5 §6.7 decisions 1–4).
|
||||||
//!
|
//!
|
||||||
//! Three things this core is shaped to get right, each an exit-gate row:
|
//! Three things this core is shaped to get right, each an exit-gate row:
|
||||||
//!
|
//!
|
||||||
@@ -14,18 +36,20 @@
|
|||||||
//! id, and those recycle. The model keeps an insertion-ordered index per id
|
//! id, and those recycle. The model keeps an insertion-ordered index per id
|
||||||
//! so a removal accounts for the *oldest* generation first, and the
|
//! so a removal accounts for the *oldest* generation first, and the
|
||||||
//! snapshot projection treats any id still claimed by two live objects as
|
//! snapshot projection treats any id still claimed by two live objects as
|
||||||
//! [`IdLookup::Ambiguous`] — fail closed (v3.4 §6.1.3).
|
//! [`IdLookup::Ambiguous`] — fail closed (v3.4 §6.1.3). Everything the
|
||||||
|
//! model *owns* is keyed by never-recycled `object.serial`; ids are only
|
||||||
|
//! ever a lookup.
|
||||||
//! - **The readiness epoch.** `graph_ready` is false until the initial graph
|
//! - **The readiness epoch.** `graph_ready` is false until the initial graph
|
||||||
//! is fully observed: the server has synced **and** no binds/withheld nodes
|
//! is fully observed: the server has synced **and** no binds/withheld nodes
|
||||||
//! remain outstanding. A bounded timeout makes it fail closed. It gates
|
//! remain outstanding. A bounded timeout makes it fail closed. It gates
|
||||||
//! sticky *retirement* only; withholding after completion is per-object.
|
//! sticky *retirement* only; withholding after completion is per-object.
|
||||||
//! - **Withholding on unresolved devices.** A node claiming a `device.id`
|
//! - **Withholding on unresolved input.** A node with no `info` yet, or one
|
||||||
//! whose Device we have not observed is held out of the snapshot entirely
|
//! claiming a `device.id` whose Device we have not resolved, is held out of
|
||||||
//! rather than admitted with a provisional `session_device` (see
|
//! the snapshot entirely rather than admitted with provisional ownership
|
||||||
//! [`classify`]).
|
//! (see [`classify`]).
|
||||||
//!
|
//!
|
||||||
//! **Two accepted limitations (Codex phase-3 review, findings 3 and 4), both
|
//! **Three accepted limitations, all low-reachability, owed to a later
|
||||||
//! low-reachability, owed to a later hardening round:**
|
//! hardening round:**
|
||||||
//!
|
//!
|
||||||
//! - *A Link dropped for a missing `object.serial`/props is unrepresented.*
|
//! - *A Link dropped for a missing `object.serial`/props is unrepresented.*
|
||||||
//! The adapter drops such a global before it reaches [`RegistryModel`], so
|
//! The adapter drops such a global before it reaches [`RegistryModel`], so
|
||||||
@@ -45,6 +69,13 @@
|
|||||||
//! silently drop `global_remove`, so this needs callback loss to trigger.
|
//! silently drop `global_remove`, so this needs callback loss to trigger.
|
||||||
//! The snapshot treats the two-claimant window as [`IdLookup::Ambiguous`]
|
//! The snapshot treats the two-claimant window as [`IdLookup::Ambiguous`]
|
||||||
//! (fail closed) meanwhile.
|
//! (fail closed) meanwhile.
|
||||||
|
//! - *An unresolvable bind takes the whole graph down, not just its node*
|
||||||
|
//! (v3.5 §6.7 decision 3). A node whose `info` never arrives keeps
|
||||||
|
//! readiness false until the deadline, then sticky-[`Readiness::TimedOut`]
|
||||||
|
//! — no fan-out at all, identical to a never-resolving Link bind. Per-node
|
||||||
|
//! quarantine (that node ineligible **and** taint-bearing, the rest of the
|
||||||
|
//! graph still working) is strictly better and is deferred because it is a
|
||||||
|
//! new concept in the *pure engine*, not a fix to the observer.
|
||||||
|
|
||||||
#![allow(dead_code)] // Wired by the phase-3 adapter (Codex's half) and consumed by later phases.
|
#![allow(dead_code)] // Wired by the phase-3 adapter (Codex's half) and consumed by later phases.
|
||||||
|
|
||||||
@@ -59,7 +90,7 @@ use crate::host::taint::snapshot::{
|
|||||||
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
|
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
|
||||||
PortSnapshot, Serial,
|
PortSnapshot, Serial,
|
||||||
};
|
};
|
||||||
use classify::{Classification, DeviceClaim};
|
use classify::{Classification, DeviceClaim, DeviceProps};
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
|
|
||||||
/// A monotonic millisecond clock value, supplied by the adapter via
|
/// A monotonic millisecond clock value, supplied by the adapter via
|
||||||
@@ -67,14 +98,17 @@ use std::collections::{BTreeMap, VecDeque};
|
|||||||
/// [`std::time::Instant`] so the readiness timeout is deterministic in tests.
|
/// [`std::time::Instant`] so the readiness timeout is deterministic in tests.
|
||||||
pub type Millis = u64;
|
pub type Millis = u64;
|
||||||
|
|
||||||
/// A Node as observed off the registry, before `session_device` has been
|
/// A Node's **bound `info` properties** — the sole source of node properties
|
||||||
/// decided. The adapter fills [`NodeProps`] with everything it can parse and
|
/// (v3.5 §6.7), delivered by [`RegEvent::NodeInfo`].
|
||||||
/// leaves `session_device` at its `false` default; the model overwrites it
|
///
|
||||||
/// from the [`classify`] result once the backing Device (if any) is resolved.
|
/// This carries no identity: the serial names the node on the event and the
|
||||||
|
/// global id was recorded by [`RegEvent::NodeAdded`], so the adapter cannot
|
||||||
|
/// contradict the index it already published. `session_device` inside
|
||||||
|
/// [`NodeObservation::props`] is left at its `false` default; the model
|
||||||
|
/// overwrites it from the [`classify`] result at projection time, once the
|
||||||
|
/// backing Device (if any) is resolved.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct NodeObservation {
|
pub struct NodeObservation {
|
||||||
pub serial: Serial,
|
|
||||||
pub id: GlobalId,
|
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub role: MediaRole,
|
pub role: MediaRole,
|
||||||
pub props: NodeProps,
|
pub props: NodeProps,
|
||||||
@@ -97,19 +131,39 @@ pub struct LinkEndpoints {
|
|||||||
/// model consumes them in [`RegistryModel::apply`].
|
/// model consumes them in [`RegistryModel::apply`].
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum RegEvent {
|
pub enum RegEvent {
|
||||||
/// A Node global appeared. Admitted immediately unless it claims an
|
/// A Node global appeared. **Index only** — the global's properties are a
|
||||||
/// unresolved Device (then withheld — see [`classify`]).
|
/// filtered subset and are not read (v3.5 §6.7). The node is withheld
|
||||||
NodeAdded(NodeObservation),
|
/// from the snapshot and is a readiness obligation until its
|
||||||
|
/// [`RegEvent::NodeInfo`] arrives.
|
||||||
|
NodeAdded { serial: Serial, id: GlobalId },
|
||||||
|
/// A bound Node's `info` properties. **Both** the first resolution and
|
||||||
|
/// every later `PROPS` change for the node's lifetime — the model tells
|
||||||
|
/// them apart, so the adapter holds no per-node "have I seen info yet?"
|
||||||
|
/// state to get wrong. An `info` for a serial we do not hold (a node
|
||||||
|
/// already removed) is ignored.
|
||||||
|
NodeInfo {
|
||||||
|
serial: Serial,
|
||||||
|
observation: NodeObservation,
|
||||||
|
},
|
||||||
/// A Port global appeared.
|
/// A Port global appeared.
|
||||||
PortAdded(PortSnapshot),
|
PortAdded(PortSnapshot),
|
||||||
/// A Client global appeared. Feeds pulse-PID derivation via `sec_pid`.
|
/// A Client global appeared. Feeds pulse-PID derivation via `sec_pid`.
|
||||||
ClientAdded(ClientSnapshot),
|
ClientAdded(ClientSnapshot),
|
||||||
/// A Device global appeared. Resolves any nodes withheld on its id.
|
/// A Device global appeared. Index only, exactly as for a Node: it does
|
||||||
DeviceAdded { id: GlobalId },
|
/// not resolve anything until [`RegEvent::DeviceInfo`] arrives.
|
||||||
|
DeviceAdded { serial: Serial, id: GlobalId },
|
||||||
|
/// A bound Device's `info` properties — the **authoritative** source of
|
||||||
|
/// `device.api` and `alsa.driver_name` (v3.5 §6.7 decision 4). Resolves
|
||||||
|
/// every node withheld on this Device's id.
|
||||||
|
DeviceInfo { serial: Serial, props: DeviceProps },
|
||||||
/// A Link global appeared. `endpoints` is `Some` when the global carried
|
/// A Link global appeared. `endpoints` is `Some` when the global carried
|
||||||
/// them (the optimisation) and `None` when the adapter must bind to learn
|
/// them (the optimisation) and `None` when the adapter must bind to learn
|
||||||
/// them (the correctness path) — the latter is an outstanding obligation
|
/// them (the correctness path) — the latter is an outstanding obligation
|
||||||
/// until a matching [`RegEvent::LinkEndpointsResolved`] arrives.
|
/// until a matching [`RegEvent::LinkEndpointsResolved`] arrives.
|
||||||
|
///
|
||||||
|
/// Unlike Nodes and Devices, Link endpoint props **are** announced on the
|
||||||
|
/// global (measured, phase-5 results F1), so this asymmetry is real and
|
||||||
|
/// deliberate.
|
||||||
LinkAdded {
|
LinkAdded {
|
||||||
serial: Serial,
|
serial: Serial,
|
||||||
id: GlobalId,
|
id: GlobalId,
|
||||||
@@ -143,7 +197,7 @@ pub enum RegEvent {
|
|||||||
/// one is worth suppressing on a tick but never on a graph event.
|
/// one is worth suppressing on a tick but never on a graph event.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum EventKind {
|
pub enum EventKind {
|
||||||
/// A registry observation: an add, a removal, a link resolution, a `/proc`
|
/// A registry observation: an add, a removal, a bind resolution, a `/proc`
|
||||||
/// probe, or the server sync.
|
/// probe, or the server sync.
|
||||||
Graph,
|
Graph,
|
||||||
/// The periodic clock sample. Carries no graph information; it exists so the
|
/// The periodic clock sample. Carries no graph information; it exists so the
|
||||||
@@ -169,16 +223,39 @@ impl RegEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an applied event could have changed the projection.
|
||||||
|
///
|
||||||
|
/// The suppression rule of v3.5 §6.7 decision 2, in the one place that can
|
||||||
|
/// enforce it: **a property update may be dropped only when the resulting
|
||||||
|
/// [`Projection`] is identical to the current one.** The projection is a pure
|
||||||
|
/// function of model state, so "state provably unchanged" *is* "projection
|
||||||
|
/// identical" — which is what [`Outcome::Suppressed`] means and why the check
|
||||||
|
/// is a cheap field comparison rather than building and diffing two snapshots.
|
||||||
|
///
|
||||||
|
/// Anything looser (dropping updates that do change state) breaks phase 4's
|
||||||
|
/// no-coalescing contract, which needs to see the empty gap between an AEC
|
||||||
|
/// module unload and a reload that reuses the index. Anything stricter
|
||||||
|
/// (publishing on every `info`, including the state-only changes PipeWire
|
||||||
|
/// emits constantly) inflates the O5 event rate with non-events.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Outcome {
|
||||||
|
/// Model state may have changed; the caller must publish the projection.
|
||||||
|
Applied,
|
||||||
|
/// Model state provably did not change; publishing is optional and the
|
||||||
|
/// adapter skips it.
|
||||||
|
Suppressed,
|
||||||
|
}
|
||||||
|
|
||||||
/// Which slot in the id index a live object occupies. `global_remove` gives
|
/// Which slot in the id index a live object occupies. `global_remove` gives
|
||||||
/// only the id, so the index remembers what each id currently holds. A Node
|
/// only the id, so the index remembers what each id currently holds. Every
|
||||||
/// slot's serial may live in either the admitted or the withheld map.
|
/// slot names its object by never-recycled serial.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
enum Slot {
|
enum Slot {
|
||||||
Node(Serial),
|
Node(Serial),
|
||||||
Port(Serial),
|
Port(Serial),
|
||||||
Link(Serial),
|
Link(Serial),
|
||||||
Client(Serial),
|
Client(Serial),
|
||||||
Device,
|
Device(Serial),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The readiness epoch. A one-time transition out of [`Readiness::Waiting`];
|
/// The readiness epoch. A one-time transition out of [`Readiness::Waiting`];
|
||||||
@@ -220,25 +297,45 @@ pub struct Projection {
|
|||||||
pub readiness: Readiness,
|
pub readiness: Readiness,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A live Node: its global id (for link endpoint lookup) plus its bound
|
||||||
|
/// properties once they arrive.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct NodeEntry {
|
||||||
|
id: GlobalId,
|
||||||
|
/// `None` while the bind is outstanding — withheld from the snapshot and
|
||||||
|
/// an outstanding readiness obligation (v3.5 §6.7 decision 3).
|
||||||
|
obs: Option<NodeObservation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live Device: its global id plus its bound properties once they arrive.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct DeviceEntry {
|
||||||
|
id: GlobalId,
|
||||||
|
/// `None` while the bind is outstanding. A node claiming this Device
|
||||||
|
/// stays withheld until it is `Some` — the Device's `device.api` and
|
||||||
|
/// `alsa.driver_name` are the authoritative inputs to `session_device`
|
||||||
|
/// (v3.5 §6.7 decision 4), so classifying without them would be the same
|
||||||
|
/// provisional answer the contract forbids.
|
||||||
|
props: Option<DeviceProps>,
|
||||||
|
}
|
||||||
|
|
||||||
/// The live model. Folds [`RegEvent`]s; project with [`RegistryModel::project`].
|
/// The live model. Folds [`RegEvent`]s; project with [`RegistryModel::project`].
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RegistryModel {
|
pub struct RegistryModel {
|
||||||
// Admitted objects, keyed by their never-recycled serial.
|
/// **Every** live Node, keyed by serial — admitted or withheld. Admission
|
||||||
nodes: BTreeMap<Serial, NodeSnapshot>,
|
/// is decided at projection time from the entry's own state, so there is
|
||||||
|
/// no admitted/withheld pair of maps to drift apart.
|
||||||
|
nodes: BTreeMap<Serial, NodeEntry>,
|
||||||
|
/// Every live Device, keyed by serial.
|
||||||
|
devices: BTreeMap<Serial, DeviceEntry>,
|
||||||
ports: BTreeMap<Serial, PortSnapshot>,
|
ports: BTreeMap<Serial, PortSnapshot>,
|
||||||
links: BTreeMap<Serial, LinkSnapshot>,
|
links: BTreeMap<Serial, LinkSnapshot>,
|
||||||
clients: BTreeMap<Serial, ClientSnapshot>,
|
clients: BTreeMap<Serial, ClientSnapshot>,
|
||||||
|
|
||||||
/// Nodes held out of the snapshot pending their Device's resolution.
|
|
||||||
withheld: BTreeMap<Serial, NodeObservation>,
|
|
||||||
/// Links whose endpoints the adapter is still binding; the id is kept so
|
/// Links whose endpoints the adapter is still binding; the id is kept so
|
||||||
/// removal and resolution can find them.
|
/// removal and resolution can find them.
|
||||||
pending_links: BTreeMap<Serial, GlobalId>,
|
pending_links: BTreeMap<Serial, GlobalId>,
|
||||||
|
|
||||||
/// Live Device global ids, ref-counted so a recycled id is only
|
|
||||||
/// considered resolved while a Device actually holds it.
|
|
||||||
resolved_devices: BTreeMap<GlobalId, usize>,
|
|
||||||
|
|
||||||
/// Insertion-ordered holders of each live global id. `global_remove`
|
/// Insertion-ordered holders of each live global id. `global_remove`
|
||||||
/// accounts for the oldest generation first (v3.4 §6.1.3).
|
/// accounts for the oldest generation first (v3.4 §6.1.3).
|
||||||
live_ids: BTreeMap<GlobalId, VecDeque<Slot>>,
|
live_ids: BTreeMap<GlobalId, VecDeque<Slot>>,
|
||||||
@@ -259,12 +356,11 @@ impl RegistryModel {
|
|||||||
pub fn new(now: Millis, timeout: Millis) -> Self {
|
pub fn new(now: Millis, timeout: Millis) -> Self {
|
||||||
Self {
|
Self {
|
||||||
nodes: BTreeMap::new(),
|
nodes: BTreeMap::new(),
|
||||||
|
devices: BTreeMap::new(),
|
||||||
ports: BTreeMap::new(),
|
ports: BTreeMap::new(),
|
||||||
links: BTreeMap::new(),
|
links: BTreeMap::new(),
|
||||||
clients: BTreeMap::new(),
|
clients: BTreeMap::new(),
|
||||||
withheld: BTreeMap::new(),
|
|
||||||
pending_links: BTreeMap::new(),
|
pending_links: BTreeMap::new(),
|
||||||
resolved_devices: BTreeMap::new(),
|
|
||||||
live_ids: BTreeMap::new(),
|
live_ids: BTreeMap::new(),
|
||||||
probed_comm: BTreeMap::new(),
|
probed_comm: BTreeMap::new(),
|
||||||
server_synced: false,
|
server_synced: false,
|
||||||
@@ -283,21 +379,22 @@ impl RegistryModel {
|
|||||||
///
|
///
|
||||||
/// This is **dynamic**, not the sticky [`Readiness::Complete`] flag: it is
|
/// This is **dynamic**, not the sticky [`Readiness::Complete`] flag: it is
|
||||||
/// true only when the initial enumeration has completed **and** there are
|
/// true only when the initial enumeration has completed **and** there are
|
||||||
/// no current obligations outstanding (a node withheld on an unresolved
|
/// no current obligations outstanding (a node whose bind is outstanding, a
|
||||||
/// Device, or a Link still being bound). The distinction is the fix for
|
/// node withheld on an unresolved Device, or a Link still being bound).
|
||||||
/// Codex phase-3 review finding 1: a Link whose endpoints are still
|
/// The distinction is the fix for Codex phase-3 review finding 1: a Link
|
||||||
/// resolving is an **invisible edge** — it is absent from the snapshot,
|
/// whose endpoints are still resolving is an **invisible edge** — it is
|
||||||
/// not merely dangling — so a decision made while one exists can miss real
|
/// absent from the snapshot, not merely dangling — so a decision made
|
||||||
/// tainted ancestry and wrongly report a candidate eligible. Unresolved
|
/// while one exists can miss real tainted ancestry and wrongly report a
|
||||||
/// ancestry ⇒ fail closed is the governing invariant (v3.4 §6.1), and an
|
/// candidate eligible. Unresolved ancestry ⇒ fail closed is the governing
|
||||||
/// unresolved Link is unresolved ancestry, so `graph_ready` must drop back
|
/// invariant (v3.4 §6.1), and round 8 adds the far more common case: an
|
||||||
/// to false whenever one is pending — even after the initial epoch.
|
/// unbound node is an invisible *vertex*, which hides everything the edge
|
||||||
|
/// case hides and its ownership besides.
|
||||||
///
|
///
|
||||||
/// [`Readiness::Complete`] stays sticky (it records that the initial
|
/// [`Readiness::Complete`] stays sticky (it records that the initial
|
||||||
/// enumeration happened, for logging and to distinguish "not started" from
|
/// enumeration happened, for logging and to distinguish "not started" from
|
||||||
/// "momentarily churning"); `graph_ready` layers the dynamic obligation
|
/// "momentarily churning"); `graph_ready` layers the dynamic obligation
|
||||||
/// check on top. Downstream (phase 6) may debounce the brief blips a
|
/// check on top. Downstream (phase 6) may debounce the brief blips a
|
||||||
/// normal Link bind causes; the observer's job is to report the truth.
|
/// normal bind causes; the observer's job is to report the truth.
|
||||||
pub fn graph_ready(&self) -> bool {
|
pub fn graph_ready(&self) -> bool {
|
||||||
matches!(self.readiness, Readiness::Complete) && !self.obligations_outstanding()
|
matches!(self.readiness, Readiness::Complete) && !self.obligations_outstanding()
|
||||||
}
|
}
|
||||||
@@ -310,102 +407,120 @@ impl RegistryModel {
|
|||||||
pulse_pid::candidate(&clients)
|
pulse_pid::candidate(&clients)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fold one observation into the model.
|
/// Fold one observation into the model. The returned [`Outcome`] tells the
|
||||||
pub fn apply(&mut self, event: RegEvent) {
|
/// caller whether the projection can have changed; see [`Outcome`] for why
|
||||||
|
/// that is the only sound place to enforce the suppression rule.
|
||||||
|
pub fn apply(&mut self, event: RegEvent) -> Outcome {
|
||||||
match event {
|
match event {
|
||||||
RegEvent::NodeAdded(obs) => self.on_node_added(obs),
|
RegEvent::NodeAdded { serial, id } => {
|
||||||
|
self.push_id(id, Slot::Node(serial));
|
||||||
|
self.nodes.insert(serial, NodeEntry { id, obs: None });
|
||||||
|
// A node awaiting its bind is a fresh obligation, so this can
|
||||||
|
// only ever *hold* readiness, never complete it — but the
|
||||||
|
// re-check is cheap and keeps the invariant local.
|
||||||
|
self.maybe_complete();
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
RegEvent::NodeInfo {
|
||||||
|
serial,
|
||||||
|
observation,
|
||||||
|
} => self.on_node_info(serial, observation),
|
||||||
RegEvent::PortAdded(port) => {
|
RegEvent::PortAdded(port) => {
|
||||||
self.push_id(port.id, Slot::Port(port.serial));
|
self.push_id(port.id, Slot::Port(port.serial));
|
||||||
self.ports.insert(port.serial, port);
|
self.ports.insert(port.serial, port);
|
||||||
|
Outcome::Applied
|
||||||
}
|
}
|
||||||
RegEvent::ClientAdded(client) => {
|
RegEvent::ClientAdded(client) => {
|
||||||
self.push_id(client.id, Slot::Client(client.serial));
|
self.push_id(client.id, Slot::Client(client.serial));
|
||||||
self.clients.insert(client.serial, client);
|
self.clients.insert(client.serial, client);
|
||||||
// A new client can change the pulse candidate; the adapter
|
// A new client can change the pulse candidate; the adapter
|
||||||
// learns that via `pulse_pid_candidate`. No readiness effect.
|
// learns that via `pulse_pid_candidate`. No readiness effect.
|
||||||
|
Outcome::Applied
|
||||||
}
|
}
|
||||||
RegEvent::DeviceAdded { id } => self.on_device_added(id),
|
RegEvent::DeviceAdded { serial, id } => {
|
||||||
|
self.push_id(id, Slot::Device(serial));
|
||||||
|
self.devices.insert(serial, DeviceEntry { id, props: None });
|
||||||
|
self.maybe_complete();
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
RegEvent::DeviceInfo { serial, props } => self.on_device_info(serial, props),
|
||||||
RegEvent::LinkAdded {
|
RegEvent::LinkAdded {
|
||||||
serial,
|
serial,
|
||||||
id,
|
id,
|
||||||
endpoints,
|
endpoints,
|
||||||
} => self.on_link_added(serial, id, endpoints),
|
} => {
|
||||||
|
self.on_link_added(serial, id, endpoints);
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
RegEvent::LinkEndpointsResolved { serial, endpoints } => {
|
RegEvent::LinkEndpointsResolved { serial, endpoints } => {
|
||||||
self.on_link_resolved(serial, endpoints)
|
self.on_link_resolved(serial, endpoints)
|
||||||
}
|
}
|
||||||
RegEvent::ProcCommProbed { pid, comm } => {
|
RegEvent::ProcCommProbed { pid, comm } => {
|
||||||
self.probed_comm.insert(pid, comm);
|
let previous = self.probed_comm.insert(pid, comm.clone());
|
||||||
|
if previous.as_ref() == Some(&comm) {
|
||||||
|
Outcome::Suppressed
|
||||||
|
} else {
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
}
|
}
|
||||||
RegEvent::Removed { id } => self.on_removed(id),
|
RegEvent::Removed { id } => self.on_removed(id),
|
||||||
RegEvent::ServerSynced => {
|
RegEvent::ServerSynced => {
|
||||||
|
let already = self.server_synced;
|
||||||
self.server_synced = true;
|
self.server_synced = true;
|
||||||
self.maybe_complete();
|
self.maybe_complete();
|
||||||
|
if already {
|
||||||
|
Outcome::Suppressed
|
||||||
|
} else {
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
}
|
}
|
||||||
RegEvent::Tick { now } => {
|
RegEvent::Tick { now } => {
|
||||||
self.last_now = now;
|
self.last_now = now;
|
||||||
self.maybe_timeout(now);
|
self.maybe_timeout(now);
|
||||||
|
Outcome::Applied
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_node_added(&mut self, obs: NodeObservation) {
|
/// First resolution *and* every later property change (v3.5 §6.7
|
||||||
self.push_id(obs.id, Slot::Node(obs.serial));
|
/// decision 2). The model distinguishes them by what it already holds, so
|
||||||
let resolved = obs
|
/// the adapter can forward every `info` callback unconditionally.
|
||||||
.device_claim
|
fn on_node_info(&mut self, serial: Serial, observation: NodeObservation) -> Outcome {
|
||||||
.device_id
|
let Some(entry) = self.nodes.get_mut(&serial) else {
|
||||||
.is_some_and(|id| self.device_resolved(id));
|
// A late `info` for a node already removed. Re-inserting it here
|
||||||
match classify::classify(&obs.device_claim, resolved) {
|
// would resurrect a dead node with no id index behind it.
|
||||||
Classification::Withhold { .. } => {
|
tracing::debug!(serial = serial.0, "observer: node info for an unknown node");
|
||||||
self.withheld.insert(obs.serial, obs);
|
return Outcome::Suppressed;
|
||||||
}
|
};
|
||||||
Classification::SessionDevice => self.admit_node(obs, true),
|
if entry.obs.as_ref() == Some(&observation) {
|
||||||
Classification::NotADevice | Classification::NotSessionDevice => {
|
// The state-only `info` callbacks PipeWire emits constantly: same
|
||||||
self.admit_node(obs, false)
|
// properties, so the projection is provably identical.
|
||||||
}
|
return Outcome::Suppressed;
|
||||||
}
|
}
|
||||||
// Withholding a node adds an obligation; admitting one can never
|
entry.obs = Some(observation);
|
||||||
// complete readiness on its own, but re-check is cheap and keeps the
|
// The first `info` retires this node's obligation, which can be the
|
||||||
// invariant local.
|
// last one outstanding.
|
||||||
self.maybe_complete();
|
self.maybe_complete();
|
||||||
|
Outcome::Applied
|
||||||
}
|
}
|
||||||
|
|
||||||
fn admit_node(&mut self, obs: NodeObservation, session_device: bool) {
|
fn on_device_info(&mut self, serial: Serial, props: DeviceProps) -> Outcome {
|
||||||
let mut props = obs.props;
|
let Some(entry) = self.devices.get_mut(&serial) else {
|
||||||
props.session_device = session_device;
|
tracing::debug!(
|
||||||
self.nodes.insert(
|
serial = serial.0,
|
||||||
obs.serial,
|
"observer: device info for an unknown device"
|
||||||
NodeSnapshot {
|
);
|
||||||
serial: obs.serial,
|
return Outcome::Suppressed;
|
||||||
id: obs.id,
|
};
|
||||||
name: obs.name,
|
if entry.props.as_ref() == Some(&props) {
|
||||||
role: obs.role,
|
return Outcome::Suppressed;
|
||||||
props,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_device_added(&mut self, id: GlobalId) {
|
|
||||||
self.push_id(id, Slot::Device);
|
|
||||||
*self.resolved_devices.entry(id).or_insert(0) += 1;
|
|
||||||
// Admit every node that was withheld waiting on exactly this Device.
|
|
||||||
let ready: Vec<Serial> = self
|
|
||||||
.withheld
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, obs)| obs.device_claim.device_id == Some(id))
|
|
||||||
.map(|(&serial, _)| serial)
|
|
||||||
.collect();
|
|
||||||
for serial in ready {
|
|
||||||
if let Some(obs) = self.withheld.remove(&serial) {
|
|
||||||
// Resolved now, so classify yields a terminal answer, never
|
|
||||||
// Withhold again.
|
|
||||||
let session_device = matches!(
|
|
||||||
classify::classify(&obs.device_claim, true),
|
|
||||||
Classification::SessionDevice
|
|
||||||
);
|
|
||||||
self.admit_node(obs, session_device);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
entry.props = Some(props);
|
||||||
|
// Resolving a Device admits every node that was withheld on it —
|
||||||
|
// which happens at projection time; here it can only retire
|
||||||
|
// obligations.
|
||||||
self.maybe_complete();
|
self.maybe_complete();
|
||||||
|
Outcome::Applied
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_link_added(&mut self, serial: Serial, id: GlobalId, endpoints: Option<LinkEndpoints>) {
|
fn on_link_added(&mut self, serial: Serial, id: GlobalId, endpoints: Option<LinkEndpoints>) {
|
||||||
@@ -423,20 +538,23 @@ impl RegistryModel {
|
|||||||
self.maybe_complete();
|
self.maybe_complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_link_resolved(&mut self, serial: Serial, endpoints: LinkEndpoints) {
|
fn on_link_resolved(&mut self, serial: Serial, endpoints: LinkEndpoints) -> Outcome {
|
||||||
// `remove` also guards against a stale resolution for a Link already
|
// `remove` also guards against a stale resolution for a Link already
|
||||||
// gone: unknown serial ⇒ ignore.
|
// gone: unknown serial ⇒ ignore.
|
||||||
if let Some(id) = self.pending_links.remove(&serial) {
|
if let Some(id) = self.pending_links.remove(&serial) {
|
||||||
self.links
|
self.links
|
||||||
.insert(serial, link_snapshot(serial, id, endpoints));
|
.insert(serial, link_snapshot(serial, id, endpoints));
|
||||||
self.maybe_complete();
|
self.maybe_complete();
|
||||||
|
Outcome::Applied
|
||||||
|
} else {
|
||||||
|
Outcome::Suppressed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_removed(&mut self, id: GlobalId) {
|
fn on_removed(&mut self, id: GlobalId) -> Outcome {
|
||||||
let Some(queue) = self.live_ids.get_mut(&id) else {
|
let Some(queue) = self.live_ids.get_mut(&id) else {
|
||||||
tracing::warn!(global_id = id.0, "observer: remove for an id we never saw");
|
tracing::warn!(global_id = id.0, "observer: remove for an id we never saw");
|
||||||
return;
|
return Outcome::Suppressed;
|
||||||
};
|
};
|
||||||
// Oldest generation first — the id may be shared during a
|
// Oldest generation first — the id may be shared during a
|
||||||
// missed-removal window.
|
// missed-removal window.
|
||||||
@@ -446,10 +564,7 @@ impl RegistryModel {
|
|||||||
}
|
}
|
||||||
match slot {
|
match slot {
|
||||||
Some(Slot::Node(serial)) => {
|
Some(Slot::Node(serial)) => {
|
||||||
if self.nodes.remove(&serial).is_none() {
|
self.nodes.remove(&serial);
|
||||||
// Was still withheld — drop the obligation.
|
|
||||||
self.withheld.remove(&serial);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Some(Slot::Port(serial)) => {
|
Some(Slot::Port(serial)) => {
|
||||||
self.ports.remove(&serial);
|
self.ports.remove(&serial);
|
||||||
@@ -461,35 +576,77 @@ impl RegistryModel {
|
|||||||
Some(Slot::Client(serial)) => {
|
Some(Slot::Client(serial)) => {
|
||||||
self.clients.remove(&serial);
|
self.clients.remove(&serial);
|
||||||
}
|
}
|
||||||
Some(Slot::Device) => {
|
Some(Slot::Device(serial)) => {
|
||||||
if let Some(count) = self.resolved_devices.get_mut(&id) {
|
self.devices.remove(&serial);
|
||||||
*count -= 1;
|
|
||||||
if *count == 0 {
|
|
||||||
self.resolved_devices.remove(&id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(global_id = id.0, "observer: empty id slot on remove");
|
tracing::warn!(global_id = id.0, "observer: empty id slot on remove");
|
||||||
|
return Outcome::Suppressed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A removal can drain the last obligation (a withheld node or pending
|
// A removal can drain the last obligation (an unbound node, a node
|
||||||
// link vanished before it resolved).
|
// withheld on a Device, or a pending link vanished before it
|
||||||
|
// resolved).
|
||||||
self.maybe_complete();
|
self.maybe_complete();
|
||||||
|
Outcome::Applied
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_id(&mut self, id: GlobalId, slot: Slot) {
|
fn push_id(&mut self, id: GlobalId, slot: Slot) {
|
||||||
self.live_ids.entry(id).or_default().push_back(slot);
|
self.live_ids.entry(id).or_default().push_back(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn device_resolved(&self, id: GlobalId) -> bool {
|
/// The bound properties of the Device a node claims by global id, or
|
||||||
self.resolved_devices.get(&id).is_some_and(|&n| n > 0)
|
/// `None` when that claim is unresolved — which covers every fail-closed
|
||||||
|
/// case at once: no such Device observed, its bind still outstanding, or
|
||||||
|
/// **the id claimed by more than one live global**, where there is no way
|
||||||
|
/// to tell whose properties these are (v3.4 §6.1.3).
|
||||||
|
///
|
||||||
|
/// ⚠️ The ambiguity test is "**exactly one** live global holds this id",
|
||||||
|
/// not "exactly one live *Device*" (Codex phase-3r review, finding 2).
|
||||||
|
/// The weaker test looks equivalent and is not: with `[Device, Port]` on
|
||||||
|
/// one id — a missed removal, the same precondition as every other
|
||||||
|
/// recycled-id hazard — it keeps answering with the older Device's
|
||||||
|
/// properties, so a node claiming that id holds a stale
|
||||||
|
/// `session_device = true`. That flag *removes* the node's owner keys and
|
||||||
|
/// its fail-closed backstop, so a forwarder wearing it can put its output
|
||||||
|
/// leg back on the eligible side: echo, from a lookup that was merely
|
||||||
|
/// looking at the wrong object type.
|
||||||
|
fn device_props(&self, id: GlobalId) -> Option<&DeviceProps> {
|
||||||
|
let slots = self.live_ids.get(&id)?;
|
||||||
|
if slots.len() != 1 {
|
||||||
|
return None; // Ambiguous ⇒ unresolved ⇒ withheld.
|
||||||
|
}
|
||||||
|
let Slot::Device(serial) = slots.front()? else {
|
||||||
|
// The id is live, but it is not a Device any more.
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
self.devices.get(serial)?.props.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify one node's device claim against the currently resolved
|
||||||
|
/// Devices. Recomputed per projection rather than cached at admission:
|
||||||
|
/// the inputs (this node's props, its Device's props) both change over an
|
||||||
|
/// object's lifetime now, and a cached classification is exactly the kind
|
||||||
|
/// of stale provisional answer §6.1.3 forbids.
|
||||||
|
fn classification(&self, obs: &NodeObservation) -> Classification {
|
||||||
|
let device = obs
|
||||||
|
.device_claim
|
||||||
|
.device_id
|
||||||
|
.and_then(|id| self.device_props(id));
|
||||||
|
classify::classify(&obs.device_claim, device)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every obligation that must clear before the initial graph is trusted:
|
/// Every obligation that must clear before the initial graph is trusted:
|
||||||
/// no node withheld on an unresolved Device, no Link awaiting its bind.
|
/// no node awaiting its bind, no node withheld on an unresolved Device,
|
||||||
|
/// no Link awaiting its bind.
|
||||||
fn obligations_outstanding(&self) -> bool {
|
fn obligations_outstanding(&self) -> bool {
|
||||||
!self.withheld.is_empty() || !self.pending_links.is_empty()
|
if !self.pending_links.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.nodes.values().any(|entry| match &entry.obs {
|
||||||
|
None => true,
|
||||||
|
Some(obs) => matches!(self.classification(obs), Classification::Withhold { .. }),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Completion needs no clock — only the sync flag and an empty obligation
|
/// Completion needs no clock — only the sync flag and an empty obligation
|
||||||
@@ -512,13 +669,34 @@ impl RegistryModel {
|
|||||||
if now >= self.deadline {
|
if now >= self.deadline {
|
||||||
self.readiness = Readiness::TimedOut;
|
self.readiness = Readiness::TimedOut;
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
withheld = self.withheld.len(),
|
unbound_nodes = self.unbound_node_count(),
|
||||||
|
withheld = self.withheld_node_count(),
|
||||||
pending_links = self.pending_links.len(),
|
pending_links = self.pending_links.len(),
|
||||||
"observer: readiness epoch timed out with obligations outstanding — fail closed"
|
"observer: readiness epoch timed out with obligations outstanding — fail closed"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nodes whose bind has not delivered `info` yet — diagnostics only.
|
||||||
|
fn unbound_node_count(&self) -> usize {
|
||||||
|
self.nodes
|
||||||
|
.values()
|
||||||
|
.filter(|entry| entry.obs.is_none())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nodes held out on an unresolved Device — diagnostics only.
|
||||||
|
fn withheld_node_count(&self) -> usize {
|
||||||
|
self.nodes
|
||||||
|
.values()
|
||||||
|
.filter(|entry| {
|
||||||
|
entry.obs.as_ref().is_some_and(|obs| {
|
||||||
|
matches!(self.classification(obs), Classification::Withhold { .. })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
/// pipewire-pulse's PID from the current clients, validated against the
|
/// pipewire-pulse's PID from the current clients, validated against the
|
||||||
/// probed `comm`. `None` whenever anything is ambiguous or unconfirmed —
|
/// probed `comm`. `None` whenever anything is ambiguous or unconfirmed —
|
||||||
/// the safe answer (key 4 unusable).
|
/// the safe answer (key 4 unusable).
|
||||||
@@ -529,9 +707,34 @@ impl RegistryModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Project the current state into the taint engine's inputs.
|
/// Project the current state into the taint engine's inputs.
|
||||||
|
///
|
||||||
|
/// A node enters the snapshot only if its bind has delivered `info`
|
||||||
|
/// **and** its device claim classifies terminally; anything else is
|
||||||
|
/// withheld (and is already holding `graph_ready` false).
|
||||||
pub fn project(&self) -> Projection {
|
pub fn project(&self) -> Projection {
|
||||||
|
let nodes: Vec<NodeSnapshot> = self
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(&serial, entry)| {
|
||||||
|
let obs = entry.obs.as_ref()?;
|
||||||
|
let session_device = match self.classification(obs) {
|
||||||
|
Classification::Withhold { .. } => return None,
|
||||||
|
Classification::SessionDevice => true,
|
||||||
|
Classification::NotADevice | Classification::NotSessionDevice => false,
|
||||||
|
};
|
||||||
|
let mut props = obs.props.clone();
|
||||||
|
props.session_device = session_device;
|
||||||
|
Some(NodeSnapshot {
|
||||||
|
serial,
|
||||||
|
id: entry.id,
|
||||||
|
name: obs.name.clone(),
|
||||||
|
role: obs.role,
|
||||||
|
props,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
let snapshot = GraphSnapshot::new(
|
let snapshot = GraphSnapshot::new(
|
||||||
self.nodes.values().cloned().collect(),
|
nodes,
|
||||||
self.ports.values().cloned().collect(),
|
self.ports.values().cloned().collect(),
|
||||||
self.links.values().cloned().collect(),
|
self.links.values().cloned().collect(),
|
||||||
self.clients.values().cloned().collect(),
|
self.clients.values().cloned().collect(),
|
||||||
|
|||||||
+821
-109
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,10 @@ pub struct Graph {
|
|||||||
/// (GStreamer opens one per stream) pass clients explicitly instead.
|
/// (GStreamer opens one per stream) pass clients explicitly instead.
|
||||||
client_by_app: BTreeMap<u32, GlobalId>,
|
client_by_app: BTreeMap<u32, GlobalId>,
|
||||||
client_by_module: BTreeMap<u64, GlobalId>,
|
client_by_module: BTreeMap<u64, GlobalId>,
|
||||||
|
/// Native (non-Pulse-emulated) clients, whose `pipewire.sec.pid` is the
|
||||||
|
/// app's **own** pid rather than pipewire-pulse's. See
|
||||||
|
/// [`Graph::native_client_node`].
|
||||||
|
native_client_by_app: BTreeMap<u32, GlobalId>,
|
||||||
session_client: Option<GlobalId>,
|
session_client: Option<GlobalId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +88,35 @@ impl Graph {
|
|||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A **native PipeWire** client's stream: `client.id` on the node, **no
|
||||||
|
/// `application.process.id`**, and the app's real pid only on the Client
|
||||||
|
/// as `pipewire.sec.pid`.
|
||||||
|
///
|
||||||
|
/// ⚠️ This is what an ordinary app actually looks like when it does not go
|
||||||
|
/// through pipewire-pulse — measured for mpv on its default ao and for
|
||||||
|
/// peerspeak's own playback stream. [`Graph::app_node`] models the
|
||||||
|
/// Pulse-emulated shape, where the pid is on the node and the Client's
|
||||||
|
/// `sec_pid` is the *daemon's*; both shapes are live on this host, and
|
||||||
|
/// only this one exercises key 4's Client fallback (round 10, R10-3).
|
||||||
|
pub fn native_client_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||||
|
let client = match self.native_client_by_app.get(&pid) {
|
||||||
|
Some(id) => *id,
|
||||||
|
None => {
|
||||||
|
let id = self.client(Some(pid));
|
||||||
|
self.native_client_by_app.insert(pid, id);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.node(
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(client),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// An ordinary application stream: its own client, its own PID.
|
/// An ordinary application stream: its own client, its own PID.
|
||||||
pub fn app_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
pub fn app_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||||
let client = self.client_of_app(pid);
|
let client = self.client_of_app(pid);
|
||||||
@@ -152,11 +185,43 @@ impl Graph {
|
|||||||
self.node(name, role, app(client, pid))
|
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 {
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both ownership carriers on a node of **any** role — an impostor, or a
|
||||||
|
/// producer-side tagging bug. Only [`MediaRole::StreamOutput`] makes it a
|
||||||
|
/// taint root (round 10, R10-1); every other role must be ignored, and
|
||||||
|
/// these are the fixtures that prove it.
|
||||||
|
pub fn peerspeak_tagged_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||||
|
let client = self.client_of_app(pid);
|
||||||
|
let name = format!("{}{name}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX);
|
||||||
|
self.node(&name, role, 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);
|
let client = self.client_of_app(pid);
|
||||||
self.node(name, MediaRole::StreamOutput, peerspeak_owned(client, 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 {
|
pub fn node(&mut self, name: &str, role: MediaRole, props: NodeProps) -> NodeRef {
|
||||||
let id = self.id();
|
let id = self.id();
|
||||||
self.node_with_id(name, role, id, props)
|
self.node_with_id(name, role, id, props)
|
||||||
|
|||||||
+226
-37
@@ -123,6 +123,44 @@ pub const CAPTURE_SINK_PREFIX: &str = "pixelpass_capture_";
|
|||||||
/// what `pulse.module.id` is for (v3.4 §5.2 correction 4).
|
/// what `pulse.module.id` is for (v3.4 §5.2 correction 4).
|
||||||
pub const ECHO_CANCEL_GROUP_PREFIX: &str = "echo-cancel-";
|
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";
|
||||||
|
|
||||||
|
/// The value peerspeak emits for [`PEERSPEAK_OWNED_PROP`], and the **only**
|
||||||
|
/// value this consumer reads as owned.
|
||||||
|
///
|
||||||
|
/// ⚠️ This doc used to say the opposite — that any truthy value counted, on
|
||||||
|
/// the theory that treating an unexpected value as "owned" is the fail-closed
|
||||||
|
/// direction. R10-4 removed that leniency and the round-10 review caught the
|
||||||
|
/// prose surviving it here and in the shared fixture. The theory is wrong:
|
||||||
|
/// leniency buys false-positive *exclusion*, not safety, and it let any
|
||||||
|
/// process suppress a rival application's audio from the share with a
|
||||||
|
/// property it did not have to spell right. Fail-closed on this feature is
|
||||||
|
/// about **ancestry** — an unresolvable graph is not eligible — not about
|
||||||
|
/// parsing. The matching lives in the observer's `peerspeak_owned`, which is
|
||||||
|
/// deliberately *not* the lenient `truthy` used for PipeWire's own booleans.
|
||||||
|
pub const PEERSPEAK_OWNED_VALUE: &str = "1";
|
||||||
|
|
||||||
|
/// 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
|
/// 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
|
/// value is the phase 5 audit output, the phase 6 status event, and the
|
||||||
/// eventual answer to "why isn't this app being shared?".
|
/// eventual answer to "why isn't this app being shared?".
|
||||||
@@ -378,39 +416,36 @@ pub fn evaluate(
|
|||||||
ctx: &ExclusionCtx,
|
ctx: &ExclusionCtx,
|
||||||
prior: &StickyState,
|
prior: &StickyState,
|
||||||
) -> (Decisions, StickyState) {
|
) -> (Decisions, StickyState) {
|
||||||
let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid);
|
// Built once and shared: it carries the Client → `pipewire.sec.pid` index
|
||||||
let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid);
|
// that key 4 falls back to (round 10, R10-3), so the components and the
|
||||||
|
// key index must be derived from the *same* one or they would disagree
|
||||||
|
// about which nodes are bounded.
|
||||||
|
let owner_ctx = owner::OwnerCtx::new(snapshot, ctx.pipewire_pulse_pid);
|
||||||
|
let components = OwnerComponents::build(snapshot, &owner_ctx);
|
||||||
|
let keys = owner::OwnerKeyIndex::build(snapshot, &owner_ctx);
|
||||||
|
|
||||||
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
|
// Pass 1 — the fail-closed view. Every decision is made from this one, so
|
||||||
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
|
// "we could not see" counts as taint.
|
||||||
|
let (taint, sticky_serials) = compute_taint(
|
||||||
seed_local_roots(snapshot, ctx, &mut taint);
|
|
||||||
seed_sticky(
|
|
||||||
snapshot,
|
snapshot,
|
||||||
|
ctx,
|
||||||
&keys,
|
&keys,
|
||||||
prior,
|
|
||||||
&components,
|
&components,
|
||||||
&mut taint,
|
prior,
|
||||||
&mut sticky_serials,
|
Uncertainty::FailsClosed,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Monotone fixpoint: every step only adds taint, or lowers a node's
|
|
||||||
// reason priority, both of which are bounded. Link propagation and the
|
|
||||||
// owner bridge feed each other — a bridged output leg has downstream
|
|
||||||
// links, and a downstream monitor reader bridges to its own siblings —
|
|
||||||
// so neither can be run once.
|
|
||||||
let edges = downstream_edges(snapshot, &mut taint);
|
|
||||||
loop {
|
|
||||||
let mut changed = false;
|
|
||||||
changed |= propagate_links(&edges.edges, &mut taint);
|
|
||||||
changed |= propagate_owner_bridge(&keys, &components, &edges, &mut taint);
|
|
||||||
changed |= propagate_unresolved_owner(snapshot, &keys, &edges, &mut taint);
|
|
||||||
if !changed {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials);
|
let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials);
|
||||||
|
|
||||||
|
// Pass 2 — the evidence-only view, and the only thing sticky state is
|
||||||
|
// ever built from (see [`Uncertainty`]).
|
||||||
|
let (evidence, _) = compute_taint(
|
||||||
|
snapshot,
|
||||||
|
ctx,
|
||||||
|
&keys,
|
||||||
|
&components,
|
||||||
|
prior,
|
||||||
|
Uncertainty::Ignored,
|
||||||
|
);
|
||||||
// ⚠️ Readiness gates **retirement only**, never addition (Codex rounds
|
// ⚠️ Readiness gates **retirement only**, never addition (Codex rounds
|
||||||
// 1 and 2, which caught the two halves of this in turn). An object
|
// 1 and 2, which caught the two halves of this in turn). An object
|
||||||
// missing from an untrustworthy snapshot has not been observed to
|
// missing from an untrustworthy snapshot has not been observed to
|
||||||
@@ -419,10 +454,105 @@ pub fn evaluate(
|
|||||||
// *observed* during a not-ready epoch is real — a reader can consume
|
// *observed* during a not-ready epoch is real — a reader can consume
|
||||||
// and buffer the call and then vanish before readiness — so discarding
|
// and buffer the call and then vanish before readiness — so discarding
|
||||||
// additions was the same defect pointing the other way.
|
// additions was the same defect pointing the other way.
|
||||||
let next_sticky = build_sticky(snapshot, &keys, &components, &taint, prior, ctx.graph_ready);
|
let next_sticky = build_sticky(
|
||||||
|
snapshot,
|
||||||
|
&keys,
|
||||||
|
&components,
|
||||||
|
&evidence,
|
||||||
|
prior,
|
||||||
|
ctx.graph_ready,
|
||||||
|
);
|
||||||
(decisions, next_sticky)
|
(decisions, next_sticky)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a pass treats "we could not see" as taint.
|
||||||
|
///
|
||||||
|
/// **Both passes exist because stickiness is a claim about history, and
|
||||||
|
/// uncertainty is not history.** A node tainted only because the graph was
|
||||||
|
/// mid-enumeration has had nothing observed about it; remembering that as
|
||||||
|
/// taint forever is over-exclusion with no evidence behind it, and phase 3r's
|
||||||
|
/// bind-everything observer makes the window it happens in systematically
|
||||||
|
/// wide (every node is withheld until its bind resolves, so any link observed
|
||||||
|
/// across that gap raises [`Reason::UnresolvedAncestry`] on its input side).
|
||||||
|
/// Measured on a live desktop: a hardware sink acquired a permanent sticky
|
||||||
|
/// taint at every startup, from one link seen while its output node was still
|
||||||
|
/// unbound.
|
||||||
|
///
|
||||||
|
/// Retiring by *reason code* is not enough, because uncertainty launders
|
||||||
|
/// itself: an unresolved node propagates [`Reason::TaintedUpstream`] to its
|
||||||
|
/// downstream, and that reason is indistinguishable from real contamination
|
||||||
|
/// once recorded. So the split is by **provenance** — the sticky pass never
|
||||||
|
/// raises an uncertainty root at all, and nothing derived from one can reach
|
||||||
|
/// it. Decisions are unaffected: they are made from the fail-closed pass,
|
||||||
|
/// which is unchanged.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
enum Uncertainty {
|
||||||
|
/// Unresolved ancestry and an unbounded tainted reader are taint
|
||||||
|
/// (v3.4 §6.1, §6.1.1, §6.1.4).
|
||||||
|
FailsClosed,
|
||||||
|
/// Only positively observed contamination counts.
|
||||||
|
Ignored,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One taint fixpoint over the snapshot. The `uncertainty` mode decides
|
||||||
|
/// whether absence of evidence is treated as evidence of contamination.
|
||||||
|
fn compute_taint(
|
||||||
|
snapshot: &GraphSnapshot,
|
||||||
|
ctx: &ExclusionCtx,
|
||||||
|
keys: &owner::OwnerKeyIndex,
|
||||||
|
components: &OwnerComponents,
|
||||||
|
prior: &StickyState,
|
||||||
|
uncertainty: Uncertainty,
|
||||||
|
) -> (BTreeMap<Serial, Reason>, BTreeSet<Serial>) {
|
||||||
|
let fails_closed = uncertainty == Uncertainty::FailsClosed;
|
||||||
|
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
|
||||||
|
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
|
||||||
|
|
||||||
|
seed_local_roots(snapshot, ctx, &mut taint);
|
||||||
|
if fails_closed {
|
||||||
|
for serial in ambiguous_id_nodes(snapshot) {
|
||||||
|
raise(&mut taint, serial, Reason::UnresolvedAncestry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seed_sticky(
|
||||||
|
snapshot,
|
||||||
|
keys,
|
||||||
|
prior,
|
||||||
|
components,
|
||||||
|
&mut taint,
|
||||||
|
&mut sticky_serials,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Edges are built identically in both passes — receiver status is a
|
||||||
|
// topological fact and must not depend on the mode, or the owner bridge
|
||||||
|
// would see two different graphs.
|
||||||
|
let mut unresolved_input: BTreeSet<Serial> = BTreeSet::new();
|
||||||
|
let edges = downstream_edges(snapshot, &mut unresolved_input);
|
||||||
|
if fails_closed {
|
||||||
|
for serial in unresolved_input {
|
||||||
|
raise(&mut taint, serial, Reason::UnresolvedAncestry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monotone fixpoint: every step only adds taint, or lowers a node's
|
||||||
|
// reason priority, both of which are bounded. Link propagation and the
|
||||||
|
// owner bridge feed each other — a bridged output leg has downstream
|
||||||
|
// links, and a downstream monitor reader bridges to its own siblings —
|
||||||
|
// so neither can be run once.
|
||||||
|
loop {
|
||||||
|
let mut changed = false;
|
||||||
|
changed |= propagate_links(&edges.edges, &mut taint);
|
||||||
|
changed |= propagate_owner_bridge(keys, components, &edges, &mut taint);
|
||||||
|
if fails_closed {
|
||||||
|
changed |= propagate_unresolved_owner(snapshot, keys, &edges, &mut taint);
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(taint, sticky_serials)
|
||||||
|
}
|
||||||
|
|
||||||
/// Roots that are visible on the node itself.
|
/// Roots that are visible on the node itself.
|
||||||
fn seed_local_roots(
|
fn seed_local_roots(
|
||||||
snapshot: &GraphSnapshot,
|
snapshot: &GraphSnapshot,
|
||||||
@@ -433,16 +563,73 @@ fn seed_local_roots(
|
|||||||
if let Some(reason) = local_root_reason(node, ctx) {
|
if let Some(reason) = local_root_reason(node, ctx) {
|
||||||
raise(taint, node.serial, reason);
|
raise(taint, node.serial, reason);
|
||||||
}
|
}
|
||||||
// A node whose own global id is ambiguous cannot be the reliable
|
|
||||||
// endpoint of any link, so its ancestry is unresolvable.
|
|
||||||
if snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous) {
|
|
||||||
raise(taint, node.serial, Reason::UnresolvedAncestry);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nodes whose own global id is ambiguous: they cannot be the reliable
|
||||||
|
/// endpoint of any link, so their ancestry is unresolvable. Uncertainty, not
|
||||||
|
/// evidence — see [`Uncertainty`].
|
||||||
|
fn ambiguous_id_nodes(snapshot: &GraphSnapshot) -> BTreeSet<Serial> {
|
||||||
|
snapshot
|
||||||
|
.nodes()
|
||||||
|
.filter(|node| snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous))
|
||||||
|
.map(|node| node.serial)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this node carry either ownership carrier? **Tag presence only** — it
|
||||||
|
/// deliberately says nothing about whether the tag is honoured, which is
|
||||||
|
/// `local_root_reason`'s business (round 10 restricts that to producers).
|
||||||
|
/// Split out so the "is it tagged?" and "does the tag count?" questions can
|
||||||
|
/// be tested, and reported, independently.
|
||||||
|
pub fn is_peerspeak_tagged(node: &NodeSnapshot) -> bool {
|
||||||
|
node.props.peerspeak_owned
|
||||||
|
|| node
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|name| name.starts_with(PEERSPEAK_OWNED_NODE_PREFIX))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nodes carrying an ownership carrier that `local_root_reason` **ignored**
|
||||||
|
/// because the node is not a producer (round 10, R10-1). Ascending by serial.
|
||||||
|
///
|
||||||
|
/// Purely diagnostic — nothing in the engine consumes it. It exists because
|
||||||
|
/// R10-1 turns a formerly load-bearing tag into a no-op, and a silently
|
||||||
|
/// ignored tag has exactly two causes, both of which someone wants to know
|
||||||
|
/// about: peerspeak tagging a node it should not (a producer-side bug this
|
||||||
|
/// would otherwise hide), or another process impersonating the tag (the F2
|
||||||
|
/// attack, now defanged but still worth seeing).
|
||||||
|
pub fn misplaced_ownership_tags(snapshot: &GraphSnapshot) -> Vec<&NodeSnapshot> {
|
||||||
|
let mut tagged: Vec<&NodeSnapshot> = snapshot
|
||||||
|
.nodes()
|
||||||
|
.filter(|node| node.role != MediaRole::StreamOutput && is_peerspeak_tagged(node))
|
||||||
|
.collect();
|
||||||
|
tagged.sort_by_key(|node| node.serial);
|
||||||
|
tagged
|
||||||
|
}
|
||||||
|
|
||||||
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
|
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.
|
||||||
|
//
|
||||||
|
// ⚠️ **Producer roles only** (round 10, R10-1). Neither carrier is a
|
||||||
|
// security boundary — both are strings any unprivileged process can put
|
||||||
|
// on its own node — so an unrestricted root is a denial of the whole
|
||||||
|
// feature: an unlinked `Stream/Input/Audio` named `peerspeak_owned_x`
|
||||||
|
// is a tainted *reader* with no owner bound to it, which fails every
|
||||||
|
// candidate closed machine-wide (Codex phase-1 F2, reproduced live).
|
||||||
|
// Restricting the root to `Stream/Output/Audio` costs nothing real —
|
||||||
|
// peerspeak only ever tags playback streams — and the attack needs the
|
||||||
|
// impostor to be a plausible playback node instead, which taints only
|
||||||
|
// its own descendants. The AEC's virtual sink/source is unaffected: it
|
||||||
|
// roots on [`Reason::AecIdentity`] below, by module id, not by this tag.
|
||||||
|
// A tag on a non-producer falls through: ignored for taint, but not
|
||||||
|
// nothing — it is either a peerspeak bug or an impostor, and
|
||||||
|
// [`misplaced_ownership_tags`] surfaces it so neither is silent.
|
||||||
|
if is_peerspeak_tagged(node) && node.role == MediaRole::StreamOutput {
|
||||||
return Some(Reason::PeerspeakOwned);
|
return Some(Reason::PeerspeakOwned);
|
||||||
}
|
}
|
||||||
if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id)
|
if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id)
|
||||||
@@ -563,7 +750,7 @@ fn nodes_of_client(
|
|||||||
/// `output node → input nodes`, resolving snapshot-local ids. An endpoint
|
/// `output node → input nodes`, resolving snapshot-local ids. An endpoint
|
||||||
/// that does not resolve taints the *other* end as unresolved ancestry when
|
/// that does not resolve taints the *other* end as unresolved ancestry when
|
||||||
/// that other end is the input side — we cannot know what is feeding it.
|
/// that other end is the input side — we cannot know what is feeding it.
|
||||||
fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reason>) -> Edges {
|
fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet<Serial>) -> Edges {
|
||||||
let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new();
|
let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new();
|
||||||
let mut receivers: BTreeSet<Serial> = BTreeSet::new();
|
let mut receivers: BTreeSet<Serial> = BTreeSet::new();
|
||||||
for link in snapshot.links() {
|
for link in snapshot.links() {
|
||||||
@@ -575,8 +762,10 @@ fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reaso
|
|||||||
receivers.insert(to);
|
receivers.insert(to);
|
||||||
}
|
}
|
||||||
(_, Some(IdLookup::Unique(to))) => {
|
(_, Some(IdLookup::Unique(to))) => {
|
||||||
// Something feeds this node and we cannot say what.
|
// Something feeds this node and we cannot say what. Reported
|
||||||
raise(taint, to, Reason::UnresolvedAncestry);
|
// rather than raised here, because whether "cannot say" is
|
||||||
|
// taint depends on which pass is running ([`Uncertainty`]).
|
||||||
|
unresolved_input.insert(to);
|
||||||
receivers.insert(to);
|
receivers.insert(to);
|
||||||
}
|
}
|
||||||
(_, Some(IdLookup::Ambiguous)) => {
|
(_, Some(IdLookup::Ambiguous)) => {
|
||||||
|
|||||||
+190
-19
@@ -67,10 +67,71 @@
|
|||||||
//! Grouping is **transitive** (union-find). That is the fail-closed
|
//! Grouping is **transitive** (union-find). That is the fail-closed
|
||||||
//! direction: bigger owner components mean more taint, never less.
|
//! direction: bigger owner components mean more taint, never less.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use super::snapshot::{GlobalId, GraphSnapshot, NodeSnapshot, Serial};
|
use super::snapshot::{GlobalId, GraphSnapshot, NodeSnapshot, Serial};
|
||||||
|
|
||||||
|
/// Everything owner-key derivation needs from outside a single node.
|
||||||
|
///
|
||||||
|
/// Introduced by round 10 (R10-3). Before it, `keys_of` read only node
|
||||||
|
/// properties, and key 4 was therefore available **only** to nodes carrying
|
||||||
|
/// `application.process.id` — which native PipeWire clients do not. mpv on its
|
||||||
|
/// default ao, and peerspeak's own playback stream, expose nothing but
|
||||||
|
/// `client.id`, so both were *unbounded*, and the moment any tainted reader
|
||||||
|
/// existed anywhere, `propagate_unresolved_owner` excluded every one of them.
|
||||||
|
/// Measured: an untagged mpv went from eligible (alone) to `unresolved-owner`
|
||||||
|
/// the instant peerspeak played audio. That is "native-PipeWire apps are never
|
||||||
|
/// shareable", which is not a feature.
|
||||||
|
///
|
||||||
|
/// The missing pid is not missing at all — it is one hop away, on the node's
|
||||||
|
/// **Client**, as `pipewire.sec.pid`, and already in the snapshot.
|
||||||
|
pub struct OwnerCtx {
|
||||||
|
pub pipewire_pulse_pid: Option<u32>,
|
||||||
|
/// `client.id` → that Client's `pipewire.sec.pid`.
|
||||||
|
///
|
||||||
|
/// Clients whose global id is **ambiguous** (two live objects claiming it,
|
||||||
|
/// i.e. the observer missed a removal) are deliberately absent: resolving
|
||||||
|
/// an ambiguous id to a pid would attribute a node to whichever Client won
|
||||||
|
/// a coin toss, and inventing an owner key is the one direction that can
|
||||||
|
/// *reduce* taint. Absent ⇒ unbounded ⇒ fails closed, as before.
|
||||||
|
client_pids: BTreeMap<GlobalId, u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OwnerCtx {
|
||||||
|
pub fn new(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
|
||||||
|
let mut client_pids: BTreeMap<GlobalId, u32> = BTreeMap::new();
|
||||||
|
// ⚠️ Tracked separately from `client_pids`, and that is the point: a
|
||||||
|
// Client with no `sec_pid` still *claims* its id. Detecting duplicates
|
||||||
|
// by looking in the pid map would let a pid-less first claimant leave
|
||||||
|
// no trace, so the next Client claiming the same id would look unique
|
||||||
|
// and its pid would be used — resolving an ambiguous id, which is the
|
||||||
|
// one guess this guard exists to refuse. Pid-less Clients are ordinary
|
||||||
|
// (the session manager's is one).
|
||||||
|
let mut seen: BTreeSet<GlobalId> = BTreeSet::new();
|
||||||
|
for client in snapshot.clients() {
|
||||||
|
if !seen.insert(client.id) {
|
||||||
|
// Two Clients claiming one id: drop it entirely rather than
|
||||||
|
// pick. See the field docs.
|
||||||
|
client_pids.remove(&client.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(pid) = client.sec_pid {
|
||||||
|
client_pids.insert(client.id, pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
pipewire_pulse_pid,
|
||||||
|
client_pids,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `pipewire.sec.pid` of this node's Client, if it has one and that
|
||||||
|
/// Client's id is unambiguous.
|
||||||
|
fn client_pid(&self, node: &NodeSnapshot) -> Option<u32> {
|
||||||
|
self.client_pids.get(&node.props.client_id?).copied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Which key bridged two legs. Ordered strongest first; the `Ord` derive is
|
/// Which key bridged two legs. Ordered strongest first; the `Ord` derive is
|
||||||
/// load-bearing for "report the strongest shared key".
|
/// load-bearing for "report the strongest shared key".
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
||||||
@@ -84,6 +145,16 @@ pub enum OwnerKey {
|
|||||||
impl OwnerKey {
|
impl OwnerKey {
|
||||||
/// Stable, machine-readable — this ends up in the phase 5 audit output
|
/// Stable, machine-readable — this ends up in the phase 5 audit output
|
||||||
/// and the phase 6 status event.
|
/// and the phase 6 status event.
|
||||||
|
///
|
||||||
|
/// ⚠️ **Known imprecision, deliberately not fixed here.** `ProcessId` now
|
||||||
|
/// covers two sources — the node's `application.process.id` and its
|
||||||
|
/// Client's `pipewire.sec.pid` (see [`keys_of`]) — so a bridge reported as
|
||||||
|
/// `application.process.id` may in fact have resolved on the Client's
|
||||||
|
/// protected pid. Pre-existing since R10-3 made the Client a fallback, and
|
||||||
|
/// widened by the review's finding 1 making it a union. Splitting it would
|
||||||
|
/// add a code to a set that is explicitly a stable contract for the audit
|
||||||
|
/// output and the "why isn't this app being shared?" answer, so it wants
|
||||||
|
/// its own decision rather than a drive-by.
|
||||||
pub fn code(self) -> &'static str {
|
pub fn code(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::LinkGroup => "node.link-group",
|
Self::LinkGroup => "node.link-group",
|
||||||
@@ -106,7 +177,7 @@ enum KeyValue {
|
|||||||
/// A key that is present but unusable (the pipewire-pulse PID; a coarse key
|
/// A key that is present but unusable (the pipewire-pulse PID; a coarse key
|
||||||
/// on a device node) is **absent** here — that is the whole mechanism of the
|
/// on a device node) is **absent** here — that is the whole mechanism of the
|
||||||
/// two exceptions.
|
/// two exceptions.
|
||||||
fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKey, KeyValue)> {
|
fn keys_of(node: &NodeSnapshot, ctx: &OwnerCtx) -> Vec<(OwnerKey, KeyValue)> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
if let Some(group) = &node.props.link_group {
|
if let Some(group) = &node.props.link_group {
|
||||||
out.push((OwnerKey::LinkGroup, KeyValue::Text(group.clone())));
|
out.push((OwnerKey::LinkGroup, KeyValue::Text(group.clone())));
|
||||||
@@ -122,14 +193,48 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKe
|
|||||||
if let Some(client) = node.props.client_id {
|
if let Some(client) = node.props.client_id {
|
||||||
out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0))));
|
out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0))));
|
||||||
}
|
}
|
||||||
if let Some(pid) = node.props.process_id {
|
// Key 4, from the node **and** from its Client (round 10, R10-3; made a
|
||||||
// Exception 1. Note the fail-closed asymmetry when the daemon PID is
|
// union rather than a fallback by the round-10 review, finding 1).
|
||||||
// unknown (`None`): the exception does *not* fire, key 4 applies to
|
//
|
||||||
// everything, and Pulse modules fuse into one owner. That is broad
|
// ⚠️ **A union, not `node.or_else(client)`, and the difference is a leak.**
|
||||||
// over-exclusion — annoying and safe — which is the direction v3.4
|
// The node's `application.process.id` is client-controlled and optional;
|
||||||
// §6.1.2's failure-mode paragraph asks for.
|
// the Client's `pipewire.sec.pid` is `pipewire.*`, protected, and the only
|
||||||
if Some(pid) != pipewire_pulse_pid {
|
// one that can carry a soundness argument (the same reason
|
||||||
out.push((OwnerKey::ProcessId, KeyValue::Num(u64::from(pid))));
|
// `propagate_unresolved_owner` sweeps everything for an unbounded reader).
|
||||||
|
// Letting the node's value *replace* the Client's meant one process using
|
||||||
|
// two Clients could escape the bridge entirely: its tainted reader reports
|
||||||
|
// a bogus node pid, its output leg omits the node pid and falls back to
|
||||||
|
// the Client's real one, the two legs are bounded by different values, so
|
||||||
|
// they neither bridge nor trip the unbounded sweep — and the output stays
|
||||||
|
// eligible while re-emitting the call. Carrying both values costs nothing
|
||||||
|
// and closes it: a leg that presents *either* value bridges.
|
||||||
|
//
|
||||||
|
// ⚠️ **Exception 1 applies to each value independently, and that is the
|
||||||
|
// whole risk here.** Measured on this host: 15 unrelated Clients share
|
||||||
|
// `sec_pid` 2528, which is pipewire-pulse's own — every Pulse-emulated app
|
||||||
|
// has one. Suppressing it per value is what keeps the union from fusing
|
||||||
|
// all fifteen into a single owner while still keeping each app's real
|
||||||
|
// per-app pid. For the common Pulse shape (node pid = the app's, Client
|
||||||
|
// `sec_pid` = the daemon's) the union therefore reduces to exactly the
|
||||||
|
// node's pid, as before.
|
||||||
|
//
|
||||||
|
// Note the fail-closed asymmetry when the daemon PID is unknown (`None`):
|
||||||
|
// the exception does *not* fire, key 4 applies to everything, and Pulse
|
||||||
|
// modules fuse into one owner. That is broad over-exclusion — annoying and
|
||||||
|
// safe — which is the direction v3.4 §6.1.2's failure-mode paragraph asks
|
||||||
|
// for.
|
||||||
|
for pid in [node.props.process_id, ctx.client_pid(node)]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
if Some(pid) == ctx.pipewire_pulse_pid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = (OwnerKey::ProcessId, KeyValue::Num(u64::from(pid)));
|
||||||
|
// The two agree far more often than not; a duplicate entry would be
|
||||||
|
// harmless but would make the audit's key list read oddly.
|
||||||
|
if !out.contains(&key) {
|
||||||
|
out.push(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
@@ -151,8 +256,74 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKe
|
|||||||
/// nothing else relates them. Its sibling output leg cannot be found, so
|
/// nothing else relates them. Its sibling output leg cannot be found, so
|
||||||
/// the engine must fail closed rather than declare it clean
|
/// the engine must fail closed rather than declare it clean
|
||||||
/// (v3.4 §6.1.1, final paragraph).
|
/// (v3.4 §6.1.1, final paragraph).
|
||||||
pub fn owner_is_bounded(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> bool {
|
///
|
||||||
keys_of(node, pipewire_pulse_pid)
|
/// # 🔴 OPEN, phase-6 blocking — the key union can *reduce* taint here
|
||||||
|
///
|
||||||
|
/// **Round 11 review, finding 1. Verified correct; deliberately not fixed in
|
||||||
|
/// that round.** Round 10 made key 4 a union of the node's
|
||||||
|
/// `application.process.id` and its Client's `pipewire.sec.pid`, and the claim
|
||||||
|
/// that this was "strictly additive" was too strong: the same key list also
|
||||||
|
/// feeds *this* predicate, so adding a value can move a node from unbounded to
|
||||||
|
/// bounded, and `propagate_unresolved_owner`'s global sweep is triggered by an
|
||||||
|
/// **un**bounded tainted reader. Concretely:
|
||||||
|
///
|
||||||
|
/// 1. A tainted reader's node claims the pipewire-pulse PID while its Client
|
||||||
|
/// holds a real protected PID `A`. Under `or_else` the node's value won and
|
||||||
|
/// exception 1 suppressed it, leaving the reader unbounded; under the union
|
||||||
|
/// it is bounded by `A`.
|
||||||
|
/// 2. Its process's output leg uses a second Client whose id is **ambiguous**
|
||||||
|
/// (the observer missed a removal), so no protected PID is available — but
|
||||||
|
/// the leg claims a bogus `application.process.id` `B`, which bounds it.
|
||||||
|
/// 3. Neither the bridge nor the sweep fires, and the output stays eligible
|
||||||
|
/// while re-emitting the call.
|
||||||
|
///
|
||||||
|
/// It cannot leak today: `evaluate()` is reached only by the dry-run audit,
|
||||||
|
/// which creates no links. It becomes live when phase 6 consumes eligibility.
|
||||||
|
///
|
||||||
|
/// **Why it is not fixed yet.** The principled repair is provenance: a
|
||||||
|
/// self-claimed `application.process.id` is not a *sound* bound, only the
|
||||||
|
/// protected keys are. But applying that bluntly makes every Pulse-emulated
|
||||||
|
/// app unbounded — their Client's `sec_pid` is the daemon's and suppressed, so
|
||||||
|
/// the node's own claim is their only per-app identity — which re-triggers the
|
||||||
|
/// §6.1.1 mass over-exclusion the whole design is built to avoid, and would
|
||||||
|
/// make the eligible half of the §5.1 matrix empty.
|
||||||
|
///
|
||||||
|
/// The targeted rule that closes the path above without that cost: **a node
|
||||||
|
/// whose Client cannot be resolved at all must not be bounded by its own
|
||||||
|
/// self-claimed PID.** An ambiguous Client already means "we do not know who
|
||||||
|
/// owns this", and a self-claim must not paper over it; a Pulse app's Client
|
||||||
|
/// *is* resolved (to the daemon's PID, then suppressed), so it keeps its
|
||||||
|
/// bound. Implementing it needs `OwnerCtx` to distinguish "resolved" from
|
||||||
|
/// "absent", and `OwnerKeyIndex` to carry boundedness separately from the key
|
||||||
|
/// set, since bridging must keep using the full union.
|
||||||
|
///
|
||||||
|
/// ⚠️ Do this **with the §5.1 matrix data in hand**, not before: the whole
|
||||||
|
/// question is how much over-exclusion the rule actually causes on a real
|
||||||
|
/// graph, and that is measurable rather than arguable.
|
||||||
|
///
|
||||||
|
/// ## Round 12 — the deferral holds, and "resolved" has a trap in it
|
||||||
|
///
|
||||||
|
/// Codex re-examined this and agreed the deferral is defensible while
|
||||||
|
/// `evaluate()` is audit-only, and that the rule above closes the recorded path
|
||||||
|
/// without unbounding normal Pulse-emulated apps — **but only under one
|
||||||
|
/// reading of "resolves"**, and the wrong reading reintroduces the hole:
|
||||||
|
///
|
||||||
|
/// - ✅ "Resolved" must mean **an unambiguous Client that yields
|
||||||
|
/// `Some(pipewire.sec.pid)`**, taken *before* the pipewire-pulse suppression
|
||||||
|
/// step. A Pulse app then still has the daemon's protected PID as
|
||||||
|
/// provenance, even though that value is omitted from the bridge keys, so it
|
||||||
|
/// stays bounded and the eligible half survives.
|
||||||
|
/// - ❌ **Do not** implement it as "a unique Client object exists". A unique
|
||||||
|
/// Client with `sec_pid = None` would satisfy that test while providing no
|
||||||
|
/// protected identity at all, leaving exactly the self-claimed-PID hole this
|
||||||
|
/// rule is meant to close.
|
||||||
|
///
|
||||||
|
/// So the matrix needs five Client cases, not two: **absent**, **ambiguous**,
|
||||||
|
/// **unique but pid-less**, **resolved-native**, and
|
||||||
|
/// **resolved-to-pipewire-pulse**. The third is the one that distinguishes the
|
||||||
|
/// two readings, and it is the row a two-case matrix would silently skip.
|
||||||
|
pub fn owner_is_bounded(node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
|
||||||
|
keys_of(node, ctx)
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(key, _)| *key != OwnerKey::ClientId)
|
.any(|(key, _)| *key != OwnerKey::ClientId)
|
||||||
}
|
}
|
||||||
@@ -168,11 +339,11 @@ pub struct OwnerKeyIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OwnerKeyIndex {
|
impl OwnerKeyIndex {
|
||||||
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
|
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
|
||||||
Self {
|
Self {
|
||||||
keys: snapshot
|
keys: snapshot
|
||||||
.nodes()
|
.nodes()
|
||||||
.map(|node| (node.serial, keys_of(node, pipewire_pulse_pid)))
|
.map(|node| (node.serial, keys_of(node, ctx)))
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,10 +423,10 @@ impl OwnerKeyIndex {
|
|||||||
pub fn strongest_shared_key(
|
pub fn strongest_shared_key(
|
||||||
a: &NodeSnapshot,
|
a: &NodeSnapshot,
|
||||||
b: &NodeSnapshot,
|
b: &NodeSnapshot,
|
||||||
pipewire_pulse_pid: Option<u32>,
|
ctx: &OwnerCtx,
|
||||||
) -> Option<OwnerKey> {
|
) -> Option<OwnerKey> {
|
||||||
let a_keys = keys_of(a, pipewire_pulse_pid);
|
let a_keys = keys_of(a, ctx);
|
||||||
let b_keys = keys_of(b, pipewire_pulse_pid);
|
let b_keys = keys_of(b, ctx);
|
||||||
// `keys_of` yields strongest-first, so the first match is the strongest.
|
// `keys_of` yields strongest-first, so the first match is the strongest.
|
||||||
a_keys.iter().find_map(|(key, value)| {
|
a_keys.iter().find_map(|(key, value)| {
|
||||||
b_keys
|
b_keys
|
||||||
@@ -279,7 +450,7 @@ pub struct OwnerComponents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OwnerComponents {
|
impl OwnerComponents {
|
||||||
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
|
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
|
||||||
let serials: Vec<Serial> = snapshot.nodes().map(|n| n.serial).collect();
|
let serials: Vec<Serial> = snapshot.nodes().map(|n| n.serial).collect();
|
||||||
let index: BTreeMap<Serial, usize> =
|
let index: BTreeMap<Serial, usize> =
|
||||||
serials.iter().enumerate().map(|(i, s)| (*s, i)).collect();
|
serials.iter().enumerate().map(|(i, s)| (*s, i)).collect();
|
||||||
@@ -290,7 +461,7 @@ impl OwnerComponents {
|
|||||||
let mut buckets: BTreeMap<(OwnerKey, KeyValue), Vec<usize>> = BTreeMap::new();
|
let mut buckets: BTreeMap<(OwnerKey, KeyValue), Vec<usize>> = BTreeMap::new();
|
||||||
for node in snapshot.nodes() {
|
for node in snapshot.nodes() {
|
||||||
let slot = index[&node.serial];
|
let slot = index[&node.serial];
|
||||||
for (key, value) in keys_of(node, pipewire_pulse_pid) {
|
for (key, value) in keys_of(node, ctx) {
|
||||||
buckets.entry((key, value)).or_default().push(slot);
|
buckets.entry((key, value)).or_default().push(slot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,20 @@ impl MediaRole {
|
|||||||
pub fn is_candidate(self) -> bool {
|
pub fn is_candidate(self) -> bool {
|
||||||
matches!(self, Self::StreamOutput)
|
matches!(self, Self::StreamOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stable machine-readable code for the audit output. Not the raw
|
||||||
|
/// `media.class`: `Other` has no single one, and the audit's codes are a
|
||||||
|
/// contract with the matrix, not with PipeWire.
|
||||||
|
pub fn code(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::StreamOutput => "stream-output",
|
||||||
|
Self::StreamInput => "stream-input",
|
||||||
|
Self::Sink => "sink",
|
||||||
|
Self::Source => "source",
|
||||||
|
Self::Duplex => "duplex",
|
||||||
|
Self::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The subset of node properties the engine actually reasons about.
|
/// The subset of node properties the engine actually reasons about.
|
||||||
@@ -97,8 +111,17 @@ impl MediaRole {
|
|||||||
/// on this feature means "not tainted".
|
/// on this feature means "not tainted".
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
pub struct NodeProps {
|
pub struct NodeProps {
|
||||||
/// `peerspeak.owned` is present and truthy (v3.4 §5.1). A correctness
|
/// `peerspeak.owned` is present and **exactly**
|
||||||
/// mechanism, explicitly *not* a security boundary.
|
/// [`super::PEERSPEAK_OWNED_VALUE`] (v3.4 §5.1, tightened by round 10's
|
||||||
|
/// R10-4 — it is not "present and truthy", and the round-10 review found
|
||||||
|
/// this doc still saying so). 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,
|
pub peerspeak_owned: bool,
|
||||||
/// `pulse.module.id`, parsed as `u64` — never `u32`, per v3.4 §5.2's
|
/// `pulse.module.id`, parsed as `u64` — never `u32`, per v3.4 §5.2's
|
||||||
/// parse-defensively note and the phase 0a truncation bug.
|
/// parse-defensively note and the phase 0a truncation bug.
|
||||||
|
|||||||
+662
-3
@@ -15,7 +15,7 @@
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use super::fixture::{Graph, NodeRef, PULSE_PID, app};
|
use super::fixture::{Graph, NodeRef, PULSE_PID, app};
|
||||||
use super::owner::{OwnerKey, strongest_shared_key};
|
use super::owner::{OwnerCtx, OwnerKey, strongest_shared_key};
|
||||||
use super::snapshot::{MediaRole, NodeProps, PortDirection, Serial};
|
use super::snapshot::{MediaRole, NodeProps, PortDirection, Serial};
|
||||||
use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate};
|
use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate};
|
||||||
|
|
||||||
@@ -176,6 +176,217 @@ fn peerspeak_tagged_nodes_are_excluded_and_plain_apps_are_not() {
|
|||||||
assert_tainted(&decisions, sink, "tainted-upstream");
|
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"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **R10-1, the F2 fix.** Neither carrier is a security boundary — both are
|
||||||
|
/// strings any unprivileged process can set on its own node — so the tag is
|
||||||
|
/// honoured only on `Stream/Output/Audio`, the one role peerspeak ever tags.
|
||||||
|
///
|
||||||
|
/// Without the restriction, a tagged `Stream/Input/Audio` **with no links at
|
||||||
|
/// all** is a tainted *reader* (`receivers` includes nodes by role, no link
|
||||||
|
/// required), and an unbounded one, so `propagate_unresolved_owner` fails
|
||||||
|
/// every candidate on the machine closed. That is a whole-feature denial from
|
||||||
|
/// an unprivileged process, reproduced live during the phase-1 review.
|
||||||
|
#[test]
|
||||||
|
fn an_ownership_tag_on_a_non_producer_is_not_a_taint_root() {
|
||||||
|
for role in [
|
||||||
|
MediaRole::StreamInput,
|
||||||
|
MediaRole::Sink,
|
||||||
|
MediaRole::Source,
|
||||||
|
MediaRole::Duplex,
|
||||||
|
MediaRole::Other,
|
||||||
|
] {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
|
||||||
|
graph.link(firefox, sink);
|
||||||
|
// Deliberately unlinked: the F2 shape needs no edges whatsoever.
|
||||||
|
let impostor = graph.peerspeak_tagged_node("rogue", role, 4_242);
|
||||||
|
|
||||||
|
let decisions = run(&graph, &ctx());
|
||||||
|
assert_untainted(&decisions, impostor);
|
||||||
|
assert!(
|
||||||
|
decisions.taint.is_empty(),
|
||||||
|
"{role:?} impostor tainted something: {:?}",
|
||||||
|
decisions.taint.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
// The whole point: the eligible half stays non-empty.
|
||||||
|
assert_partition(&decisions, &[("firefox", firefox)], &[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The live F2 reproduction, verbatim.** The measured impostor was an
|
||||||
|
/// *unbounded* reader — `client.id` present, `application.process.id` absent
|
||||||
|
/// — which is what turns "one bogus tainted node" into "nothing on this
|
||||||
|
/// machine is shareable": `propagate_unresolved_owner` cannot prove any
|
||||||
|
/// candidate independent of a reader it cannot attribute to an owner.
|
||||||
|
///
|
||||||
|
/// Measured before the fix: `BASELINE eligible=1 excluded=[]` →
|
||||||
|
/// `WITH IMPOSTOR eligible=0 excluded=[firefox → unresolved-owner]`.
|
||||||
|
///
|
||||||
|
/// Distinct from the row above, which uses a *bounded* impostor and so would
|
||||||
|
/// still pass if only the cheap half of the fix were present.
|
||||||
|
#[test]
|
||||||
|
fn an_unbounded_tagged_impostor_cannot_exclude_a_bystander_app() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
|
||||||
|
let mpv = graph.app_node("mpv", MediaRole::StreamOutput, 31_284);
|
||||||
|
for node in [firefox, mpv] {
|
||||||
|
graph.link(node, sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseline = run(&graph, &ctx());
|
||||||
|
assert_partition(&baseline, &[("firefox", firefox), ("mpv", mpv)], &[]);
|
||||||
|
|
||||||
|
// Both carriers, no pid, no links — everything an unprivileged process
|
||||||
|
// can arrange for itself in one `pw-cli` invocation.
|
||||||
|
let rogue_client = graph.client(Some(PULSE_PID));
|
||||||
|
let impostor = graph.node(
|
||||||
|
&format!("{}rogue_4242", super::PEERSPEAK_OWNED_NODE_PREFIX),
|
||||||
|
MediaRole::StreamInput,
|
||||||
|
NodeProps {
|
||||||
|
peerspeak_owned: true,
|
||||||
|
client_id: Some(rogue_client),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let decisions = run(&graph, &ctx());
|
||||||
|
assert_untainted(&decisions, impostor);
|
||||||
|
assert_partition(&decisions, &[("firefox", firefox), ("mpv", mpv)], &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tag that R10-1 ignores is still reported, so that neither a peerspeak
|
||||||
|
/// tagging bug nor an impersonation attempt is silent.
|
||||||
|
#[test]
|
||||||
|
fn ignored_ownership_tags_are_surfaced_for_diagnostics() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("call", 7);
|
||||||
|
graph.link(call, sink);
|
||||||
|
let impostor = graph.peerspeak_tagged_node("rogue", MediaRole::StreamInput, 4_242);
|
||||||
|
|
||||||
|
let snapshot = graph.build();
|
||||||
|
let misplaced: Vec<Serial> = super::misplaced_ownership_tags(&snapshot)
|
||||||
|
.iter()
|
||||||
|
.map(|node| node.serial)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Exactly the ignored one: the honoured producer is not "misplaced".
|
||||||
|
assert_eq!(misplaced, vec![impostor.serial]);
|
||||||
|
assert_ne!(impostor.serial, call.serial);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
|
||||||
|
// ⚠️ Refuse a duplicated key rather than resolving it (Codex phase-1
|
||||||
|
// review, finding 3). This side takes the first match and peerspeak's
|
||||||
|
// took the last, so a duplicate in a byte-identical file could leave both
|
||||||
|
// repos green having selected *different* contracts.
|
||||||
|
for (index, (key, _)) in pinned.iter().enumerate() {
|
||||||
|
assert!(
|
||||||
|
!pinned[..index].iter().any(|(seen, _)| seen == key),
|
||||||
|
"fixture defines {key:?} twice; the two repos would disagree on which wins"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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"));
|
||||||
|
// ⚠️ **Equality, and that is now the whole rule**: carrier 1 is matched
|
||||||
|
// exactly, not as "anything but false/0" (round 10, R10-4). This assert
|
||||||
|
// used to be followed by a weaker `value != "false" && value != "0"`
|
||||||
|
// check, which described a leniency that no longer exists — the round-10
|
||||||
|
// review's finding 6, and a real trap: a future producer reading the old
|
||||||
|
// fixture prose could emit "true" and silently lose this carrier.
|
||||||
|
//
|
||||||
|
// That this consumer actually *listens* to the fixture's value, through
|
||||||
|
// the production observer wiring rather than a helper, is asserted by
|
||||||
|
// `observer::adapter::tests::the_fixture_value_is_the_only_owned_spelling`.
|
||||||
|
assert_eq!(super::PEERSPEAK_OWNED_VALUE, get("prop_value"));
|
||||||
|
|
||||||
|
// 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]
|
#[test]
|
||||||
fn aec_identity_is_exact_equality_and_other_modules_stay_eligible() {
|
fn aec_identity_is_exact_equality_and_other_modules_stay_eligible() {
|
||||||
let mut graph = Graph::new();
|
let mut graph = Graph::new();
|
||||||
@@ -507,8 +718,9 @@ fn owner_key_union_falls_through_a_present_but_unequal_key() {
|
|||||||
snapshot.node(b.serial).unwrap(),
|
snapshot.node(b.serial).unwrap(),
|
||||||
);
|
);
|
||||||
assert_ne!(a.props.client_id, b.props.client_id);
|
assert_ne!(a.props.client_id, b.props.client_id);
|
||||||
|
let owner_ctx = OwnerCtx::new(&snapshot, Some(PULSE_PID));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strongest_shared_key(a, b, Some(PULSE_PID)),
|
strongest_shared_key(a, b, &owner_ctx),
|
||||||
Some(OwnerKey::ProcessId)
|
Some(OwnerKey::ProcessId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -519,11 +731,12 @@ fn the_strongest_shared_key_wins_when_several_match() {
|
|||||||
let a = graph.group_node("a", MediaRole::StreamInput, "g", 500);
|
let a = graph.group_node("a", MediaRole::StreamInput, "g", 500);
|
||||||
let b = graph.group_node("b", MediaRole::StreamOutput, "g", 500);
|
let b = graph.group_node("b", MediaRole::StreamOutput, "g", 500);
|
||||||
let snapshot = graph.build();
|
let snapshot = graph.build();
|
||||||
|
let owner_ctx = OwnerCtx::new(&snapshot, Some(PULSE_PID));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strongest_shared_key(
|
strongest_shared_key(
|
||||||
snapshot.node(a.serial).unwrap(),
|
snapshot.node(a.serial).unwrap(),
|
||||||
snapshot.node(b.serial).unwrap(),
|
snapshot.node(b.serial).unwrap(),
|
||||||
Some(PULSE_PID)
|
&owner_ctx
|
||||||
),
|
),
|
||||||
Some(OwnerKey::LinkGroup)
|
Some(OwnerKey::LinkGroup)
|
||||||
);
|
);
|
||||||
@@ -560,6 +773,321 @@ fn the_pipewire_pulse_pid_does_not_fuse_unrelated_modules() {
|
|||||||
assert_untainted(&decisions, b_in);
|
assert_untainted(&decisions, b_in);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **R10-3, the fix.** A native PipeWire client puts no
|
||||||
|
/// `application.process.id` on its node — only `client.id` — so before the
|
||||||
|
/// Client fallback it had no key 4, was therefore *unbounded*, and
|
||||||
|
/// `propagate_unresolved_owner` excluded it the moment any tainted reader
|
||||||
|
/// existed anywhere on the machine.
|
||||||
|
///
|
||||||
|
/// Measured live: an untagged mpv was eligible alone, and became
|
||||||
|
/// `unresolved-owner` the instant peerspeak played audio. Since peerspeak
|
||||||
|
/// playing audio is the only situation in which this feature runs at all, that
|
||||||
|
/// amounted to "native-PipeWire apps are never shareable".
|
||||||
|
#[test]
|
||||||
|
fn a_native_client_is_bounded_by_its_clients_sec_pid() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
|
||||||
|
// The tainted reader that arms the unresolved-owner arm. Bounded itself
|
||||||
|
// (a real pid), exactly as the live `sunshine` was — so this is the
|
||||||
|
// bounded-reader arm, not the keyless-reader one.
|
||||||
|
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
|
||||||
|
graph.link(hw, sunshine);
|
||||||
|
|
||||||
|
// mpv on its default ao: client.id only, pid on the Client.
|
||||||
|
let mpv = graph.native_client_node("mpv", MediaRole::StreamOutput, 31_284);
|
||||||
|
graph.link(mpv, hw);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("mpv", mpv)],
|
||||||
|
&[("call", call, "peerspeak-owned")],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fallback must bridge a native app's *own* legs, or it has bought
|
||||||
|
/// boundedness without buying correctness: an app that reads the call and
|
||||||
|
/// re-emits it on a second native node would be declared clean.
|
||||||
|
#[test]
|
||||||
|
fn the_sec_pid_fallback_still_bridges_a_native_apps_own_legs() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
|
||||||
|
// One native process, two nodes, no link between them — the forwarder
|
||||||
|
// shape, in the native flavour.
|
||||||
|
let leg_in = graph.native_client_node("forwarder-in", MediaRole::StreamInput, 50_000);
|
||||||
|
let leg_out = graph.native_client_node("forwarder-out", MediaRole::StreamOutput, 50_000);
|
||||||
|
graph.link(hw, leg_in);
|
||||||
|
|
||||||
|
let decisions = run(&graph, &ctx());
|
||||||
|
assert_tainted(&decisions, leg_out, "tainted-owner-bridge");
|
||||||
|
assert_partition(
|
||||||
|
&decisions,
|
||||||
|
&[],
|
||||||
|
&[
|
||||||
|
("call", call, "peerspeak-owned"),
|
||||||
|
("forwarder-out", leg_out, "tainted-owner-bridge"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The risk the fallback creates, and the guard on it.** Every
|
||||||
|
/// Pulse-emulated Client carries pipewire-pulse's own PID as `sec_pid` —
|
||||||
|
/// measured, 15 unrelated Clients sharing 2528 on this host. An unguarded
|
||||||
|
/// fallback would give all of them key 4 with the *same* value and fuse them
|
||||||
|
/// into one owner, so a single tainted Pulse app would exclude every other
|
||||||
|
/// Pulse app on the machine.
|
||||||
|
///
|
||||||
|
/// Exception 1 therefore applies to the fallback exactly as it does to the
|
||||||
|
/// node's own property. Without that, this row goes red.
|
||||||
|
#[test]
|
||||||
|
fn the_sec_pid_fallback_does_not_fuse_every_pulse_client() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
|
||||||
|
// Three unrelated Pulse-emulated apps, each on its own Client, none
|
||||||
|
// exposing a node-level pid — so each can only reach key 4 through its
|
||||||
|
// Client, whose sec_pid is the daemon's.
|
||||||
|
let pulse_app = |graph: &mut Graph, name: &str, role| {
|
||||||
|
let client = graph.client(Some(PULSE_PID));
|
||||||
|
graph.node(
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(client),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// One of them reads the tainted sink; the other two must not care.
|
||||||
|
let reader = pulse_app(&mut graph, "recorder", MediaRole::StreamInput);
|
||||||
|
graph.link(hw, reader);
|
||||||
|
let other_a = pulse_app(&mut graph, "player-a", MediaRole::StreamOutput);
|
||||||
|
let other_b = pulse_app(&mut graph, "player-b", MediaRole::StreamOutput);
|
||||||
|
|
||||||
|
let decisions = run(&graph, &ctx());
|
||||||
|
// They are unbounded (`client.id` alone never bounds an owner), so the
|
||||||
|
// fail-closed arm still excludes them — but as `unresolved-owner`, NOT as
|
||||||
|
// `tainted-owner-bridge`. That distinction is the whole assertion: a
|
||||||
|
// bridge reason here would mean the daemon pid had fused three unrelated
|
||||||
|
// applications into one owner, and unlike fail-closed exclusion, fusion
|
||||||
|
// does not go away when the apps are given real pids
|
||||||
|
// (`distinct_sec_pids_bound_each_native_app_separately` is that half).
|
||||||
|
assert_tainted(&decisions, other_a, "unresolved-owner");
|
||||||
|
assert_tainted(&decisions, other_b, "unresolved-owner");
|
||||||
|
for node in [other_a, other_b] {
|
||||||
|
assert_ne!(
|
||||||
|
decisions.taint.get(&node.serial).map(|e| e.reason.code()),
|
||||||
|
Some("tainted-owner-bridge"),
|
||||||
|
"the daemon pid must not bridge unrelated Pulse clients"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same three apps, given **real per-app** `sec_pid`s: now the fallback
|
||||||
|
/// fires, all three are bounded, and only the one actually reading the call is
|
||||||
|
/// affected. This is the row that proves the guard above suppresses the daemon
|
||||||
|
/// pid *specifically* rather than disabling the fallback outright.
|
||||||
|
#[test]
|
||||||
|
fn distinct_sec_pids_bound_each_native_app_separately() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
|
||||||
|
let reader = graph.native_client_node("recorder", MediaRole::StreamInput, 6_001);
|
||||||
|
graph.link(hw, reader);
|
||||||
|
let other_a = graph.native_client_node("player-a", MediaRole::StreamOutput, 6_002);
|
||||||
|
let other_b = graph.native_client_node("player-b", MediaRole::StreamOutput, 6_003);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("player-a", other_a), ("player-b", other_b)],
|
||||||
|
&[("call", call, "peerspeak-owned")],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An **ambiguous** `client.id` — two live Clients claiming it, meaning the
|
||||||
|
/// observer missed a removal — must not yield a fallback pid. Inventing an
|
||||||
|
/// owner key is the one direction that can *reduce* taint, so resolving the
|
||||||
|
/// ambiguity by coin toss is the wrong kind of guess.
|
||||||
|
#[test]
|
||||||
|
fn an_ambiguous_client_id_yields_no_fallback_pid() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
|
||||||
|
graph.link(hw, sunshine);
|
||||||
|
|
||||||
|
// Two Clients, one id, distinct real pids.
|
||||||
|
let shared_id = graph.client(Some(6_010));
|
||||||
|
graph.client_with_id(shared_id, Some(6_011));
|
||||||
|
let app = graph.node(
|
||||||
|
"native-app",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(shared_id),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.link(app, hw);
|
||||||
|
|
||||||
|
// Unbounded ⇒ fails closed, exactly as before R10-3.
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[],
|
||||||
|
&[
|
||||||
|
("call", call, "peerspeak-owned"),
|
||||||
|
("native-app", app, "unresolved-owner"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ambiguity guard must not depend on the *first* Client claiming an id
|
||||||
|
/// having a `sec_pid`.
|
||||||
|
///
|
||||||
|
/// Found by auditing R10-3 rather than by a failing case: the first cut
|
||||||
|
/// detected a duplicate id by looking it up in the pid map, which is only
|
||||||
|
/// populated for Clients that carry a pid at all. A pid-less Client therefore
|
||||||
|
/// left no trace, and the next Client claiming the same id was treated as
|
||||||
|
/// unique — resolving an ambiguous id, which is exactly the guess the guard
|
||||||
|
/// exists to refuse. Pid-less Clients are ordinary here (`device_node`'s
|
||||||
|
/// session client is one), so this is reachable, not theoretical.
|
||||||
|
#[test]
|
||||||
|
fn a_pidless_first_client_still_makes_its_id_ambiguous() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
|
||||||
|
graph.link(hw, sunshine);
|
||||||
|
|
||||||
|
// First claimant has NO sec_pid; second has one.
|
||||||
|
let shared_id = graph.client(None);
|
||||||
|
graph.client_with_id(shared_id, Some(6_011));
|
||||||
|
let app = graph.node(
|
||||||
|
"native-app",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(shared_id),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.link(app, hw);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[],
|
||||||
|
&[
|
||||||
|
("call", call, "peerspeak-owned"),
|
||||||
|
("native-app", app, "unresolved-owner"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A process using **two** Clients cannot escape the bridge by presenting a
|
||||||
|
/// bogus pid on one leg and none on the other.
|
||||||
|
///
|
||||||
|
/// ⚠️ **This is the round-10 review's finding 1, and it was a real leak while
|
||||||
|
/// key 4 was `node.or_else(client)`.** The node's `application.process.id` is
|
||||||
|
/// client-controlled; the Client's `pipewire.sec.pid` is protected. Letting
|
||||||
|
/// the node's value *replace* the Client's meant the reader was bounded by
|
||||||
|
/// `12_345` and the output leg by `50_000`, so they shared no key, did not
|
||||||
|
/// bridge, and — both being bounded — neither tripped the unbounded sweep.
|
||||||
|
/// The output stayed eligible while re-emitting the call.
|
||||||
|
///
|
||||||
|
/// Carrying both values fixes it: the two legs share the Client pid.
|
||||||
|
///
|
||||||
|
/// Reachability, stated honestly: `evaluate()` today is reached only by the
|
||||||
|
/// dry-run audit, which creates no links, so this could not echo on this
|
||||||
|
/// branch. It becomes live the moment phase 6 consumes these decisions.
|
||||||
|
#[test]
|
||||||
|
fn one_process_with_two_clients_cannot_split_its_pid_to_escape_the_bridge() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
|
||||||
|
// One native process, two Clients, one protected pid.
|
||||||
|
let reader_client = graph.client(Some(50_000));
|
||||||
|
let output_client = graph.client(Some(50_000));
|
||||||
|
|
||||||
|
// Its reading leg claims a pid that is not its own.
|
||||||
|
let reader = graph.node(
|
||||||
|
"two-client-reader",
|
||||||
|
MediaRole::StreamInput,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(reader_client),
|
||||||
|
process_id: Some(12_345),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.link(hw, reader);
|
||||||
|
|
||||||
|
// Its re-emitting leg claims no pid at all.
|
||||||
|
let output = graph.node(
|
||||||
|
"two-client-output",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(output_client),
|
||||||
|
process_id: None,
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.link(output, hw);
|
||||||
|
|
||||||
|
// A genuinely unrelated app must survive, or "exclude everything" would
|
||||||
|
// pass this test — the §5.1 eligible-half rule.
|
||||||
|
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
|
||||||
|
graph.link(bystander, hw);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("mpv", bystander)],
|
||||||
|
&[
|
||||||
|
("call", call, "peerspeak-owned"),
|
||||||
|
("two-client-output", output, "tainted-owner-bridge"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The node's own `application.process.id` is used even when its Client's
|
||||||
|
/// `sec_pid` is the daemon's — the single most common shape here, since a
|
||||||
|
/// Pulse-emulated node's pid is the app's while its Client's is
|
||||||
|
/// pipewire-pulse's.
|
||||||
|
///
|
||||||
|
/// ⚠️ Both values are now carried (round-10 review, finding 1), so this is no
|
||||||
|
/// longer "the node's wins" but "exception 1 is applied per value": the
|
||||||
|
/// daemon's `sec_pid` is dropped and the node's real pid is kept, leaving the
|
||||||
|
/// same single key as before.
|
||||||
|
#[test]
|
||||||
|
fn the_nodes_own_process_id_wins_over_its_clients() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
// `app_node` is exactly that shape: node pid 11_114, Client sec_pid
|
||||||
|
// PULSE_PID. If the Client's won, exception 1 would suppress key 4 and
|
||||||
|
// this node would be unbounded.
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
|
||||||
|
graph.link(hw, sunshine);
|
||||||
|
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
|
||||||
|
graph.link(firefox, hw);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("firefox", firefox)],
|
||||||
|
&[("call", call, "peerspeak-owned")],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unknown_pipewire_pulse_pid_over_excludes_rather_than_leaks() {
|
fn an_unknown_pipewire_pulse_pid_over_excludes_rather_than_leaks() {
|
||||||
// v3.4 §6.1.2's failure-mode paragraph: if pixelpass cannot identify
|
// v3.4 §6.1.2's failure-mode paragraph: if pixelpass cannot identify
|
||||||
@@ -820,6 +1348,137 @@ fn an_ambiguous_recycled_global_id_fails_closed() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Uncertainty is not history — it never enters sticky state
|
||||||
|
// (round 9, from a live phase-5 audit run; see `Uncertainty` in mod.rs)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unresolved_ancestry_does_not_survive_being_resolved() {
|
||||||
|
// Measured live on a desktop: a link is observed while its output node is
|
||||||
|
// still unbound, the input side fails closed — correctly — and then that
|
||||||
|
// fail-closed mark became *sticky*, so a hardware sink stayed excluded for
|
||||||
|
// the process lifetime even after the node resolved and turned out to be
|
||||||
|
// an ordinary game. Phase 3r's bind-everything observer widens that window
|
||||||
|
// to every node, so this must clear.
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let ghost = graph.dangling_id();
|
||||||
|
let client = graph.client_of_app(6000);
|
||||||
|
let victim = graph.node("victim-in", MediaRole::StreamInput, app(client, 6000));
|
||||||
|
let sibling = graph.node("victim-out", MediaRole::StreamOutput, app(client, 6000));
|
||||||
|
graph.link_ids(ghost, victim.id);
|
||||||
|
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
|
||||||
|
let c = ctx();
|
||||||
|
|
||||||
|
// While the ancestry is genuinely unresolved, the decision is unchanged:
|
||||||
|
// fail closed, both the victim and its sibling excluded.
|
||||||
|
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
|
||||||
|
assert_partition(
|
||||||
|
&first,
|
||||||
|
&[("firefox", firefox)],
|
||||||
|
&[("victim-out", sibling, "tainted-owner-bridge")],
|
||||||
|
);
|
||||||
|
assert_tainted(&first, victim, "unresolved-ancestry");
|
||||||
|
|
||||||
|
// The node behind that id turns up — nothing tainted, it was simply not
|
||||||
|
// observed yet. The uncertainty is gone, so nothing may remain of it.
|
||||||
|
let late_client = graph.client_of_app(7100);
|
||||||
|
let resolved = graph.node_with_id(
|
||||||
|
"was-unbound",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
ghost,
|
||||||
|
app(late_client, 7100),
|
||||||
|
);
|
||||||
|
let (second, _) = evaluate(&graph.build(), &c, &sticky);
|
||||||
|
assert_partition(
|
||||||
|
&second,
|
||||||
|
&[
|
||||||
|
("firefox", firefox),
|
||||||
|
("victim-out", sibling),
|
||||||
|
("was-unbound", resolved),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uncertainty_laundered_into_downstream_taint_is_not_sticky_either() {
|
||||||
|
// Retiring by reason *code* would not be enough: an unresolved node
|
||||||
|
// propagates `tainted-upstream`, which is indistinguishable from real
|
||||||
|
// contamination once recorded. The split has to be by provenance, so a
|
||||||
|
// node two hops from the uncertainty must clear too.
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let ghost = graph.dangling_id();
|
||||||
|
let forwarder_client = graph.client_of_app(6100);
|
||||||
|
let forwarder_in = graph.node(
|
||||||
|
"fwd-in",
|
||||||
|
MediaRole::StreamInput,
|
||||||
|
app(forwarder_client, 6100),
|
||||||
|
);
|
||||||
|
let forwarder_out = graph.node(
|
||||||
|
"fwd-out",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
app(forwarder_client, 6100),
|
||||||
|
);
|
||||||
|
let downstream_client = graph.client_of_app(6200);
|
||||||
|
let downstream = graph.node("downstream", MediaRole::Sink, app(downstream_client, 6200));
|
||||||
|
let downstream_leg = graph.node(
|
||||||
|
"downstream-out",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
app(downstream_client, 6200),
|
||||||
|
);
|
||||||
|
graph.link_ids(ghost, forwarder_in.id);
|
||||||
|
graph.link(forwarder_out, downstream);
|
||||||
|
let c = ctx();
|
||||||
|
|
||||||
|
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
|
||||||
|
assert_tainted(&first, forwarder_in, "unresolved-ancestry");
|
||||||
|
assert_tainted(&first, downstream, "tainted-upstream");
|
||||||
|
assert!(
|
||||||
|
first.candidates[&downstream_leg.serial].reason().is_some(),
|
||||||
|
"while the ancestry is unresolved the downstream owner is excluded too"
|
||||||
|
);
|
||||||
|
|
||||||
|
let late_client = graph.client_of_app(7200);
|
||||||
|
graph.node_with_id(
|
||||||
|
"was-unbound",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
ghost,
|
||||||
|
app(late_client, 7200),
|
||||||
|
);
|
||||||
|
let (second, _) = evaluate(&graph.build(), &c, &sticky);
|
||||||
|
assert_eq!(
|
||||||
|
second.candidates[&downstream_leg.serial].reason(),
|
||||||
|
None,
|
||||||
|
"nothing derived from the uncertainty may outlive it"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
second.candidates[&forwarder_out.serial].reason(),
|
||||||
|
None,
|
||||||
|
"including the unresolved node's own owner siblings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_taint_is_still_sticky_when_its_topology_goes_away() {
|
||||||
|
// The other half of the same rule, stated positively: *evidence* is
|
||||||
|
// history and must survive. This is the guard on the change above — if
|
||||||
|
// provenance splitting ever leaks into the evidence path, peerspeak's own
|
||||||
|
// audio starts escaping.
|
||||||
|
let (graph, call, rec_in, rec_out, firefox) = sticky_scene();
|
||||||
|
let c = ctx();
|
||||||
|
let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
|
||||||
|
let (second, _) = evaluate(&graph.build_without(&[rec_in]), &c, &sticky);
|
||||||
|
assert_partition(
|
||||||
|
&second,
|
||||||
|
&[("firefox", firefox)],
|
||||||
|
&[
|
||||||
|
("call", call, "peerspeak-owned"),
|
||||||
|
("rec-out", rec_out, "tainted-owner-bridge"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
// Stickiness and lifetime-awareness (v3.4 §6.1.3)
|
// Stickiness and lifetime-awareness (v3.4 §6.1.3)
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+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