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:
@@ -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