feat(host): build desktop audio exclusion foundation
This commit is contained in:
@@ -155,7 +155,17 @@ impl Graph {
|
||||
/// A device node as the session manager creates it: no strong key,
|
||||
/// WirePlumber's client and PID — shared with every other device — and
|
||||
/// a `device.id`, which is what marks it as session-manager-exported.
|
||||
/// Each call models a distinct physical device; use [`Self::device_node_on`]
|
||||
/// when two terminals belong to the same card.
|
||||
pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef {
|
||||
let device_id = self.id();
|
||||
self.device_node_on(name, role, device_id)
|
||||
}
|
||||
|
||||
/// A passive terminal exported by a particular physical Device. Sink
|
||||
/// and source nodes given the same id model the hidden playback-to-capture
|
||||
/// path that an ALSA/USB device may expose outside PipeWire's Link graph.
|
||||
pub fn device_node_on(&mut self, name: &str, role: MediaRole, device_id: GlobalId) -> NodeRef {
|
||||
let session = match self.session_client {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
@@ -164,7 +174,7 @@ impl Graph {
|
||||
id
|
||||
}
|
||||
};
|
||||
self.node(name, role, device(session, SESSION_PID))
|
||||
self.node(name, role, device(session, SESSION_PID, device_id))
|
||||
}
|
||||
|
||||
/// A node that *belongs to* a Device but is not a passive device node —
|
||||
@@ -247,6 +257,18 @@ impl Graph {
|
||||
}
|
||||
|
||||
pub fn port(&mut self, node: NodeRef, direction: PortDirection, exclusive: bool) {
|
||||
self.port_on_channel(node, direction, exclusive, None);
|
||||
}
|
||||
|
||||
/// A port with the channel identity Phase 6 uses for deterministic link
|
||||
/// pairing. Returns its snapshot-local id for exact plan assertions.
|
||||
pub fn port_on_channel(
|
||||
&mut self,
|
||||
node: NodeRef,
|
||||
direction: PortDirection,
|
||||
exclusive: bool,
|
||||
channel: Option<&str>,
|
||||
) -> GlobalId {
|
||||
let serial = self.serial();
|
||||
let id = self.id();
|
||||
self.ports.push(PortSnapshot {
|
||||
@@ -254,9 +276,11 @@ impl Graph {
|
||||
id,
|
||||
node: node.id,
|
||||
direction,
|
||||
channel: channel.map(str::to_string),
|
||||
exclusive,
|
||||
monitor: false,
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
/// A signal edge: audio flows `from → to`.
|
||||
@@ -386,10 +410,11 @@ pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps {
|
||||
/// here is deliberately *more* pessimistic than reality — it hands the
|
||||
/// engine a second coarse key it could fuse devices on, so a test that
|
||||
/// passes here also passes against the real props.
|
||||
pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps {
|
||||
pub fn device(session_client: GlobalId, session_pid: u32, device_id: GlobalId) -> NodeProps {
|
||||
NodeProps {
|
||||
client_id: Some(session_client),
|
||||
process_id: Some(session_pid),
|
||||
device_id: Some(device_id),
|
||||
session_device: true,
|
||||
..NodeProps::default()
|
||||
}
|
||||
|
||||
+40
-1
@@ -34,7 +34,12 @@
|
||||
//! *is* a real Link whose output node is the sink node itself (measured).
|
||||
//! A port-granular walk would need a synthetic edge; a node-granular one
|
||||
//! does not.
|
||||
//! 3. **Owner bridges** — the intra-process hop the graph cannot see. See
|
||||
//! 3. **Hardware-device bridges** — a passive sink can feed a passive source
|
||||
//! on the same physical Device through a mixer/loopback path that PipeWire
|
||||
//! does not expose as a Link. The observer positively classifies both
|
||||
//! terminals and retains their shared `device.id`; the walk conservatively
|
||||
//! adds `sink → source` for that one Device.
|
||||
//! 4. **Owner bridges** — the intra-process hop the graph cannot see. See
|
||||
//! [`owner`]; this is the hard one.
|
||||
//!
|
||||
//! ## Stickiness
|
||||
@@ -784,6 +789,40 @@ fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet<Se
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// A physical sound device may route playback back into capture in its
|
||||
// own mixer/firmware without publishing a PipeWire Link (HDA "Stereo
|
||||
// Mix", USB loopback channels, vendor DSPs). Control-name inspection is
|
||||
// neither portable nor proof of absence, so v1 fails closed: once a
|
||||
// passive hardware sink is tainted, passive capture terminals exported
|
||||
// by the same Device are downstream too.
|
||||
//
|
||||
// Both guards are load-bearing. `session_device` limits this to the
|
||||
// observer's positive hardware-terminal allowlist, so an app-associated
|
||||
// filter cannot invent a bridge. `device_id` limits it to one physical
|
||||
// Device, so the shared WirePlumber client does not fuse every card.
|
||||
let hardware_outputs: Vec<(Serial, snapshot::GlobalId)> = snapshot
|
||||
.nodes()
|
||||
.filter(|node| {
|
||||
node.props.session_device && matches!(node.role, MediaRole::Sink | MediaRole::Duplex)
|
||||
})
|
||||
.filter_map(|node| node.props.device_id.map(|id| (node.serial, id)))
|
||||
.collect();
|
||||
let hardware_inputs: Vec<(Serial, snapshot::GlobalId)> = snapshot
|
||||
.nodes()
|
||||
.filter(|node| {
|
||||
node.props.session_device && matches!(node.role, MediaRole::Source | MediaRole::Duplex)
|
||||
})
|
||||
.filter_map(|node| node.props.device_id.map(|id| (node.serial, id)))
|
||||
.collect();
|
||||
for (from, output_device) in hardware_outputs {
|
||||
for &(to, input_device) in &hardware_inputs {
|
||||
if from != to && output_device == input_device {
|
||||
edges.entry(from).or_default().push(to);
|
||||
receivers.insert(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
for targets in edges.values_mut() {
|
||||
targets.sort_unstable();
|
||||
targets.dedup();
|
||||
|
||||
@@ -139,6 +139,15 @@ pub struct NodeProps {
|
||||
/// The stream negotiated an encoded/passthrough format; a second link
|
||||
/// would refuse or corrupt it (v3.4 §6.2).
|
||||
pub passthrough: bool,
|
||||
/// `device.id` — the snapshot-local PipeWire Device this node belongs
|
||||
/// to. This is retained separately from [`Self::session_device`]: the
|
||||
/// latter says the node is a positively-classified passive hardware
|
||||
/// terminal, while this id lets the taint walk relate the playback and
|
||||
/// capture terminals exported by that *same* device.
|
||||
///
|
||||
/// Like every [`GlobalId`], this is valid only within this snapshot. It
|
||||
/// must never enter sticky identity or survive a recompute.
|
||||
pub device_id: Option<GlobalId>,
|
||||
/// This node is a **passive device node exported by the session
|
||||
/// manager** — a real sound card's sink or source, not something that
|
||||
/// forwards audio.
|
||||
@@ -219,6 +228,9 @@ pub struct PortSnapshot {
|
||||
/// Owning node, by snapshot-local id.
|
||||
pub node: GlobalId,
|
||||
pub direction: PortDirection,
|
||||
/// `audio.channel` (for example `FL`, `FR`, `MONO`). Phase 6 pairs
|
||||
/// ports by channel, never by global-id or enumeration order.
|
||||
pub channel: Option<String>,
|
||||
/// `port.exclusive` — fan-out will be refused (v3.4 §6.2).
|
||||
pub exclusive: bool,
|
||||
/// `port.monitor`. Recorded for phase 6 link creation; taint does not
|
||||
|
||||
+83
-1
@@ -16,7 +16,7 @@ use std::collections::BTreeSet;
|
||||
|
||||
use super::fixture::{Graph, NodeRef, PULSE_PID, app};
|
||||
use super::owner::{OwnerCtx, OwnerKey, strongest_shared_key};
|
||||
use super::snapshot::{MediaRole, NodeProps, PortDirection, Serial};
|
||||
use super::snapshot::{GlobalId, MediaRole, NodeProps, PortDirection, Serial};
|
||||
use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate};
|
||||
|
||||
fn ctx() -> ExclusionCtx {
|
||||
@@ -176,6 +176,88 @@ fn peerspeak_tagged_nodes_are_excluded_and_plain_apps_are_not() {
|
||||
assert_tainted(&decisions, sink, "tainted-upstream");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Hidden hardware playback-to-capture paths — same Device only
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn same_hardware_device_closes_an_unpublished_playback_to_capture_hop() {
|
||||
let mut graph = Graph::new();
|
||||
let card = GlobalId(700);
|
||||
let sink = graph.device_node_on("card-playback", MediaRole::Sink, card);
|
||||
let source = graph.device_node_on("card-capture", MediaRole::Source, card);
|
||||
let call = graph.peerspeak_node("peerspeak-call", 7);
|
||||
let music = graph.app_node("music", MediaRole::StreamOutput, 8);
|
||||
let recorder_in = graph.app_node("recorder-in", MediaRole::StreamInput, 9);
|
||||
let recorder_out = graph.app_node("recorder-out", MediaRole::StreamOutput, 9);
|
||||
|
||||
graph.link(call, sink);
|
||||
graph.link(music, sink);
|
||||
// There is deliberately no sink → source Link: the hardware bridge is
|
||||
// the route being modeled.
|
||||
graph.link(source, recorder_in);
|
||||
|
||||
let decisions = run(&graph, &ctx());
|
||||
assert_partition(
|
||||
&decisions,
|
||||
&[("music", music)],
|
||||
&[
|
||||
("call", call, "peerspeak-owned"),
|
||||
("recorder-out", recorder_out, "tainted-owner-bridge"),
|
||||
],
|
||||
);
|
||||
assert_tainted(&decisions, sink, "tainted-upstream");
|
||||
assert_tainted(&decisions, source, "tainted-upstream");
|
||||
assert_tainted(&decisions, recorder_in, "tainted-upstream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_hardware_devices_do_not_invent_a_capture_path() {
|
||||
let mut graph = Graph::new();
|
||||
let sink = graph.device_node_on("speaker", MediaRole::Sink, GlobalId(700));
|
||||
let source = graph.device_node_on("usb-mic", MediaRole::Source, GlobalId(701));
|
||||
let call = graph.peerspeak_node("peerspeak-call", 7);
|
||||
let recorder_in = graph.app_node("recorder-in", MediaRole::StreamInput, 9);
|
||||
let recorder_out = graph.app_node("recorder-out", MediaRole::StreamOutput, 9);
|
||||
|
||||
graph.link(call, sink);
|
||||
graph.link(source, recorder_in);
|
||||
|
||||
let decisions = run(&graph, &ctx());
|
||||
assert_partition(
|
||||
&decisions,
|
||||
&[("recorder-out", recorder_out)],
|
||||
&[("call", call, "peerspeak-owned")],
|
||||
);
|
||||
assert_untainted(&decisions, source);
|
||||
assert_untainted(&decisions, recorder_in);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_device_microphone_use_is_intentionally_over_excluded() {
|
||||
// The hardware's private mixer/firmware path is not observable in the
|
||||
// PipeWire graph. If an app captures the same device receiving the call,
|
||||
// v1 cannot prove that its capture is clean, so its playback is excluded.
|
||||
let mut graph = Graph::new();
|
||||
let card = GlobalId(700);
|
||||
let sink = graph.device_node_on("headset-output", MediaRole::Sink, card);
|
||||
let mic = graph.device_node_on("headset-mic", MediaRole::Source, card);
|
||||
let call = graph.peerspeak_node("peerspeak-call", 7);
|
||||
let firefox_in = graph.app_node("firefox-mic", MediaRole::StreamInput, 11_114);
|
||||
let firefox_out = graph.app_node("firefox-audio", MediaRole::StreamOutput, 11_114);
|
||||
graph.link(call, sink);
|
||||
graph.link(mic, firefox_in);
|
||||
|
||||
assert_partition(
|
||||
&run(&graph, &ctx()),
|
||||
&[],
|
||||
&[
|
||||
("call", call, "peerspeak-owned"),
|
||||
("firefox-out", firefox_out, "tainted-owner-bridge"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Each ownership carrier must work **alone** (v3.5 §5.1).
|
||||
///
|
||||
/// ⚠️ The phase-3r lesson, applied deliberately: a gate that asserts a value
|
||||
|
||||
Reference in New Issue
Block a user