Files
pixelpass/src/host/taint/owner.rs
T
molluskandClaude Opus 5 c78eb2dd39 host/taint: a self-claimed pid is not provenance (F11-1)
Boundedness now requires a strong key, or a key-4 value backed by a
**resolved Client** — an unambiguous Client yielding Some(pipewire.sec.pid),
read before pipewire-pulse suppression. A node whose Client cannot be
resolved at all is unbounded whatever `application.process.id` it puts on
itself, so it can no longer spare itself from `propagate_unresolved_owner`'s
sweep with a value it made up.

Closes the round-11 review's finding 1: the key-4 union could *reduce* taint,
because the same key list feeds boundedness and the sweep is armed by an
UNbounded tainted reader. The recorded three-step path (reader bounded by its
Client's real pid, output leg on an ambiguous Client claiming a bogus pid, no
shared key so no bridge either) is now a test.

Bridging is untouched: it still uses the full union, so boundedness is stored
on OwnerKeyIndex rather than re-derived from the key set, and `bounded_by` is
the single implementation of the predicate.

Five-case Client matrix as tests (absent · ambiguous · unique-but-pid-less ·
resolved-native · resolved-to-pipewire-pulse). The pid-less row is the one
that distinguishes the correct reading of "resolved" from "a unique Client
exists", which would have left the hole open. Mutation-verified: dropping the
provenance test fails four of the six rows and passes the two that must not
regress.

Measured cost on the live graph: zero. Before- and after-binaries audited the
same graph simultaneously (tagged producer + parec on the monitor as a real
tainted reader, so the sweep was armed) — 181 records each, the same 14
distinct decision states, none exclusive to either side, no unresolved-owner
on either, eligible half non-empty throughout. O5 unmoved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 04:44:58 -04:00

