host/taint: close the partial fixes found in Codex round 2
The verification round earned its place: five of the six round-1 fixes were partial, and two of the gaps were worse than the bugs they replaced. 1. ⚠️ The round-1 sticky fix smuggled the suppressed key back in. `client_serials_of` recorded the shared `WirePlumber [export]` client as a member of a tainted hardware sink's owner, so the *second* recompute expanded that client to every sound card on the box, tainted the microphone, and excluded every app holding one — the §6.1.1 catastrophe arriving one epoch late instead of never. `client.id` may now only be recorded, or expanded, for nodes where it is a usable owner key. The regression test evaluates an unchanged snapshot three times: a correct engine's answer must not drift when nothing has. 2. Sticky followed a surviving *connection*, not a surviving *owner*. A process can leave one client idle and open a second — GStreamer opens one per stream as a matter of course — and the new leg escaped. `StickyOwner` now carries owner **fingerprints** (strong keys and a usable PID, never `client.id`), applied only while some serial member is still live, so a recyclable key cannot resurrect a dead owner. 3. An **ambiguous** link input endpoint tainted every claimant but made none of them a receiver, so their sibling output legs stayed eligible. Taint without receiver status cannot start an owner bridge. 4. `device.id` is a raw observation, not the classification the coarse-key exception needs — PipeWire defines it only as "the Device this node belongs to", so a forwarding node carrying one would have lost both its owner keys and its ability to trip the backstop. Replaced by `session_device`, a phase-3 obligation (`device.id` AND `device.api`) documented to fail closed when it cannot classify. 5. Readiness now gates sticky **retirement only**. Round 1 stopped a not-ready epoch erasing history; it also stopped it recording any, so a reader could consume and buffer the call during that epoch, vanish before readiness, and leave its output eligible. 6. Added the unresolved-output-plus-unknown-role fixture: deleting one `receivers.insert` survived all 42 previous tests. Mutation-verified: 7/7 reverts killed by their intended test. Two attempts did not land first time and both were my error, not the engine's — the client-key guard is applied at two sites so removing one is not a revert (removing the pair is, and that is killed), and the fingerprint-lifetime test put the recycled node in a snapshot *after* the entry had already been retired, so the guard was never consulted. Rewritten to place it in the same snapshot that first sees the owner gone. Cost comment corrected again, to O(D·(V+E+Σ|sources|·|targets|)). 49 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -131,8 +131,16 @@ impl Graph {
|
||||
id
|
||||
}
|
||||
};
|
||||
let device_id = self.id();
|
||||
self.node(name, role, device(session, SESSION_PID, device_id))
|
||||
self.node(name, role, device(session, SESSION_PID))
|
||||
}
|
||||
|
||||
/// A node that *belongs to* a Device but is not a passive device node —
|
||||
/// a filter associated with a card. Phase 3 must not classify this as a
|
||||
/// session device, or it loses both its coarse owner keys and its
|
||||
/// ability to trip the fail-closed backstop.
|
||||
pub fn device_associated_filter(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||
let client = self.client_of_app(pid);
|
||||
self.node(name, role, app(client, pid))
|
||||
}
|
||||
|
||||
/// A **virtual** sink an application created natively: an `Audio/Sink`
|
||||
@@ -313,11 +321,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, device_id: GlobalId) -> NodeProps {
|
||||
pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps {
|
||||
NodeProps {
|
||||
client_id: Some(session_client),
|
||||
process_id: Some(session_pid),
|
||||
device_id: Some(device_id),
|
||||
session_device: true,
|
||||
..NodeProps::default()
|
||||
}
|
||||
}
|
||||
|
||||
+99
-35
@@ -202,6 +202,17 @@ pub struct StickyOwner {
|
||||
/// accumulates: that is what makes "clear only once all member objects
|
||||
/// have disappeared" true across churn.
|
||||
pub members: BTreeSet<ObjectRef>,
|
||||
/// Owner keys remembered across connections — strong keys and a usable
|
||||
/// process id, never `client.id`. Applied only while some serial member
|
||||
/// above is still live, which is what keeps a recyclable key from
|
||||
/// resurrecting a dead owner.
|
||||
///
|
||||
/// Needed because a live Client is not the same thing as a live owner:
|
||||
/// a process can leave one connection idle and open a second, and
|
||||
/// GStreamer opens one connection per stream as a matter of course, so
|
||||
/// following connections alone lets the next leg escape (Codex round 2,
|
||||
/// finding 2).
|
||||
pub fingerprints: BTreeSet<owner::Fingerprint>,
|
||||
/// The reason recorded for each node that was tainted in its own right.
|
||||
/// Kept per node rather than collapsed to one owner-wide reason, or a
|
||||
/// forwarder's output leg inherits its *input* leg's `tainted-upstream`
|
||||
@@ -309,14 +320,16 @@ impl Decisions {
|
||||
/// no incremental dirty-set.
|
||||
///
|
||||
/// ⚠️ **Cost is not O(V+E), despite what v3.4 §6.4 says.** Each fixpoint
|
||||
/// pass re-runs a full link BFS *and* a full owner scan, and one pass is
|
||||
/// consumed per layer of owner-bridge hops, so it is O((V+E)·D) for a
|
||||
/// bridge depth D. D is 1 for every topology observed so far and 2 for a
|
||||
/// forwarder feeding a forwarder; a 60-layer chain is covered by a test
|
||||
/// purely to catch an accidental blow-up. Phase 5 records the real
|
||||
/// recompute-duration distribution and maximum, which is what the "full
|
||||
/// recompute is fine for v1" claim should actually rest on — a measured
|
||||
/// headroom, not a node count.
|
||||
/// pass re-runs a full link BFS *and* a full owner scan, and the bridge
|
||||
/// scans every tainted source in a component for each target, so the bound
|
||||
/// is `O(D · (V + E + Σ_C |sources_C|·|targets_C|))` — worst case
|
||||
/// `O(D · (V² + E))` — for an owner-bridge depth D. D is 1 for every
|
||||
/// topology observed so far and 2 for a forwarder feeding a forwarder, and
|
||||
/// components on a real desktop are two or three nodes; the quadratic term
|
||||
/// needs one owner with many legs. A 60-layer chain test guards the depth
|
||||
/// dimension only. Phase 5 records the real recompute-duration
|
||||
/// distribution and maximum, which is what "full recompute is fine for v1"
|
||||
/// should rest on — measured headroom, not a node count.
|
||||
pub fn evaluate(
|
||||
snapshot: &GraphSnapshot,
|
||||
ctx: &ExclusionCtx,
|
||||
@@ -331,6 +344,7 @@ pub fn evaluate(
|
||||
seed_local_roots(snapshot, ctx, &mut taint);
|
||||
seed_sticky(
|
||||
snapshot,
|
||||
&keys,
|
||||
prior,
|
||||
&components,
|
||||
&mut taint,
|
||||
@@ -354,17 +368,15 @@ pub fn evaluate(
|
||||
}
|
||||
|
||||
let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials);
|
||||
let next_sticky = if ctx.graph_ready {
|
||||
build_sticky(snapshot, &components, &taint, prior)
|
||||
} else {
|
||||
// ⚠️ Never retire a sticky owner on the strength of a snapshot we
|
||||
// have already declared untrustworthy (Codex round 1). An object
|
||||
// missing from a partial graph has not been observed to disappear,
|
||||
// and `build_sticky` drops entries whose members are all absent —
|
||||
// so one not-ready recompute could erase the taint history and the
|
||||
// next ready one would hand back a clean bill of health.
|
||||
prior.clone()
|
||||
};
|
||||
// ⚠️ Readiness gates **retirement only**, never addition (Codex rounds
|
||||
// 1 and 2, which caught the two halves of this in turn). An object
|
||||
// missing from an untrustworthy snapshot has not been observed to
|
||||
// disappear, so retiring on that basis erases history and the next
|
||||
// ready recompute hands back a clean bill of health. But taint
|
||||
// *observed* during a not-ready epoch is real — a reader can consume
|
||||
// and buffer the call and then vanish before readiness — so discarding
|
||||
// additions was the same defect pointing the other way.
|
||||
let next_sticky = build_sticky(snapshot, &keys, &components, &taint, prior, ctx.graph_ready);
|
||||
(decisions, next_sticky)
|
||||
}
|
||||
|
||||
@@ -415,8 +427,13 @@ fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason>
|
||||
}
|
||||
|
||||
/// Carry taint forward from previous snapshots (v3.4 §6.1.3).
|
||||
///
|
||||
/// An owner is re-seeded from three kinds of evidence, all lifetime-scoped
|
||||
/// to a still-live member: its own surviving nodes, nodes on a surviving
|
||||
/// **Client**, and nodes presenting a remembered owner **fingerprint**.
|
||||
fn seed_sticky(
|
||||
snapshot: &GraphSnapshot,
|
||||
keys: &owner::OwnerKeyIndex,
|
||||
prior: &StickyState,
|
||||
components: &OwnerComponents,
|
||||
taint: &mut BTreeMap<Serial, Reason>,
|
||||
@@ -431,17 +448,29 @@ fn seed_sticky(
|
||||
live_nodes.push(*serial);
|
||||
}
|
||||
}
|
||||
// ⚠️ A surviving **Client** re-seeds too. An app can close
|
||||
// A surviving **Client** re-seeds too. An app can close
|
||||
// every stream it had while keeping its PipeWire connection
|
||||
// open, then open a fresh one — Firefox does exactly this.
|
||||
// Seeding only from live nodes let that new leg come back
|
||||
// Eligible while the owner was still, by v3.4 §6.1.3's own
|
||||
// rule, tainted (its buffers outlive its streams).
|
||||
ObjectRef::Client(serial) => {
|
||||
live_nodes.extend(nodes_of_client(snapshot, *serial));
|
||||
live_nodes.extend(nodes_of_client(snapshot, keys, *serial));
|
||||
}
|
||||
}
|
||||
}
|
||||
if live_nodes.is_empty() && !entry.members.iter().any(|m| is_live(snapshot, *m)) {
|
||||
// Nothing of this owner remains; its fingerprints are just
|
||||
// recyclable strings now and must not be applied to anyone.
|
||||
continue;
|
||||
}
|
||||
// Fingerprints reach a *new connection* of the same still-live
|
||||
// process, which neither of the two paths above can see.
|
||||
for fingerprint in &entry.fingerprints {
|
||||
live_nodes.extend(
|
||||
snapshot
|
||||
.nodes()
|
||||
.filter(|node| keys.has_fingerprint(node.serial, fingerprint))
|
||||
.map(|node| node.serial),
|
||||
);
|
||||
}
|
||||
// The owner is sticky, not the individual node: a leg that appears
|
||||
// later in the same still-live owner inherits the taint.
|
||||
for serial in live_nodes {
|
||||
@@ -458,7 +487,21 @@ fn seed_sticky(
|
||||
/// Nodes currently attached to a client, by the client's **serial**. The
|
||||
/// client's snapshot-local id is resolved fresh each time, so a recycled id
|
||||
/// can never resurrect a dead owner.
|
||||
fn nodes_of_client(snapshot: &GraphSnapshot, client: Serial) -> Vec<Serial> {
|
||||
///
|
||||
/// Nodes for which `client.id` is not a usable owner key — session-manager
|
||||
/// device nodes — are excluded, or the shared `WirePlumber [export]` Client
|
||||
/// would drag every sound card on the box into one sticky owner.
|
||||
///
|
||||
/// The same gate is applied when *recording* clients into a sticky entry
|
||||
/// (`owner::client_serials_of`). Either one alone closes the leak; both are
|
||||
/// kept because they answer different questions ("may this client be
|
||||
/// remembered?" and "may this client speak for that node?"), and the
|
||||
/// regression test kills the removal of the pair.
|
||||
fn nodes_of_client(
|
||||
snapshot: &GraphSnapshot,
|
||||
keys: &owner::OwnerKeyIndex,
|
||||
client: Serial,
|
||||
) -> Vec<Serial> {
|
||||
let Some(id) = snapshot
|
||||
.clients()
|
||||
.find(|c| c.serial == client)
|
||||
@@ -466,12 +509,10 @@ fn nodes_of_client(snapshot: &GraphSnapshot, client: Serial) -> Vec<Serial> {
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
// If that id is ambiguous, two clients claim it and we cannot say which
|
||||
// nodes belong to the sticky one — so claim them all, which is the
|
||||
// fail-closed direction.
|
||||
snapshot
|
||||
.nodes()
|
||||
.filter(|node| node.props.client_id == Some(id))
|
||||
.filter(|node| keys.uses_client_key(node.serial))
|
||||
.map(|node| node.serial)
|
||||
.collect()
|
||||
}
|
||||
@@ -495,6 +536,19 @@ fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reaso
|
||||
raise(taint, to, Reason::UnresolvedAncestry);
|
||||
receivers.insert(to);
|
||||
}
|
||||
(_, Some(IdLookup::Ambiguous)) => {
|
||||
// Several nodes claim the input id and we cannot say which
|
||||
// one this link feeds, so every claimant is a receiver.
|
||||
// They are already tainted as unresolved by their own
|
||||
// ambiguous id — but taint without receiver status cannot
|
||||
// start an owner bridge, so their sibling output legs stayed
|
||||
// Eligible (Codex round 2, finding 3).
|
||||
receivers.extend(
|
||||
snapshot
|
||||
.nodes_with_id(link.input_node)
|
||||
.map(|node| node.serial),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -620,7 +674,7 @@ fn propagate_unresolved_owner(
|
||||
taint: &mut BTreeMap<Serial, Reason>,
|
||||
) -> bool {
|
||||
let tainted_reader = snapshot.nodes().any(|node| {
|
||||
node.props.device_id.is_none()
|
||||
!node.props.session_device
|
||||
&& edges.receivers.contains(&node.serial)
|
||||
&& taint.get(&node.serial).is_some_and(|r| r.propagates())
|
||||
});
|
||||
@@ -708,20 +762,24 @@ fn local_exclusion(snapshot: &GraphSnapshot, node: &NodeSnapshot) -> Option<Reas
|
||||
/// all member objects have disappeared" true across churn.
|
||||
fn build_sticky(
|
||||
snapshot: &GraphSnapshot,
|
||||
keys: &owner::OwnerKeyIndex,
|
||||
components: &OwnerComponents,
|
||||
taint: &BTreeMap<Serial, Reason>,
|
||||
prior: &StickyState,
|
||||
retire_absent: bool,
|
||||
) -> StickyState {
|
||||
let mut entries: Vec<StickyOwner> = Vec::new();
|
||||
|
||||
// Carry forward prior entries that still have at least one live member.
|
||||
// An entry with none is gone for good: serials never recycle, so a
|
||||
// vanished member can never come back.
|
||||
// vanished member can never come back — but only a *trustworthy*
|
||||
// snapshot is allowed to conclude that a member is absent.
|
||||
for entry in &prior.owners {
|
||||
if entry
|
||||
.members
|
||||
.iter()
|
||||
.any(|member| is_live(snapshot, *member))
|
||||
if !retire_absent
|
||||
|| entry
|
||||
.members
|
||||
.iter()
|
||||
.any(|member| is_live(snapshot, *member))
|
||||
{
|
||||
entries.push(entry.clone());
|
||||
}
|
||||
@@ -742,12 +800,17 @@ fn build_sticky(
|
||||
}
|
||||
let mut refs: BTreeSet<ObjectRef> = members.iter().map(|s| ObjectRef::Node(*s)).collect();
|
||||
refs.extend(
|
||||
owner::client_serials_of(snapshot, members)
|
||||
owner::client_serials_of(snapshot, keys, members)
|
||||
.into_iter()
|
||||
.map(ObjectRef::Client),
|
||||
);
|
||||
let fingerprints = members
|
||||
.iter()
|
||||
.flat_map(|serial| keys.fingerprints(*serial))
|
||||
.collect();
|
||||
entries.push(StickyOwner {
|
||||
members: refs,
|
||||
fingerprints,
|
||||
node_reasons,
|
||||
});
|
||||
}
|
||||
@@ -791,6 +854,7 @@ fn merge_overlapping(mut entries: Vec<StickyOwner>) -> Vec<StickyOwner> {
|
||||
.or_insert(reason);
|
||||
}
|
||||
entry.members.extend(other.members);
|
||||
entry.fingerprints.extend(other.fingerprints);
|
||||
absorbed = true;
|
||||
}
|
||||
}
|
||||
|
||||
+67
-8
@@ -114,9 +114,9 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKe
|
||||
if let Some(module) = node.props.pulse_module_id {
|
||||
out.push((OwnerKey::PulseModuleId, KeyValue::Num(module)));
|
||||
}
|
||||
// Exception 2: coarse keys never bridge nodes exported from a real
|
||||
// Device — they all share the session manager's client.
|
||||
if node.props.device_id.is_some() {
|
||||
// Exception 2: coarse keys never bridge passive session-manager device
|
||||
// nodes — they all share the session manager's client.
|
||||
if node.props.session_device {
|
||||
return out;
|
||||
}
|
||||
if let Some(client) = node.props.client_id {
|
||||
@@ -191,6 +191,53 @@ impl OwnerKeyIndex {
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `client.id` a usable owner key for this node?
|
||||
///
|
||||
/// ⚠️ Load-bearing for sticky state. A device node's `client.id` is
|
||||
/// suppressed by exception 2, so recording the session manager's Client
|
||||
/// as a *member* of a tainted device's sticky owner would smuggle the
|
||||
/// suppressed key back in: the next recompute would expand that Client
|
||||
/// to every hardware node on the box — the microphone included — and
|
||||
/// the §6.1.1 catastrophe would arrive one epoch late instead of never.
|
||||
/// (Codex round 2, finding 1.)
|
||||
pub fn uses_client_key(&self, serial: Serial) -> bool {
|
||||
self.keys
|
||||
.get(&serial)
|
||||
.is_some_and(|keys| keys.iter().any(|(key, _)| *key == OwnerKey::ClientId))
|
||||
}
|
||||
|
||||
/// The owner keys that are safe to remember *across* connections, for
|
||||
/// sticky taint: the strong keys plus a usable process id.
|
||||
///
|
||||
/// `client.id` is deliberately excluded — it identifies a *connection*,
|
||||
/// and the whole point of a fingerprint is to survive one process
|
||||
/// closing a connection and opening another. A live Client member is
|
||||
/// what covers the same-connection case, precisely.
|
||||
///
|
||||
/// These are recyclable strings and numbers, so they are only ever
|
||||
/// applied while some **serial** member of the owner is still live
|
||||
/// (v3.4 §6.1.3): while the process is alive, its PID cannot have been
|
||||
/// handed to anyone else.
|
||||
pub fn fingerprints(&self, serial: Serial) -> Vec<Fingerprint> {
|
||||
self.keys
|
||||
.get(&serial)
|
||||
.map(|keys| {
|
||||
keys.iter()
|
||||
.filter(|(key, _)| *key != OwnerKey::ClientId)
|
||||
.map(|(key, value)| Fingerprint(*key, value.clone()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Does this node currently present `fingerprint`?
|
||||
pub fn has_fingerprint(&self, serial: Serial, fingerprint: &Fingerprint) -> bool {
|
||||
self.keys.get(&serial).is_some_and(|keys| {
|
||||
keys.iter()
|
||||
.any(|(key, value)| *key == fingerprint.0 && *value == fingerprint.1)
|
||||
})
|
||||
}
|
||||
|
||||
/// See [`owner_is_bounded`].
|
||||
pub fn is_bounded(&self, serial: Serial) -> bool {
|
||||
self.keys
|
||||
@@ -218,6 +265,10 @@ pub fn strongest_shared_key(
|
||||
})
|
||||
}
|
||||
|
||||
/// A remembered owner key — see [`OwnerKeyIndex::fingerprints`].
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
pub struct Fingerprint(OwnerKey, KeyValue);
|
||||
|
||||
/// Nodes partitioned into owner components.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct OwnerComponents {
|
||||
@@ -315,15 +366,23 @@ impl UnionFind {
|
||||
/// Client objects belonging to an owner component, so sticky taint can be
|
||||
/// keyed on every object that constitutes the owner (v3.4 §6.1.3: clear the
|
||||
/// entry only once **all** member objects are gone).
|
||||
pub fn client_serials_of(snapshot: &GraphSnapshot, nodes: &[Serial]) -> Vec<Serial> {
|
||||
pub fn client_serials_of(
|
||||
snapshot: &GraphSnapshot,
|
||||
keys: &OwnerKeyIndex,
|
||||
nodes: &[Serial],
|
||||
) -> Vec<Serial> {
|
||||
let mut out: Vec<Serial> = nodes
|
||||
.iter()
|
||||
// Only nodes for which `client.id` is a *usable* owner key. See
|
||||
// `uses_client_key`: recording a device node's shared session-manager
|
||||
// Client here would defeat exception 2 on the next recompute.
|
||||
.filter(|serial| keys.uses_client_key(**serial))
|
||||
.filter_map(|serial| snapshot.node(*serial))
|
||||
.filter_map(|node| node.props.client_id)
|
||||
.filter_map(|id: GlobalId| match snapshot.client_by_id(id) {
|
||||
Some(super::snapshot::IdLookup::Unique(serial)) => Some(serial),
|
||||
_ => None,
|
||||
})
|
||||
// An ambiguous client id means two Clients claim it and we cannot
|
||||
// say which one is ours, so remember both: an entry that recorded
|
||||
// neither could be retired while its owner was still live.
|
||||
.flat_map(|id: GlobalId| snapshot.clients_with_id(id).map(|client| client.serial))
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
|
||||
@@ -116,16 +116,28 @@ 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` — set only on nodes exported from a real `Device`
|
||||
/// object by the session manager.
|
||||
/// 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.
|
||||
///
|
||||
/// ✅ **Measured 2026-07-21**: this is the discriminator between "a
|
||||
/// hardware device node, one of a set that all share WirePlumber's
|
||||
/// `client.id`" and "a virtual sink with its own client". The five
|
||||
/// ALSA nodes carry `device.id` 43/45/46 with `device.api=alsa`; the
|
||||
/// three `support.null-audio-sink` nodes carry none. See
|
||||
/// [`super::owner`] exception 2, which turns on exactly this.
|
||||
pub device_id: Option<GlobalId>,
|
||||
/// ⚠️ **A classification the observer owes, not a raw property.** The
|
||||
/// first cut set this from bare `device.id`, and Codex refuted it:
|
||||
/// PipeWire defines `device.id` only as "the Device this Node belongs
|
||||
/// to", which does not promise the node is passive. A forwarding node
|
||||
/// that happens to carry one would silently lose its coarse owner keys
|
||||
/// *and* its ability to trip the fail-closed backstop — two protections
|
||||
/// at once. **Phase 3 must require `device.id` AND `device.api`**
|
||||
/// (measured: `device.api=alsa` on all five real ALSA nodes, absent on
|
||||
/// every `support.null-audio-sink`), and should treat anything it
|
||||
/// cannot positively classify as *not* a device — that is the
|
||||
/// fail-closed direction here, because the flag only ever *removes*
|
||||
/// taint mechanisms.
|
||||
///
|
||||
/// What it is for: every device node on the box shares the session
|
||||
/// manager's `client.id` (measured: 42, `WirePlumber [export]`), so
|
||||
/// coarse owner keys must not bridge them. See [`super::owner`]
|
||||
/// exception 2.
|
||||
pub session_device: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -255,6 +267,18 @@ impl GraphSnapshot {
|
||||
self.client_ids.get(&id).copied()
|
||||
}
|
||||
|
||||
/// Every node claiming a global id. More than one means the id is
|
||||
/// [`IdLookup::Ambiguous`] and each claimant must be treated as a
|
||||
/// possible endpoint of any link naming it.
|
||||
pub fn nodes_with_id(&self, id: GlobalId) -> impl Iterator<Item = &NodeSnapshot> {
|
||||
self.nodes.values().filter(move |node| node.id == id)
|
||||
}
|
||||
|
||||
/// Every client claiming a global id — same fail-closed reasoning.
|
||||
pub fn clients_with_id(&self, id: GlobalId) -> impl Iterator<Item = &ClientSnapshot> {
|
||||
self.clients.values().filter(move |client| client.id == id)
|
||||
}
|
||||
|
||||
/// Ports belonging to a node, by the node's snapshot-local id.
|
||||
pub fn ports_of(&self, node: GlobalId) -> impl Iterator<Item = &PortSnapshot> {
|
||||
self.ports.values().filter(move |p| p.node == node)
|
||||
|
||||
@@ -1275,3 +1275,218 @@ fn a_deep_forwarder_chain_converges() {
|
||||
"the whole chain plus peerspeak's own playback"
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Regressions from Codex round 2 — the verification round, where five of
|
||||
// the round-1 fixes turned out to be partial. Two of these are worse than
|
||||
// the bugs they were meant to close.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn repeated_evaluation_does_not_contaminate_devices_through_sticky_clients() {
|
||||
// Round 2 finding 1, and the sharpest one so far: the round-1 sticky
|
||||
// fix smuggled the suppressed key back in. Recording the shared
|
||||
// `WirePlumber [export]` Client as a member of the tainted hardware
|
||||
// sink's owner meant the *second* recompute expanded that Client to
|
||||
// every sound card on the box, tainted the microphone, and excluded
|
||||
// every app holding one. The single-evaluate test could not see it.
|
||||
let mut graph = Graph::new();
|
||||
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||
let mic = graph.device_node("mic", MediaRole::Source);
|
||||
let call = graph.peerspeak_node("peerspeak", 7);
|
||||
graph.link(call, hw);
|
||||
let ff_in = graph.app_node("firefox-mic", MediaRole::StreamInput, 11114);
|
||||
let ff_out = graph.app_node("firefox-out", MediaRole::StreamOutput, 11114);
|
||||
graph.link(mic, ff_in);
|
||||
|
||||
let snapshot = graph.build();
|
||||
let c = ctx();
|
||||
let (first, sticky) = evaluate(&snapshot, &c, &StickyState::default());
|
||||
assert_partition(
|
||||
&first,
|
||||
&[("ff-out", ff_out)],
|
||||
&[("call", call, "peerspeak-owned")],
|
||||
);
|
||||
|
||||
// The identical graph, evaluated again. Nothing changed, so nothing
|
||||
// about the answer may change either.
|
||||
let (second, sticky) = evaluate(&snapshot, &c, &sticky);
|
||||
assert_partition(
|
||||
&second,
|
||||
&[("ff-out", ff_out)],
|
||||
&[("call", call, "peerspeak-owned")],
|
||||
);
|
||||
assert_untainted(&second, mic);
|
||||
|
||||
// And it must not drift on the third, either.
|
||||
let (third, _) = evaluate(&snapshot, &c, &sticky);
|
||||
assert_partition(
|
||||
&third,
|
||||
&[("ff-out", ff_out)],
|
||||
&[("call", call, "peerspeak-owned")],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_connection_of_a_still_tainted_process_inherits_the_taint() {
|
||||
// Round 2 finding 2. Following a surviving *connection* is not the same
|
||||
// as following a surviving *owner*: the process leaves its first client
|
||||
// idle and opens a second one, which the client expansion cannot see.
|
||||
// GStreamer opens one connection per stream as a matter of course.
|
||||
let (mut graph, call, rec_in, rec_out, firefox) = sticky_scene();
|
||||
let c = ctx();
|
||||
let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
|
||||
|
||||
let client_b = graph.client(Some(PULSE_PID));
|
||||
let late = graph.node("rec-out-late", MediaRole::StreamOutput, app(client_b, 8080));
|
||||
let (next, _) = evaluate(&graph.build_without(&[rec_in, rec_out]), &c, &sticky);
|
||||
assert_partition(
|
||||
&next,
|
||||
&[("firefox", firefox)],
|
||||
&[
|
||||
("call", call, "peerspeak-owned"),
|
||||
("late", late, "tainted-owner-bridge"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fingerprint_does_not_outlive_its_owner() {
|
||||
// The other side of that fix. A fingerprint is a recyclable PID, so it
|
||||
// may only be applied while some *serial* member of the owner is still
|
||||
// live; once the owner is fully gone, a new process handed the same PID
|
||||
// must start clean.
|
||||
//
|
||||
// ⚠️ The recycled node must exist in the **same** snapshot that first
|
||||
// sees the owner fully gone. A later snapshot proves nothing: the entry
|
||||
// has been retired by then, so the liveness guard is never consulted
|
||||
// and the test passes no matter what it does. (The first version of
|
||||
// this test made exactly that mistake and survived the mutation that
|
||||
// deletes the guard.)
|
||||
let (mut graph, call, rec_in, rec_out, firefox) = sticky_scene();
|
||||
let c = ctx();
|
||||
let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
|
||||
|
||||
let recorder_client = graph.client_of_app(8080);
|
||||
graph.drop_clients(&[recorder_client]);
|
||||
// A different process that happens to be handed the same PID, present
|
||||
// in the very snapshot where the old owner disappears.
|
||||
let reborn_client = graph.client(Some(PULSE_PID));
|
||||
let reborn = graph.node("reborn", MediaRole::StreamOutput, app(reborn_client, 8080));
|
||||
|
||||
let (after, _) = evaluate(&graph.build_without(&[rec_in, rec_out]), &c, &sticky);
|
||||
assert_partition(
|
||||
&after,
|
||||
&[("firefox", firefox), ("reborn", reborn)],
|
||||
&[("call", call, "peerspeak-owned")],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ambiguous_input_endpoint_makes_every_claimant_a_receiver() {
|
||||
// Round 2 finding 3. Both claimants were already tainted as unresolved
|
||||
// through their own ambiguous id — but taint without receiver status
|
||||
// cannot start an owner bridge, so both sibling output legs stayed
|
||||
// Eligible while one of them was re-emitting the call.
|
||||
let mut graph = Graph::new();
|
||||
let call = graph.peerspeak_node("call", 7);
|
||||
let shared = graph.dangling_id();
|
||||
let ca = graph.client_of_app(8000);
|
||||
let cb = graph.client_of_app(9000);
|
||||
let in_a = graph.node_with_id("in-a", MediaRole::Other, shared, app(ca, 8000));
|
||||
let in_b = graph.node_with_id("in-b", MediaRole::Other, shared, app(cb, 9000));
|
||||
let out_a = graph.node("out-a", MediaRole::StreamOutput, app(ca, 8000));
|
||||
let out_b = graph.node("out-b", MediaRole::StreamOutput, app(cb, 9000));
|
||||
graph.link_ids(call.id, shared);
|
||||
|
||||
let decisions = run(&graph, &ctx());
|
||||
assert_partition(
|
||||
&decisions,
|
||||
&[],
|
||||
&[
|
||||
("call", call, "peerspeak-owned"),
|
||||
("out-a", out_a, "tainted-owner-bridge"),
|
||||
("out-b", out_b, "tainted-owner-bridge"),
|
||||
],
|
||||
);
|
||||
assert_tainted(&decisions, in_a, "unresolved-ancestry");
|
||||
assert_tainted(&decisions, in_b, "unresolved-ancestry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unresolved_output_endpoint_to_an_unknown_role_node_still_bridges() {
|
||||
// Round 2 finding 6: a mutation the 42-test suite survived. The
|
||||
// unknown-role test used a *resolved* output endpoint, and the
|
||||
// unresolved-output test used a `StreamInput`, which the role union put
|
||||
// back into `receivers` anyway — so deleting the receiver insert from
|
||||
// the unresolved-output arm changed nothing. This fixture needs both.
|
||||
let mut graph = Graph::new();
|
||||
let ghost = graph.dangling_id();
|
||||
let client = graph.client_of_app(8080);
|
||||
let odd = graph.node("odd", MediaRole::Other, app(client, 8080));
|
||||
let out = graph.node("out", MediaRole::StreamOutput, app(client, 8080));
|
||||
graph.link_ids(ghost, odd.id);
|
||||
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
|
||||
|
||||
let decisions = run(&graph, &ctx());
|
||||
assert_partition(
|
||||
&decisions,
|
||||
&[("firefox", firefox)],
|
||||
&[("out", out, "tainted-owner-bridge")],
|
||||
);
|
||||
assert_tainted(&decisions, odd, "unresolved-ancestry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_associated_filter_still_bridges_on_its_client() {
|
||||
// Round 2 finding 4. `device.id` alone does not mean "passive device
|
||||
// node" — PipeWire defines it only as the Device a node belongs to. A
|
||||
// filter associated with a card would have lost both its coarse owner
|
||||
// keys *and* its ability to trip the fail-closed backstop, so the flag
|
||||
// is now a classification phase 3 owes (device.id AND device.api), and
|
||||
// anything unclassified is treated as not-a-device.
|
||||
let mut graph = Graph::new();
|
||||
let call = graph.peerspeak_node("call", 7);
|
||||
let filter_in = graph.device_associated_filter("card-filter-in", MediaRole::Sink, 4321);
|
||||
let leaked_out =
|
||||
graph.device_associated_filter("card-filter-out", MediaRole::StreamOutput, 4321);
|
||||
graph.link(call, filter_in);
|
||||
|
||||
assert_partition(
|
||||
&run(&graph, &ctx()),
|
||||
&[],
|
||||
&[
|
||||
("call", call, "peerspeak-owned"),
|
||||
("filter-out", leaked_out, "tainted-owner-bridge"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_not_ready_snapshot_still_records_new_taint() {
|
||||
// Round 2 finding 5: the round-1 fix stopped a not-ready epoch erasing
|
||||
// history, but also stopped it *recording* any. A reader can consume
|
||||
// and buffer the call during that epoch and vanish before readiness,
|
||||
// and its output leg came back Eligible. Readiness gates retirement
|
||||
// only.
|
||||
let (graph, call, rec_in, rec_out, firefox) = sticky_scene();
|
||||
let not_ready = ExclusionCtx {
|
||||
graph_ready: false,
|
||||
..ctx()
|
||||
};
|
||||
let (_, sticky) = evaluate(&graph.build(), ¬_ready, &StickyState::default());
|
||||
assert!(
|
||||
!sticky.is_empty(),
|
||||
"taint observed during a not-ready epoch is still taint"
|
||||
);
|
||||
|
||||
let (ready, _) = evaluate(&graph.build_without(&[rec_in]), &ctx(), &sticky);
|
||||
assert_partition(
|
||||
&ready,
|
||||
&[("firefox", firefox)],
|
||||
&[
|
||||
("call", call, "peerspeak-owned"),
|
||||
("rec-out", rec_out, "tainted-owner-bridge"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user