Merge round 9: uncertainty is not history
Found by the phase-5 audit minutes after phase 3r landed — a permanent sticky taint on a hardware sink, from one link seen during enumeration. Sticky state is now built from an evidence-only pass; decisions still fail closed.
This commit is contained in:
+129
-34
@@ -381,36 +381,28 @@ pub fn evaluate(
|
||||
let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid);
|
||||
let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid);
|
||||
|
||||
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
|
||||
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
|
||||
|
||||
seed_local_roots(snapshot, ctx, &mut taint);
|
||||
seed_sticky(
|
||||
// Pass 1 — the fail-closed view. Every decision is made from this one, so
|
||||
// "we could not see" counts as taint.
|
||||
let (taint, sticky_serials) = compute_taint(
|
||||
snapshot,
|
||||
ctx,
|
||||
&keys,
|
||||
prior,
|
||||
&components,
|
||||
&mut taint,
|
||||
&mut sticky_serials,
|
||||
prior,
|
||||
Uncertainty::FailsClosed,
|
||||
);
|
||||
|
||||
// Monotone fixpoint: every step only adds taint, or lowers a node's
|
||||
// reason priority, both of which are bounded. Link propagation and the
|
||||
// owner bridge feed each other — a bridged output leg has downstream
|
||||
// links, and a downstream monitor reader bridges to its own siblings —
|
||||
// so neither can be run once.
|
||||
let edges = downstream_edges(snapshot, &mut taint);
|
||||
loop {
|
||||
let mut changed = false;
|
||||
changed |= propagate_links(&edges.edges, &mut taint);
|
||||
changed |= propagate_owner_bridge(&keys, &components, &edges, &mut taint);
|
||||
changed |= propagate_unresolved_owner(snapshot, &keys, &edges, &mut taint);
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials);
|
||||
|
||||
// Pass 2 — the evidence-only view, and the only thing sticky state is
|
||||
// ever built from (see [`Uncertainty`]).
|
||||
let (evidence, _) = compute_taint(
|
||||
snapshot,
|
||||
ctx,
|
||||
&keys,
|
||||
&components,
|
||||
prior,
|
||||
Uncertainty::Ignored,
|
||||
);
|
||||
// ⚠️ 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
|
||||
@@ -419,10 +411,105 @@ pub fn evaluate(
|
||||
// *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);
|
||||
let next_sticky = build_sticky(
|
||||
snapshot,
|
||||
&keys,
|
||||
&components,
|
||||
&evidence,
|
||||
prior,
|
||||
ctx.graph_ready,
|
||||
);
|
||||
(decisions, next_sticky)
|
||||
}
|
||||
|
||||
/// Whether a pass treats "we could not see" as taint.
|
||||
///
|
||||
/// **Both passes exist because stickiness is a claim about history, and
|
||||
/// uncertainty is not history.** A node tainted only because the graph was
|
||||
/// mid-enumeration has had nothing observed about it; remembering that as
|
||||
/// taint forever is over-exclusion with no evidence behind it, and phase 3r's
|
||||
/// bind-everything observer makes the window it happens in systematically
|
||||
/// wide (every node is withheld until its bind resolves, so any link observed
|
||||
/// across that gap raises [`Reason::UnresolvedAncestry`] on its input side).
|
||||
/// Measured on a live desktop: a hardware sink acquired a permanent sticky
|
||||
/// taint at every startup, from one link seen while its output node was still
|
||||
/// unbound.
|
||||
///
|
||||
/// Retiring by *reason code* is not enough, because uncertainty launders
|
||||
/// itself: an unresolved node propagates [`Reason::TaintedUpstream`] to its
|
||||
/// downstream, and that reason is indistinguishable from real contamination
|
||||
/// once recorded. So the split is by **provenance** — the sticky pass never
|
||||
/// raises an uncertainty root at all, and nothing derived from one can reach
|
||||
/// it. Decisions are unaffected: they are made from the fail-closed pass,
|
||||
/// which is unchanged.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Uncertainty {
|
||||
/// Unresolved ancestry and an unbounded tainted reader are taint
|
||||
/// (v3.4 §6.1, §6.1.1, §6.1.4).
|
||||
FailsClosed,
|
||||
/// Only positively observed contamination counts.
|
||||
Ignored,
|
||||
}
|
||||
|
||||
/// One taint fixpoint over the snapshot. The `uncertainty` mode decides
|
||||
/// whether absence of evidence is treated as evidence of contamination.
|
||||
fn compute_taint(
|
||||
snapshot: &GraphSnapshot,
|
||||
ctx: &ExclusionCtx,
|
||||
keys: &owner::OwnerKeyIndex,
|
||||
components: &OwnerComponents,
|
||||
prior: &StickyState,
|
||||
uncertainty: Uncertainty,
|
||||
) -> (BTreeMap<Serial, Reason>, BTreeSet<Serial>) {
|
||||
let fails_closed = uncertainty == Uncertainty::FailsClosed;
|
||||
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
|
||||
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
|
||||
|
||||
seed_local_roots(snapshot, ctx, &mut taint);
|
||||
if fails_closed {
|
||||
for serial in ambiguous_id_nodes(snapshot) {
|
||||
raise(&mut taint, serial, Reason::UnresolvedAncestry);
|
||||
}
|
||||
}
|
||||
seed_sticky(
|
||||
snapshot,
|
||||
keys,
|
||||
prior,
|
||||
components,
|
||||
&mut taint,
|
||||
&mut sticky_serials,
|
||||
);
|
||||
|
||||
// Edges are built identically in both passes — receiver status is a
|
||||
// topological fact and must not depend on the mode, or the owner bridge
|
||||
// would see two different graphs.
|
||||
let mut unresolved_input: BTreeSet<Serial> = BTreeSet::new();
|
||||
let edges = downstream_edges(snapshot, &mut unresolved_input);
|
||||
if fails_closed {
|
||||
for serial in unresolved_input {
|
||||
raise(&mut taint, serial, Reason::UnresolvedAncestry);
|
||||
}
|
||||
}
|
||||
|
||||
// Monotone fixpoint: every step only adds taint, or lowers a node's
|
||||
// reason priority, both of which are bounded. Link propagation and the
|
||||
// owner bridge feed each other — a bridged output leg has downstream
|
||||
// links, and a downstream monitor reader bridges to its own siblings —
|
||||
// so neither can be run once.
|
||||
loop {
|
||||
let mut changed = false;
|
||||
changed |= propagate_links(&edges.edges, &mut taint);
|
||||
changed |= propagate_owner_bridge(keys, components, &edges, &mut taint);
|
||||
if fails_closed {
|
||||
changed |= propagate_unresolved_owner(snapshot, keys, &edges, &mut taint);
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(taint, sticky_serials)
|
||||
}
|
||||
|
||||
/// Roots that are visible on the node itself.
|
||||
fn seed_local_roots(
|
||||
snapshot: &GraphSnapshot,
|
||||
@@ -433,14 +520,20 @@ fn seed_local_roots(
|
||||
if let Some(reason) = local_root_reason(node, ctx) {
|
||||
raise(taint, node.serial, reason);
|
||||
}
|
||||
// A node whose own global id is ambiguous cannot be the reliable
|
||||
// endpoint of any link, so its ancestry is unresolvable.
|
||||
if snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous) {
|
||||
raise(taint, node.serial, Reason::UnresolvedAncestry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nodes whose own global id is ambiguous: they cannot be the reliable
|
||||
/// endpoint of any link, so their ancestry is unresolvable. Uncertainty, not
|
||||
/// evidence — see [`Uncertainty`].
|
||||
fn ambiguous_id_nodes(snapshot: &GraphSnapshot) -> BTreeSet<Serial> {
|
||||
snapshot
|
||||
.nodes()
|
||||
.filter(|node| snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous))
|
||||
.map(|node| node.serial)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
|
||||
if node.props.peerspeak_owned {
|
||||
return Some(Reason::PeerspeakOwned);
|
||||
@@ -563,7 +656,7 @@ fn nodes_of_client(
|
||||
/// `output node → input nodes`, resolving snapshot-local ids. An endpoint
|
||||
/// that does not resolve taints the *other* end as unresolved ancestry when
|
||||
/// that other end is the input side — we cannot know what is feeding it.
|
||||
fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reason>) -> Edges {
|
||||
fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet<Serial>) -> Edges {
|
||||
let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new();
|
||||
let mut receivers: BTreeSet<Serial> = BTreeSet::new();
|
||||
for link in snapshot.links() {
|
||||
@@ -575,8 +668,10 @@ fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reaso
|
||||
receivers.insert(to);
|
||||
}
|
||||
(_, Some(IdLookup::Unique(to))) => {
|
||||
// Something feeds this node and we cannot say what.
|
||||
raise(taint, to, Reason::UnresolvedAncestry);
|
||||
// Something feeds this node and we cannot say what. Reported
|
||||
// rather than raised here, because whether "cannot say" is
|
||||
// taint depends on which pass is running ([`Uncertainty`]).
|
||||
unresolved_input.insert(to);
|
||||
receivers.insert(to);
|
||||
}
|
||||
(_, Some(IdLookup::Ambiguous)) => {
|
||||
|
||||
@@ -820,6 +820,137 @@ fn an_ambiguous_recycled_global_id_fails_closed() {
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Uncertainty is not history — it never enters sticky state
|
||||
// (round 9, from a live phase-5 audit run; see `Uncertainty` in mod.rs)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn unresolved_ancestry_does_not_survive_being_resolved() {
|
||||
// Measured live on a desktop: a link is observed while its output node is
|
||||
// still unbound, the input side fails closed — correctly — and then that
|
||||
// fail-closed mark became *sticky*, so a hardware sink stayed excluded for
|
||||
// the process lifetime even after the node resolved and turned out to be
|
||||
// an ordinary game. Phase 3r's bind-everything observer widens that window
|
||||
// to every node, so this must clear.
|
||||
let mut graph = Graph::new();
|
||||
let ghost = graph.dangling_id();
|
||||
let client = graph.client_of_app(6000);
|
||||
let victim = graph.node("victim-in", MediaRole::StreamInput, app(client, 6000));
|
||||
let sibling = graph.node("victim-out", MediaRole::StreamOutput, app(client, 6000));
|
||||
graph.link_ids(ghost, victim.id);
|
||||
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
|
||||
let c = ctx();
|
||||
|
||||
// While the ancestry is genuinely unresolved, the decision is unchanged:
|
||||
// fail closed, both the victim and its sibling excluded.
|
||||
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
|
||||
assert_partition(
|
||||
&first,
|
||||
&[("firefox", firefox)],
|
||||
&[("victim-out", sibling, "tainted-owner-bridge")],
|
||||
);
|
||||
assert_tainted(&first, victim, "unresolved-ancestry");
|
||||
|
||||
// The node behind that id turns up — nothing tainted, it was simply not
|
||||
// observed yet. The uncertainty is gone, so nothing may remain of it.
|
||||
let late_client = graph.client_of_app(7100);
|
||||
let resolved = graph.node_with_id(
|
||||
"was-unbound",
|
||||
MediaRole::StreamOutput,
|
||||
ghost,
|
||||
app(late_client, 7100),
|
||||
);
|
||||
let (second, _) = evaluate(&graph.build(), &c, &sticky);
|
||||
assert_partition(
|
||||
&second,
|
||||
&[
|
||||
("firefox", firefox),
|
||||
("victim-out", sibling),
|
||||
("was-unbound", resolved),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncertainty_laundered_into_downstream_taint_is_not_sticky_either() {
|
||||
// Retiring by reason *code* would not be enough: an unresolved node
|
||||
// propagates `tainted-upstream`, which is indistinguishable from real
|
||||
// contamination once recorded. The split has to be by provenance, so a
|
||||
// node two hops from the uncertainty must clear too.
|
||||
let mut graph = Graph::new();
|
||||
let ghost = graph.dangling_id();
|
||||
let forwarder_client = graph.client_of_app(6100);
|
||||
let forwarder_in = graph.node(
|
||||
"fwd-in",
|
||||
MediaRole::StreamInput,
|
||||
app(forwarder_client, 6100),
|
||||
);
|
||||
let forwarder_out = graph.node(
|
||||
"fwd-out",
|
||||
MediaRole::StreamOutput,
|
||||
app(forwarder_client, 6100),
|
||||
);
|
||||
let downstream_client = graph.client_of_app(6200);
|
||||
let downstream = graph.node("downstream", MediaRole::Sink, app(downstream_client, 6200));
|
||||
let downstream_leg = graph.node(
|
||||
"downstream-out",
|
||||
MediaRole::StreamOutput,
|
||||
app(downstream_client, 6200),
|
||||
);
|
||||
graph.link_ids(ghost, forwarder_in.id);
|
||||
graph.link(forwarder_out, downstream);
|
||||
let c = ctx();
|
||||
|
||||
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
|
||||
assert_tainted(&first, forwarder_in, "unresolved-ancestry");
|
||||
assert_tainted(&first, downstream, "tainted-upstream");
|
||||
assert!(
|
||||
first.candidates[&downstream_leg.serial].reason().is_some(),
|
||||
"while the ancestry is unresolved the downstream owner is excluded too"
|
||||
);
|
||||
|
||||
let late_client = graph.client_of_app(7200);
|
||||
graph.node_with_id(
|
||||
"was-unbound",
|
||||
MediaRole::StreamOutput,
|
||||
ghost,
|
||||
app(late_client, 7200),
|
||||
);
|
||||
let (second, _) = evaluate(&graph.build(), &c, &sticky);
|
||||
assert_eq!(
|
||||
second.candidates[&downstream_leg.serial].reason(),
|
||||
None,
|
||||
"nothing derived from the uncertainty may outlive it"
|
||||
);
|
||||
assert_eq!(
|
||||
second.candidates[&forwarder_out.serial].reason(),
|
||||
None,
|
||||
"including the unresolved node's own owner siblings"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_taint_is_still_sticky_when_its_topology_goes_away() {
|
||||
// The other half of the same rule, stated positively: *evidence* is
|
||||
// history and must survive. This is the guard on the change above — if
|
||||
// provenance splitting ever leaks into the evidence path, peerspeak's own
|
||||
// audio starts escaping.
|
||||
let (graph, call, rec_in, rec_out, firefox) = sticky_scene();
|
||||
let c = ctx();
|
||||
let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
|
||||
let (second, _) = evaluate(&graph.build_without(&[rec_in]), &c, &sticky);
|
||||
assert_partition(
|
||||
&second,
|
||||
&[("firefox", firefox)],
|
||||
&[
|
||||
("call", call, "peerspeak-owned"),
|
||||
("rec-out", rec_out, "tainted-owner-bridge"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Stickiness and lifetime-awareness (v3.4 §6.1.3)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user