host/taint: carry both pids as owner keys; gate the wiring and the contract
Round 10 review, findings 1, 4 and 6. Finding 1 (P1, phase 6) — key 4 was `node.or_else(client)`, so a node's client-controlled application.process.id REPLACED its Client's protected pipewire.sec.pid. One process using two Clients could therefore split its identity: the tainted reader reports a bogus node pid, the output leg omits the node pid and falls back to the Client's real one, the legs are bounded by different values, and they neither bridge nor trip the unbounded sweep — the output stays eligible while re-emitting the call. Now a union of both values, deduplicated, with exception 1 applied to each independently so the pipewire-pulse pid still cannot fuse unrelated Clients. Mutation-verified: reverting to or_else fails ONLY the new split-Client test (so the union changes nothing else), dropping exception 1 fails 32 rows, and using the Client pid alone fails 16. Not reachable today — evaluate() is reached only by the dry-run audit, which creates no links. It becomes live when phase 6 consumes these decisions. Finding 4 — R10-4's test called peerspeak_owned() directly, so reverting node_observation_from_props to truthy() left it green; the only case it shared with production, exact "1", passes under both. A new test builds a real pw_properties dict and drives the production wiring, and the mutation now fails exactly that test while the helper test still passes. Finding 6 — the cross-repo fixture still documented carrier 1 as "any value other than false/0", which R10-4 made exact-"1". A producer following it could emit "true" and silently lose the carrier. Fixture updated in both repos (byte-identical, verified), along with the stale prose in taint/tests and snapshot.rs, and the contract is now also exercised through the production adapter rather than only against the constants. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+85
-13
@@ -362,17 +362,18 @@ fn ownership_carriers_match_the_cross_repo_fixture() {
|
||||
|
||||
assert_eq!(super::PEERSPEAK_OWNED_PROP, get("prop_key"));
|
||||
assert_eq!(super::PEERSPEAK_OWNED_NODE_PREFIX, get("node_name_prefix"));
|
||||
// ⚠️ **Equality, and that is now the whole rule**: carrier 1 is matched
|
||||
// exactly, not as "anything but false/0" (round 10, R10-4). This assert
|
||||
// used to be followed by a weaker `value != "false" && value != "0"`
|
||||
// check, which described a leniency that no longer exists — the round-10
|
||||
// review's finding 6, and a real trap: a future producer reading the old
|
||||
// fixture prose could emit "true" and silently lose this carrier.
|
||||
//
|
||||
// That this consumer actually *listens* to the fixture's value, through
|
||||
// the production observer wiring rather than a helper, is asserted by
|
||||
// `observer::adapter::tests::the_fixture_value_is_the_only_owned_spelling`.
|
||||
assert_eq!(super::PEERSPEAK_OWNED_VALUE, get("prop_value"));
|
||||
|
||||
// …and that value must be one this consumer reads as truthy. `truthy`
|
||||
// lives at the observer boundary; assert the property of it that matters
|
||||
// here rather than reaching across modules for the function.
|
||||
let value = get("prop_value");
|
||||
assert!(
|
||||
value != "false" && value != "0",
|
||||
"pinned prop value {value:?} would read as untruthy"
|
||||
);
|
||||
|
||||
// And the fixture's own worked example must be one this engine excludes,
|
||||
// through carrier 2, exactly as written in the shared file.
|
||||
let mut graph = Graph::new();
|
||||
@@ -991,10 +992,81 @@ fn a_pidless_first_client_still_makes_its_id_ambiguous() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The node's own `application.process.id` wins when both are available. It is
|
||||
/// a direct statement about the node; the Client's is a one-hop inference, and
|
||||
/// they can legitimately differ (a Pulse-emulated node's pid is the app's while
|
||||
/// its Client's `sec_pid` is the daemon's — the single most common shape here).
|
||||
/// A process using **two** Clients cannot escape the bridge by presenting a
|
||||
/// bogus pid on one leg and none on the other.
|
||||
///
|
||||
/// ⚠️ **This is the round-10 review's finding 1, and it was a real leak while
|
||||
/// key 4 was `node.or_else(client)`.** The node's `application.process.id` is
|
||||
/// client-controlled; the Client's `pipewire.sec.pid` is protected. Letting
|
||||
/// the node's value *replace* the Client's meant the reader was bounded by
|
||||
/// `12_345` and the output leg by `50_000`, so they shared no key, did not
|
||||
/// bridge, and — both being bounded — neither tripped the unbounded sweep.
|
||||
/// The output stayed eligible while re-emitting the call.
|
||||
///
|
||||
/// Carrying both values fixes it: the two legs share the Client pid.
|
||||
///
|
||||
/// Reachability, stated honestly: `evaluate()` today is reached only by the
|
||||
/// dry-run audit, which creates no links, so this could not echo on this
|
||||
/// branch. It becomes live the moment phase 6 consumes these decisions.
|
||||
#[test]
|
||||
fn one_process_with_two_clients_cannot_split_its_pid_to_escape_the_bridge() {
|
||||
let mut graph = Graph::new();
|
||||
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||
let call = graph.peerspeak_node("peerspeak", 7);
|
||||
graph.link(call, hw);
|
||||
|
||||
// One native process, two Clients, one protected pid.
|
||||
let reader_client = graph.client(Some(50_000));
|
||||
let output_client = graph.client(Some(50_000));
|
||||
|
||||
// Its reading leg claims a pid that is not its own.
|
||||
let reader = graph.node(
|
||||
"two-client-reader",
|
||||
MediaRole::StreamInput,
|
||||
NodeProps {
|
||||
client_id: Some(reader_client),
|
||||
process_id: Some(12_345),
|
||||
..NodeProps::default()
|
||||
},
|
||||
);
|
||||
graph.link(hw, reader);
|
||||
|
||||
// Its re-emitting leg claims no pid at all.
|
||||
let output = graph.node(
|
||||
"two-client-output",
|
||||
MediaRole::StreamOutput,
|
||||
NodeProps {
|
||||
client_id: Some(output_client),
|
||||
process_id: None,
|
||||
..NodeProps::default()
|
||||
},
|
||||
);
|
||||
graph.link(output, hw);
|
||||
|
||||
// A genuinely unrelated app must survive, or "exclude everything" would
|
||||
// pass this test — the §5.1 eligible-half rule.
|
||||
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
|
||||
graph.link(bystander, hw);
|
||||
|
||||
assert_partition(
|
||||
&run(&graph, &ctx()),
|
||||
&[("mpv", bystander)],
|
||||
&[
|
||||
("call", call, "peerspeak-owned"),
|
||||
("two-client-output", output, "tainted-owner-bridge"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The node's own `application.process.id` is used even when its Client's
|
||||
/// `sec_pid` is the daemon's — the single most common shape here, since a
|
||||
/// Pulse-emulated node's pid is the app's while its Client's is
|
||||
/// pipewire-pulse's.
|
||||
///
|
||||
/// ⚠️ Both values are now carried (round-10 review, finding 1), so this is no
|
||||
/// longer "the node's wins" but "exception 1 is applied per value": the
|
||||
/// daemon's `sec_pid` is dropped and the node's real pid is kept, leaving the
|
||||
/// same single key as before.
|
||||
#[test]
|
||||
fn the_nodes_own_process_id_wins_over_its_clients() {
|
||||
let mut graph = Graph::new();
|
||||
|
||||
Reference in New Issue
Block a user