Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
471b8221ff |
@@ -64,6 +64,11 @@ const HARDWARE_PCM_FACTORIES: &[&str] = &[
|
||||
"api.alsa.pcm.source",
|
||||
];
|
||||
|
||||
/// The `device.api` every entry in [`HARDWARE_PCM_FACTORIES`] belongs to.
|
||||
/// A single value rather than a list, because the allowlist is ALSA-only;
|
||||
/// this constant is the thing to change when that stops being true.
|
||||
const HARDWARE_PCM_API: &str = "alsa";
|
||||
|
||||
/// ALSA drivers that expose a hardware-PCM `factory.name` but are **not**
|
||||
/// passive terminals — audio written in reappears on their capture side
|
||||
/// through a path the PipeWire Link graph cannot see, so classifying them
|
||||
@@ -199,8 +204,20 @@ pub fn classify(claim: &DeviceClaim, device: Option<&DeviceProps>) -> Classifica
|
||||
.flatten()
|
||||
.any(|d| NON_TERMINAL_ALSA_DRIVERS.contains(&d));
|
||||
let driver_ok = driver.is_some() && !driver_denied;
|
||||
let api_present = device.device_api.is_some() || claim.device_api.is_some();
|
||||
let is_hardware_pcm = api_present && on_factory_allowlist && driver_ok;
|
||||
// The API must positively be the one the factory allowlist is written
|
||||
// for, not merely present (Codex phase-3r review, finding 3). "Present"
|
||||
// admitted `device.api=v4l2` alongside `factory.name=api.alsa.pcm.sink`
|
||||
// — a contradiction no truthful configuration produces, which is exactly
|
||||
// why it should be read as an observation gone wrong rather than as
|
||||
// corroboration. Disagreement between the two sides fails closed for the
|
||||
// same reason. ⚠️ Tied to [`HARDWARE_PCM_FACTORIES`] being ALSA-only:
|
||||
// adding a BlueZ factory means allowing `bluez5` here too.
|
||||
let api_ok = match (device.device_api.as_deref(), claim.device_api.as_deref()) {
|
||||
(Some(from_device), Some(from_node)) if from_device != from_node => false,
|
||||
(Some(api), _) | (None, Some(api)) => api == HARDWARE_PCM_API,
|
||||
(None, None) => false,
|
||||
};
|
||||
let is_hardware_pcm = api_ok && on_factory_allowlist && driver_ok;
|
||||
if is_hardware_pcm {
|
||||
Classification::SessionDevice
|
||||
} else {
|
||||
|
||||
+23
-13
@@ -596,21 +596,31 @@ impl RegistryModel {
|
||||
}
|
||||
|
||||
/// The bound properties of the Device a node claims by global id, or
|
||||
/// `None` when that claim is unresolved — which covers all three
|
||||
/// fail-closed cases at once: no such Device observed, its bind still
|
||||
/// outstanding, or **two live Devices sharing the recycled id**, where
|
||||
/// there is no way to tell whose properties these are (v3.4 §6.1.3).
|
||||
/// `None` when that claim is unresolved — which covers every fail-closed
|
||||
/// case at once: no such Device observed, its bind still outstanding, or
|
||||
/// **the id claimed by more than one live global**, where there is no way
|
||||
/// to tell whose properties these are (v3.4 §6.1.3).
|
||||
///
|
||||
/// ⚠️ The ambiguity test is "**exactly one** live global holds this id",
|
||||
/// not "exactly one live *Device*" (Codex phase-3r review, finding 2).
|
||||
/// The weaker test looks equivalent and is not: with `[Device, Port]` on
|
||||
/// one id — a missed removal, the same precondition as every other
|
||||
/// recycled-id hazard — it keeps answering with the older Device's
|
||||
/// properties, so a node claiming that id holds a stale
|
||||
/// `session_device = true`. That flag *removes* the node's owner keys and
|
||||
/// its fail-closed backstop, so a forwarder wearing it can put its output
|
||||
/// leg back on the eligible side: echo, from a lookup that was merely
|
||||
/// looking at the wrong object type.
|
||||
fn device_props(&self, id: GlobalId) -> Option<&DeviceProps> {
|
||||
let mut found: Option<Serial> = None;
|
||||
for slot in self.live_ids.get(&id)? {
|
||||
if let Slot::Device(serial) = slot {
|
||||
if found.is_some() {
|
||||
return None; // Ambiguous ⇒ unresolved ⇒ withheld.
|
||||
}
|
||||
found = Some(*serial);
|
||||
}
|
||||
let slots = self.live_ids.get(&id)?;
|
||||
if slots.len() != 1 {
|
||||
return None; // Ambiguous ⇒ unresolved ⇒ withheld.
|
||||
}
|
||||
self.devices.get(&found?)?.props.as_ref()
|
||||
let Slot::Device(serial) = slots.front()? else {
|
||||
// The id is live, but it is not a Device any more.
|
||||
return None;
|
||||
};
|
||||
self.devices.get(serial)?.props.as_ref()
|
||||
}
|
||||
|
||||
/// Classify one node's device claim against the currently resolved
|
||||
|
||||
@@ -346,6 +346,53 @@ fn classify_device_api_from_either_side_corroborates() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_contradictory_api_fails_closed() {
|
||||
// Codex phase-3r review, finding 3. A `device.api` that is merely
|
||||
// *present* is not corroboration: `v4l2` under an ALSA PCM factory is a
|
||||
// contradiction, and the safe reading of a contradiction is "an
|
||||
// observation went wrong", not "close enough".
|
||||
let claim = DeviceClaim {
|
||||
device_id: Some(gid(7)),
|
||||
device_api: None,
|
||||
factory_name: Some("api.alsa.pcm.sink".to_string()),
|
||||
alsa_driver_name: Some("snd_hda_intel".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
classify(
|
||||
&claim,
|
||||
Some(&device_with(Some("v4l2"), Some("snd_hda_intel")))
|
||||
),
|
||||
Classification::NotSessionDevice,
|
||||
"a non-ALSA api under an ALSA factory must not corroborate"
|
||||
);
|
||||
|
||||
// The two sides disagreeing fails closed for the same reason.
|
||||
let disagreeing = DeviceClaim {
|
||||
device_id: Some(gid(7)),
|
||||
device_api: Some("bluez5".to_string()),
|
||||
factory_name: Some("api.alsa.pcm.sink".to_string()),
|
||||
alsa_driver_name: Some("snd_hda_intel".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
classify(&disagreeing, Some(&alsa_device())),
|
||||
Classification::NotSessionDevice,
|
||||
"node and Device naming different APIs must fail closed"
|
||||
);
|
||||
|
||||
// An empty value is not a value.
|
||||
let empty = DeviceClaim {
|
||||
device_id: Some(gid(7)),
|
||||
device_api: Some(String::new()),
|
||||
factory_name: Some("api.alsa.pcm.sink".to_string()),
|
||||
alsa_driver_name: Some("snd_hda_intel".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
classify(&empty, Some(&device_with(None, Some("snd_hda_intel")))),
|
||||
Classification::NotSessionDevice
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_allowlist_is_exact_not_substring() {
|
||||
// A factory that merely *contains* an allowlisted name must not pass.
|
||||
@@ -1300,6 +1347,47 @@ fn model_removed_device_withholds_its_nodes_again() {
|
||||
assert!(!m.graph_ready(), "and it is an obligation again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_device_id_shared_with_another_object_type_withholds() {
|
||||
// Codex phase-3r review, finding 2 (certain). The ambiguity test has to
|
||||
// be "exactly one live global holds this id", not "exactly one live
|
||||
// *Device*": a Port recycling the id is the same missed-removal
|
||||
// condition, and answering from the older Device leaves the claiming
|
||||
// node wearing a stale `session_device = true` — which strips its owner
|
||||
// keys and backstop, the difference between over-exclusion and echo.
|
||||
let mut m = model();
|
||||
add_device(&mut m, 4200, 42, alsa_device());
|
||||
add_device_node(
|
||||
&mut m,
|
||||
100,
|
||||
50,
|
||||
MediaRole::Sink,
|
||||
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
||||
);
|
||||
assert!(
|
||||
m.project()
|
||||
.snapshot
|
||||
.node(ser(100))
|
||||
.unwrap()
|
||||
.props
|
||||
.session_device
|
||||
);
|
||||
|
||||
// A Port appears on the recycled id 42.
|
||||
m.apply(port(4300, 42, 50, PortDirection::In));
|
||||
assert_eq!(
|
||||
m.project().snapshot.nodes().count(),
|
||||
0,
|
||||
"a contested device id resolves nothing"
|
||||
);
|
||||
assert!(!m.graph_ready(), "and it is an outstanding obligation");
|
||||
|
||||
// Accounting for the Device's removal leaves the Port holding the id
|
||||
// alone — still not a Device, so the node stays withheld.
|
||||
m.apply(RegEvent::Removed { id: gid(42) });
|
||||
assert_eq!(m.project().snapshot.nodes().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_ambiguous_device_id_withholds_its_nodes() {
|
||||
// Two live Devices on one recycled id: there is no way to know whose
|
||||
|
||||
+34
-129
@@ -381,28 +381,36 @@ pub fn evaluate(
|
||||
let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid);
|
||||
let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid);
|
||||
|
||||
// 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,
|
||||
&components,
|
||||
prior,
|
||||
Uncertainty::FailsClosed,
|
||||
);
|
||||
let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials);
|
||||
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
|
||||
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
|
||||
|
||||
// Pass 2 — the evidence-only view, and the only thing sticky state is
|
||||
// ever built from (see [`Uncertainty`]).
|
||||
let (evidence, _) = compute_taint(
|
||||
seed_local_roots(snapshot, ctx, &mut taint);
|
||||
seed_sticky(
|
||||
snapshot,
|
||||
ctx,
|
||||
&keys,
|
||||
&components,
|
||||
prior,
|
||||
Uncertainty::Ignored,
|
||||
&components,
|
||||
&mut taint,
|
||||
&mut sticky_serials,
|
||||
);
|
||||
|
||||
// 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);
|
||||
// ⚠️ 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
|
||||
@@ -411,105 +419,10 @@ 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,
|
||||
&evidence,
|
||||
prior,
|
||||
ctx.graph_ready,
|
||||
);
|
||||
let next_sticky = build_sticky(snapshot, &keys, &components, &taint, 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,
|
||||
@@ -520,20 +433,14 @@ 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);
|
||||
@@ -656,7 +563,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, unresolved_input: &mut BTreeSet<Serial>) -> Edges {
|
||||
fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reason>) -> Edges {
|
||||
let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new();
|
||||
let mut receivers: BTreeSet<Serial> = BTreeSet::new();
|
||||
for link in snapshot.links() {
|
||||
@@ -668,10 +575,8 @@ fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet<Se
|
||||
receivers.insert(to);
|
||||
}
|
||||
(_, Some(IdLookup::Unique(to))) => {
|
||||
// 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);
|
||||
// Something feeds this node and we cannot say what.
|
||||
raise(taint, to, Reason::UnresolvedAncestry);
|
||||
receivers.insert(to);
|
||||
}
|
||||
(_, Some(IdLookup::Ambiguous)) => {
|
||||
|
||||
@@ -820,137 +820,6 @@ 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