host/taint: owner key 4 falls back to the Client's pipewire.sec.pid
Native PipeWire clients put no application.process.id on their nodes — only client.id. keys_of read node properties alone, so those nodes had no key 4, were therefore unbounded, and propagate_unresolved_owner excluded them the moment any tainted reader existed anywhere on the machine. Measured: 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, that amounted to "native PipeWire apps are never shareable". The tainted reader that armed it was sunshine, which is itself bounded — so this is the bounded-reader arm, not the keyless-reader case §6.1.1 narrates. The pid is one hop away, on the node's Client, already in the snapshot. RISK, 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 fuse all of them into one owner. Exception 1 therefore applies to the fallback exactly as it does to the node's own property, so the fallback strictly *adds* correct bounding rather than trading it. Ambiguous client ids yield no fallback pid: inventing an owner key is the one direction that can reduce taint, so a coin toss is the wrong guess. The client index is threaded through a new OwnerCtx rather than a sixth positional Option<u32>, and evaluate() builds one and shares it, so the components and the key index cannot disagree about who is bounded. Round 10, R10-3. 6 new rows; 3 mutations verified — removing the fallback, dropping the pulse-pid exception (11 rows die), and resolving an ambiguous client id instead of dropping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+82
-15
@@ -67,10 +67,65 @@
|
||||
//! Grouping is **transitive** (union-find). That is the fail-closed
|
||||
//! 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};
|
||||
|
||||
/// 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();
|
||||
let mut ambiguous: BTreeSet<GlobalId> = BTreeSet::new();
|
||||
for client in snapshot.clients() {
|
||||
if client_pids.contains_key(&client.id) || ambiguous.contains(&client.id) {
|
||||
// Two Clients claiming one id: drop it entirely rather than
|
||||
// pick. See the field docs.
|
||||
client_pids.remove(&client.id);
|
||||
ambiguous.insert(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
|
||||
/// load-bearing for "report the strongest shared key".
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
||||
@@ -106,7 +161,7 @@ enum KeyValue {
|
||||
/// 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
|
||||
/// 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();
|
||||
if let Some(group) = &node.props.link_group {
|
||||
out.push((OwnerKey::LinkGroup, KeyValue::Text(group.clone())));
|
||||
@@ -122,13 +177,25 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKe
|
||||
if let Some(client) = node.props.client_id {
|
||||
out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0))));
|
||||
}
|
||||
if let Some(pid) = node.props.process_id {
|
||||
// Exception 1. Note the fail-closed asymmetry when the daemon PID is
|
||||
// unknown (`None`): the exception does *not* fire, key 4 applies to
|
||||
// Key 4, from the node if it has one, otherwise from its Client
|
||||
// (round 10, R10-3). The node's own property wins when both exist: it is
|
||||
// the direct statement, and the Client's is a one-hop inference.
|
||||
//
|
||||
// ⚠️ **Exception 1 must apply to the fallback too, and this is the whole
|
||||
// risk of the fallback.** Measured on this host: 15 unrelated Clients
|
||||
// share `sec_pid` 2528, which is pipewire-pulse's own — every
|
||||
// Pulse-emulated app has one. Adding key 4 unguarded would fuse all
|
||||
// fifteen into a single owner and bridge taint between completely
|
||||
// unrelated applications, which is enormous over-exclusion. Guarded, the
|
||||
// fallback strictly *adds* correct bounding: it fires only for native
|
||||
// clients, which are exactly the ones that carry a real per-app pid here.
|
||||
if let Some(pid) = node.props.process_id.or_else(|| ctx.client_pid(node)) {
|
||||
// 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.
|
||||
if Some(pid) != pipewire_pulse_pid {
|
||||
if Some(pid) != ctx.pipewire_pulse_pid {
|
||||
out.push((OwnerKey::ProcessId, KeyValue::Num(u64::from(pid))));
|
||||
}
|
||||
}
|
||||
@@ -151,8 +218,8 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKe
|
||||
/// nothing else relates them. Its sibling output leg cannot be found, so
|
||||
/// the engine must fail closed rather than declare it clean
|
||||
/// (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)
|
||||
pub fn owner_is_bounded(node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
|
||||
keys_of(node, ctx)
|
||||
.iter()
|
||||
.any(|(key, _)| *key != OwnerKey::ClientId)
|
||||
}
|
||||
@@ -168,11 +235,11 @@ pub struct OwnerKeyIndex {
|
||||
}
|
||||
|
||||
impl OwnerKeyIndex {
|
||||
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
|
||||
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
|
||||
Self {
|
||||
keys: snapshot
|
||||
.nodes()
|
||||
.map(|node| (node.serial, keys_of(node, pipewire_pulse_pid)))
|
||||
.map(|node| (node.serial, keys_of(node, ctx)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -252,10 +319,10 @@ impl OwnerKeyIndex {
|
||||
pub fn strongest_shared_key(
|
||||
a: &NodeSnapshot,
|
||||
b: &NodeSnapshot,
|
||||
pipewire_pulse_pid: Option<u32>,
|
||||
ctx: &OwnerCtx,
|
||||
) -> Option<OwnerKey> {
|
||||
let a_keys = keys_of(a, pipewire_pulse_pid);
|
||||
let b_keys = keys_of(b, pipewire_pulse_pid);
|
||||
let a_keys = keys_of(a, ctx);
|
||||
let b_keys = keys_of(b, ctx);
|
||||
// `keys_of` yields strongest-first, so the first match is the strongest.
|
||||
a_keys.iter().find_map(|(key, value)| {
|
||||
b_keys
|
||||
@@ -279,7 +346,7 @@ pub struct 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 index: BTreeMap<Serial, usize> =
|
||||
serials.iter().enumerate().map(|(i, s)| (*s, i)).collect();
|
||||
@@ -290,7 +357,7 @@ impl OwnerComponents {
|
||||
let mut buckets: BTreeMap<(OwnerKey, KeyValue), Vec<usize>> = BTreeMap::new();
|
||||
for node in snapshot.nodes() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user