diff --git a/src/host/aec/mod.rs b/src/host/aec/mod.rs new file mode 100644 index 0000000..ffe78be --- /dev/null +++ b/src/host/aec/mod.rs @@ -0,0 +1,261 @@ +//! Phase 4 — the AEC identity validation state machine (impl plan §4, design +//! v3.4 §5.2/§5.3). +//! +//! peerspeak's echo canceller (`module-echo-cancel`) creates four graph nodes +//! that all carry `pulse.module.id == `, and the +//! playback leg among them is a `Stream/Output/Audio` node wired straight to +//! the speakers — a fan-out candidate that would copy the whole remote call +//! into the share unless it is excluded (v3.4 §5.2, measured ≈desktop level). +//! The taint engine (phase 2) already excludes it *given* the module index in +//! [`ExclusionCtx::aec_module_id`](crate::host::taint::ExclusionCtx); this +//! module is what decides, at runtime and fail-closed, whether that index may +//! be trusted and handed over. +//! +//! **Why a state machine and not a one-shot check (v3.4 §5.3).** The identity +//! is an *observed correlation on PipeWire 1.6.8*, not a documented contract, +//! and a start-time enumeration races in both directions: peerspeak's +//! `enable()` returns before the playback hazard leg is even in the graph, and +//! pixelpass's capture spawns lazily on the first viewer, at a moment peerspeak +//! does not control. So validation is a bounded epoch, and the identity can be +//! *lost* mid-share (the module unloads) as well as *gained*. +//! +//! **The two traps this is shaped around:** +//! +//! - **Revocation is loss of the whole module identity, not one leg corking** +//! (v3.4 §5.3). Each [`AecValidator::observe`] rescans the snapshot for *any* +//! node bearing the index; [`AecState::Validated`] drops to +//! [`AecState::Revoked`] only when that set becomes **empty**. A single leg +//! corking or relinking (still ≥1 present) stays `Validated` — getting this +//! wrong turns a normal cork into a spurious share-wide audio stop. +//! - **Module indices are reused verbatim across unload/reload** (v3.4 §5.2 +//! correction 3 — both a reload's module index *and* its `node.link-group` +//! came back byte-identical, and node ids were recycled *and reassigned +//! across legs*). So [`AecState::Failed`] and [`AecState::Revoked`] are +//! **sticky terminal**: a later node reappearing with the same index does +//! **not** un-revoke and alias onto the new module. A genuine reload gets a +//! *fresh* [`AecValidator`] (peerspeak re-tells pixelpass the index on every +//! load), never a resurrected one. +//! +//! **Scope.** This is the validation state machine + `--aec` parsing only. +//! Foreign / second-AEC detection (a non-owned `echo-cancel-*` group, v3.4 +//! §5.4 / D3) and the `foreign_aec_warning`/`aec_failed`/`aec_revoked` status +//! *events* are phase 6's, which reads this machine's [`AecState`]. Wiring the +//! parsed [`AecConfig`] out of the CLI and calling [`AecValidator::observe`] +//! in the recompute loop is integration (phases 5/8). The node-side +//! `pulse.module.id` parse (JSON-number-vs-string, u64-not-u32) is phase 3's +//! adapter; this module consumes the already-parsed +//! [`NodeProps::pulse_module_id`](crate::host::taint::snapshot::NodeProps). + +#![allow(dead_code)] // Wired into `--aec` parsing + the recompute loop by later phases. + +#[cfg(test)] +mod tests; + +use crate::host::observer::Millis; +use crate::host::taint::snapshot::GraphSnapshot; + +/// The parsed `--aec=off|pulse-module:` argument (decision D5). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AecConfig { + /// `--aec=off` — peerspeak's AEC is not in play, so there is nothing to + /// exclude and fan-out proceeds with no AEC identity. Not the same as an + /// *absent* argument (that default is the caller's; see [`parse_aec_arg`]). + Off, + /// `--aec=pulse-module:` — validate this live module index before + /// trusting it. The index is compared as `u64`, never `u32` (v3.4 §5.2). + PulseModule(u64), +} + +/// Why an `--aec` argument was rejected. Rejection is fatal at the CLI edge — +/// there is no fail-closed *default* index, because a wrong index would exclude +/// the wrong node (or nothing), so a malformed value must not silently become +/// "no AEC". +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AecParseError { + /// The value was empty. + Empty, + /// Not `off` and not `pulse-module:...`. + UnknownForm, + /// `pulse-module:` with nothing after the colon. + MissingIndex, + /// The index was not a bare `u64` decimal (sign, whitespace, non-digit, or + /// `> u64::MAX`). + InvalidIndex, +} + +/// Parse one `--aec` value. `off` and `pulse-module:` are the only forms. +/// +/// The index accepts values `> u32::MAX` (v3.4 §5.2: `pulse.module.id` sits +/// next to the `object.serial` u32-truncation bug, so it is only ever compared +/// as `u64`) and rejects anything Rust's [`u64`] parser rejects — a leading +/// sign, surrounding or interior whitespace, non-decimal digits, and overflow. +/// Matching is exact and case-sensitive: the argument is machine-generated by +/// peerspeak from `EchoCancelGuard::module_index`, not typed by a user. +pub fn parse_aec_arg(value: &str) -> Result { + if value.is_empty() { + return Err(AecParseError::Empty); + } + if value == "off" { + return Ok(AecConfig::Off); + } + if let Some(index) = value.strip_prefix("pulse-module:") { + if index.is_empty() { + return Err(AecParseError::MissingIndex); + } + // A bare decimal only: reject a leading sign (Rust's `u64` parser + // accepts `+7`), interior/surrounding whitespace, and any non-digit, + // before letting the parser catch overflow. Leading zeros are harmless. + if !index.bytes().all(|b| b.is_ascii_digit()) { + return Err(AecParseError::InvalidIndex); + } + return index + .parse::() + .map(AecConfig::PulseModule) + .map_err(|_| AecParseError::InvalidIndex); + } + Err(AecParseError::UnknownForm) +} + +/// The validation epoch (v3.4 §5.3, verbatim). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AecState { + /// `--aec=off` — no AEC identity, fan-out proceeds with no exclusion. + /// Terminal. + NotConfigured, + /// Waiting for the first node bearing the index. **No fan-out occurs here** + /// — silence is the safe direction. Ends at `Validated` on first sight, or + /// `Failed` once the graph is fully enumerated and the bounded deadline + /// passes with the index never seen. + Validating, + /// The index was observed live. Fan-out is permitted, excluding that + /// identity transitively (phase 2 / v3.4 §6.1). + Validated, + /// The deadline expired with the index never observed. **Fail closed** — no + /// fan-out; the caller reports a capability failure rather than sharing. + /// Sticky terminal. + Failed, + /// The whole module identity disappeared mid-share (every node bearing the + /// index gone). **Stop fan-out now** and drop the owned link proxies; do + /// not keep the numeric index and hope, because it is reused. Sticky + /// terminal — see the module header's second trap. + Revoked, +} + +/// The bounded, read-only AEC identity validator. Fold the live graph in with +/// [`AecValidator::observe`] once per recompute; read the result with +/// [`AecValidator::state`], [`AecValidator::fan_out_permitted`], and +/// [`AecValidator::validated_module_id`]. +#[derive(Clone, Debug)] +pub struct AecValidator { + /// The index to validate. `None` iff [`AecConfig::Off`] (state stays + /// [`AecState::NotConfigured`] forever). + target: Option, + state: AecState, + /// The `Validating → Failed` budget, applied *after* the deadline is armed. + timeout: Millis, + /// The absolute `Failed` deadline, armed the first time the graph reports + /// ready (the "registry sync barrier" of v3.4 §5.3) and never re-armed — + /// `graph_ready` is dynamic and can flap, but the epoch budget must not + /// restart. `None` until then: while the initial enumeration is still in + /// flight, a not-yet-seen index is *unknown*, not *absent*, so it must not + /// time out to `Failed`. + deadline: Option, +} + +impl AecValidator { + /// `timeout` is the `Validating → Failed` budget, counted from the moment + /// the graph first becomes ready (not from construction). An `Off` config + /// starts (and stays) [`AecState::NotConfigured`]. + pub fn new(config: AecConfig, timeout: Millis) -> Self { + match config { + AecConfig::Off => Self { + target: None, + state: AecState::NotConfigured, + timeout, + deadline: None, + }, + AecConfig::PulseModule(index) => Self { + target: Some(index), + state: AecState::Validating, + timeout, + deadline: None, + }, + } + } + + pub fn state(&self) -> AecState { + self.state + } + + /// The validated index to place in + /// [`ExclusionCtx::aec_module_id`](crate::host::taint::ExclusionCtx) — + /// `Some` **only** in [`AecState::Validated`]. `None` everywhere else, + /// including `NotConfigured` (no AEC ⇒ nothing to exclude) and the + /// fail-closed states (whose `None` must be paired with + /// [`Self::fan_out_permitted`] `== false`, i.e. no fan-out at all — *not* + /// a fan-out that merely skips AEC exclusion). + pub fn validated_module_id(&self) -> Option { + match self.state { + AecState::Validated => self.target, + _ => None, + } + } + + /// Whether fan-out may proceed at all right now. True only in + /// [`AecState::NotConfigured`] (fan out, no exclusion) and + /// [`AecState::Validated`] (fan out, excluding the identity). `Validating`, + /// `Failed` and `Revoked` all forbid it — silence over echo. + pub fn fan_out_permitted(&self) -> bool { + matches!(self.state, AecState::NotConfigured | AecState::Validated) + } + + /// Fold one recompute's view of the graph into the machine. + /// + /// `graph_ready` is the observer's dynamic readiness + /// ([`Projection::graph_ready`](crate::host::observer::Projection)); `now` + /// is a monotonic millisecond clock. Positive evidence (a node bearing the + /// index) is authoritative and validates regardless of `graph_ready` — + /// seeing the node *is* seeing it — but the `Failed` deadline only begins + /// once `graph_ready` has first become true, so a slow initial enumeration + /// can never masquerade as a genuinely-absent module. + pub fn observe(&mut self, snapshot: &GraphSnapshot, graph_ready: bool, now: Millis) { + // `Off` (NotConfigured) and both sticky terminals are no-ops: there is + // nothing to look for, and a reappearing reused index must not revive a + // Failed/Revoked epoch (v3.4 §5.2 correction 3). + let Some(target) = self.target else { + return; + }; + match self.state { + AecState::Validating => { + if self.index_present(snapshot, target) { + self.state = AecState::Validated; + return; + } + // Arm the deadline once, on the first ready graph. + if self.deadline.is_none() && graph_ready { + self.deadline = Some(now.saturating_add(self.timeout)); + } + if self.deadline.is_some_and(|deadline| now >= deadline) { + self.state = AecState::Failed; + } + } + AecState::Validated => { + // Revocation is the whole identity gone, not one leg corking. + if !self.index_present(snapshot, target) { + self.state = AecState::Revoked; + } + } + AecState::NotConfigured | AecState::Failed | AecState::Revoked => {} + } + } + + /// Whether any node in the snapshot bears the target module index. The same + /// exact-`u64`-equality predicate the taint engine roots on + /// (`taint/mod.rs`), kept here so "is the identity live?" has one + /// definition. + fn index_present(&self, snapshot: &GraphSnapshot, target: u64) -> bool { + snapshot + .nodes() + .any(|node| node.props.pulse_module_id == Some(target)) + } +} diff --git a/src/host/aec/tests.rs b/src/host/aec/tests.rs new file mode 100644 index 0000000..35b538f --- /dev/null +++ b/src/host/aec/tests.rs @@ -0,0 +1,324 @@ +//! Phase 4 exit gate (impl plan §4): a fake-clock / event-sequence transition +//! matrix, because these are timing semantics a live poke cannot cover. + +use super::*; +use crate::host::taint::snapshot::{ + GlobalId, GraphSnapshot, MediaRole, NodeProps, NodeSnapshot, Serial, +}; + +/// A `Stream/Output/Audio` node carrying `pulse.module.id == module` (or none). +/// Only the fields the validator reads matter; the rest take their defaults. +fn node(serial: u64, module: Option) -> NodeSnapshot { + NodeSnapshot { + serial: Serial(serial), + id: GlobalId(serial as u32), + name: None, + role: MediaRole::StreamOutput, + props: NodeProps { + pulse_module_id: module, + ..NodeProps::default() + }, + } +} + +/// A snapshot holding exactly the given nodes (no ports/links/clients — the +/// validator reads only nodes). +fn snapshot(nodes: Vec) -> GraphSnapshot { + GraphSnapshot::new(nodes, vec![], vec![], vec![]) +} + +fn empty() -> GraphSnapshot { + snapshot(vec![]) +} + +const IDX: u64 = 536_870_919; // 0x20000007 — a real pipewire-pulse module index. +const TIMEOUT: Millis = 2_000; + +// --------------------------------------------------------------------------- +// Parsing (D5): off / pulse-module: / > u32::MAX / absent / malformed. +// --------------------------------------------------------------------------- + +#[test] +fn parses_off() { + assert_eq!(parse_aec_arg("off"), Ok(AecConfig::Off)); +} + +#[test] +fn parses_pulse_module_index() { + assert_eq!( + parse_aec_arg("pulse-module:536870919"), + Ok(AecConfig::PulseModule(536_870_919)), + ); +} + +#[test] +fn parses_index_beyond_u32() { + // v3.4 §5.2: compare as u64, never u32. A value one past u32::MAX must + // round-trip, not truncate or reject. + let big = u64::from(u32::MAX) + 1; + assert_eq!( + parse_aec_arg(&format!("pulse-module:{big}")), + Ok(AecConfig::PulseModule(big)), + ); + assert_eq!( + parse_aec_arg(&format!("pulse-module:{}", u64::MAX)), + Ok(AecConfig::PulseModule(u64::MAX)), + ); +} + +#[test] +fn rejects_empty() { + assert_eq!(parse_aec_arg(""), Err(AecParseError::Empty)); +} + +#[test] +fn rejects_unknown_form() { + assert_eq!(parse_aec_arg("on"), Err(AecParseError::UnknownForm)); + assert_eq!(parse_aec_arg("module:5"), Err(AecParseError::UnknownForm)); + assert_eq!(parse_aec_arg("536870919"), Err(AecParseError::UnknownForm)); +} + +#[test] +fn rejects_missing_index() { + assert_eq!( + parse_aec_arg("pulse-module:"), + Err(AecParseError::MissingIndex), + ); +} + +#[test] +fn rejects_malformed_index() { + for bad in [ + "pulse-module:-1", // sign + "pulse-module:+7", // sign + "pulse-module: 7", // leading whitespace + "pulse-module:7 ", // trailing whitespace + "pulse-module:0x7", // hex + "pulse-module:7.0", // non-integer + "pulse-module:abc", // non-numeric + "pulse-module:18446744073709551616", // u64::MAX + 1 (overflow) + ] { + assert_eq!( + parse_aec_arg(bad), + Err(AecParseError::InvalidIndex), + "{bad} should be InvalidIndex", + ); + } +} + +// --------------------------------------------------------------------------- +// NotConfigured (--aec=off): benign, terminal, fan-out with no exclusion. +// --------------------------------------------------------------------------- + +#[test] +fn off_is_not_configured_and_permits_fan_out_with_no_identity() { + let mut v = AecValidator::new(AecConfig::Off, TIMEOUT); + assert_eq!(v.state(), AecState::NotConfigured); + assert!(v.fan_out_permitted()); + assert_eq!(v.validated_module_id(), None); + + // Even a snapshot full of module nodes never moves it off NotConfigured. + v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 10_000); + assert_eq!(v.state(), AecState::NotConfigured); + assert!(v.fan_out_permitted()); + assert_eq!(v.validated_module_id(), None); +} + +// --------------------------------------------------------------------------- +// Row: Validating → Validated on first matching node; no fan-out before. +// --------------------------------------------------------------------------- + +#[test] +fn validating_forbids_fan_out_and_exposes_no_identity() { + let v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT); + assert_eq!(v.state(), AecState::Validating); + assert!(!v.fan_out_permitted()); + assert_eq!(v.validated_module_id(), None); +} + +#[test] +fn validating_to_validated_on_first_matching_node() { + let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT); + // A node with a *different* index does not validate. + v.observe(&snapshot(vec![node(1, Some(IDX + 1))]), true, 0); + assert_eq!(v.state(), AecState::Validating); + + v.observe(&snapshot(vec![node(2, Some(IDX))]), true, 100); + assert_eq!(v.state(), AecState::Validated); + assert!(v.fan_out_permitted()); + assert_eq!(v.validated_module_id(), Some(IDX)); +} + +#[test] +fn positive_evidence_validates_even_before_graph_ready() { + // Seeing the node is authoritative; readiness only gates the Failed clock. + let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT); + v.observe(&snapshot(vec![node(1, Some(IDX))]), false, 0); + assert_eq!(v.state(), AecState::Validated); + assert_eq!(v.validated_module_id(), Some(IDX)); +} + +#[test] +fn validated_index_is_compared_beyond_u32() { + let big = u64::from(u32::MAX) + 7; + let mut v = AecValidator::new(AecConfig::PulseModule(big), TIMEOUT); + // A node whose id equals `big` only in its low 32 bits must not match. + v.observe( + &snapshot(vec![node(1, Some(big & u64::from(u32::MAX)))]), + true, + 0, + ); + assert_eq!(v.state(), AecState::Validating); + + v.observe(&snapshot(vec![node(2, Some(big))]), true, 1); + assert_eq!(v.state(), AecState::Validated); + assert_eq!(v.validated_module_id(), Some(big)); +} + +// --------------------------------------------------------------------------- +// Row: Validating → Failed on deadline expiry; and the deadline is armed only +// once the graph is ready (the registry sync barrier). +// --------------------------------------------------------------------------- + +#[test] +fn validating_to_failed_on_deadline_expiry() { + let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT); + v.observe(&empty(), true, 0); // arms deadline at 0 + 2000 + assert_eq!(v.state(), AecState::Validating); + + v.observe(&empty(), true, TIMEOUT - 1); + assert_eq!(v.state(), AecState::Validating); + + v.observe(&empty(), true, TIMEOUT); // now >= deadline + assert_eq!(v.state(), AecState::Failed); + assert!(!v.fan_out_permitted()); + assert_eq!(v.validated_module_id(), None); +} + +#[test] +fn deadline_is_not_armed_until_graph_ready() { + // The whole point of arming-on-ready: a slow initial enumeration is + // "unknown", not "absent", and must never time out to Failed. + let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT); + // Long past the would-be deadline, but the graph has never been ready. + v.observe(&empty(), false, 10 * TIMEOUT); + assert_eq!(v.state(), AecState::Validating); + // Still no Failed even much later, as long as ready stays false. + v.observe(&empty(), false, 100 * TIMEOUT); + assert_eq!(v.state(), AecState::Validating); +} + +#[test] +fn deadline_armed_once_survives_ready_flapping() { + // graph_ready is dynamic (it drops back to false while a Link is binding). + // The epoch budget must be armed on the *first* ready and not restarted. + let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT); + v.observe(&empty(), true, 1_000); // arm at 1000 → deadline 3000 + v.observe(&empty(), false, 2_000); // ready flaps off; must not disarm + assert_eq!(v.state(), AecState::Validating); + // At the original deadline it fails, even though ready is false now — the + // budget did not restart from the flap. + v.observe(&empty(), false, 3_000); + assert_eq!(v.state(), AecState::Failed); +} + +#[test] +fn failed_is_sticky_even_if_the_index_reappears() { + let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT); + v.observe(&empty(), true, 0); + v.observe(&empty(), true, TIMEOUT); + assert_eq!(v.state(), AecState::Failed); + + // A node bearing the index shows up late — must not resurrect the epoch. + v.observe(&snapshot(vec![node(1, Some(IDX))]), true, TIMEOUT + 1); + assert_eq!(v.state(), AecState::Failed); + assert!(!v.fan_out_permitted()); + assert_eq!(v.validated_module_id(), None); +} + +// --------------------------------------------------------------------------- +// Row: partial-node disappearance ⇒ stays Validated; all gone ⇒ Revoked. +// --------------------------------------------------------------------------- + +#[test] +fn partial_leg_disappearance_stays_validated() { + let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT); + // The module's four nodes all carry the index. + let four = snapshot(vec![ + node(1, Some(IDX)), + node(2, Some(IDX)), + node(3, Some(IDX)), + node(4, Some(IDX)), + ]); + v.observe(&four, true, 0); + assert_eq!(v.state(), AecState::Validated); + + // Three legs cork/relink away; one still bears the index → still Validated. + v.observe(&snapshot(vec![node(4, Some(IDX))]), true, 10); + assert_eq!(v.state(), AecState::Validated); + assert_eq!(v.validated_module_id(), Some(IDX)); +} + +#[test] +fn all_nodes_gone_revokes() { + let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT); + v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0); + assert_eq!(v.state(), AecState::Validated); + + // The whole identity unloads: no node bears the index any more. + v.observe(&empty(), true, 10); + assert_eq!(v.state(), AecState::Revoked); +} + +#[test] +fn revoked_stops_fan_out_and_exposes_no_identity() { + let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT); + v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0); + v.observe(&empty(), true, 10); + assert_eq!(v.state(), AecState::Revoked); + assert!(!v.fan_out_permitted()); + assert_eq!(v.validated_module_id(), None); +} + +#[test] +fn a_node_that_merely_changes_index_revokes() { + // Not a disappearance in the id sense, but the *identity* is gone: no node + // bears our index any more, even though a same-serial node lingers. + let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT); + v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0); + v.observe(&snapshot(vec![node(1, Some(IDX + 1))]), true, 10); + assert_eq!(v.state(), AecState::Revoked); +} + +// --------------------------------------------------------------------------- +// Row: a retained stale index does not alias onto a reloaded module — indices +// ARE reused (v3.4 §5.2 correction 3). This is the sharpest safety property. +// --------------------------------------------------------------------------- + +#[test] +fn revoked_index_does_not_alias_onto_a_reloaded_module() { + let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT); + v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0); + v.observe(&empty(), true, 10); + assert_eq!(v.state(), AecState::Revoked); + + // A *different* module later reloads and pactl hands it the very same + // index (measured: 536870919 came back verbatim). A resurrecting machine + // would silently start excluding this unrelated module's node. Ours must + // stay Revoked and fail closed; a real reload gets a fresh validator. + v.observe(&snapshot(vec![node(99, Some(IDX))]), true, 20); + assert_eq!(v.state(), AecState::Revoked); + assert!(!v.fan_out_permitted()); + assert_eq!(v.validated_module_id(), None); +} + +#[test] +fn a_fresh_validator_re_validates_the_reused_index() { + // The counterpart: because peerspeak re-tells pixelpass the index on every + // load, the correct response to a reload is a new machine, which validates + // the reused index cleanly — proving stickiness costs nothing legitimate. + let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT); + v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0); + assert_eq!(v.state(), AecState::Validated); + assert_eq!(v.validated_module_id(), Some(IDX)); +} diff --git a/src/host/mod.rs b/src/host/mod.rs index ec59cd3..8f8ca5d 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -1,3 +1,4 @@ +pub mod aec; pub mod audio; mod capture; mod observer;