feat(host): build desktop audio exclusion foundation

This commit is contained in:
2026-08-21 15:39:07 -04:00
parent 5d3da8b006
commit 781defcd84
16 changed files with 2586 additions and 564 deletions
+27 -2
View File
@@ -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
View File
@@ -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();
+12
View File
@@ -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
View File
@@ -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