632 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The owner bridge — grouping nodes that belong to the same *owner* even
//! though the graph shows no Link between them.
//!
//! This is the subtlest part of the design (v3.4 §6.1.2). Measured fact it
//! exists to handle: a `module-loopback` forwarder's input leg and output
//! leg have **no Link between them**, so walking Links alone from the
//! leaking output leg finds no inbound links at all — a dead end that reads
//! as "clean". The legs are related only by shared properties.
//!
//! ## The rule
//!
//! A union of keys, strongest first:
//!
//! | # | key | scope |
//! | --- | --- | --- |
//! | 1 | `node.link-group` | per module/filter instance |
//! | 2 | `pulse.module.id` | per pactl module |
//! | 3 | `client.id` | per **connection** |
//! | 4 | `application.process.id` | per process |
//!
//! ⚠️ **"Resolves" means the two legs carry the key AND the values are
//! EQUAL — not "the first key present".** A first-present implementation
//! reproduces the exact measured leak: for `gst-launch pulsesrc ! pulsesink`
//! both legs carry `client.id` (209 and 210) but the values *differ*, so
//! first-present stops at key 3, sees a mismatch, and concludes "different
//! owners". The legs are in fact one process (`application.process.id`
//! 20172 on both). So: try each key in order, and a key resolves only if
//! both legs carry it and the values are equal; otherwise fall through.
//!
//! ## Two exceptions, both guarding against mass over-exclusion
//!
//! 1. **Never bridge on key 4 when the value is pipewire-pulse's own PID**
//! (v3.4 §6.1.2). Module-created streams all carry the daemon's PID, so
//! bridging on it fuses every Pulse module into one owner and a single
//! tainted module input would exclude every module-created stream on the
//! box. Keys 1 and 2 already cover those cases precisely.
//!
//! 2. **Coarse keys (3 and 4) may not bridge nodes exported from a real
//! `Device`** — i.e. nodes carrying `device.id`. ⚠️ This rule is *not*
//! in design v3.4; it was found while implementing, and it is the exact
//! analogue of exception 1 for the session manager.
//! ✅ **MEASURED on the live graph 2026-07-21:**
//!
//! | node | `client.id` | `device.id` | `factory.name` |
//! | --- | --- | --- | --- |
//! | 5 × `alsa_{output,input}.*` | **42** (`WirePlumber [export]`) | 43/45/46 | `api.alsa.pcm.{sink,source}` |
//! | 3 × `sink-sunshine-*` | 83 / 86 / 92 (each its own) | **absent** | `support.null-audio-sink` |
//!
//! So one shared coarse key genuinely does relate every hardware device
//! on the box, and `device.id` cleanly separates that set from virtual
//! sinks. Without the rule, the hardware sink carrying peerspeak's
//! playback (tainted by design, every single recompute) would bridge to
//! *every other device node including the microphone source*, whose
//! readers would then taint their owners' playback legs — reproducing
//! precisely the §6.1.1 catastrophe ("excludes any app using a
//! microphone") through a different door.
//!
//! ⚠️ **Keyed on `device.id`, NOT on `media.class` being `Audio/Sink`.**
//! The first cut suppressed coarse keys for every device-*role* node,
//! and Codex refuted it: a **native virtual sink** — an app that creates
//! an `Audio/Sink` plus a re-emitting stream on one client, with no
//! `link-group` and no `pulse.module.id` — would then have had its only
//! correlation stripped, and it would have leaked the whole call. Such a
//! sink has no `device.id`, so it now bridges on `client.id` as it
//! should.
//!
//! Grouping is **transitive** (union-find). That is the fail-closed
//! direction: bigger owner components mean more taint, never less.
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();
// ⚠️ 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()
}
/// Does this node have **protected provenance** — an unambiguous Client
/// yielding `Some(pipewire.sec.pid)`?
///
/// ⚠️ Read **before** the pipewire-pulse suppression in [`keys_of`], and
/// that ordering is the whole rule (F11-1, below). A Pulse-emulated app's
/// Client resolves to the daemon's PID; the value is then omitted from the
/// bridge keys as too coarse to *group* on, but it is still a protected
/// `pipewire.*` answer to "who is this", so the app keeps its provenance.
///
/// ❌ Not "a unique Client object exists". A unique Client with
/// `sec_pid = None` satisfies that and carries no protected identity at
/// all, which is exactly the hole [`owner_is_bounded`] closes.
fn client_is_resolved(&self, node: &NodeSnapshot) -> bool {
self.client_pid(node).is_some()
}
}
/// 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)]
pub enum OwnerKey {
LinkGroup,
PulseModuleId,
ClientId,
ProcessId,
}
impl OwnerKey {
/// Stable, machine-readable — this ends up in the phase 5 audit output
/// 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 {
match self {
Self::LinkGroup => "node.link-group",
Self::PulseModuleId => "pulse.module.id",
Self::ClientId => "client.id",
Self::ProcessId => "application.process.id",
}
}
}
/// The value a node presents for a given key, if it presents one at all.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
enum KeyValue {
Text(String),
Num(u64),
}
/// Owner keys usable on this node, strongest first.
///
/// 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, 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())));
}
if let Some(module) = node.props.pulse_module_id {
out.push((OwnerKey::PulseModuleId, KeyValue::Num(module)));
}
// 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 {
out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0))));
}
// Key 4, from the node **and** from its Client (round 10, R10-3; made a
// union rather than a fallback by the round-10 review, finding 1).
//
// ⚠️ **A union, not `node.or_else(client)`, and the difference is a leak.**
// The node's `application.process.id` is client-controlled and optional;
// the Client's `pipewire.sec.pid` is `pipewire.*`, protected, and the only
// one that can carry a soundness argument (the same reason
// `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
}
/// Can this node's owner be positively bounded — i.e. can we enumerate its
/// sibling legs and be right?
///
/// ⚠️ Not the same as "has any usable key", and the difference is a leak.
/// `client.id` alone does **not** bound an owner: that is the measured
/// GStreamer refutation, where one process presented two different
/// `client.id`s for its two legs. So an owner is bounded only by a strong
/// key (link-group / pulse.module.id) or by a *usable* process id — usable
/// meaning key 4 was not suppressed as pipewire-pulse's own PID.
///
/// The case this exists for is v3.4 §12's "module forwarder with neither
/// `link-group` nor `pulse.module.id`": its process id is the daemon's and
/// therefore suppressed, its two legs may carry different `client.id`s, and
/// 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).
///
/// # F11-1 — CLOSED. A self-claimed PID is not provenance
///
/// **The rule, implemented below:** a strong key (`node.link-group`,
/// `pulse.module.id`) bounds an owner on its own; **key 4 bounds an owner only
/// when the node's Client resolves** — see [`OwnerCtx::client_is_resolved`].
/// A node whose Client cannot be resolved at all is *unbounded*, whatever
/// `application.process.id` it puts on itself.
///
/// The five Client cases, which is the matrix this needed (two of them are
/// indistinguishable under the wrong reading of "resolves"):
///
/// | Client | node's own pid | bounded? | why |
/// | --- | --- | --- | --- |
/// | **absent** | claimed | **no** | nothing corroborates the claim |
/// | **ambiguous** (two Clients, one id) | claimed | **no** | "we do not know who owns this" must not be papered over |
/// | **unique but pid-less** | claimed | **no** | a Client object is not an identity; `sec_pid` is |
/// | **resolved-native** (`sec_pid` = the app's) | absent | **yes** | protected pid, and it *is* key 4 |
/// | **resolved-to-pipewire-pulse** | claimed | **yes** | protected provenance; the daemon pid is suppressed as a *grouping* key only |
///
/// The last row is what keeps this from being the blunt fix. Applying
/// "self-claims are not sound" without the provenance test unbounds every
/// Pulse-emulated app — 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 exists to avoid
/// and empties the eligible half of the §5.1 matrix.
///
/// **Cost, measured on the live graph** (2026-07-26): **zero**. The
/// before- and after-binaries audited the *same* graph simultaneously — both
/// are read-only observers, which is the only way to A/B a partition without
/// churn between runs — with a tagged producer feeding the default sink,
/// `parec` on its monitor as a real tainted reader (so the sweep was armed,
/// not merely present in the code), and Firefox, `aplay` and `pacat` as
/// bystanders. **181 records each, the same 14 distinct decision states, none
/// exclusive to either side, no `unresolved-owner` on either.** The eligible
/// half stayed non-empty throughout: native (`aplay`), Pulse-emulated
/// (`pacat`) and Firefox all eligible. O5 is unmoved: identical p50 (15 µs)
/// and busy fraction (0.0012), and the after-binary's worst per-record
/// recompute was *lower* (217 µs vs 243 µs — noise, same debug build, same
/// concurrent load).
///
/// Why it costs nothing here: every real app on this box is either native
/// (Client `sec_pid` = its own pid) or Pulse-emulated (Client `sec_pid` = the
/// daemon's), and **both resolve**. Sweeping all 18 live nodes for the
/// predicate's inputs directly, the only unresolved-Client nodes were
/// `Dummy-Driver` and `Freewheel-Driver`, which carry no pid key to lose;
/// session-manager device nodes are unresolved too (their Client is pid-less)
/// but exception 2 already strips key 4 from them. That is the answer the
/// deferral was waiting for: the rule bites exactly the anomalous shapes, and
/// this host has none.
///
/// ## The leak it closes (round 11 review, finding 1)
///
/// 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.
///
/// Step 2 is now unbounded ⇒ the sweep fires ⇒ the leg is excluded. Note it
/// could not leak *yet* when it was filed — `evaluate()` is reached only by the
/// dry-run audit, which creates no links — and that is why the fix waited for
/// the §5.1 measurement instead of guessing at its cost.
///
/// ## What this is deliberately NOT
///
/// It is not a claim that `application.process.id` is now unused: it still
/// bridges (a self-claim is fine as *evidence that two legs are related* —
/// the fail-closed direction), and a resolved-Client node is still bounded by
/// whichever key-4 value survives suppression. Only *boundedness* — the
/// permission to say "I can enumerate this owner's other legs, so a
/// differently-keyed output is provably someone else" — now demands a
/// `pipewire.*` answer to "who is this".
///
/// ⚠️ Bridging must keep using the **full** union, so boundedness is carried
/// separately from the key set in [`OwnerKeyIndex`] rather than being
/// re-derived from it.
pub fn owner_is_bounded(node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
bounded_by(&keys_of(node, ctx), node, ctx)
}
/// [`owner_is_bounded`]'s rule, over an already-computed key list.
///
/// The single implementation: [`OwnerKeyIndex::build`] has the keys in hand and
/// must not recompute them, and two copies of a predicate this load-bearing is
/// how the two spellings drift apart.
fn bounded_by(keys: &[(OwnerKey, KeyValue)], node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
let mut has_process_key = false;
for (key, _) in keys {
match key {
// Strong keys are per-instance and name the sibling set directly.
OwnerKey::LinkGroup | OwnerKey::PulseModuleId => return true,
OwnerKey::ProcessId => has_process_key = true,
// Never: one process can present two `client.id`s (the measured
// GStreamer refutation, above).
OwnerKey::ClientId => {}
}
}
// F11-1. The key may be the node's own claim, the Client's protected pid,
// or both — `keys_of` does not record which, and it does not need to: a
// resolved Client is provenance for the node *whatever* value key 4 ends
// up carrying, and without one there is no protected identity to stand on.
has_process_key && ctx.client_is_resolved(node)
}
/// Owner keys computed once per snapshot.
///
/// `keys_of` allocates a `Vec` and clones the `link-group` string, and the
/// bridge asks for keys once per (tainted member × component member) pair —
/// so recomputing was the hot spot in an otherwise linear pass.
#[derive(Debug, Default)]
pub struct OwnerKeyIndex {
keys: BTreeMap<Serial, Vec<(OwnerKey, KeyValue)>>,
/// Nodes whose owner is positively bounded — see [`owner_is_bounded`].
///
/// ⚠️ **Stored, not derived from `keys`.** Since F11-1 the predicate needs
/// the node's Client as well as its key list, and the two answers are
/// deliberately different: the full union still bridges, while a
/// self-claimed pid no longer bounds.
bounded: BTreeSet<Serial>,
}
impl OwnerKeyIndex {
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
let mut keys = BTreeMap::new();
let mut bounded = BTreeSet::new();
for node in snapshot.nodes() {
let node_keys = keys_of(node, ctx);
if bounded_by(&node_keys, node, ctx) {
bounded.insert(node.serial);
}
keys.insert(node.serial, node_keys);
}
Self { keys, bounded }
}
/// The strongest key these two nodes share directly, if any.
pub fn strongest_shared(&self, a: Serial, b: Serial) -> Option<OwnerKey> {
let (Some(a_keys), Some(b_keys)) = (self.keys.get(&a), self.keys.get(&b)) else {
return None;
};
// Stored strongest-first, so the first match is the strongest.
a_keys.iter().find_map(|(key, value)| {
b_keys
.iter()
.any(|(other_key, other_value)| other_key == key && other_value == value)
.then_some(*key)
})
}
/// 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`]. A node outside this snapshot is unbounded,
/// which is the fail-closed answer.
pub fn is_bounded(&self, serial: Serial) -> bool {
self.bounded.contains(&serial)
}
}
/// The strongest key two nodes share, or `None` if they share none. Used to
/// *name* the key in a bridge decision; membership itself is transitive and
/// comes from [`OwnerComponents`].
pub fn strongest_shared_key(
a: &NodeSnapshot,
b: &NodeSnapshot,
ctx: &OwnerCtx,
) -> Option<OwnerKey> {
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
.iter()
.any(|(other_key, other_value)| other_key == key && other_value == value)
.then_some(*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 {
/// node serial → component index.
of_node: BTreeMap<Serial, usize>,
/// component index → member node serials, ascending.
members: Vec<Vec<Serial>>,
}
impl OwnerComponents {
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();
let mut uf = UnionFind::new(serials.len());
// Group by (key, value) and union within each group. Equivalent to
// the pairwise "some key resolves" rule, and O(n log n).
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, ctx) {
buckets.entry((key, value)).or_default().push(slot);
}
}
for group in buckets.values() {
for pair in group.windows(2) {
uf.union(pair[0], pair[1]);
}
}
// Compact roots into dense component indices, deterministically.
let mut root_to_component: BTreeMap<usize, usize> = BTreeMap::new();
let mut members: Vec<Vec<Serial>> = Vec::new();
let mut of_node = BTreeMap::new();
for (slot, serial) in serials.iter().enumerate() {
let root = uf.find(slot);
let component = *root_to_component.entry(root).or_insert_with(|| {
members.push(Vec::new());
members.len() - 1
});
members[component].push(*serial);
of_node.insert(*serial, component);
}
Self { of_node, members }
}
pub fn component_of(&self, serial: Serial) -> Option<usize> {
self.of_node.get(&serial).copied()
}
/// Member serials of the component containing `serial`, including it.
/// Empty if the node is not in this snapshot.
pub fn members_with(&self, serial: Serial) -> &[Serial] {
match self.component_of(serial) {
Some(component) => &self.members[component],
None => &[],
}
}
pub fn components(&self) -> impl Iterator<Item = &[Serial]> {
self.members.iter().map(Vec::as_slice)
}
}
struct UnionFind {
parent: Vec<usize>,
}
impl UnionFind {
fn new(len: usize) -> Self {
Self {
parent: (0..len).collect(),
}
}
fn find(&mut self, mut node: usize) -> usize {
while self.parent[node] != node {
self.parent[node] = self.parent[self.parent[node]];
node = self.parent[node];
}
node
}
fn union(&mut self, a: usize, b: usize) {
let (a, b) = (self.find(a), self.find(b));
if a != b {
// Lowest root wins, so components are deterministic.
let (low, high) = if a < b { (a, b) } else { (b, a) };
self.parent[high] = low;
}
}
}
/// 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,
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)
// 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();
out
}