diff --git a/Cargo.lock b/Cargo.lock index 8e504bf..f0525f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1280,6 +1280,11 @@ dependencies = [ [[package]] name = "rutster-trunk" version = "0.0.0" +dependencies = [ + "async-trait", + "tokio", + "url", +] [[package]] name = "ryu" diff --git a/crates/rutster-trunk/Cargo.toml b/crates/rutster-trunk/Cargo.toml index bd92d0d..2f05691 100644 --- a/crates/rutster-trunk/Cargo.toml +++ b/crates/rutster-trunk/Cargo.toml @@ -5,4 +5,33 @@ version = "0.0.0" license.workspace = true edition.workspace = true repository.workspace = true -description = "Rented carrier transport — CPaaS media-leg ingress / out-of-tree SBC; no first-party SIP (filled in step 5, ADR-0007)." +description = "Rented carrier transport — CPaaS media-leg ingress; no first-party SIP (spearhead step 5, ADR-0007)." + +[dependencies] +# async-trait: lets us declare `async fn` in the CallControlClient trait while +# still using it as a trait object (`Box`) in the route +# handlers. Native async-fn-in-traits (stable since 1.75) does NOT support +# `dyn` dispatch without manual desugaring — async_trait rewrites the signature +# to `fn -> Pin>` for us (spec §3.4). +async-trait = { workspace = true } +# url: the `TwilioCredentials::webhook_base` field is a `url::Url` (the +# operator's public base URL Twilio calls back). Parsed in `config::twilio_credentials`. +url = { workspace = true } + +[dev-dependencies] +# tokio: only the dev-dep is needed for the mock's `#[tokio::test]` attribute +# (`#[tokio::test]` expands to a `#[test]` that spins up a current-thread +# runtime + drives the async fn to completion). The library itself is +# runtime-agnostic: async_trait's desugared futures poll on whatever runtime +# the caller drives them with — the mock's bodies are synchronous (lock + push, +# no `.await`), so no tokio dependency leaks into the library's public surface. +tokio = { workspace = true } + +[features] +default = [] +# The live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live` +# so the routine CI gate stays feature-default-off: `MockCallControlClient` is +# the per-PR test surface; the maintainer runs `cargo test --features=twilio-live` +# + the e2e suite only when validating a release (against real Twilio creds). +# Spec §1.2 + plan T6 + ADR-0009 (credentials never reach the brain). +twilio-live = [] diff --git a/crates/rutster-trunk/src/lib.rs b/crates/rutster-trunk/src/lib.rs index 96aee3b..0821cce 100644 --- a/crates/rutster-trunk/src/lib.rs +++ b/crates/rutster-trunk/src/lib.rs @@ -1,24 +1,38 @@ //! # rutster-trunk //! -//! **Status:** stub. Fills in at spearhead step 5 (a real phone number via rented transport). -//! //! Carrier / PSTN reach for rutster — **rented transport, no first-party SIP** -//! ([ADR-0007](../../../docs/adr/0007-trunk-rented-transport.md)). This crate will hold the +//! ([ADR-0007](../../../docs/adr/0007-trunk-rented-transport.md)). This crate holds the //! rented-transport ingress: the **CPaaS media-leg adapter** — a WebSocket server accepting a //! provider raw-media fork (Twilio Media Streams / Telnyx) plus the provider call-control //! client — and later the glue for an **out-of-tree SBC** when PSTN media must stay on-prem. //! It owns **no SIP stack**: SIP lives outside the trust boundary, in the rented transport //! (green zone — [ADR-0008](../../../docs/adr/0008-fob-and-green-zone.md)). //! -//! Slice 1's WebRTC-only ingress needs none of this — this stub exists to lock the crate -//! boundary without anticipating code (spec §2.2). Its future dependency direction is -//! `rutster-trunk` → `rutster-call-model` + `rutster-media`: PSTN audio is resampled to the -//! canonical tap format and handed to the media plane as a media-leg ingress, parallel to -//! WebRTC. +//! ## Spearhead step 5 — what's here vs what's deferred +//! +//! The provider call-control seam ([`provider`](crate::provider)) lands in T2: the +//! `CallControlClient` trait, `MockCallControlClient`, and `TwilioCredentials` (with +//! redacted `Debug`). The live `TwilioCallControlClient` (T6) is feature-gated behind +//! `twilio-live`. The WSS ingress (`g711`, `twilio_media_streams`, `session`, +//! `loop_driver`) lands in the parallel FOB-side tasks (T1/T3/T4) — see the slice-5 spec. +//! +//! ## Dependency direction +//! +//! `rutster-trunk` → `rutster-call-model` + `rutster-media` (once the FOB-side media path +//! lands): PSTN audio is resampled to the canonical tap format and handed to the media plane +//! as a media-leg ingress, parallel to WebRTC. The provider (call-control) side is +//! green-zone and depends on no FOB media types — only on `url` + `async-trait` (T2). + +pub mod provider; #[cfg(test)] mod tests { - /// Stub crates lock boundaries; the compile-test is the lock. + /// Stub crates lock boundaries; the compile-test is the lock. Now that the + /// provider module is populated (T2), this test also guards the crate-level + /// re-exports compile against the public API surface. #[test] - fn crate_compiles() {} + fn crate_compiles() { + // Touch the public API so the test exercises the re-export wiring. + let _mock = crate::provider::MockCallControlClient::new(); + } } diff --git a/crates/rutster-trunk/src/provider/mock.rs b/crates/rutster-trunk/src/provider/mock.rs new file mode 100644 index 0000000..7d98c26 --- /dev/null +++ b/crates/rutster-trunk/src/provider/mock.rs @@ -0,0 +1,204 @@ +//! # MockCallControlClient — in-process test double for `CallControlClient` +//! +//! CI tests + the PSTN-sim e2e integration tests (T7, T8) use this mock; the +//! live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live` +//! and runs only on the maintainer's machine against real Twilio credentials. +//! +//! ## Why `std::sync::Mutex` (not `tokio::sync::Mutex`) +//! +//! The mock's async fn bodies are **synchronous** — they lock, push a record, +//! unlock, and return. There is no `.await` while holding the lock, so the +//! std mutex (cheaper, no async-overhead, no runtime coupling) is correct. +//! `tokio::sync::Mutex` is for guards held across `.await` points; using it +//! here would be cargo-cult. The async fns exist only to satisfy the trait's +//! `async` signature (desugared by `async_trait` to `Pin>`); their +//! bodies do not actually suspend. +//! +//! ## Poison recovery +//! +//! `std::sync::Mutex` "poisons" on panic — a previous lock holder that panicked +//! leaves the mutex poisoned, and `.lock()` returns `Err(PoisonError)`. For a +//! *test double*, a poisoned mutex means a previous test assertion failed; the +//! right move is to recover the inner value (`.into_inner()`) rather than +//! propagate the poison (which would cascade one test failure into every +//! subsequent test). [`lock_or_recover`] does this idiomatically — and avoids +//! the `unwrap()`/`expect()` that AGENTS.md's hot-path rule forbids in library +//! code (the mock is library code, not `#[cfg(test)]`, because T7/T8 integration +//! tests in `tests/` import it). + +use std::sync::{Mutex, MutexGuard}; + +use async_trait::async_trait; + +use super::{CallControlClient, CallControlError, SpendToken}; + +/// Recover a `MutexGuard` even if the mutex is poisoned. +/// +/// Poison indicates a prior lock-holder panicked (a test assertion failed +/// while holding the lock). For a test double, propagating the poison would +/// cascade the failure into every subsequent test that touches the mock; +/// recovering the inner guard (`PoisonError::into_inner`) localizes the +/// failure. This is the standard documented recovery pattern for non-invariant +/// mutexes (see `std::sync::PoisonError::into_inner` docs). +fn lock_or_recover(lock: &Mutex) -> MutexGuard<'_, T> { + lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// A captured `originate` call — for test assertions. +/// +/// `spend_token_present` (not the token itself) is recorded because `SpendToken` +/// is opaque in step 6; the mock only needs to verify whether a token was +/// supplied, not inspect it. This keeps the mock forward-compatible with step +/// 6's real (opaque, possibly non-`Clone`) token type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OriginateRecord { + /// The `to_phone` arg passed to `originate`. + pub to: String, + /// The `from_phone` arg passed to `originate`. + pub from: String, + /// Whether the caller passed `Some(SpendToken)` (step 6 will; step 5 passes `None`). + pub spend_token_present: bool, +} + +/// In-process test double for [`CallControlClient`]. No network IO. +/// +/// `originate` returns a synthetic `CA...` correlation ID (matching Twilio's +/// CallSid format — `CA` + 32 hex chars — so downstream code that parses the +/// prefix is exercised honestly) and records the call. `hangup` records the +/// terminate. Both are infallible in the mock (no real provider to fail); the +/// `Result` wrappers return `Ok` so test wiring that propagates the `Result` +/// is exercised identically to the live path. +pub struct MockCallControlClient { + /// Monotonic counter for synthetic CallSid generation. `AtomicU64` rather + /// than a `Mutex` because an atomic increment needs no guard (no + /// critical section) — cheaper and lock-poison-free. + counter: std::sync::atomic::AtomicU64, + originated: Mutex>, + hung_up: Mutex>, +} + +impl MockCallControlClient { + pub fn new() -> Self { + Self { + counter: std::sync::atomic::AtomicU64::new(0), + originated: Mutex::new(Vec::new()), + hung_up: Mutex::new(Vec::new()), + } + } + + /// Snapshot of every `originate` call the mock received, in arrival order. + pub fn originated_calls(&self) -> Vec { + lock_or_recover(&self.originated).clone() + } + + /// Snapshot of every `correlation_id` passed to `hangup`, in arrival order. + pub fn hung_up_calls(&self) -> Vec { + lock_or_recover(&self.hung_up).clone() + } +} + +impl Default for MockCallControlClient { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl CallControlClient for MockCallControlClient { + async fn originate( + &self, + to_phone: &str, + from_phone: &str, + spend_token: Option, + ) -> Result { + let id = self + .counter + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // Twilio CallSids are "CA" + 32 hex chars. The mock mirrors that shape + // so any code that parses the prefix/length exercises the real path. + let call_sid = format!("CA{id:032x}"); + + let mut originated = lock_or_recover(&self.originated); + originated.push(OriginateRecord { + to: to_phone.to_owned(), + from: from_phone.to_owned(), + spend_token_present: spend_token.is_some(), + }); + + Ok(call_sid) + } + + async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError> { + let mut hung_up = lock_or_recover(&self.hung_up); + // Idempotent in the mock (matching the live provider): recording the + // same id twice is not an error, matching Twilio's `Status=completed` + // semantics. A dedup check would be test-specific logic the real + // provider doesn't expose. + hung_up.push(correlation_id.to_owned()); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn originate_returns_call_sid_and_records_call() { + let mock = MockCallControlClient::new(); + let sid = mock + .originate("+15551234567", "+15550000000", None) + .await + .expect("mock originate never errors"); + + // Twilio CallSid shape: "CA" + 32 hex chars = 34 chars total. + assert!(sid.starts_with("CA"), "expected CA prefix, got {sid}"); + assert_eq!(sid.len(), 34, "expected 34-char CallSid, got {sid}"); + + let calls = mock.originated_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].to, "+15551234567"); + assert_eq!(calls[0].from, "+15550000000"); + assert!( + !calls[0].spend_token_present, + "step 5 passes None for spend_token" + ); + } + + #[tokio::test] + async fn originate_produces_distinct_call_sids_per_call() { + let mock = MockCallControlClient::new(); + let sid_a = mock.originate("+1", "+2", None).await.unwrap(); + let sid_b = mock.originate("+3", "+4", None).await.unwrap(); + assert_ne!(sid_a, sid_b, "each originate must get a unique CallSid"); + assert_eq!(mock.originated_calls().len(), 2); + } + + #[tokio::test] + async fn hangup_records_correlation_id_and_is_idempotent() { + let mock = MockCallControlClient::new(); + mock.hangup("CAdeadbeef").await.unwrap(); + // Calling again with the same id is a no-op success (live provider is idempotent). + mock.hangup("CAdeadbeef").await.unwrap(); + mock.hangup("CAcafef00d").await.unwrap(); + + let hung = mock.hung_up_calls(); + // The mock records every call (no dedup) — 2 for the repeat + 1 for the other. + assert_eq!(hung.len(), 3); + assert_eq!( + hung.iter().filter(|s| s.as_str() == "CAdeadbeef").count(), + 2 + ); + } + + #[tokio::test] + async fn spend_token_some_is_recorded_as_present() { + // Step 5 callers pass None, but the trait accepts Some(SpendToken) for + // forward-compat with step 6. The mock records presence only. + let mock = MockCallControlClient::new(); + mock.originate("+1", "+2", Some(SpendToken)).await.unwrap(); + let calls = mock.originated_calls(); + assert_eq!(calls.len(), 1); + assert!(calls[0].spend_token_present); + } +} diff --git a/crates/rutster-trunk/src/provider/mod.rs b/crates/rutster-trunk/src/provider/mod.rs new file mode 100644 index 0000000..6fccb21 --- /dev/null +++ b/crates/rutster-trunk/src/provider/mod.rs @@ -0,0 +1,209 @@ +//! # Provider — the call-control seam (green zone, ADR-0008) +//! +//! This module locks the boundary between rutster's FOB (the memory-safe Rust +//! trust domain that owns the per-call media vertical) and the rented carrier +//! transport (a CPaaS provider — Twilio for the spearhead MVP, Telnyx or others +//! later). Per [ADR-0007](../../../../docs/adr/0007-trunk-rented-transport.md), +//! rutster parses **zero SIP bytes**: PSTN reach is *rented transport*. +//! Per [ADR-0008](../../../../docs/adr/0008-fob-and-green-zone.md), the call-control +//! client lives in the **green zone** — arm's length, behind a trait — because it +//! is neither hot-path (it fires only on originate + inbound-webhook receipt) nor +//! security-constitutive (the actual media never transits it; only REST commands do). +//! +//! ## Why a trait (not a concrete Twilio client inline) +//! +//! The trait locks the seam so the **next provider** (Telnyx, etc.) is an +//! *implementation*, not a *refactor*. The binary's route handlers depend on +//! `dyn CallControlClient`, not on `TwilioCallControlClient` directly — swapping +//! providers is a constructor change in `main.rs`, not a rewrite of the handlers. +//! This is the same "lock the seam" discipline that `AudioPipe` applies to the +//! media direction. +//! +//! ## Credential isolation (ADR-0009 — load-bearing for spearhead step 6) +//! +//! [`TwilioCredentials`] lives **only** in this crate (`rutster-trunk`). It is +//! `pub` to the binary crate (`crates/rutster/`) so `config.rs` can construct +//! it from env vars and hand it to `TwilioCallControlClient::new`, but it is +//! **never re-exported through the workspace root**, never appears in +//! `rutster-media`'s or `rutster-tap`'s public API, and never crosses the +//! WebSocket tap protocol to the brain. The brain carries ONLY audio (PCM +//! in/out), function-call events, and `speech_started`/`speech_stopped` +//! advisories — never the account SID, the `auth_token`, the REST endpoint +//! URL, or the `CallSid`. T10's static assertion test re-checks this +//! invariant mechanically. +//! +//! The `auth_token` field's [`Debug`](std::fmt::Debug) impl is **hand-written** +//! (NOT derived) so the token is rendered as `` — derived `Debug` +//! would print the secret in any `tracing::debug!` or `{:?}` log line. This is +//! the same posture as `api_key.rs`'s redacted Debug (slice-3). + +use std::net::SocketAddr; + +pub mod mock; + +pub use mock::{MockCallControlClient, OriginateRecord}; + +/// Call-control operations a rented-transport provider exposes. +/// +/// `Send + Sync` so it can live behind an `Arc` shared +/// across the axum route handlers' tokio tasks. The `#[async_trait]` attribute +/// (from the `async-trait` crate) rewrites each `async fn` into a synchronous +/// `fn -> Pin + Send>>` — that boxing is *why* the +/// trait is usable as a trait object (`dyn CallControlClient`). Native +/// `async fn` in traits (stable since Rust 1.75) does not yet support `dyn` +/// dispatch, so the macro is the pragmatic bridge (spec §3.4). +#[async_trait::async_trait] +pub trait CallControlClient: Send + Sync { + /// Originate an outbound call to `to_phone` from `from_phone`. + /// + /// The provider answers the PSTN call itself, opens a Media Streams fork + /// back to rutster, and returns a **provider-assigned correlation ID** + /// (Twilio: the `CallSid`, a `CA...` string). The binary correlates this + /// ID with its own `ChannelId` for log/CDR join. + /// + /// `spend_token` is the **pre-paved seam for spearhead step 6** (the spend + /// cap). This slice (step 5) passes `None` everywhere — no spend gate yet. + /// Step 6's spend gate will mint a token before any originate is dispatched + /// and pass `Some(token)` here; the live impl will reject `None` once the + /// gate is live. **Do not remove this parameter** "to simplify" — step 6 + /// needs the signature stable so it is additive, not a refactor. + async fn originate( + &self, + to_phone: &str, + from_phone: &str, + spend_token: Option, + ) -> Result; + + /// Hang up an in-progress call. Idempotent — calling with an already-ended + /// `correlation_id` returns `Ok(())` (the provider's REST API is itself + /// idempotent on `Status=completed`). Returns after the provider + /// acknowledges the terminate. + async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError>; +} + +/// A provider call-control failure. A newtype over `String` rather than a +/// rich enum because the MVP's only concern is "did it work" (the route handler +/// maps `Err` → HTTP 502); structured error variants (auth-failed vs +/// rate-limited vs network) land with the second provider (Telnyx) when the +/// distinction starts to matter operationally. +#[derive(Debug, Clone)] +pub struct CallControlError(pub String); + +impl std::fmt::Display for CallControlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "call-control error: {}", self.0) + } +} + +impl std::error::Error for CallControlError {} + +/// A placeholder for the spearhead step-6 **spend-gate token**. +/// +/// This slice (step 5) passes `None` for `spend_token` on every `originate` +/// call — the spend/abuse gate ([ADR-0009](../../../../docs/adr/0009-spend-gate-honest-rescope.md)) +/// does not exist yet. The type exists so the trait signature is **locked** +/// now: step 6 will flesh `SpendToken` out into a real carrier (minted by the +/// spend gate, carrying the per-call budget) without changing +/// `CallControlClient::originate`'s signature — every existing call site stays +/// valid; only the `None` → `Some(token)` migration is incremental. +/// +/// A unit struct (not a zero-field tuple struct) purely for construction +/// ergonomics: `SpendToken` (no parens) is how the spend gate will build it. +pub struct SpendToken; + +/// Twilio account credentials + the operator's media-ingress topology. +/// +/// Lives **only** in `rutster-trunk` (ADR-0009 — provider credentials never +/// reach the brain). `pub` to the binary crate so `config::twilio_credentials` +/// can construct it; never re-exported through the workspace. +/// +/// `Debug` is **hand-implemented** below to redact `auth_token` — deriving +/// `Debug` would print the secret to any `tracing::debug!` / `{:?}` / panic +/// backtrace line. The redaction is a security-constitutive choice, not a +/// convenience. +/// +/// The `twilio-live` feature gates the live client (`TwilioCallControlClient`, +/// T6) that consumes this struct. The struct itself is unconditionally +/// compiled because `config.rs`'s env parser returns `Option` +/// regardless of feature flags — the binary holds the parsed config even when +/// the live REST client is compiled out of the CI default build. +#[derive(Clone)] +pub struct TwilioCredentials { + /// Twilio account SID (the `AC...` identifier). Not a secret per se (it + /// appears in URLs), but treated as sensitive for defense-in-depth. + pub account_sid: String, + /// Twilio auth token — **NEVER logged** (see the hand-impl `Debug` below), + /// never passed to the brain, never placed in a tracing field. The live + /// client uses it for HTTP basic auth on the REST API only. + pub auth_token: String, + /// The local socket address where rutster's `TwilioMediaStreamsServer` + /// (T3) binds to accept inbound Twilio Media Streams WSS connections. + /// Twilio is told to fork audio here (via the TwiML `` directive). + pub media_streams_bind: SocketAddr, + /// The operator's public base URL — the address Twilio calls back via the + /// webhook (`POST /v1/trunk/webhook`) for inbound-call signaling. In local + /// dev this is `http://localhost:8080`; production is a public HTTPS URL. + pub webhook_base: url::Url, +} + +impl std::fmt::Debug for TwilioCredentials { + /// Hand-implemented so `auth_token` renders as ``. + /// + /// Do NOT replace with `#[derive(Debug)]` — that would print the secret + /// token in any `{:?}` formatting (panic backtraces, `tracing::debug!`, + /// test-failure diffs). The other fields are operator-configured and + /// safe to log. This mirrors `api_key.rs`'s redacted-Debug pattern + /// (slice-3) — the codebase's standing posture on secrets. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TwilioCredentials") + .field("account_sid", &self.account_sid) + .field("auth_token", &"") + .field("media_streams_bind", &self.media_streams_bind) + .field("webhook_base", &self.webhook_base) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn twilio_credentials_debug_redacts_auth_token() { + let creds = TwilioCredentials { + account_sid: "AC_test_sid".into(), + auth_token: "super-secret-token-do-not-log".into(), + media_streams_bind: "0.0.0.0:8081".parse().unwrap(), + webhook_base: "https://example.com".parse().unwrap(), + }; + let rendered = format!("{:?}", creds); + assert!( + !rendered.contains("super-secret-token-do-not-log"), + "auth_token leaked into Debug output: {rendered}" + ); + assert!( + rendered.contains(""), + "Debug output should mark auth_token as : {rendered}" + ); + // Non-secret fields remain visible for operator log-correlation. + assert!(rendered.contains("AC_test_sid")); + assert!(rendered.contains("0.0.0.0:8081")); + } + + #[test] + fn call_control_error_displays_and_contains_message() { + let err = CallControlError("boom".into()); + assert_eq!(format!("{err}"), "call-control error: boom"); + // std::error::Error bounds check (compiles only if the impl is present). + let _: &dyn std::error::Error = &err; + } + + #[test] + fn spend_token_is_unit_constructable_for_future_step6_use() { + // This slice passes None; the construction here proves the type is + // usable as Some(SpendToken) once step 6 mints real tokens. + let _token = SpendToken; + let none: Option = None; + assert!(none.is_none()); + } +}