Skip to main content

phantom_protocol/api/
session.rs

1//! Client-First Transport Session
2//!
3//! `PhantomSession` is the user-facing client session: `connect_with_transport`
4//! returns instantly and spawns a background task that drives the hybrid
5//! post-quantum handshake and then the data pump, with `send()` calls queued
6//! in-memory until the handshake completes. It is the transport-level API that
7//! sits directly above a `SessionTransport` byte-pipe (PhantomUDP / TCP /
8//! WebSocket / WASI / Embedded / MimicTls) and below any application protocol an
9//! embedder layers on top of `send()` / `recv()`.
10//!
11//! This file also carries the shared client/server data pump (`run_data_pump`)
12//! and every per-packet build/parse helper (`send_app_data`, `handle_packet`,
13//! the keep-alive / cover / window-update / path-validation senders), so any
14//! change to encrypt/decrypt, framing, or stream routing happens here, in one
15//! place, for both sides.
16
17use crate::crypto::hybrid_sign::HybridVerifyingKey;
18use crate::errors::CoreError;
19use crate::observability::attrs::{AeadAlgorithm, ReplayReason};
20use crate::observability::{Observability, ObservabilityConfig};
21use crate::runtime::{Runtime, TokioRuntime};
22use crate::transport::handshake::{HandshakeClient, ServerReject, ServerReply, EARLY_DATA_MAX_LEN};
23use crate::transport::multiplexer::StreamDemultiplexer;
24use crate::transport::packet_coalescer_codec::unwrap_coalesced_packet;
25use crate::transport::path_validation_codec::build_path_validation_packet;
26use crate::transport::session::{Session, SessionState};
27use crate::transport::shaping::{self, PaddingPolicy};
28use crate::transport::stream::Stream;
29use crate::transport::types::{
30    LegType, PacketFlags, PacketHeader, PhantomPacket, SessionId, StreamId as TransportStreamId,
31    WIRE_VERSION,
32};
33use bytes::Bytes;
34use dashmap::DashMap;
35use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
36use std::sync::Arc;
37use tokio::sync::{mpsc, oneshot, Mutex};
38
39/// Generate a fresh 128-bit session identifier from the thread-local CSPRNG.
40///
41/// Replaces the historical `rand::random::<u32>()` (32 bits, insufficient to
42/// avoid birthday collisions at scale and not advertised as cryptographic).
43/// `rand::thread_rng` is seeded from the OS at thread startup and uses a
44/// modern stream cipher (ChaCha) — adequate for non-secret identifiers.
45fn new_session_id() -> String {
46    let bytes: [u8; 16] = rand::random();
47    format!("phantom-{}", hex::encode(bytes))
48}
49
50// ─── Connection State ───────────────────────────────────────────────────────
51
52/// Connection state for `PhantomSession`.
53///
54/// The session is usable from the moment it's created — sends are queued
55/// until the handshake completes.
56#[cfg_attr(feature = "bindings", derive(uniffi::Enum))]
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[repr(u8)]
59#[non_exhaustive]
60pub enum ConnectionState {
61    /// Connection initiated, handshake pending
62    Connecting = 0,
63    /// Classical (X25519) channel established — data flows
64    ClassicalReady = 1,
65    /// PQC upgrade in progress
66    PqcUpgrading = 2,
67    /// Full hybrid PQC protection active
68    PqcReady = 3,
69    /// Fully connected and operational
70    Connected = 4,
71    /// Connection failed
72    Failed = 5,
73    /// Gracefully closed
74    Closed = 6,
75    /// The active path went silent (liveness lost); the session is held alive
76    /// (keys retained, outbound buffered) awaiting a `migrate()` or the path's
77    /// return. The embedder reacts by calling `migrate()` (Phase 4 / P4.3).
78    Migrating = 7,
79    /// The session is dead: the path stayed down past the migration idle-timeout
80    /// with no recovery. Terminal — `recv()` errors instead of hanging (P4.3).
81    Dead = 8,
82}
83
84/// Anti-fingerprint traffic-shaping configuration (WIRE v6, direction #4). Set on
85/// an established session via [`PhantomSession::set_traffic_shaping`]. **All
86/// shaping is opt-in** — the default (and the field defaults here) is no shaping,
87/// so a session pays nothing unless an embedder enables it.
88///
89/// Currently carries the size-padding policy (deliverable (c)); the timing-jitter
90/// (d) and cover-traffic (e) knobs will be added as further fields in later
91/// phases. Padding hides the datagram *size*; it costs bounded (≈ ≤12% worst-case)
92/// extra bandwidth.
93#[cfg_attr(feature = "bindings", derive(uniffi::Record))]
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub struct TrafficShapingConfig {
96    /// Size-padding policy. [`PaddingPolicy::None`] (default) = no padding;
97    /// [`PaddingPolicy::Padme`] = pad each packet up to a PADÉ bucket.
98    pub padding: PaddingPolicy,
99    /// Send-timing jitter ceiling in milliseconds (deliverable (d)). `0` (default)
100    /// = no jitter; otherwise each packet waits a uniform random `[0, jitter_ms]`
101    /// ms before it is sent, so the inter-packet timing no longer tracks the
102    /// application's writes — at a cost of up to `jitter_ms` of added latency.
103    pub jitter_ms: u32,
104    /// Cover-traffic floor interval in milliseconds (deliverable (e)). `0`
105    /// (default) = no cover traffic; otherwise the session maintains a minimum
106    /// outbound packet rate of `1000 / cover_interval_ms` packets/sec, emitting an
107    /// encrypted dummy (`COVER`) packet whenever no packet has gone out for
108    /// `cover_interval_ms` — hiding idle/active patterns and volume, at a steady
109    /// bandwidth cost. A typical value is 100–500 ms (10–2 packets/sec).
110    pub cover_interval_ms: u32,
111}
112
113/// Apply a [`TrafficShapingConfig`] to an established [`Session`] (#9). Shared by
114/// the immediate (`set_traffic_shaping` on a live session) and the deferred
115/// (background-task, at session install) paths.
116fn apply_shaping(session: &Session, cfg: TrafficShapingConfig) {
117    session.set_padding_policy(cfg.padding);
118    session.set_jitter_ms(cfg.jitter_ms);
119    session.set_cover_interval_ms(cfg.cover_interval_ms);
120}
121
122impl ConnectionState {
123    fn from_u8(v: u8) -> Self {
124        match v {
125            0 => Self::Connecting,
126            1 => Self::ClassicalReady,
127            2 => Self::PqcUpgrading,
128            3 => Self::PqcReady,
129            4 => Self::Connected,
130            5 => Self::Failed,
131            6 => Self::Closed,
132            7 => Self::Migrating,
133            8 => Self::Dead,
134            _ => Self::Failed,
135        }
136    }
137
138    /// Whether data can flow (classical or better). `Migrating` counts as ready:
139    /// the keep-alive window still accepts `send()` (buffered + retransmitted until
140    /// the path recovers), so the embedder's send path doesn't error mid-migration.
141    pub fn is_data_ready(&self) -> bool {
142        matches!(
143            self,
144            Self::ClassicalReady
145                | Self::PqcUpgrading
146                | Self::PqcReady
147                | Self::Connected
148                | Self::Migrating
149        )
150    }
151}
152
153// ─── Resumption Hint ────────────────────────────────────────────────────────
154
155/// 0-RTT resumption material extracted from a completed session.
156///
157/// Produced by [`PhantomSession::resumption_hint`] after a handshake
158/// completes, and fed back into [`connect_pinned_with_resumption`] to
159/// attempt a 0-RTT reconnect to the same server.
160///
161/// Both fields are exactly 32 bytes — this record is the
162/// UniFFI-representable surface for the internal `(session_id,
163/// resumption_secret)` tuple. The fields are `Vec<u8>` because UniFFI
164/// has no fixed-size-array type, so the length is a runtime invariant
165/// checked when the hint is used.
166///
167/// Store the hint alongside the pinned `HybridVerifyingKey` of the
168/// server it was negotiated against: the `resumption_secret` is
169/// server-pinned, and reusing a hint across servers is a configuration
170/// bug.
171#[cfg_attr(feature = "bindings", derive(uniffi::Record))]
172#[derive(Clone)]
173#[non_exhaustive]
174pub struct ResumptionHint {
175    /// The negotiated session id (32 bytes).
176    pub session_id: Vec<u8>,
177    /// The resumption secret (32 bytes) — sensitive; treat like a key.
178    pub resumption_secret: Vec<u8>,
179}
180
181// INFOLEAK-1: hand-written redacting `Debug` (not derived) so a mobile/FFI
182// consumer that logs the hint with `{:?}` cannot leak the 0-RTT `resumption_secret`
183// — the one secret-bearing type that crosses the FFI boundary. Mirrors the
184// REDACTED `Debug` on `HybridSigningKey` / `HybridSecretKey`.
185impl std::fmt::Debug for ResumptionHint {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        f.debug_struct("ResumptionHint")
188            .field(
189                "session_id",
190                &format_args!("<{} bytes>", self.session_id.len()),
191            )
192            .field("resumption_secret", &"REDACTED")
193            .finish()
194    }
195}
196
197// ─── Transport Abstraction ──────────────────────────────────────────────────
198
199// `SessionTransport` now lives in `crate::transport::session_transport` — a
200// dependency-light module that can compile in a `no_std + alloc` build. It is
201// re-exported here so `crate::api::session::SessionTransport` and the public
202// `phantom_protocol::api::SessionTransport` path stay stable.
203pub use crate::transport::session_transport::{FramePhase, SessionTransport};
204
205/// Transport decorator that records `record_send` / `record_recv` on the
206/// session's [`Observability`] for every frame that crosses the wire — so the
207/// data-plane packet/byte counters reflect a real run without threading the
208/// handle through every send site. Wraps the concrete `SessionTransport` just
209/// before the data pump takes over, so handshake bytes are not counted as
210/// data-plane packets (they have their own handshake metric).
211struct ObservedTransport<T> {
212    inner: T,
213    observability: Arc<Observability>,
214    leg: LegType,
215}
216
217impl<T> ObservedTransport<T> {
218    fn new(inner: T, observability: Arc<Observability>, leg: LegType) -> Self {
219        Self {
220            inner,
221            observability,
222            leg,
223        }
224    }
225}
226
227impl<T: SessionTransport> SessionTransport for ObservedTransport<T> {
228    async fn send_bytes(&self, data: &[u8]) -> Result<(), CoreError> {
229        let result = self.inner.send_bytes(data).await;
230        if result.is_ok() {
231            self.observability.record_send(data.len(), self.leg);
232        }
233        result
234    }
235
236    async fn recv_bytes(&self) -> Result<Bytes, CoreError> {
237        let result = self.inner.recv_bytes().await;
238        if let Ok(ref bytes) = result {
239            self.observability.record_recv(bytes.len(), self.leg);
240        }
241        result
242    }
243
244    // ── Transparent forwarding of the non-I/O trait surface ───────────────────
245    //
246    // ObservedTransport wraps the concrete transport for the whole data pump, so
247    // every control method the pump calls on it (phase, CID stamping, migration)
248    // MUST reach the inner transport — otherwise they silently hit the trait's
249    // defaults (no-op / `false`) and the feature is dead through the pump. (The
250    // pre-ε code only forwarded send/recv, so the FFI `migrate()` and the
251    // server-side migration detection were no-ops once wrapped; ε needs them live
252    // to rotate the CID on migration, so the wrapper is made fully transparent.)
253    fn set_frame_phase(&self, phase: FramePhase) {
254        self.inner.set_frame_phase(phase);
255    }
256
257    fn set_outbound_cid(&self, cid: [u8; 8]) {
258        self.inner.set_outbound_cid(cid);
259    }
260
261    fn has_migration_candidate(&self) -> bool {
262        self.inner.has_migration_candidate()
263    }
264
265    fn send_to_candidate(
266        &self,
267        data: &[u8],
268    ) -> impl core::future::Future<Output = Result<bool, CoreError>> + Send {
269        self.inner.send_to_candidate(data)
270    }
271
272    fn confirm_authenticated_source(&self) {
273        self.inner.confirm_authenticated_source();
274    }
275
276    fn promote_candidate(&self) -> bool {
277        self.inner.promote_candidate()
278    }
279
280    fn migrate(
281        &self,
282        local_addr: String,
283    ) -> impl core::future::Future<Output = Result<(), CoreError>> + Send {
284        self.inner.migrate(local_addr)
285    }
286
287    fn migrate_server(
288        &self,
289        local_addr: String,
290    ) -> impl core::future::Future<Output = Result<(), CoreError>> + Send {
291        self.inner.migrate_server(local_addr)
292    }
293}
294
295// ─── Session ────────────────────────────────────────────────────────────────
296
297/// Client-first session — instant construction, non-blocking `send()`.
298///
299/// # Design
300///
301/// The real entry point is `connect_with_transport` (NOT the inert legacy
302/// `connect()` constructor — see its doc): it returns instantly and runs the
303/// handshake + data pump in the background, so sends issued before the handshake
304/// finishes are buffered and auto-flushed once the channel is up.
305///
306/// ```text
307///   // instant — spawns the background handshake + pump:
308///   let session = PhantomSession::connect_with_transport(addr, transport, pinned_key);
309///   session.send(data).await;   // queued until handshake completes
310///   session.send(data2).await;  // also queued
311///   // ... handshake completes in background ...
312///   // queued data auto-flushed, new sends go directly
313/// ```
314///
315/// The session progresses through states:
316/// `Connecting → ClassicalReady → PqcUpgrading → PqcReady → Connected`
317#[cfg_attr(feature = "bindings", derive(uniffi::Object))]
318pub struct PhantomSession {
319    /// Session identifier
320    id: String,
321    /// Target server address
322    peer_addr: String,
323    /// Connection state (atomic for lock-free reads)
324    state: Arc<AtomicU8>,
325    /// Queued messages before connection is ready
326    send_queue: Arc<Mutex<Vec<Vec<u8>>>>,
327    /// Channel to send commands to the background handshake task
328    cmd_tx: mpsc::Sender<SessionCommand>,
329    /// Command receiver — taken by the background task when spawned
330    #[allow(dead_code)]
331    cmd_rx: Mutex<Option<mpsc::Receiver<SessionCommand>>>,
332    /// Received messages channel. Carries `Bytes` (not `Vec<u8>`) so the recv
333    /// path can fan out via cheap refcount clones to both the stream demux
334    /// and the synchronous `recv()` consumer without deep-copying the payload.
335    recv_rx: Mutex<mpsc::Receiver<Bytes>>,
336    /// Multiplexes incoming packets to independent streams
337    demux: Arc<StreamDemultiplexer>,
338    /// Active outgoing streams (ARQ management)
339    streams: Arc<DashMap<u32, Arc<Stream>>>,
340    /// Negotiated session handle, populated by the background task
341    /// once the handshake completes. Exposed via `resumption_hint`
342    /// for Phase 4.1 0-RTT clients. `None` while still handshaking
343    /// or after a failure.
344    inner_session: Arc<Mutex<Option<Arc<Session>>>>,
345    /// 0-RTT verdict. `None` while handshaking, after a failure, or when the
346    /// client sent no early-data on this connect. `Some(true)` — the server
347    /// consumed the early-data; `Some(false)` — the client sent early-data and
348    /// the server rejected it. Exposed via `early_data_accepted()`.
349    early_data_accepted: Arc<Mutex<Option<bool>>>,
350    /// Anti-fingerprint traffic-shaping config (#9). Set via `set_traffic_shaping`
351    /// at any time — **including before the (async) client handshake completes** —
352    /// and applied to the negotiated `Session` the moment it is installed by the
353    /// background task, so the very first data packets are already shaped. A
354    /// `parking_lot::Mutex` (no poison, never held across an `.await`). Default:
355    /// no shaping.
356    shaping: Arc<parking_lot::Mutex<TrafficShapingConfig>>,
357    /// Session observability handle. Server-accepted sessions share the
358    /// `PhantomListener`'s instance (so its `snapshot()` aggregates every
359    /// session it accepted); client sessions get their own. The data pump
360    /// records send/recv, the security drops, and the session lifecycle
361    /// (open/close) against it. A ZST no-op when `telemetry-otel` is off.
362    observability: Arc<Observability>,
363}
364
365/// Commands for the background session task
366pub enum SessionCommand {
367    /// Queue data for sending
368    Send(Vec<u8>),
369    /// Send data on a specific stream reliably
370    SendStreamReliable { stream_id: u32, data: bytes::Bytes },
371    /// Send data on a specific stream unreliably
372    SendStreamUnreliable { stream_id: u32, data: bytes::Bytes },
373    /// Close a specific stream
374    CloseStream { stream_id: u32 },
375    /// Migrate to a new local address (Phase 4 / P4.2 — embedder-triggered). Carries
376    /// the new local bind address as a `String`; the pump rebinds the transport and
377    /// bumps the send `path_id` (best-effort, never fatal to the session).
378    Migrate(String),
379    /// Migrate the SERVER's send path to a new local address (Rust-only, the server-side
380    /// mirror of [`Migrate`](Self::Migrate)). Carries the new local bind address as a
381    /// `String`; the pump rebinds the server's send socket (its receive keeps flowing on
382    /// the old address via the listener demux during the overlap) and rotates the s2c send
383    /// `path_id` + outbound CID in lock-step, so the client sees — and follows — a fresh
384    /// server source with a fresh, unlinkable ConnId. Best-effort, never fatal.
385    MigrateServer(String),
386    /// Close the session
387    Close,
388}
389
390impl PhantomSession {
391    /// Create a new session and start the background handshake task.
392    ///
393    /// Requires `expected_server_key` for MITM resistance — the client will
394    /// abort the handshake unless the server presents this exact verifying key.
395    /// Callers obtain this key out-of-band (e.g. from `PhantomListener::verifying_key_bytes`).
396    ///
397    /// The handshake runs in the background:
398    /// 1. Exchange hybrid PQC `ClientHello`/`ServerHello`.
399    /// 2. Verify server identity against `expected_server_key`.
400    /// 3. Derive AEAD keys; flush queued sends as encrypted packets.
401    ///
402    /// All network I/O goes through the provided `SessionTransport`. The
403    /// task that drives the handshake + data pump runs on the default
404    /// [`TokioRuntime`]; use
405    /// [`connect_with_transport_with_runtime`](Self::connect_with_transport_with_runtime)
406    /// to substitute a different `Runtime`.
407    pub fn connect_with_transport<T: SessionTransport>(
408        peer_addr: &str,
409        transport: T,
410        expected_server_key: HybridVerifyingKey,
411    ) -> Self {
412        Self::connect_with_transport_with_runtime(
413            peer_addr,
414            transport,
415            expected_server_key,
416            Arc::new(TokioRuntime),
417        )
418    }
419
420    /// Like [`connect_with_transport`](Self::connect_with_transport) but
421    /// runs the background task on the supplied `Runtime`. Intended for
422    /// WASM / embedded / test backends that don't drive `tokio::spawn`.
423    pub fn connect_with_transport_with_runtime<T: SessionTransport>(
424        peer_addr: &str,
425        transport: T,
426        expected_server_key: HybridVerifyingKey,
427        runtime: Arc<dyn Runtime>,
428    ) -> Self {
429        Self::spawn_client(peer_addr, transport, expected_server_key, runtime, None)
430    }
431
432    /// Connect with a **0-RTT resumption attempt**.
433    ///
434    /// `resumption_hint` is the `(session_id, resumption_secret)` tuple
435    /// from a prior session's [`PhantomSession::resumption_hint`].
436    /// `early_data` (≤ [`EARLY_DATA_MAX_LEN`] bytes) is sealed and carried
437    /// inside the resuming ClientHello so it reaches the server on the very
438    /// first flight — saving a round-trip versus 1-RTT.
439    ///
440    /// Acceptance is best-effort: a stale/unknown ticket or an AEAD failure
441    /// leaves [`early_data_accepted`](Self::early_data_accepted) at
442    /// `Some(false)` and the handshake completes as a normal 1-RTT exchange —
443    /// the caller must then send that payload over the normal channel.
444    /// Returns `Err` only when `early_data` exceeds the cap.
445    ///
446    /// Runs on the default [`TokioRuntime`].
447    pub fn connect_with_resumption<T: SessionTransport>(
448        peer_addr: &str,
449        transport: T,
450        expected_server_key: HybridVerifyingKey,
451        resumption_hint: ([u8; 32], [u8; 32]),
452        early_data: Vec<u8>,
453    ) -> Result<Self, CoreError> {
454        // fips bootstrap POST gate. `connect_with_resumption`
455        // returns `Result`, so unlike the infallible `connect_with_transport*`
456        // entry points we can surface the POST failure directly to the
457        // caller (mirrors the `PhantomListener::bind*` and
458        // `connect_pinned*` convention). The same POST is also checked
459        // in `background_task` as a defense-in-depth backstop.
460        #[cfg(feature = "fips")]
461        crate::crypto::self_tests::ensure_post_passed()
462            .map_err(|e| CoreError::FipsSelfTestFailure(format!("{e:?}")))?;
463
464        if early_data.len() > EARLY_DATA_MAX_LEN {
465            return Err(CoreError::ValidationError(format!(
466                "early_data is {} bytes, exceeds the {}-byte 0-RTT cap",
467                early_data.len(),
468                EARLY_DATA_MAX_LEN
469            )));
470        }
471        let (resume_id, resume_secret) = resumption_hint;
472        Ok(Self::spawn_client(
473            peer_addr,
474            transport,
475            expected_server_key,
476            Arc::new(TokioRuntime),
477            Some((resume_id, resume_secret, early_data)),
478        ))
479    }
480
481    /// Shared constructor body for [`connect_with_transport_with_runtime`]
482    /// and [`connect_with_resumption`]. `resumption_request` is `None`
483    /// for a plain handshake, `Some((id, secret, early_data))` to attempt a
484    /// 0-RTT resumption.
485    fn spawn_client<T: SessionTransport>(
486        peer_addr: &str,
487        transport: T,
488        expected_server_key: HybridVerifyingKey,
489        runtime: Arc<dyn Runtime>,
490        resumption_request: Option<([u8; 32], [u8; 32], Vec<u8>)>,
491    ) -> Self {
492        let (cmd_tx, cmd_rx) = mpsc::channel(256);
493        let (recv_tx, recv_rx) = mpsc::channel(256);
494
495        let state = Arc::new(AtomicU8::new(ConnectionState::Connecting as u8));
496        let send_queue = Arc::new(Mutex::new(Vec::new()));
497        let peer = peer_addr.to_string();
498        let (demux, _ctrl_rx) = StreamDemultiplexer::new(256);
499        let demux = Arc::new(demux);
500
501        let streams = Arc::new(DashMap::new());
502        let inner_session: Arc<Mutex<Option<Arc<Session>>>> = Arc::new(Mutex::new(None));
503        let early_data_accepted: Arc<Mutex<Option<bool>>> = Arc::new(Mutex::new(None));
504        // #9 — shared pending traffic-shaping config, applied at session install.
505        let shaping = Arc::new(parking_lot::Mutex::new(TrafficShapingConfig::default()));
506        // Client sessions have no listener, so they own their observability
507        // instance (its `snapshot()` reflects just this connection).
508        let observability = Observability::new(ObservabilityConfig::default());
509
510        let session = Self {
511            id: new_session_id(),
512            peer_addr: peer.clone(),
513            state: state.clone(),
514            send_queue: send_queue.clone(),
515            cmd_tx: cmd_tx.clone(),
516            cmd_rx: Mutex::new(None), // taken by background task
517            recv_rx: Mutex::new(recv_rx),
518            demux: demux.clone(),
519            streams: streams.clone(),
520            inner_session: inner_session.clone(),
521            early_data_accepted: early_data_accepted.clone(),
522            shaping: shaping.clone(),
523            observability: observability.clone(),
524        };
525
526        // Spawn the background handshake + data pump task on the supplied
527        // runtime. `SpawnHandle` is detached: dropping it leaves the task
528        // running. The session is owned by the caller for its lifetime
529        // and natural shutdown comes via `SessionCommand::Close`.
530        let runtime_for_pump = runtime.clone();
531        let _detached = runtime.spawn(Box::pin(Self::background_task(
532            state,
533            send_queue,
534            cmd_tx,
535            cmd_rx,
536            recv_tx,
537            transport,
538            peer,
539            demux,
540            streams,
541            expected_server_key,
542            runtime_for_pump,
543            inner_session,
544            early_data_accepted,
545            shaping,
546            resumption_request,
547            observability,
548        )));
549
550        session
551    }
552
553    /// Install a server-side `Session` (already derived by `HandshakeServer::process_client_hello`)
554    /// and spawn the data pump on the default [`TokioRuntime`]. Used by
555    /// `PhantomListener::accept` after driving the server handshake.
556    ///
557    /// `PhantomListener::accept` itself now uses
558    /// `from_accepted_server_session_with_runtime` so the listener's
559    /// runtime is honored. This wrapper is preserved for callers that
560    /// do not have a runtime handle and want the default `TokioRuntime`.
561    #[allow(dead_code)]
562    pub(crate) fn from_accepted_server_session<T: SessionTransport>(
563        peer_addr: String,
564        transport: T,
565        server_session: Arc<Session>,
566    ) -> Arc<Self> {
567        Self::from_accepted_server_session_with_runtime(
568            peer_addr,
569            transport,
570            server_session,
571            Arc::new(TokioRuntime),
572            Observability::new(ObservabilityConfig::default()),
573            LegType::Tcp,
574        )
575    }
576
577    /// Runtime-aware variant of [`from_accepted_server_session`].
578    pub(crate) fn from_accepted_server_session_with_runtime<T: SessionTransport>(
579        peer_addr: String,
580        transport: T,
581        server_session: Arc<Session>,
582        runtime: Arc<dyn Runtime>,
583        observability: Arc<Observability>,
584        leg: LegType,
585    ) -> Arc<Self> {
586        let (cmd_tx, cmd_rx) = mpsc::channel(256);
587        let (recv_tx, recv_rx) = mpsc::channel(256);
588
589        let state = Arc::new(AtomicU8::new(ConnectionState::Connected as u8));
590        let send_queue = Arc::new(Mutex::new(Vec::new()));
591        let (demux, _ctrl_rx) = StreamDemultiplexer::new(256);
592        let demux = Arc::new(demux);
593        let streams = Arc::new(DashMap::new());
594
595        let inner_session: Arc<Mutex<Option<Arc<Session>>>> =
596            Arc::new(Mutex::new(Some(server_session.clone())));
597
598        let session = Arc::new(Self {
599            id: new_session_id(),
600            peer_addr: peer_addr.clone(),
601            state: state.clone(),
602            send_queue: send_queue.clone(),
603            cmd_tx,
604            cmd_rx: Mutex::new(None),
605            recv_rx: Mutex::new(recv_rx),
606            demux: demux.clone(),
607            streams: streams.clone(),
608            inner_session,
609            // Server side: 0-RTT early-data is delivered via
610            // `AcceptOutcome`, not this client-facing field.
611            early_data_accepted: Arc::new(Mutex::new(None)),
612            // Server side: the session is already established here, so
613            // `set_traffic_shaping` applies immediately; default = no shaping.
614            shaping: Arc::new(parking_lot::Mutex::new(TrafficShapingConfig::default())),
615            // Shares the listener's instance so its `snapshot()` aggregates
616            // every accepted session.
617            observability: observability.clone(),
618        });
619
620        let session_id = *server_session.id();
621        let runtime_for_pump = runtime.clone();
622        // WIRE-001: the server handshake is complete — raise the receive frame
623        // cap from the tight unauthenticated handshake limit to the steady-state
624        // application limit before the data pump takes over.
625        transport.set_frame_phase(FramePhase::Established);
626        // ε / WIRE v5: switch the transport off the bootstrap ConnId onto this
627        // session's rotating CID_0 (the c2s chain the client routes on; the
628        // demux registers the matching inbound window). The server→client
629        // direction rotates too, so neither flow keeps a stable cleartext id.
630        transport.set_outbound_cid(server_session.current_outbound_cid());
631        let observed = Arc::new(ObservedTransport::new(
632            transport,
633            observability.clone(),
634            leg,
635        ));
636        let _detached = runtime.spawn(Box::pin(run_data_pump(
637            server_session,
638            session_id,
639            observed,
640            state,
641            send_queue,
642            cmd_rx,
643            recv_tx,
644            demux,
645            streams,
646            runtime_for_pump,
647            observability,
648            leg,
649        )));
650
651        session
652    }
653
654    /// Background task: performs handshake, then pumps data.
655    #[allow(clippy::too_many_arguments)]
656    async fn background_task<T: SessionTransport>(
657        state: Arc<AtomicU8>,
658        send_queue: Arc<Mutex<Vec<Vec<u8>>>>,
659        _cmd_tx: mpsc::Sender<SessionCommand>,
660        cmd_rx: mpsc::Receiver<SessionCommand>,
661        recv_tx: mpsc::Sender<Bytes>,
662        transport: T,
663        peer: String,
664        demux: Arc<StreamDemultiplexer>,
665        streams: Arc<DashMap<u32, Arc<Stream>>>,
666        expected_server_key: HybridVerifyingKey,
667        runtime: Arc<dyn Runtime>,
668        inner_session: Arc<Mutex<Option<Arc<Session>>>>,
669        early_data_accepted: Arc<Mutex<Option<bool>>>,
670        shaping: Arc<parking_lot::Mutex<TrafficShapingConfig>>,
671        resumption_request: Option<([u8; 32], [u8; 32], Vec<u8>)>,
672        observability: Arc<Observability>,
673    ) {
674        // DEBUG: the peer address is correlatable; keep it off default logs.
675        log::debug!("PhantomSession: starting handshake with {}", peer);
676
677        // fips bootstrap POST gate, mirroring the listener and
678        // `connect_pinned*` paths: the synchronous Rust-only entry
679        // points (`connect_with_transport*` / `connect_with_resumption`)
680        // also need to honor FIPS 140-3 §7.7 before any cryptographic
681        // work. Cached `OnceLock` makes the second+ call an atomic
682        // read; the first call runs the full POST battery.
683        //
684        // On failure we cannot return a `CoreError` (the entry points
685        // are infallible by API contract) — instead we transition the
686        // state machine to `Failed` and bail, matching the existing
687        // handshake-failure shape. The error string lands in the log.
688        #[cfg(feature = "fips")]
689        if let Err(e) = crate::crypto::self_tests::ensure_post_passed() {
690            log::error!(
691                "PhantomSession: FIPS POST self-test failed; refusing to handshake: {:?}",
692                e
693            );
694            state.store(ConnectionState::Failed as u8, Ordering::Relaxed);
695            return;
696        }
697
698        // Retain a copy of any 0-RTT early-data so it can be losslessly
699        // re-sent over the established session if the server rejects it (C3 —
700        // the rejection-retransmission contract). `run_client_handshake`
701        // consumes `resumption_request`, so clone the blob first.
702        let pending_early_data: Option<Vec<u8>> = resumption_request
703            .as_ref()
704            .and_then(|(_, _, ed)| (!ed.is_empty()).then(|| ed.clone()));
705
706        // ── Stage 1 & 2: Hybrid Handshake (optionally 0-RTT resumption) ──
707        // HS-02: bound the whole client handshake by a wall-clock deadline so a
708        // silent or stalling server can't hang the connect indefinitely. The
709        // TIMER is `runtime.sleep` (NOT raw tokio::time) so it stays correct
710        // under WasmRuntime/EmbeddedRuntime; `select!` is just the combinator.
711        const CLIENT_HANDSHAKE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(10);
712        // Scoped so the handshake future's borrow of `transport` ends before
713        // `transport` is moved into the data pump below.
714        let handshake_result = {
715            let handshake_fut =
716                run_client_handshake(&transport, &expected_server_key, resumption_request);
717            let handshake_timeout = runtime.sleep(CLIENT_HANDSHAKE_DEADLINE);
718            tokio::pin!(handshake_fut);
719            tokio::select! {
720                r = &mut handshake_fut => r,
721                _ = handshake_timeout => Err(CoreError::Timeout),
722            }
723        };
724        let (crypto_session, ed_accepted) = match handshake_result {
725            Ok((session, accepted)) => (Arc::new(session), accepted),
726            Err(e) => {
727                log::error!("PhantomSession: handshake failed: {}", e);
728                state.store(ConnectionState::Failed as u8, Ordering::Relaxed);
729                return;
730            }
731        };
732        log::info!("PhantomSession: Handshake complete — hybrid channel ready");
733
734        // Phase 4.1 — publish the negotiated Session + the 0-RTT
735        // verdict via the outer PhantomSession so `resumption_hint()`
736        // and `early_data_accepted()` can reach them after the
737        // background task moves the Arc into the pump.
738        {
739            let mut guard = inner_session.lock().await;
740            *guard = Some(crypto_session.clone());
741        }
742        // #9 — apply any traffic-shaping config the embedder set BEFORE the
743        // handshake completed (connect is async), so the very first data packets
744        // are already shaped rather than only after a manual post-establishment
745        // `set_traffic_shaping`. A later `set_traffic_shaping` re-applies live.
746        apply_shaping(&crypto_session, *shaping.lock());
747        *early_data_accepted.lock().await = ed_accepted;
748
749        // C3 — 0-RTT rejection retransmission contract. If we sent early-data
750        // and the server rejected it (`Some(false)`), it never reached the
751        // application layer, so re-send it losslessly over the now-established
752        // 1-RTT session. Prepend it to the pre-handshake send queue (drained
753        // first by the pump onto the reliable raw-app stream) so it lands
754        // *ahead* of anything the app queued while connecting — preserving the
755        // order in which the bytes were originally offered. `Some(true)` (the
756        // server consumed it) and `None` (none sent) need no action.
757        if ed_accepted == Some(false) {
758            if let Some(ed) = pending_early_data {
759                send_queue.lock().await.insert(0, ed);
760                log::debug!(
761                    "PhantomSession: 0-RTT early-data rejected; re-queued for 1-RTT delivery"
762                );
763            }
764        }
765
766        let session_id = *crypto_session.id();
767        state.store(ConnectionState::Connected as u8, Ordering::Relaxed);
768        log::debug!("PhantomSession: fully connected to {}", peer);
769
770        // Wrap the (post-handshake) transport so every data-plane send/recv is
771        // recorded. The generic client path can ride any `SessionTransport`, but
772        // the observability leg label is not threaded through `connect_with_*`,
773        // so it is fixed to TCP here (the only metric this skews is the per-leg
774        // packet/byte slice; the totals are correct).
775        // WIRE-001: the handshake is done — raise the frame cap from the tight
776        // unauthenticated handshake limit to the steady-state application limit.
777        transport.set_frame_phase(FramePhase::Established);
778        // ε / WIRE v5: stamp this session's rotating CID_0 on every post-handshake
779        // datagram (the chain the server's demux routes on) instead of the
780        // bootstrap ConnId.
781        transport.set_outbound_cid(crypto_session.current_outbound_cid());
782        let observed = Arc::new(ObservedTransport::new(
783            transport,
784            observability.clone(),
785            LegType::Tcp,
786        ));
787        run_data_pump(
788            crypto_session,
789            session_id,
790            observed,
791            state,
792            send_queue,
793            cmd_rx,
794            recv_tx,
795            demux,
796            streams,
797            runtime,
798            observability,
799            LegType::Tcp,
800        )
801        .await;
802    }
803}
804
805/// Drive the client side of the Phantom Protocol handshake to completion.
806///
807/// When `resumption` is `Some((resume_id, resume_secret, early_data))` the
808/// first-flight `ClientHello` carries the resume id and, when `early_data` is
809/// non-empty, a sealed 0-RTT blob folded into `ClientHello.early_data` — so it
810/// reaches the server on the first flight. A cookie/PoW `HelloRetryRequest` is
811/// answered in-loop, reusing the same hello (the early-data blob rides along).
812///
813/// Returns the established `Session` and the 0-RTT verdict (resolved
814/// decision 1):
815/// - `Some(true)`  — the client sent early-data and the server consumed it
816/// - `Some(false)` — the client sent early-data and the server rejected it
817///   (stale ticket / oversized / AEAD failure)
818/// - `None`        — the client sent no early-data on this connect
819async fn run_client_handshake<T: SessionTransport>(
820    transport: &T,
821    expected_server_key: &HybridVerifyingKey,
822    resumption: Option<([u8; 32], [u8; 32], Vec<u8>)>,
823) -> Result<(Session, Option<bool>), CoreError> {
824    let handshake = HandshakeClient::new()?;
825
826    // Build the first-flight ClientHello. A resumption request folds the
827    // resume id and (optionally) a sealed 0-RTT early-data blob into the
828    // single hello; otherwise it is a plain hello.
829    let mut hello = match &resumption {
830        Some((resume_id, resume_secret, early_data)) => {
831            let ed: Option<&[u8]> = if early_data.is_empty() {
832                None
833            } else {
834                Some(early_data.as_slice())
835            };
836            handshake.create_client_hello_with_resume(*resume_id, resume_secret, ed)
837        }
838        None => handshake.create_client_hello(),
839    };
840
841    // HS-02: cap the number of HelloRetryRequest rounds. The legitimate flow
842    // needs at most one cookie round + one PoW round; a bound of 3 leaves slack
843    // for a benign reorder. Without it, a MITM answering every ClientHello with
844    // a fresh cheap HelloRetryRequest could loop the client forever.
845    const MAX_CLIENT_RETRY_ROUNDS: u32 = 3;
846    // Reviewer §5: bound how many injected/genuine ServerRejects we read past while still
847    // waiting for a ServerHello, so a reject flood can't loop the inner read forever.
848    const MAX_CLIENT_REJECT_ROUNDS: u32 = 3;
849    let mut retry_rounds: u32 = 0;
850    let mut reject_rounds: u32 = 0;
851    // Reviewer §5: an *injected* ServerReject (a tiny pre-crypto blob a network attacker can
852    // spray) must not abort a healthy handshake. Remember it and keep reading for a valid
853    // ServerHello; surface it only if one never arrives (do NOT auto-downgrade — Invariant 7).
854    let mut remembered_reject: Option<ServerReject> = None;
855
856    loop {
857        // (Re)send the current hello (fresh, or cookie/PoW-updated after a HelloRetryRequest).
858        let bytes = borsh::to_vec(&hello).map_err(|e| {
859            CoreError::SerializationError(format!("ClientHello encode failed: {}", e))
860        })?;
861        transport.send_bytes(&bytes).await?;
862
863        // Read responses for THIS hello, reading past an injected ServerReject (WITHOUT
864        // re-sending) until a ServerHello (success), a HelloRetryRequest (re-send with the
865        // cookie/PoW), or the channel ends.
866        loop {
867            let resp = match transport.recv_bytes().await {
868                Ok(r) => r,
869                Err(e) => {
870                    // No further responses: surface a remembered reject (a genuine version
871                    // mismatch) over the raw transport error.
872                    return match &remembered_reject {
873                        Some(r) => Err(CoreError::HandshakeError(format!(
874                            "server rejected the handshake: unsupported protocol version \
875                             (client speaks v{}, server speaks v{})",
876                            hello.version, r.supported_version
877                        ))),
878                        None => Err(e),
879                    };
880                }
881            };
882
883            // T4.4: the reply leads with an explicit discriminant byte
884            // (`[kind] ‖ borsh(body)`); dispatch on it instead of trial-deserializing by
885            // size. An unknown kind / malformed body is a handshake error, not a misparse.
886            match ServerReply::from_wire(&resp) {
887                Ok(ServerReply::Hello(sh)) => {
888                    let (session, accepted) =
889                        handshake.process_server_hello(&hello, &sh, Some(expected_server_key))?;
890                    return Ok((session, accepted));
891                }
892                Ok(ServerReply::Reject(reject)) => {
893                    // The marker is an extra sanity check on top of the discriminant. We do
894                    // NOT auto-downgrade to `reject.supported_version` (Invariant 7).
895                    if reject.has_marker() {
896                        reject_rounds += 1;
897                        if reject_rounds > MAX_CLIENT_REJECT_ROUNDS {
898                            return Err(CoreError::HandshakeError(format!(
899                                "server rejected the handshake: unsupported protocol version \
900                                 (client speaks v{}, server speaks v{})",
901                                hello.version, reject.supported_version
902                            )));
903                        }
904                        // reviewer §5: keep waiting for a valid ServerHello — read the next
905                        // frame WITHOUT re-sending, so a single forged reject can't kill the
906                        // handshake.
907                        remembered_reject = Some(reject);
908                        continue;
909                    }
910                    return Err(CoreError::HandshakeError(
911                        "server reject missing marker".into(),
912                    ));
913                }
914                Ok(ServerReply::Retry(retry)) => {
915                    retry_rounds += 1;
916                    if retry_rounds > MAX_CLIENT_RETRY_ROUNDS {
917                        return Err(CoreError::HandshakeError(format!(
918                            "server demanded more than {MAX_CLIENT_RETRY_ROUNDS} HelloRetryRequest rounds"
919                        )));
920                    }
921                    log::info!("PhantomSession: Received HelloRetryRequest, retrying...");
922                    hello.cookie = retry.cookie;
923                    if let Some(challenge) = retry.challenge {
924                        // H3: cap the accepted difficulty and bound the solver, so an
925                        // injected/malicious HelloRetryRequest (e.g. difficulty 255)
926                        // surfaces a handshake error instead of pinning a CPU core.
927                        log::info!("PhantomSession: Solving PoW challenge...");
928                        hello.pow_solution = Some(
929                            challenge
930                                .solve_capped(crate::crypto::pow::MAX_CLIENT_POW_DIFFICULTY)
931                                .map_err(|e| CoreError::HandshakeError(e.to_string()))?,
932                        );
933                    }
934                    break; // re-send the cookie/PoW-updated hello (outer loop)
935                }
936                Err(e) => {
937                    return Err(CoreError::HandshakeError(format!(
938                        "invalid server reply: {e}"
939                    )));
940                }
941            }
942        }
943    }
944}
945
946/// Reserved stream id for the connectionless `send()`/`recv()` surface. The
947/// demultiplexer hands out ids of two and above, so this never collides with a
948/// user-opened stream. Idle keep-alives ([`send_keepalive`]) also stamp it for a
949/// well-formed, consistent header.
950const RAW_APP_STREAM_ID: u32 = 1;
951
952/// Shared client/server data pump.
953///
954/// After the handshake completes (client side) or after the server `Session` is
955/// derived (server side), this loop:
956///   - drains the queued early-data buffer,
957///   - listens for incoming packets and decrypts them,
958///   - encrypts outgoing application/stream packets,
959///   - sends ACKs for reliable packets.
960// The 12 parameters represent the complete session-identity and I/O surface.
961// Grouping them into a struct would require a generic struct (due to `T:
962// SessionTransport`), add indirection with no safety or clarity gain, and
963// constitute a public-API change. The function is private (`async fn`, no
964// `pub`), so the extra arguments are contained here.
965#[allow(clippy::too_many_arguments)]
966async fn run_data_pump<T: SessionTransport>(
967    crypto_session: Arc<Session>,
968    session_id: SessionId,
969    transport: Arc<T>,
970    state: Arc<AtomicU8>,
971    send_queue: Arc<Mutex<Vec<Vec<u8>>>>,
972    mut cmd_rx: mpsc::Receiver<SessionCommand>,
973    recv_tx: mpsc::Sender<Bytes>,
974    demux: Arc<StreamDemultiplexer>,
975    streams: Arc<DashMap<u32, Arc<Stream>>>,
976    runtime: Arc<dyn Runtime>,
977    observability: Arc<Observability>,
978    leg: LegType,
979) {
980    // Session is now established and active — bump the active-session gauge.
981    // The matching `session_closed` at teardown (below) lets the gauge fall,
982    // so it tracks live sessions instead of growing monotonically.
983    observability.session_opened(leg);
984
985    // Liveness (P4.3): stamp "alive now" at establishment so the inbound-silence
986    // sweep measures from the data-plane start, not from session construction (which
987    // predates the multi-KB handshake and would otherwise look stale immediately).
988    crypto_session.update_activity();
989
990    // ── Raw-app session stream (reserved id 1) ──
991    // The connectionless `send()` / `recv()` surface is multiplexed onto one
992    // reserved stream so it gets the same reliable-delivery machinery as
993    // explicitly-opened streams: `drain_streams_priority_ordered` (re)transmits
994    // its buffered segments on the poll tick / outbound-ready notify, and
995    // inbound ACKs for id 1 clear them via `Stream::ack`. The demultiplexer
996    // hands out ids 2+, so this never collides with a user-opened stream.
997    let raw_stream = Arc::new(Stream::new(RAW_APP_STREAM_ID as TransportStreamId));
998    streams.insert(RAW_APP_STREAM_ID, raw_stream.clone());
999
1000    // ── Flush queued early-data onto the raw-app stream ──
1001    // Routed through the stream (not a one-shot direct send) so queued
1002    // pre-handshake data is buffered for retransmit just like post-handshake
1003    // sends — a dropped early-data frame is recovered, not lost.
1004    {
1005        let mut queue = send_queue.lock().await;
1006        let count = queue.len();
1007        'flush: for msg in queue.drain(..) {
1008            for chunk in msg.chunks(TRANSPORT_MTU) {
1009                if let Err(e) = raw_stream
1010                    .send_reliable(Bytes::copy_from_slice(chunk))
1011                    .await
1012                {
1013                    // T4.5 fail-closed: the reliable offset space is exhausted (~2^32
1014                    // segments) — refuse rather than wrap. Astronomically unreachable;
1015                    // the session stalls and the liveness sweep tears it down.
1016                    log::error!("PhantomSession: early-data flush aborted — {e}");
1017                    break 'flush;
1018                }
1019            }
1020        }
1021        if count > 0 {
1022            log::info!(
1023                "PhantomSession: queued {} early-data message(s) onto the raw-app stream",
1024                count
1025            );
1026            crypto_session.notify_outbound_ready();
1027        }
1028    }
1029
1030    // ── Receive-delivery decoupling ──
1031    // The reader task hands decrypted application data to a dedicated delivery
1032    // task over an UNBOUNDED channel and never blocks on app delivery, so a slow
1033    // `recv()` consumer cannot head-of-line-stall inbound ACK / WINDOW_UPDATE /
1034    // control processing. The delivery task does the app-paced `recv_tx.send()`
1035    // and credits the flow-control window on *real* consumption; enforced
1036    // send-side flow control (`Stream::poll_send`) bounds the in-flight backlog
1037    // to ~one window, and `undelivered_bytes` + `RECV_DELIVERY_HARD_CAP` guard
1038    // against a peer that ignores flow control.
1039    let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
1040    let undelivered_bytes = Arc::new(AtomicU64::new(0));
1041    {
1042        let recv_tx_deliver = recv_tx; // move the session recv channel here
1043        let demux_deliver = demux.clone();
1044        let streams_deliver = streams.clone();
1045        let crypto_deliver = crypto_session.clone();
1046        let undelivered_deliver = undelivered_bytes.clone();
1047        runtime.spawn(Box::pin(async move {
1048            while let Some((stream_id, bytes)) = deliver_rx.recv().await {
1049                let len = bytes.len() as u64;
1050                // Best-effort, non-blocking notification to the (vestigial) demux.
1051                demux_deliver.route_data(stream_id, bytes.clone());
1052                // Account the item the instant it leaves the UNBOUNDED delivery
1053                // queue (which the reader's HARD_CAP guards) — BEFORE the
1054                // app-paced `recv_tx.send()` below, which can block for a long
1055                // time on a slow consumer. Decrementing (and crediting) only
1056                // after a successful send would (a) keep this item counted
1057                // against the cap while it sits in the bounded recv pipeline,
1058                // inflating `undelivered_bytes`, and (b) leak the count entirely
1059                // if the send then fails. The byte is now in the bounded
1060                // recv-channel pipeline (capacity-limited, its own backpressure),
1061                // so it no longer belongs to the unbounded backlog.
1062                undelivered_deliver.fetch_sub(len, Ordering::AcqRel);
1063                // Credit the flow-control window: the item has been pulled into
1064                // the app-delivery pipeline (matching the inline ACK's "accepted
1065                // into my in-memory delivery queue" semantics). The pull rate is
1066                // still paced by `recv_tx.send()` completing below, so credit
1067                // tracks app consumption (one item of look-ahead) — backpressure
1068                // is preserved. Wake the send loop to flush the WINDOW_UPDATE
1069                // (emitted there — the sole outbound writer — so it is sealed
1070                // under the live epoch; the epoch's two writers both serialise
1071                // through `rekey_lock`, so the flush is always epoch-consistent).
1072                if let Some(stream) = streams_deliver.get(&stream_id) {
1073                    if let Some(credit) = stream.record_app_consumed(len as u32) {
1074                        stream.stage_window_update_credit(credit);
1075                        crypto_deliver.notify_outbound_ready();
1076                    }
1077                }
1078                // Real, app-paced delivery to the session recv channel. A closed
1079                // channel means the consumer is gone → session ending; stop. The
1080                // item was already removed from the backlog accounting above, so
1081                // breaking here leaks nothing.
1082                if recv_tx_deliver.send(bytes).await.is_err() {
1083                    break;
1084                }
1085            }
1086        }));
1087    }
1088
1089    // ── Receive (reader) task: deserialize, decrypt, hand off to delivery ──
1090    let transport_recv = transport.clone();
1091    let transport_send_ack = transport.clone();
1092    let crypto_recv = crypto_session.clone();
1093    let demux_recv = demux.clone();
1094    let streams_recv = streams.clone();
1095    let undelivered_reader = undelivered_bytes.clone();
1096    let observability_recv = observability.clone();
1097    // Completion signal for the receive task. `SpawnHandle` from the
1098    // runtime trait does not expose a `Future` for `.await` directly
1099    // (different runtimes provide different join futures), so we wire a
1100    // one-shot channel — the recv task sends `()` right before exiting
1101    // and the main loop selects on the receiver to detect transport
1102    // closure.
1103    let (recv_done_tx, mut recv_done_rx) = oneshot::channel::<()>();
1104    let transport_for_path = transport.clone();
1105    let recv_handle = runtime.spawn(Box::pin(async move {
1106        // Reusable buffer for ACK frame serialization. Hoisted out of the
1107        // loop (Phase 2.3) so we don't pay a fresh `Vec::new()` allocation
1108        // for every ACK we emit on a busy reliable stream. 256 bytes is
1109        // comfortably larger than a serialized empty `PhantomPacket` (the
1110        // 15-byte header plus the AEAD tag — no cleartext length prefixes
1111        // since v6), so the underlying buffer is never reallocated after the
1112        // first frame.
1113        let mut ack_buf: Vec<u8> = Vec::with_capacity(256);
1114        // Buffering ceiling: the delivery queue is unbounded so the reader
1115        // never blocks, but a peer that ignores flow control could flood it.
1116        // Compliant senders are bounded by ~one window per stream (enforced
1117        // `poll_send`), far below this cap; crossing it means the peer is
1118        // misbehaving, so we tear the session down rather than buffer without
1119        // limit. 4 MiB tolerates many streams × the 64 KiB window with margin.
1120        const RECV_DELIVERY_HARD_CAP: u64 = 4 * 1024 * 1024;
1121        loop {
1122            // Flow-control / anti-flood gate: if the app-delivery backlog
1123            // has blown past the cap, the peer is not honouring the window —
1124            // close instead of growing the in-memory queue unboundedly. Cheap
1125            // pre-check, before any AEAD work.
1126            if undelivered_reader.load(Ordering::Acquire) > RECV_DELIVERY_HARD_CAP {
1127                log::warn!(
1128                    "PhantomSession: receive backlog {} B exceeds cap — peer ignoring flow \
1129                     control; closing session",
1130                    undelivered_reader.load(Ordering::Acquire)
1131                );
1132                break;
1133            }
1134            let data = match transport_recv.recv_bytes().await {
1135                Ok(b) => b,
1136                Err(_) => break,
1137            };
1138
1139            // Remove header protection (T4.6) and parse: a malformed / unparseable
1140            // / short-of-the-AEAD-tag frame (no legitimate peer produces one) is
1141            // dropped — never a panic. Since WIRE v6 the WHOLE 15-byte header is
1142            // HP-masked (`HP_PROTECTED_OFFSET == 0`, no cleartext header byte on
1143            // the wire); `parse_protected` unmasks it with this session's recv HP
1144            // key and reconstructs the off-wire 32-byte `session_id` from session
1145            // context before the header is interpreted.
1146            let packet = match crypto_recv.parse_protected(&data) {
1147                Ok(v) => v,
1148                Err(_) => continue,
1149            };
1150            // Pinned wire-version gate: the format is not negotiated, so a
1151            // frame carrying any other version byte is dropped.
1152            if packet.header.version != WIRE_VERSION {
1153                continue;
1154            }
1155            handle_packet(
1156                packet,
1157                session_id,
1158                &crypto_recv,
1159                &streams_recv,
1160                &demux_recv,
1161                &transport_send_ack,
1162                &transport_for_path,
1163                &deliver_tx,
1164                &undelivered_reader,
1165                &mut ack_buf,
1166                &observability_recv,
1167                leg,
1168            )
1169            .await;
1170        }
1171        // Reader exiting → drop `deliver_tx` so the delivery task drains any
1172        // queued items and then sees the channel closed and exits.
1173        drop(deliver_tx);
1174        // Signal the main loop that the recv task has exited so it can
1175        // also unwind. `send` returns `Err(())` if the receiver was
1176        // already dropped — that case is harmless, the main loop has
1177        // already shut down.
1178        let _ = recv_done_tx.send(());
1179    }));
1180
1181    // MTU for transport packets
1182    const TRANSPORT_MTU: usize = 1300;
1183    // Phase 2.4: the 10 ms `poll_interval` stays as a retransmit-timer
1184    // fallback (streams without an explicit notifier reference still
1185    // get swept), but `send_notify.notified()` joins the select! so the
1186    // pump wakes immediately when a producer calls
1187    // `Session::notify_outbound_ready()`. This drops idle CPU usage to
1188    // zero on quiet sessions while keeping the worst-case post-queue
1189    // latency at <10 ms even for producers that haven't been wired into
1190    // the notifier yet.
1191    let mut poll_interval = tokio::time::interval(std::time::Duration::from_millis(10));
1192    let send_notify = crypto_session.send_notifier();
1193    // Liveness keep-alive bookkeeping (P4.3): `Some(t)` while in the `Migrating`
1194    // window (the pump-local truth + how long); `died` records an idle-timeout death
1195    // so the teardown publishes `Dead` instead of overwriting it with `Closed`.
1196    let mut migrating_since: Option<std::time::Instant> = None;
1197    let mut died = false;
1198    // Idle keep-alive bookkeeping (Direction #3 — download-only liveness): the
1199    // pump-local instant of the last keep-alive PING we emitted, so we send at most
1200    // one per `keepalive_interval` (no 10 ms-heartbeat spam). Seeded at "now" so the
1201    // first PING waits a full interval after the data plane starts.
1202    let mut last_keepalive = std::time::Instant::now();
1203    // Cover-traffic bookkeeping (WIRE v6, deliverable (e)): the send PN observed at
1204    // the last cover check, and the instant of the last observed outbound activity.
1205    // Any real packet advances the PN, resetting the idle window, so cover only
1206    // fills genuine gaps (idle-fill + a floor rate). Seeded at "now"/current PN.
1207    let mut last_outbound_pn = crypto_session.peek_send_pn();
1208    let mut last_outbound_at = std::time::Instant::now();
1209    // Outbound WINDOW_UPDATE control packets are emitted on the send loop — the
1210    // sole outbound writer — so the encrypted control frame is always sealed under
1211    // the epoch live when it stamps. The epoch has two writers (this loop's own
1212    // `rekey()` and the receive task's authenticated forward catch-up in
1213    // `decrypt_packet_accepting_rekey`), but both serialise through the session's
1214    // `rekey_lock`, so the seal is always epoch-consistent. The delivery task only
1215    // stages the relative credit (`Stream::stage_window_update_credit`) and
1216    // wakes us; the wire sequence is drawn from the stream's own send-sequence
1217    // space inside `flush_pending_window_updates` (no private counter, so it
1218    // can never collide with application data on the AEAD nonce).
1219
1220    loop {
1221        tokio::select! {
1222            _ = poll_interval.tick() => {
1223                flush_pending_window_updates(
1224                    &transport, &crypto_session, session_id, &streams,
1225                )
1226                .await;
1227                drain_streams_priority_ordered(
1228                    &transport,
1229                    &crypto_session,
1230                    session_id,
1231                    &streams,
1232                )
1233                .await;
1234                // Idle keep-alive (Direction #3 — download-only liveness): on an
1235                // otherwise-idle Connected path, emit one small ENCRYPTED PING so a
1236                // download-only path (which sends only ACKs) has an outstanding probe
1237                // to anchor the liveness sweep below — and the peer's PONG refreshes
1238                // its activity timer. Runs before the sweep so a just-emitted PING is
1239                // already marked outstanding this tick.
1240                maybe_send_keepalive(
1241                    &transport, &crypto_session, session_id, &mut last_keepalive,
1242                )
1243                .await;
1244                // Cover traffic (WIRE v6, deliverable (e)): on this same heartbeat,
1245                // maintain the minimum outbound packet rate — emit a COVER dummy when
1246                // the outbound path has been idle past the floor interval. No-op when
1247                // cover is disabled (default) or real traffic is flowing.
1248                maybe_send_cover(
1249                    &transport,
1250                    &crypto_session,
1251                    session_id,
1252                    &mut last_outbound_pn,
1253                    &mut last_outbound_at,
1254                )
1255                .await;
1256                // Liveness sweep (P4.3): the 10 ms heartbeat is the reliable place to
1257                // evaluate inbound silence vs. outstanding data and surface
1258                // Migrating / recover / Dead. A `Dead` verdict ends the pump.
1259                if apply_liveness(&crypto_session, &state, &mut migrating_since) {
1260                    died = true;
1261                    break;
1262                }
1263            }
1264            _ = send_notify.notified() => {
1265                // Same drain logic as the tick arm — fast-wake path. Also flush
1266                // any flow-control credit the delivery task staged.
1267                flush_pending_window_updates(
1268                    &transport, &crypto_session, session_id, &streams,
1269                )
1270                .await;
1271                drain_streams_priority_ordered(
1272                    &transport,
1273                    &crypto_session,
1274                    session_id,
1275                    &streams,
1276                )
1277                .await;
1278            }
1279            cmd_opt = cmd_rx.recv() => {
1280                match cmd_opt {
1281                    Some(SessionCommand::Send(data)) => {
1282                        // Route through the raw-app stream so the payload is
1283                        // buffered for retransmit until ACKed (drained by
1284                        // `drain_streams_priority_ordered`), instead of being
1285                        // fired once and forgotten on the wire.
1286                        for chunk in data.chunks(TRANSPORT_MTU) {
1287                            if let Err(e) = raw_stream
1288                                .send_reliable(Bytes::copy_from_slice(chunk))
1289                                .await
1290                            {
1291                                log::error!("PhantomSession: send aborted — {e}");
1292                                break;
1293                            }
1294                        }
1295                        crypto_session.notify_outbound_ready();
1296                    }
1297                    Some(SessionCommand::SendStreamReliable { stream_id, data }) => {
1298                        if let Some(stream) = streams.get(&stream_id) {
1299                            for chunk in data.chunks(TRANSPORT_MTU) {
1300                                if let Err(e) =
1301                                    stream.send_reliable(Bytes::copy_from_slice(chunk)).await
1302                                {
1303                                    log::error!("PhantomSession: stream send aborted — {e}");
1304                                    break;
1305                                }
1306                            }
1307                        }
1308                    }
1309                    Some(SessionCommand::SendStreamUnreliable { stream_id, data }) => {
1310                        if let Some(stream) = streams.get(&stream_id) {
1311                            for chunk in data.chunks(TRANSPORT_MTU) {
1312                                stream.send_unreliable(Bytes::copy_from_slice(chunk)).await;
1313                            }
1314                        }
1315                    }
1316                    Some(SessionCommand::CloseStream { stream_id }) => {
1317                        if let Some(stream) = streams.get(&stream_id) {
1318                            stream.finish().await;
1319                            let _ = send_app_data(
1320                                &transport,
1321                                &crypto_session,
1322                                session_id,
1323                                stream_id as TransportStreamId,
1324                                &[],
1325                                PacketFlags::FIN,
1326                                None, // bare FIN is a control frame — no reliable offset
1327                            ).await;
1328                        }
1329                        streams.remove(&stream_id);
1330                        demux.close_stream(stream_id);
1331                    }
1332                    Some(SessionCommand::Migrate(local_addr)) => {
1333                        // Embedder-triggered connection migration (Phase 4 / P4.2).
1334                        // Rebind the transport to the new local socket FIRST (it keeps
1335                        // the old socket for the overlap); only on a successful rebind
1336                        // bump the send `path_id` so every subsequent packet from the
1337                        // new socket carries a fresh, not-yet-Validated path label —
1338                        // which is what makes the server detect + challenge the new
1339                        // path (a still-`0` path_id would be skipped, path 0 being
1340                        // permanently Validated). Both happen inside this `select!`
1341                        // arm, so no send interleaves between them. Best-effort: a
1342                        // failed rebind leaves the session untouched on the old socket
1343                        // (broken-rebind safety) — migration never tears it down.
1344                        match transport.migrate(local_addr).await {
1345                            Ok(()) => {
1346                                let new_path = crypto_session.next_migration_path_id();
1347                                // ε / WIRE v5: rotate the outbound CID so every
1348                                // post-migration datagram stamps an
1349                                // independent-random ConnId an observer cannot link
1350                                // to the pre-migration flow. The new CID_{i+1} is
1351                                // already in the server's pre-registered inbound
1352                                // window (which slides post-AEAD beyond K migrations).
1353                                transport.set_outbound_cid(crypto_session.advance_outbound_cid());
1354                                log::info!(
1355                                    "PhantomSession: migrated send path -> path_id {}, CID rotated",
1356                                    new_path
1357                                );
1358                                // Wake the send loop so app data + L1 retransmits flow
1359                                // from the new socket immediately, triggering the
1360                                // server-side new-source detection.
1361                                crypto_session.notify_outbound_ready();
1362                            }
1363                            Err(e) => {
1364                                log::warn!(
1365                                    "PhantomSession: migrate rebind failed (staying on the old path): {}",
1366                                    e
1367                                );
1368                            }
1369                        }
1370                    }
1371                    Some(SessionCommand::MigrateServer(local_addr)) => {
1372                        // Server-side migration (the mirror of `Migrate`). Rebind the
1373                        // server's SEND socket to the new local address FIRST (its receive
1374                        // keeps flowing on the old address through the listener demux during
1375                        // the overlap, so c2s never drops); only on a successful rebind
1376                        // rotate the s2c send `path_id` + outbound CID in lock-step, so the
1377                        // client sees a fresh server source with a fresh, unlinkable ConnId
1378                        // and follows it (its unconnected socket hears the new source). Both
1379                        // happen inside this `select!` arm, so no send interleaves between
1380                        // them. Best-effort: a failed rebind leaves the session on the old
1381                        // send socket — server migration never tears it down.
1382                        match transport.migrate_server(local_addr).await {
1383                            Ok(()) => {
1384                                let new_path = crypto_session.next_migration_path_id();
1385                                transport.set_outbound_cid(crypto_session.advance_outbound_cid());
1386                                log::info!(
1387                                    "PhantomSession: migrated server send path -> path_id {}, s2c CID rotated",
1388                                    new_path
1389                                );
1390                                // Wake the send loop so the next s2c packet carries the new
1391                                // source + path_id + CID immediately.
1392                                crypto_session.notify_outbound_ready();
1393                            }
1394                            Err(e) => {
1395                                log::warn!(
1396                                    "PhantomSession: server migrate rebind failed (staying on the old send socket): {}",
1397                                    e
1398                                );
1399                            }
1400                        }
1401                    }
1402                    Some(SessionCommand::Close) => {
1403                        log::info!("PhantomSession: closing");
1404                        // `disconnect()` is a *graceful* close (doc: "Send the
1405                        // graceful close frame and shut the session down" — TCP-FIN
1406                        // semantics: finish sending, then close). Mirror the
1407                        // handle-drop (`None`) arm so buffered `send()` data still
1408                        // reaches the peer: `session.send(x); session.disconnect()`
1409                        // must not lose `x`, just like `send(x); drop(session)`.
1410                        flush_pending_window_updates(
1411                            &transport, &crypto_session, session_id, &streams,
1412                        )
1413                        .await;
1414                        drain_streams_priority_ordered(
1415                            &transport, &crypto_session, session_id, &streams,
1416                        )
1417                        .await;
1418                        break;
1419                    }
1420                    None => {
1421                        log::info!("PhantomSession: command channel dropped");
1422                        // The outer `PhantomSession` handle was dropped. Data already
1423                        // handed to `send()` was routed onto the raw-app stream but may
1424                        // not have hit the wire yet (transmission happens on the next
1425                        // tick / notify of THIS loop). Flush it before exiting so a
1426                        // fire-and-forget `send()` immediately followed by dropping the
1427                        // handle still reaches the peer — otherwise a freshly-accepted
1428                        // server session that does `recv(); send(echo)` then drops loses
1429                        // the echo, and the client's `recv()` hangs to its timeout.
1430                        flush_pending_window_updates(
1431                            &transport, &crypto_session, session_id, &streams,
1432                        )
1433                        .await;
1434                        drain_streams_priority_ordered(
1435                            &transport, &crypto_session, session_id, &streams,
1436                        )
1437                        .await;
1438                        break;
1439                    }
1440                }
1441            }
1442            _ = &mut recv_done_rx => {
1443                log::error!("PhantomSession: receive task ended unexpectedly (transport closed)");
1444                break;
1445            }
1446        }
1447    }
1448
1449    // Abort the recv task if it's still running; idempotent on a finished
1450    // handle. Goes through the runtime-agnostic `SpawnHandle::abort`.
1451    recv_handle.abort();
1452    // A liveness idle-timeout death already published `ConnectionState::Dead`; only a
1453    // normal teardown (graceful close / transport drop) publishes `Closed`.
1454    if !died {
1455        state.store(ConnectionState::Closed as u8, Ordering::Relaxed);
1456    }
1457    // Session torn down — drop the active-session gauge back down.
1458    observability.session_closed(leg);
1459}
1460
1461/// Evaluate path liveness once (Phase 4 / P4.3) and apply the resulting transition to
1462/// both the internal [`SessionState`] and the FFI-visible [`ConnectionState`]. Returns
1463/// `true` when the session has died (idle-timeout in `Migrating`), so the caller ends
1464/// the pump. `migrating_since` is the pump-local truth for the keep-alive window.
1465fn apply_liveness(
1466    crypto_session: &Arc<Session>,
1467    state: &Arc<AtomicU8>,
1468    migrating_since: &mut Option<std::time::Instant>,
1469) -> bool {
1470    use crate::transport::liveness::{liveness_verdict, LivenessVerdict};
1471    let cfg = crypto_session.liveness_config();
1472    let snap = crypto_session.bandwidth_snapshot();
1473    let silence = crypto_session.last_activity_elapsed();
1474    let in_migrating = migrating_since.is_some();
1475    let migrating_for = migrating_since
1476        .map(|t| t.elapsed())
1477        .unwrap_or(std::time::Duration::ZERO);
1478    // Direction #3 (download-only liveness): an outstanding idle keep-alive PING is
1479    // an outstanding probe just like in-flight reliable data, so fold it into the
1480    // sweep's `inflight > 0` gate. This is what lets a download-only path — which
1481    // sends only ACKs and so has zero reliable bytes in flight — declare the path
1482    // down when the PING goes unanswered (the PONG would have refreshed activity).
1483    let effective_inflight = if crypto_session.keepalive_outstanding() {
1484        snap.inflight_bytes.max(1)
1485    } else {
1486        snap.inflight_bytes
1487    };
1488    match liveness_verdict(
1489        silence,
1490        effective_inflight,
1491        snap.min_rtt,
1492        in_migrating,
1493        migrating_for,
1494        &cfg,
1495    ) {
1496        LivenessVerdict::PathDown => {
1497            *migrating_since = Some(std::time::Instant::now());
1498            crypto_session.set_state(SessionState::Migrating);
1499            state.store(ConnectionState::Migrating as u8, Ordering::Relaxed);
1500            log::info!(
1501                "PhantomSession: path down (no inbound for {silence:?} with data in flight) \
1502                 — entering Migrating; the embedder should migrate()"
1503            );
1504            false
1505        }
1506        LivenessVerdict::Recovered => {
1507            *migrating_since = None;
1508            crypto_session.set_state(SessionState::Connected);
1509            state.store(ConnectionState::Connected as u8, Ordering::Relaxed);
1510            log::info!("PhantomSession: path recovered — back to Connected");
1511            false
1512        }
1513        LivenessVerdict::Dead => {
1514            crypto_session.set_state(SessionState::Closed);
1515            state.store(ConnectionState::Dead as u8, Ordering::Relaxed);
1516            log::warn!("PhantomSession: migration idle-timeout elapsed — session dead");
1517            true
1518        }
1519        LivenessVerdict::Unchanged => false,
1520    }
1521}
1522
1523/// Emit an idle keep-alive PING when the path is idle (Direction #3 —
1524/// download-only liveness). Decides via the pure [`should_send_keepalive`] gate
1525/// over the live signals (Connected? nothing in flight? inbound silent ≥ interval?
1526/// no recent PING?). On a fire it sends one empty `ENCRYPTED | KEEPALIVE` packet,
1527/// marks the probe outstanding (so the very next liveness sweep treats the path as
1528/// awaiting a response even with no reliable data queued), and records the send
1529/// instant for the per-interval throttle. Best-effort: a send failure just leaves
1530/// `last_keepalive` unchanged so the next tick retries.
1531async fn maybe_send_keepalive<T: SessionTransport>(
1532    transport: &Arc<T>,
1533    crypto_session: &Arc<Session>,
1534    session_id: SessionId,
1535    last_keepalive: &mut std::time::Instant,
1536) {
1537    use crate::transport::liveness::should_send_keepalive;
1538    let cfg = crypto_session.liveness_config();
1539    // Cheap fast-path: skip everything when keep-alives are disabled.
1540    if cfg.keepalive_interval.is_none() {
1541        return;
1542    }
1543    let connected = crypto_session.state() == SessionState::Connected;
1544    let snap = crypto_session.bandwidth_snapshot();
1545    // An already-outstanding PING is itself "in flight" — fold it into the gate so
1546    // we don't queue a second PING before the first is answered or times out.
1547    let inflight = if crypto_session.keepalive_outstanding() {
1548        snap.inflight_bytes.max(1)
1549    } else {
1550        snap.inflight_bytes
1551    };
1552    if !should_send_keepalive(
1553        connected,
1554        inflight,
1555        crypto_session.last_activity_elapsed(),
1556        last_keepalive.elapsed(),
1557        &cfg,
1558    ) {
1559        return;
1560    }
1561    // PING (not a PONG): a bare KEEPALIVE that the peer echoes back as KEEPALIVE|ACK.
1562    if send_keepalive(transport, crypto_session, session_id, false).await {
1563        crypto_session.mark_keepalive_outstanding();
1564        *last_keepalive = std::time::Instant::now();
1565    }
1566}
1567
1568/// Emit any flow-control credit the receive **delivery** task staged.
1569///
1570/// The delivery task credits the window on real app consumption and stages the
1571/// relative credit via `Stream::stage_window_update_credit` + a send-loop wake;
1572/// the send loop (this, the sole outbound writer) actually encrypts and sends the
1573/// `WINDOW_UPDATE`, so the control frame is always sealed under the epoch live
1574/// when it stamps. The epoch can be advanced by either this loop's own `rekey()`
1575/// or the receive task's authenticated forward catch-up, but both serialise
1576/// through `rekey_lock`, so the seal is always epoch-consistent. The staged
1577/// credits are snapshotted out of the `DashMap` first so no
1578/// shard lock is held across the `.await` (which would deadlock the delivery /
1579/// reader tasks that also touch `streams`).
1580async fn flush_pending_window_updates<T: SessionTransport>(
1581    transport: &Arc<T>,
1582    crypto_session: &Arc<Session>,
1583    session_id: SessionId,
1584    streams: &Arc<DashMap<u32, Arc<Stream>>>,
1585) {
1586    let pending: Vec<(u32, u32, Arc<Stream>)> = streams
1587        .iter()
1588        .filter_map(|e| {
1589            e.value()
1590                .take_pending_window_update()
1591                .map(|c| (*e.key(), c, e.value().clone()))
1592        })
1593        .collect();
1594    for (stream_id, credit, stream) in pending {
1595        if !send_window_update(
1596            transport,
1597            crypto_session,
1598            session_id,
1599            stream_id as TransportStreamId,
1600            credit,
1601        )
1602        .await
1603        {
1604            // The send failed (transient transport hiccup): re-stage the credit
1605            // so the next send-loop pass — the 10 ms tick at the latest — retries
1606            // it. Dropping it silently would under-credit the peer and could
1607            // eventually stall the sender. Credits accumulate, so a retry simply
1608            // folds back in; a permanently dead transport tears the session down
1609            // via the reader, which ends this loop.
1610            stream.stage_window_update_credit(credit);
1611        }
1612    }
1613}
1614
1615/// Drain every stream with pending data, scheduling them in strict
1616/// priority order (higher `Stream::priority()` wins). Streams of equal
1617/// priority are drained in stream-id order (deterministic so tests
1618/// don't get flaky under DashMap's hash-order shuffle).
1619///
1620/// This is **strict priority**: a stream with priority N never yields
1621/// to a stream with priority < N while it still has data. A future
1622/// weighted-fair scheduler can replace this without changing the
1623/// caller surface. Phase 4.3.
1624async fn drain_streams_priority_ordered<T: SessionTransport>(
1625    transport: &Arc<T>,
1626    crypto_session: &Arc<Session>,
1627    session_id: SessionId,
1628    streams: &Arc<DashMap<u32, Arc<Stream>>>,
1629) {
1630    // Snapshot the stream set so we can sort without holding DashMap
1631    // shard locks across awaits. Each entry is (priority, stream_id,
1632    // stream-Arc) — Arc clones are cheap (refcount bump).
1633    let mut snapshot: Vec<(u32, u32, Arc<Stream>)> = streams
1634        .iter()
1635        .map(|e| (e.value().priority(), *e.key(), e.value().clone()))
1636        .collect();
1637    // Descending priority; ties broken by stream id ascending so the
1638    // order is stable across iterations.
1639    snapshot.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
1640
1641    for (_priority, stream_id, stream) in snapshot {
1642        loop {
1643            // Bytes of new data the congestion window currently permits.
1644            // Recomputed each iteration: every send grows inflight, so the
1645            // budget shrinks and the drain stops once the window is full.
1646            let snap = crypto_session.bandwidth_snapshot();
1647            let budget = snap.cwnd_bytes.saturating_sub(snap.inflight_bytes);
1648            let Some(seg) = stream.poll_send(budget).await else {
1649                break;
1650            };
1651            // A retransmission means the prior send was lost — tell congestion
1652            // control so BBR enters FastRecovery and the pacing rate backs off.
1653            if seg.retransmit {
1654                crypto_session.on_packet_lost(seg.data.len() as u64);
1655            }
1656            let base = if seg.reliable {
1657                PacketFlags::RELIABLE
1658            } else {
1659                PacketFlags::UNRELIABLE
1660            };
1661            // Reliable segments carry their gap-free `stream_offset` in the AEAD
1662            // plaintext (A.5) for in-order reassembly; unreliable segments do not.
1663            let reliable_offset = if seg.reliable {
1664                Some(seg.stream_offset)
1665            } else {
1666                None
1667            };
1668            if !send_app_data(
1669                transport,
1670                crypto_session,
1671                session_id,
1672                stream_id as TransportStreamId,
1673                &seg.data,
1674                base,
1675                reliable_offset,
1676            )
1677            .await
1678            {
1679                log::error!("PhantomSession: priority-ordered drain send failed");
1680                // `poll_send` already stamped `sent_at` on this reliable
1681                // segment, but the bytes never reached the wire. Clear it so the
1682                // next drain re-offers it immediately instead of stalling a full
1683                // RTO before the retransmit pass. Unreliable segments were
1684                // removed by `poll_send` (fire-and-forget) — nothing to reset.
1685                if seg.reliable {
1686                    stream.mark_unsent(seg.stream_offset).await;
1687                }
1688                break;
1689            }
1690        }
1691    }
1692}
1693
1694/// Build a `DeliverySample` from a successful Stream ack callback and
1695/// feed it into the session's BBR estimator (Phase 4.4). The BBR loop
1696/// internally re-sets the pacer rate via `Session::on_packet_acked`,
1697/// so the next outbound packet is paced at the freshly-estimated
1698/// bottleneck bandwidth.
1699///
1700/// `ack_delay_us` is the `Sack::ack_delay_us` field carried in the ACK's AEAD
1701/// plaintext (microseconds the receiver held the ACK before sending) —
1702/// subtracted from the observed RTT to yield the propagation delay. Pass 0 when
1703/// no peer-side delay is known (the estimator treats it as "no delay reported").
1704fn feed_bbr_on_ack(
1705    crypto_session: &Arc<Session>,
1706    sent_at: tokio::time::Instant,
1707    packet_bytes: u64,
1708    ack_delay_us: u64,
1709) {
1710    let sample = crate::transport::bandwidth_estimator::DeliverySample {
1711        delivered_bytes: 0, // BandwidthEstimator tracks its own counter
1712        sent_at: sent_at.into_std(),
1713        acked_at: std::time::Instant::now(),
1714        packet_bytes,
1715        is_app_limited: false,
1716        ack_delay_us,
1717    };
1718    let _ = crypto_session.on_packet_acked(sample);
1719}
1720
1721/// Wait until the pacer has tokens for `bytes` bytes. No-op when the
1722/// pacer is unlimited (the default until BBR sets a finite rate).
1723async fn pace_send(crypto_session: &Arc<Session>, bytes: u64) {
1724    // Anti-fingerprint send-timing jitter (WIRE v6, deliverable (d)): when enabled,
1725    // wait a uniform random [0, max] ms before this send so the inter-packet timing
1726    // no longer tracks the application's writes. Applied independently of the pacer
1727    // (a wire-rate limiter) and before it, so the total delay is jitter + pacing.
1728    // Opt-in (default 0 → no-op, no latency cost).
1729    let jitter_max = crypto_session.send_jitter();
1730    if !jitter_max.is_zero() {
1731        let delay = shaping::random_jitter(jitter_max.as_millis() as u32);
1732        if !delay.is_zero() {
1733            tokio::time::sleep(delay).await;
1734        }
1735    }
1736    let pacer = crypto_session.pacer();
1737    if !pacer.is_enabled() {
1738        return;
1739    }
1740    loop {
1741        if pacer.try_consume(bytes) {
1742            return;
1743        }
1744        let wait = pacer.time_until_available(bytes);
1745        if wait.is_zero() {
1746            // Tokens should be available; retry the consume to handle
1747            // a concurrent race with another sender.
1748            continue;
1749        }
1750        // Cap the wait to keep the loop responsive — a stale wait
1751        // estimate from a long-idle pacer is corrected on the next
1752        // iteration.
1753        let cap = std::time::Duration::from_millis(50);
1754        let wait = wait.min(cap);
1755        tokio::time::sleep(wait).await;
1756    }
1757}
1758
1759/// Decide whether a rekey is needed before stamping a packet and, if so, perform
1760/// it. A rekey fires when the direction-wide AEAD-invocation high-watermark
1761/// ([`Session::send_needs_rekey`]) is crossed (Invariant 8). The per-stream C1
1762/// watermark is gone — under ① the packet number is a per-direction `u64` that
1763/// cannot wrap within a session, so the nonce can never repeat.
1764///
1765/// Returns the extra flag bits to OR into the header, or `None` if a rekey was
1766/// required but failed (epoch saturated at `u8::MAX`) — the caller MUST fail the
1767/// send so the session reconnects rather than reusing a nonce.
1768///
1769/// T5.5(b) — the returned `PacketFlags::REKEY` bit is set not only on the single
1770/// rotation-trigger packet but on EVERY packet sent at the new epoch until the
1771/// peer acknowledges the rekey ([`Session::rekey_unconfirmed`] clears once an
1772/// authenticated inbound packet is seen at the new epoch). Re-advertising the
1773/// flag is what makes the receive-side catch-up gate in
1774/// [`Session::decrypt_packet_accepting_rekey`] safe: a lost rotation-trigger
1775/// packet no longer strands the peer, because the next new-epoch packet (incl. a
1776/// reliable retransmit) still carries REKEY and drives the catch-up.
1777fn rekey_before_stamp(crypto_session: &Arc<Session>) -> Option<u16> {
1778    if crypto_session.send_needs_rekey() {
1779        // Crossed the high-watermark: rotate now. `rekey()` marks the session
1780        // `rekey_unconfirmed`, so the flag below re-arms automatically.
1781        if let Err(e) = crypto_session.rekey() {
1782            log::error!("PhantomSession: mid-session rekey failed: {}", e);
1783            return None;
1784        }
1785    }
1786    // Re-advertise REKEY while our last rekey is still unacknowledged — even when
1787    // no rotation happened on this packet (the trigger may have rotated several
1788    // packets ago and been lost).
1789    Some(if crypto_session.rekey_unconfirmed() {
1790        PacketFlags::REKEY
1791    } else {
1792        0
1793    })
1794}
1795
1796/// V2 send. Builds `PhantomPacket` with `PacketFlags::ENCRYPTED` and
1797/// the negotiated rekey epoch; AEAD nonce derives from the header
1798/// (`Session::encrypt_packet`), so a failed peer decrypt no longer
1799/// desyncs the local counter.
1800async fn send_app_data<T: SessionTransport>(
1801    transport: &Arc<T>,
1802    crypto_session: &Arc<Session>,
1803    session_id: SessionId,
1804    stream_id: TransportStreamId,
1805    payload: &[u8],
1806    base_flags: u16,
1807    reliable_offset: Option<u32>,
1808) -> bool {
1809    // Always OR in ENCRYPTED for application data.
1810    let mut flag_bits = base_flags | PacketFlags::ENCRYPTED;
1811    // Mid-session rekey: rotate to a fresh key BEFORE stamping this header when the
1812    // direction-wide AEAD high-watermark is crossed, so the header carries the new
1813    // epoch (+ the REKEY flag). The peer follows on the authenticated epoch bump
1814    // (it trial-decrypts under the next key).
1815    match rekey_before_stamp(crypto_session) {
1816        Some(extra) => flag_bits |= extra,
1817        // Epoch saturated (u8::MAX): can't rotate further. Surface as a failed
1818        // send so the caller re-offers; the session reconnects rather than wrap.
1819        None => return false,
1820    }
1821    // ① — Phase 4: draw the per-direction packet number at send time (so a
1822    // retransmit gets a fresh PN and the nonce is never reused).
1823    let packet_number = crypto_session.next_send_pn();
1824    // Build the inner AEAD plaintext (owned so size-padding can extend it). For
1825    // reliable data, prepend the gap-free per-stream `stream_offset` (A.5, 4
1826    // big-endian bytes) so the receiver reassembles in send order regardless of
1827    // `sequence` holes left by interleaved control frames. Unreliable / control
1828    // frames carry no offset. This all lives inside the AEAD (authenticated,
1829    // invisible on the wire).
1830    let mut plaintext: Vec<u8> = match reliable_offset {
1831        Some(off) => {
1832            let mut v = Vec::with_capacity(4 + payload.len());
1833            v.extend_from_slice(&off.to_be_bytes());
1834            v.extend_from_slice(payload);
1835            v
1836        }
1837        None => payload.to_vec(),
1838    };
1839    // Anti-fingerprint size padding (WIRE v6, deliverable (c)): when the session's
1840    // padding policy is enabled, pad this packet up to a PADÉ bucket INSIDE the
1841    // AEAD plaintext and flag it `PADDED`, so the on-wire datagram size no longer
1842    // tracks the payload size. The receiver strips the trailer after a successful
1843    // decrypt. Opt-in (default `None` → no-op, zero overhead). The `PADDED` flag
1844    // rides in the AAD (and is HP-masked on the wire), so a tamper fails the AEAD.
1845    let trailer = shaping::padding_trailer_len(plaintext.len(), crypto_session.padding_policy());
1846    if trailer > 0 {
1847        shaping::append_padding(&mut plaintext, trailer);
1848        flag_bits |= PacketFlags::PADDED;
1849    }
1850    let header = PacketHeader::new(
1851        session_id,
1852        stream_id,
1853        packet_number,
1854        PacketFlags::new(flag_bits),
1855    )
1856    .with_epoch(crypto_session.current_epoch())
1857    // Stamp the current send-side path_id (D5 — Phase 4). Default 0 (the implicit
1858    // handshake path) is behaviour-preserving; after a `migrate()` bump this carries
1859    // the new path label so the peer detects the new path and issues a challenge.
1860    // Retransmits flow through here too, so ARQ re-carries on the new path (D7).
1861    .with_path_id(crypto_session.current_send_path_id());
1862    // The data-plane packet carries no `extensions` (TLV headroom stays empty),
1863    // so the AEAD AAD binds an empty extensions slice — matching the wire.
1864    let ciphertext = match crypto_session.encrypt_packet(&header, &plaintext, &[]) {
1865        Ok(c) => c,
1866        Err(e) => {
1867            log::error!("PhantomSession: encrypt_packet failed: {}", e);
1868            return false;
1869        }
1870    };
1871    let packet = PhantomPacket::new(header, ciphertext);
1872    // Header protection (T4.6): XOR-mask the whole 15-byte header before it hits
1873    // the wire (WIRE v6: `HP_PROTECTED_OFFSET == 0`, no cleartext header byte).
1874    // Infallible in practice (the payload always carries the AEAD tag).
1875    let buf = match crypto_session.protect_packet(&packet) {
1876        Ok(b) => b,
1877        Err(e) => {
1878            log::error!("PhantomSession: header protection failed: {}", e);
1879            return false;
1880        }
1881    };
1882    let size = buf.len();
1883    // Pacing is a wire-rate limiter, so it consumes the full on-wire size.
1884    pace_send(crypto_session, size as u64).await;
1885    if let Err(e) = transport.send_bytes(&buf[..size]).await {
1886        log::error!("PhantomSession: transport send failed: {}", e);
1887        return false;
1888    }
1889    // Inflight/cwnd accounting MUST use the same unit the ACK and loss paths
1890    // settle in. `Stream::ack` returns and `on_packet_lost` subtracts the
1891    // segment's *payload* length (`seg.data.len()`), so the send side has to add
1892    // the payload length too — adding the full wire size here leaked the
1893    // per-packet framing overhead (15-byte header + AEAD tag) as phantom
1894    // inflight, which silently exhausted the congestion window after a few dozen
1895    // packets and stalled long-lived sessions. (Bandwidth/BDP derive from acked
1896    // bytes, so they stay in the same payload unit.)
1897    crypto_session.on_packet_sent(payload.len() as u64);
1898    true
1899}
1900
1901/// Emit a V2 WINDOW_UPDATE packet announcing `new_window` bytes of
1902/// receive capacity for `stream_id`. Encrypted under the current
1903/// session epoch (Phase 4.3 flow control).
1904async fn send_window_update<T: SessionTransport>(
1905    transport: &Arc<T>,
1906    crypto_session: &Arc<Session>,
1907    session_id: SessionId,
1908    stream_id: TransportStreamId,
1909    new_window: u32,
1910) -> bool {
1911    let mut flag_bits = PacketFlags::ENCRYPTED | PacketFlags::WINDOW_UPDATE;
1912    // WINDOW_UPDATE obeys the same direction-wide rekey discipline before stamping.
1913    match rekey_before_stamp(crypto_session) {
1914        Some(extra) => flag_bits |= extra,
1915        None => return false,
1916    }
1917    let packet_number = crypto_session.next_send_pn();
1918    let header = PacketHeader::new(
1919        session_id,
1920        stream_id,
1921        packet_number,
1922        PacketFlags::new(flag_bits),
1923    )
1924    .with_epoch(crypto_session.current_epoch());
1925    let payload = new_window.to_be_bytes();
1926    let ciphertext = match crypto_session.encrypt_packet(&header, &payload, &[]) {
1927        Ok(c) => c,
1928        Err(e) => {
1929            log::error!("PhantomSession: WINDOW_UPDATE encrypt failed: {}", e);
1930            return false;
1931        }
1932    };
1933    let packet = PhantomPacket::new(header, ciphertext);
1934    let buf = match crypto_session.protect_packet(&packet) {
1935        Ok(b) => b,
1936        Err(e) => {
1937            log::error!(
1938                "PhantomSession: WINDOW_UPDATE header protection failed: {}",
1939                e
1940            );
1941            return false;
1942        }
1943    };
1944    if let Err(e) = transport.send_bytes(&buf).await {
1945        log::error!("PhantomSession: WINDOW_UPDATE send failed: {}", e);
1946        return false;
1947    }
1948    true
1949}
1950
1951/// Emit an idle keep-alive packet (Direction #3 — download-only liveness): a
1952/// small `ENCRYPTED | KEEPALIVE` packet with an **empty** payload, stamped on the
1953/// current send path.
1954///
1955/// `is_pong` selects the role: a bare `KEEPALIVE` is a PING (`is_pong = false`);
1956/// `KEEPALIVE | ACK` is the PONG echo a receiver sends back (`is_pong = true`).
1957/// Either way the payload is empty, so the peer's `recv()` never sees it. The
1958/// packet is sealed exactly like application data — ENCRYPTED (Inv-2), a fresh
1959/// per-direction packet number (no nonce reuse), header-protected — so an off-path
1960/// peer can neither forge nor replay it (the replay window rejects a duplicate PN
1961/// after AEAD verify, Inv-4). Returns `false` on a rekey-saturation or
1962/// seal/transport failure (the caller just skips the keep-alive — it is
1963/// best-effort).
1964async fn send_keepalive<T: SessionTransport>(
1965    transport: &Arc<T>,
1966    crypto_session: &Arc<Session>,
1967    session_id: SessionId,
1968    is_pong: bool,
1969) -> bool {
1970    let mut flag_bits = PacketFlags::ENCRYPTED | PacketFlags::KEEPALIVE;
1971    if is_pong {
1972        flag_bits |= PacketFlags::ACK;
1973    }
1974    // Obey the same direction-wide rekey discipline before stamping the header.
1975    match rekey_before_stamp(crypto_session) {
1976        Some(extra) => flag_bits |= extra,
1977        None => return false,
1978    }
1979    let packet_number = crypto_session.next_send_pn();
1980    let header = PacketHeader::new(
1981        session_id,
1982        // Reserved raw-app stream id (1) — the keep-alive carries no stream data,
1983        // but a stable id keeps the header well-formed and consistent with the
1984        // session's own send()/recv() surface.
1985        RAW_APP_STREAM_ID as TransportStreamId,
1986        packet_number,
1987        PacketFlags::new(flag_bits),
1988    )
1989    .with_epoch(crypto_session.current_epoch())
1990    .with_path_id(crypto_session.current_send_path_id());
1991    let ciphertext = match crypto_session.encrypt_packet(&header, &[], &[]) {
1992        Ok(c) => c,
1993        Err(e) => {
1994            log::error!("PhantomSession: keep-alive encrypt failed: {}", e);
1995            return false;
1996        }
1997    };
1998    let packet = PhantomPacket::new(header, ciphertext);
1999    let buf = match crypto_session.protect_packet(&packet) {
2000        Ok(b) => b,
2001        Err(e) => {
2002            log::error!("PhantomSession: keep-alive header protection failed: {}", e);
2003            return false;
2004        }
2005    };
2006    if let Err(e) = transport.send_bytes(&buf).await {
2007        log::error!("PhantomSession: keep-alive send failed: {}", e);
2008        return false;
2009    }
2010    true
2011}
2012
2013/// Emit one anti-fingerprint COVER (dummy) packet (WIRE v6, deliverable (e)): an
2014/// `ENCRYPTED | COVER` packet with **empty** inner plaintext, PADÉ-padded to a
2015/// bucket so it is not a tiny distinctive size on the wire. It carries no stream
2016/// data; the peer AEAD-authenticates it (which refreshes its liveness timer and
2017/// makes off-path injection impossible) then drops it before the data path, so it
2018/// never reaches `recv()`. Cover is always padded, independent of the session's
2019/// data-padding policy.
2020async fn send_cover<T: SessionTransport>(
2021    transport: &Arc<T>,
2022    crypto_session: &Arc<Session>,
2023    session_id: SessionId,
2024) -> bool {
2025    let mut flag_bits = PacketFlags::ENCRYPTED | PacketFlags::COVER;
2026    // Same direction-wide rekey discipline as any other send.
2027    match rekey_before_stamp(crypto_session) {
2028        Some(extra) => flag_bits |= extra,
2029        None => return false,
2030    }
2031    let mut plaintext = Vec::new();
2032    let trailer = shaping::padding_trailer_len(0, PaddingPolicy::Padme);
2033    if trailer > 0 {
2034        shaping::append_padding(&mut plaintext, trailer);
2035        flag_bits |= PacketFlags::PADDED;
2036    }
2037    let packet_number = crypto_session.next_send_pn();
2038    let header = PacketHeader::new(
2039        session_id,
2040        RAW_APP_STREAM_ID as TransportStreamId,
2041        packet_number,
2042        PacketFlags::new(flag_bits),
2043    )
2044    .with_epoch(crypto_session.current_epoch())
2045    .with_path_id(crypto_session.current_send_path_id());
2046    let ciphertext = match crypto_session.encrypt_packet(&header, &plaintext, &[]) {
2047        Ok(c) => c,
2048        Err(e) => {
2049            log::error!("PhantomSession: cover encrypt failed: {}", e);
2050            return false;
2051        }
2052    };
2053    let packet = PhantomPacket::new(header, ciphertext);
2054    let buf = match crypto_session.protect_packet(&packet) {
2055        Ok(b) => b,
2056        Err(e) => {
2057            log::error!("PhantomSession: cover header protection failed: {}", e);
2058            return false;
2059        }
2060    };
2061    if let Err(e) = transport.send_bytes(&buf).await {
2062        log::error!("PhantomSession: cover send failed: {}", e);
2063        return false;
2064    }
2065    true
2066}
2067
2068/// Maintain a minimum outbound packet rate with cover traffic (WIRE v6, deliverable
2069/// (e)): when no packet has gone out for `cover_interval`, emit a COVER dummy so
2070/// silence + volume no longer leak (idle-fill + a floor rate of `1000 / interval_ms`
2071/// packets/sec). `last_pn` / `last_at` track the last observed outbound activity —
2072/// any real packet advances the send PN, resetting the idle window, so cover only
2073/// fills genuine gaps and never piles on top of active traffic.
2074async fn maybe_send_cover<T: SessionTransport>(
2075    transport: &Arc<T>,
2076    crypto_session: &Arc<Session>,
2077    session_id: SessionId,
2078    last_pn: &mut u64,
2079    last_at: &mut std::time::Instant,
2080) {
2081    let interval = crypto_session.cover_interval();
2082    if interval.is_zero() {
2083        return;
2084    }
2085    if crypto_session.state() != SessionState::Connected {
2086        return;
2087    }
2088    let pn = crypto_session.peek_send_pn();
2089    if pn != *last_pn {
2090        // Real (or prior cover) traffic went out since the last check — reset.
2091        *last_pn = pn;
2092        *last_at = std::time::Instant::now();
2093        return;
2094    }
2095    if last_at.elapsed() >= interval && send_cover(transport, crypto_session, session_id).await {
2096        *last_pn = crypto_session.peek_send_pn();
2097        *last_at = std::time::Instant::now();
2098    }
2099}
2100
2101/// Emit a V2 PATH_VALIDATION packet on `path_id` carrying the given
2102/// 32-byte challenge or response payload. Encrypted under the current
2103/// session epoch.
2104/// Build + encrypt a `PATH_VALIDATION` packet, returning its on-wire bytes. The
2105/// caller routes them: to the established peer (a response echo) via `send_bytes`,
2106/// or to a migration candidate (a server-issued challenge) via `send_to_candidate`
2107/// (Phase 4). Returns `None` only if the AEAD seal fails.
2108fn encrypt_path_validation(
2109    crypto_session: &Arc<Session>,
2110    session_id: SessionId,
2111    path_id: u8,
2112    payload: [u8; crate::transport::path::PATH_CHALLENGE_LEN],
2113) -> Option<Vec<u8>> {
2114    let packet_number = crypto_session.next_send_pn();
2115    let mut packet = build_path_validation_packet(session_id, path_id, packet_number, payload);
2116    let flag_bits = packet.header.flags.0 | PacketFlags::ENCRYPTED;
2117    packet.header.flags = PacketFlags::new(flag_bits);
2118    packet.header.epoch = crypto_session.current_epoch();
2119    let plaintext = std::mem::take(&mut packet.payload);
2120    let ciphertext = match crypto_session.encrypt_packet(&packet.header, &plaintext, &[]) {
2121        Ok(c) => c,
2122        Err(e) => {
2123            log::error!("PhantomSession: PATH_VALIDATION encrypt failed: {}", e);
2124            return None;
2125        }
2126    };
2127    packet.payload = ciphertext;
2128    match crypto_session.protect_packet(&packet) {
2129        Ok(buf) => Some(buf),
2130        Err(e) => {
2131            log::error!(
2132                "PhantomSession: PATH_VALIDATION header protection failed: {}",
2133                e
2134            );
2135            None
2136        }
2137    }
2138}
2139
2140/// Send a `PATH_VALIDATION` packet to the established peer (a response echo).
2141async fn send_path_validation<T: SessionTransport>(
2142    transport: &Arc<T>,
2143    crypto_session: &Arc<Session>,
2144    session_id: SessionId,
2145    path_id: u8,
2146    payload: [u8; crate::transport::path::PATH_CHALLENGE_LEN],
2147) -> bool {
2148    let buf = match encrypt_path_validation(crypto_session, session_id, path_id, payload) {
2149        Some(b) => b,
2150        None => return false,
2151    };
2152    if let Err(e) = transport.send_bytes(&buf).await {
2153        log::error!("PhantomSession: PATH_VALIDATION send failed: {}", e);
2154        return false;
2155    }
2156    true
2157}
2158
2159/// Hard cap on concurrent receive streams a peer can open on one session (H-3). The recv
2160/// path auto-creates a `Stream` for any of the 2^32 `stream_id`s; without a cap a peer can
2161/// spray distinct ids to explode the stream table. With the per-stream reorder budget,
2162/// `MAX_STREAMS` times `MAX_RECV_REORDER_BYTES` bounds the session's total reorder memory.
2163/// Sized well above QUIC's ~100-stream default so real multiplexing is unaffected.
2164const MAX_STREAMS: usize = 256;
2165
2166/// EPS-02 symmetric-rotation step — extracted from [`handle_packet`] so the role
2167/// branch is unit-tested always-on (not only by the `#[ignore]` `udp_integration`
2168/// suite). After the post-AEAD path detects a peer migration (`note_migration_path`
2169/// returned a slide), rotate OUR OWN outbound CID so the return direction's
2170/// cleartext ConnId does not stay stable across the peer's move (§12.5). A server
2171/// (whose client migrated) rotates the s2c CID path-id-silent (the socket-routed
2172/// client needs no window slide, and not bumping the send `path_id` prevents a
2173/// ping-pong). A client (whose server migrated) bumps its send `path_id` and
2174/// rotates the c2s CID (the path_id bump slides the server's c2s demux window onto
2175/// the rotated CID — the no-stranding fix), ending the exchange in one round.
2176fn apply_eps02_peer_migration_rotation<T: SessionTransport>(crypto: &Session, transport: &T) {
2177    if crypto.is_server() {
2178        transport.set_outbound_cid(crypto.advance_outbound_cid());
2179    } else {
2180        crypto.next_migration_path_id();
2181        transport.set_outbound_cid(crypto.advance_outbound_cid());
2182    }
2183}
2184
2185/// Recv-side handler for a packet:
2186/// - session-id guard → drop any frame not stamped with the negotiated
2187///   session id before touching any state (H1).
2188/// - decrypt (REQUIRED on application data — a non-empty unencrypted
2189///   post-handshake packet is a downgrade indicator and is dropped).
2190/// - ACK (now `ENCRYPTED | ACK`, post-decrypt) → parse the authenticated
2191///   `Sack` from the plaintext, retire every covered segment, feed BBR per
2192///   retired segment + route to the stream / demux. Forged/plaintext ACKs
2193///   cannot reach this path (H1); a malformed SACK is dropped, never a panic.
2194/// - PATH_VALIDATION flag → drive the path registry: verify against an
2195///   outstanding challenge if one exists, otherwise echo the payload
2196///   back as a response.
2197/// - WINDOW_UPDATE flag → apply the peer's announced flow-control window.
2198/// - COALESCED flag → split the decrypted bundle into sub-payloads and
2199///   route each through the demux as an independent application chunk.
2200#[allow(clippy::too_many_arguments)]
2201async fn handle_packet<T: SessionTransport>(
2202    packet: PhantomPacket,
2203    session_id: SessionId,
2204    crypto_recv: &Arc<Session>,
2205    streams_recv: &Arc<DashMap<u32, Arc<Stream>>>,
2206    demux_recv: &Arc<StreamDemultiplexer>,
2207    transport_send_ack: &Arc<T>,
2208    transport_for_path: &Arc<T>,
2209    // The reader hands decrypted application data to the delivery task via
2210    // this unbounded channel instead of blocking on `recv_tx`/the demux — so a
2211    // slow `recv()` consumer can never head-of-line-stall inbound ACK/control.
2212    deliver_tx: &mpsc::UnboundedSender<(u32, Bytes)>,
2213    undelivered_bytes: &AtomicU64,
2214    ack_buf: &mut Vec<u8>,
2215    observability: &Observability,
2216    leg: LegType,
2217) {
2218    let stream_id: u32 = packet.header.stream_id.into();
2219    let path_id = packet.header.path_id;
2220
2221    // Bind every inbound frame to the negotiated session (H1). In ε / WIRE v5 the
2222    // inner `session_id` is off-wire: `parse_protected` reconstructed
2223    // `header.session_id` from this session's id, so this comparison is now a
2224    // structural backstop (always true on a correctly-routed frame). The real
2225    // cross-session bind is the AEAD AAD, which still authenticates `session_id`
2226    // — a frame mis-delivered to the wrong session reconstructs that session's id
2227    // into the AAD → wrong AAD → AEAD fail below, so forged ACK/FIN injection can
2228    // never reach the stream table, BBR, or the path registry. Retained as a
2229    // defensive backstop (design §2.1).
2230    if packet.header.session_id != session_id {
2231        return;
2232    }
2233
2234    // Mark path activity even before decrypt (the path id is plaintext
2235    // header bytes; this is just a liveness signal for the sweep).
2236    crypto_recv.mark_path_seen(path_id);
2237
2238    // NOTE: ACK/FIN are NO LONGER processed here, pre-decrypt. They are
2239    // authenticated `ENCRYPTED | ACK` control frames now (H1) and are handled
2240    // *after* the AEAD gate below — see the ACK branch following the decrypt.
2241
2242    // Decrypt if marked. V2 sessions REQUIRE ENCRYPTED on application
2243    // data — a non-empty unencrypted V2 application-data packet is a
2244    // downgrade indicator and is dropped (same posture as V1).
2245    let plaintext: Vec<u8> = if packet.header.flags.contains(PacketFlags::ENCRYPTED) {
2246        // Accept a single authenticated forward rekey step (C1): if this
2247        // packet's epoch is one ahead, the peer rekeyed — trial-decrypt under
2248        // the next key and only commit the ratchet on AEAD success, so a forged
2249        // epoch can't desync us. Same-epoch packets take the ordinary path.
2250        match crypto_recv.decrypt_packet_accepting_rekey(
2251            &packet.header,
2252            &packet.payload,
2253            &packet.extensions,
2254        ) {
2255            Ok(pt) => pt,
2256            Err(e) => {
2257                // Distinguish the two drop reasons for the security metrics: a
2258                // post-AEAD sliding-window replay reject vs an AEAD-verify
2259                // failure (Invariant 4 — replay is checked after AEAD opens).
2260                // decrypt_packet doesn't surface old-vs-duplicate, so record the
2261                // representative `Duplicate` reason.
2262                if matches!(e, CoreError::ReplayDetected(_)) {
2263                    observability.record_replay_rejected(ReplayReason::Duplicate);
2264                } else {
2265                    observability.record_aead_failure(leg, AeadAlgorithm::Aes256Gcm);
2266                }
2267                log::warn!("PhantomSession: V2 decrypt failed (dropping packet): {}", e);
2268                return;
2269            }
2270        }
2271    } else {
2272        // Stripped-flag downgrade defense (Invariant 2, M-2): ANY unencrypted post-handshake
2273        // packet is dropped — including an empty-payload one whose only remaining effect would
2274        // be a forged standalone FIN tearing down an `open_stream()` stream without AEAD
2275        // verification. Legitimate data and control frames (incl. FIN) always set ENCRYPTED.
2276        observability.record_unencrypted_dropped(leg);
2277        log::warn!(
2278            "PhantomSession: dropping unencrypted post-handshake packet (downgrade / forged FIN?)"
2279        );
2280        return;
2281    };
2282
2283    // Strip anti-fingerprint size padding (WIRE v6, deliverable (c)): a PADDED
2284    // packet's AEAD plaintext ends with a `‹zeros› ‖ pad_n:u16be` trailer. The
2285    // PADDED flag is AEAD-authenticated (it is part of the header AAD verified
2286    // above), so this only runs on genuine padded packets; a malformed trailer
2287    // from a buggy peer is dropped without panic. Stripping here — before any
2288    // downstream parse — means the SACK / keepalive / data paths all see the real
2289    // inner plaintext, exactly as if no padding had been applied.
2290    let plaintext: Vec<u8> = if packet.header.flags.contains(PacketFlags::PADDED) {
2291        match shaping::strip_padding(&plaintext) {
2292            Ok(inner) => inner.to_vec(),
2293            Err(_) => {
2294                log::warn!("PhantomSession: dropping packet with malformed padding trailer");
2295                return;
2296            }
2297        }
2298    } else {
2299        plaintext
2300    };
2301
2302    // Liveness (P4.3): an authenticated inbound packet (it passed AEAD above) proves
2303    // the peer is alive on some path — refresh the activity timer so the pump's
2304    // liveness sweep does not false-trip. Plaintext/forged packets never reach here
2305    // (a failed decrypt returned early), so an off-path attacker cannot keep a dead
2306    // session looking alive.
2307    if packet.header.flags.contains(PacketFlags::ENCRYPTED) {
2308        crypto_recv.update_activity();
2309        // M-1: this packet just AEAD-authenticated, so its source really is the peer — possibly
2310        // at a NEW address (migration / NAT rebind). Commit it as the migration candidate ONLY
2311        // now (post-decrypt), so a spoofed CID-matched datagram (which never decrypts) cannot
2312        // clobber the candidate slot and misdirect / stall a legitimate migration. No-op for
2313        // same-source packets and for non-address transports (default trait impl).
2314        transport_for_path.confirm_authenticated_source();
2315        // ε / WIRE v5 (P4b): the path_id is now authenticated. If the peer migrated
2316        // (a new forward path_id), slide our inbound CID demux window so its rotated
2317        // CID stays routable for arbitrarily many migrations. No-op on the client and
2318        // for a path_id that is not newer (reorder / duplicate / passive rebind).
2319        if let Some(slide) = crypto_recv.note_migration_path(packet.header.path_id) {
2320            crypto_recv.signal_cid_slide(slide);
2321            // EPS-02 (symmetric rotation) — the peer migrated, so rotate our OWN outbound
2322            // CID too; otherwise the return direction keeps a stable cleartext ConnId across
2323            // the move and a both-networks observer relinks the session by it (§12.5). BOTH
2324            // sides now act, but the mechanism differs by demux topology:
2325            //
2326            //  * SERVER detecting a CLIENT migration: rotate the s2c CID only, path_id-SILENT.
2327            //    The client is socket-routed (accepts any inbound CID), so it needs no window
2328            //    slide; and NOT bumping the server's send path_id is what prevents a ping-pong
2329            //    (the client would otherwise see a forward server path_id and re-reflect).
2330            //
2331            //  * CLIENT detecting a SERVER migration (D4, the EPS-02 closure for server-
2332            //    initiated migration): rotate the c2s CID AND bump our send path_id. The
2333            //    server DOES demux c2s by a CID window keyed on the client path_id, so the
2334            //    path_id bump is what makes it slide that window to the rotated c2s CID — the
2335            //    no-stranding fix (rotating the CID alone, without the path_id bump, was the
2336            //    hazard the old "client must not rotate" rule avoided). This terminates in one
2337            //    round: the server, seeing the client's forward path_id, slides its c2s window
2338            //    AND runs its own path_id-silent s2c re-rotation (the SERVER arm above), from
2339            //    which the client sees no new forward server path_id → note_migration_path
2340            //    returns None → no re-reflection. The reflected c2s comes from the SAME client
2341            //    source, so the server's confirm_authenticated_source is a no-op for it.
2342            //
2343            // The `path_id` bump (session layer) and the CID rotation (transport layer)
2344            // are not a single atomic step, so a send racing this rotation can stamp a
2345            // one-step-skewed `(path_id=N, CID_{N-1})` or `(path_id=N-1, CID_N)` pair. That
2346            // is harmless: the peer demux routes by CID against a window with `T = 2`
2347            // trailing + `K = 16` leading slack (cid_chain), which absorbs a ±1 skew, so the
2348            // skewed packet still routes and the L1 ARQ would re-carry it anyway — no strand.
2349            // Same two-step shape as the `Migrate` / `migrate_server` pump arms above.
2350            apply_eps02_peer_migration_rotation(crypto_recv, transport_for_path.as_ref());
2351        }
2352    }
2353
2354    // Idle keep-alive (Direction #3 — download-only liveness). A keep-alive carries
2355    // no application bytes; its sole effect is the `update_activity()` above (which
2356    // refreshed this side's liveness timer and cleared any outstanding probe). It
2357    // is handled here, BEFORE the ACK branch, because a PONG is `KEEPALIVE | ACK`
2358    // and must not be mis-parsed as a SACK. A bare `KEEPALIVE` is a PING → echo a
2359    // `KEEPALIVE | ACK` PONG so the peer's own liveness timer + outstanding-probe
2360    // flag clear; a `KEEPALIVE | ACK` is that PONG → nothing more to do. Either way
2361    // we return so the empty payload never reaches the SACK / data paths.
2362    if packet.header.flags.contains(PacketFlags::KEEPALIVE) {
2363        if !packet.header.flags.contains(PacketFlags::ACK) {
2364            // PING → reply with a PONG (KEEPALIVE | ACK). Best-effort; a drop just
2365            // means the peer re-PINGs next interval (its probe stays outstanding).
2366            let _ = send_keepalive(transport_send_ack, crypto_recv, session_id, true).await;
2367        }
2368        return;
2369    }
2370
2371    // Cover traffic (WIRE v6, deliverable (e)): a COVER packet carries no application
2372    // data (its inner plaintext is empty after the padding strip above). Its only
2373    // effect is the `update_activity()` already done above (it AEAD-authenticated, so
2374    // it proves the peer is alive and cannot be off-path injected). Drop it here,
2375    // before the SACK / data paths, so the empty payload never surfaces in `recv()`.
2376    // (A cover packet is never an ACK — it is `ENCRYPTED | COVER | PADDED` — so this
2377    // must precede the ACK branch below.)
2378    if packet.header.flags.contains(PacketFlags::COVER) {
2379        return;
2380    }
2381
2382    // Authenticated SACK ACK (H1, L1-A). ACKs are `ENCRYPTED | ACK` control
2383    // frames whose AEAD *plaintext* carries a `Sack` (largest_acked,
2384    // ack_delay_us, and the inclusive received ranges). We act on the ACK only
2385    // *after* AEAD verify, which authenticates the header (including `session_id`)
2386    // and the SACK plaintext — so a forged or stripped-flag ACK (dropped above by
2387    // the downgrade defense) can neither retire a pending segment, restore a
2388    // flow-control permit, poison BBR, nor close a stream. A malformed SACK from
2389    // a buggy (but authenticated) peer is dropped without panic and retires
2390    // nothing.
2391    if packet.header.flags.contains(PacketFlags::ACK) {
2392        let sack = match crate::transport::sack::Sack::from_wire(&plaintext) {
2393            Ok(s) => s,
2394            Err(e) => {
2395                log::debug!(
2396                    "PhantomSession: dropping malformed SACK ({} B): {}",
2397                    plaintext.len(),
2398                    e
2399                );
2400                return;
2401            }
2402        };
2403        if let Some(stream) = streams_recv.get(&stream_id) {
2404            // Retire EVERY segment the SACK covers (cumulative). RTT is sampled
2405            // inside `on_sack` per Karn (only for never-retransmitted segments);
2406            // feed BBR per retired segment using the real `ack_delay_us`.
2407            let result = stream.on_sack(&sack).await;
2408            for retired in result.retired {
2409                if let Some(sent_at) = retired.sent_at {
2410                    feed_bbr_on_ack(crypto_recv, sent_at, retired.size, sack.ack_delay_us as u64);
2411                }
2412            }
2413            // L1-B (#7 — congestion 4.4 fix): the SACK gap detector just declared
2414            // segments lost; wake the send loop so Pass-0 fast-retransmits them promptly.
2415            // We do NOT feed BBR's loss signal here. Loss is fed exactly ONCE per loss
2416            // event, at the *retransmission* point (`drain_streams_priority_ordered`'s
2417            // `if seg.retransmit { on_packet_lost(...) }`), which covers BOTH a SACK-gap
2418            // fast-retransmit and an RTO-timeout retransmit. Feeding it again here would
2419            // double-count: `on_packet_lost` decrements the purely-incremental
2420            // `inflight_bytes`, so a SACK-gap-lost segment fed at both detection AND
2421            // retransmission nets `+b −b −b +b −b = −b` over its send/loss/resend/ack
2422            // lifecycle — a permanent inflight under-count that inflates the cwnd budget
2423            // (`cwnd − inflight`) and accumulates with every SACK-gap loss → over-send,
2424            // exactly when the controller should be backing off. Retransmits bypass the
2425            // cwnd gate, so a lost segment is always retransmitted → the single feed at
2426            // the retransmission point reliably fires (and a spurious gap that gets ACKed
2427            // before retransmit correctly feeds no loss at all).
2428            if !result.lost.is_empty() {
2429                crypto_recv.notify_outbound_ready();
2430            }
2431        }
2432        // Best-effort, non-blocking: the demux/PhantomStream path is vestigial;
2433        // routing the ACK/close notification to it must never block the reader.
2434        // Route `largest_acked` to preserve the existing close/notify semantics
2435        // (the waiter only needs *an* ACK signal for the stream).
2436        demux_recv.route_ack(stream_id, sack.largest_acked);
2437        if packet.header.flags.contains(PacketFlags::FIN) {
2438            demux_recv.route_close(stream_id);
2439        }
2440        return;
2441    }
2442
2443    // WINDOW_UPDATE dispatch (Phase 4.3 flow control). Payload is a
2444    // big-endian u32 carrying relative flow-control credit — the bytes the
2445    // peer's application just consumed, which we ADD to our send window.
2446    if packet.header.flags.contains(PacketFlags::WINDOW_UPDATE) {
2447        if plaintext.len() != 4 {
2448            log::warn!(
2449                "PhantomSession: WINDOW_UPDATE payload length {} (expected 4)",
2450                plaintext.len()
2451            );
2452            return;
2453        }
2454        let credit = u32::from_be_bytes([plaintext[0], plaintext[1], plaintext[2], plaintext[3]]);
2455        if let Some(stream) = streams_recv.get(&stream_id) {
2456            // Relative-credit flow control — add the granted credit, then
2457            // wake the send loop so a window-blocked sender resumes immediately
2458            // instead of waiting a full poll tick.
2459            stream.apply_peer_window_update(credit);
2460            crypto_recv.notify_outbound_ready();
2461        }
2462        return;
2463    }
2464
2465    // PATH_VALIDATION dispatch (Phase 4.2): the codec inspects the *plaintext*
2466    // because the wire packet was sealed by the AEAD layer.
2467    if packet.header.flags.contains(PacketFlags::PATH_VALIDATION) {
2468        if plaintext.len() != crate::transport::path::PATH_CHALLENGE_LEN {
2469            log::warn!(
2470                "PhantomSession: PATH_VALIDATION plaintext length {} (expected {})",
2471                plaintext.len(),
2472                crate::transport::path::PATH_CHALLENGE_LEN
2473            );
2474            return;
2475        }
2476        let mut payload_buf = [0u8; crate::transport::path::PATH_CHALLENGE_LEN];
2477        payload_buf.copy_from_slice(&plaintext);
2478        // If we have an in-flight challenge on this path, try to
2479        // verify against it. If verification succeeds, the path
2480        // transitions to Validated and we're done. If it fails, the
2481        // registry already transitioned to Failed — also done.
2482        match crypto_recv.path_state(path_id) {
2483            Some(crate::transport::path::PathStateKind::Validating) => {
2484                // The peer echoed our challenge on this path. If it validates AND a
2485                // migration candidate is pending, SWITCH the active peer to it (D7)
2486                // and reset RTT/cwnd for the new network (D8) — no re-handshake,
2487                // keys persist; subsequent app data + ARQ retransmits flow to the
2488                // new peer. (P4.1 only challenged; P4.2 performs the switch.)
2489                if crypto_recv.complete_path_validation(path_id, &payload_buf)
2490                    && transport_for_path.promote_candidate()
2491                {
2492                    crypto_recv.reset_congestion();
2493                    for s in streams_recv.iter() {
2494                        s.value().reset_rto();
2495                    }
2496                    // M-3: if this was a passive-rebind validation (the reserved id),
2497                    // retire the path so a LATER rebind re-registers it fresh and can
2498                    // be challenged again. The reserved id stays Validated otherwise,
2499                    // and `begin_path_validation` on a Validated path returns None, so
2500                    // the second rebind would never issue a challenge. Active-migration
2501                    // ids are left intact (they are retired by their own lifecycle).
2502                    if path_id == crate::transport::session::REBIND_VALIDATION_PATH_ID {
2503                        crypto_recv.retire_path(path_id);
2504                    }
2505                }
2506                return;
2507            }
2508            Some(crate::transport::path::PathStateKind::Validated)
2509            | Some(crate::transport::path::PathStateKind::Failed) => {
2510                // Terminal state — ignore.
2511                return;
2512            }
2513            _ => {
2514                // Unknown or Unvalidated: treat this packet as an
2515                // incoming challenge and echo the payload back as our
2516                // response. The remote will then verify it against its
2517                // own pending challenge.
2518                let _ = send_path_validation(
2519                    transport_for_path,
2520                    crypto_recv,
2521                    session_id,
2522                    path_id,
2523                    payload_buf,
2524                )
2525                .await;
2526                return;
2527            }
2528        }
2529    }
2530
2531    // PATH-001 split (D10, Phase 4). Runs AFTER AEAD verify + the per-direction
2532    // replay window, so it never acts on an attacker-chosen plaintext path_id.
2533    //
2534    // PATH-001b (recv, relaxed): AEAD-authenticated, non-replayed app data is
2535    // DELIVERED regardless of which path it arrived on. Dropping it by source buys
2536    // no security (only the real peer holds the keys; replays are already rejected)
2537    // and would break a seamless NAT-rebind / migration. PATH-001a (the strict
2538    // send-gate) lives in the send loop: app data is only ever sent to the
2539    // established peer — a candidate gets a PATH_CHALLENGE, never app data.
2540    //
2541    // Server-side migration (P4.1): if this app packet arrived on a not-yet-
2542    // Validated path AND the transport flagged a migration candidate (a new source
2543    // for this CID), proactively issue + send a challenge to the candidate so the
2544    // new path can validate. We do NOT switch the peer here (that is P4.2); the
2545    // challenge goes to the candidate under its anti-amplification budget.
2546    if !matches!(
2547        crypto_recv.path_state(path_id),
2548        Some(crate::transport::path::PathStateKind::Validated)
2549    ) {
2550        if transport_for_path.has_migration_candidate() {
2551            if let Some(challenge) = crypto_recv.begin_path_validation(path_id) {
2552                if let Some(buf) =
2553                    encrypt_path_validation(crypto_recv, session_id, path_id, challenge)
2554                {
2555                    // To the candidate, NOT the peer; capped at 3× by the transport.
2556                    let _ = transport_for_path.send_to_candidate(&buf).await;
2557                }
2558            }
2559        } else {
2560            // No migration candidate (non-address transport, or a path id seen
2561            // without a source change): track it for a possible later challenge.
2562            crypto_recv.register_unvalidated_path(path_id);
2563        }
2564        // PATH-001b: fall through and deliver the authenticated data below.
2565    } else if transport_for_path.has_migration_candidate() {
2566        // M-3 (passive NAT rebind): the frame arrived on an already-Validated path —
2567        // the path-0 rebind case, where the peer's source address changed WITHOUT it
2568        // calling `migrate()`, so it never bumped `path_id`. The active-migration gate
2569        // above is skipped (path is Validated), so without this branch the new
2570        // authenticated source would never be challenged → never promoted → the
2571        // downstream (server→client) direction keeps targeting the OLD, now-dead
2572        // address → stall. Detection is therefore ADDRESS-driven, not path-id-driven:
2573        // a migration candidate exists only because `confirm_authenticated_source`
2574        // committed an AEAD-authenticated source that differs from the established
2575        // peer (M-1). We challenge that candidate on the RESERVED validation path-id
2576        // (carved out of the migration id space), which the registry can take through
2577        // `Validating → Validated` independently of the always-Validated path 0. The
2578        // challenge goes ONLY to the candidate (its claimed address), under the same
2579        // 3× anti-amplification cap — anti-spoof is preserved exactly as for an active
2580        // migration. The peer switch happens later, when the candidate echoes the
2581        // challenge (the PATH_VALIDATION completion branch above).
2582        let rebind_path = crate::transport::session::REBIND_VALIDATION_PATH_ID;
2583        if let Some(challenge) = crypto_recv.begin_path_validation(rebind_path) {
2584            if let Some(buf) =
2585                encrypt_path_validation(crypto_recv, session_id, rebind_path, challenge)
2586            {
2587                let _ = transport_for_path.send_to_candidate(&buf).await;
2588            }
2589        }
2590    }
2591
2592    // COALESCED dispatch (Phase 2.5): split the decrypted bundle into sub-payloads
2593    // and hand each, IN ORDER, to the single FIFO delivery task. Bundles are NOT
2594    // reassembled by stream offset — they are not emitted by the live sender (a
2595    // recv-side capability only), are not independently sequenced, and do not
2596    // auto-ACK (the outer sequence was consumed by the replay window). Delivered in
2597    // arrival order, preserving the bundle's internal order.
2598    if packet.header.flags.contains(PacketFlags::COALESCED) {
2599        let inner_for_codec = PhantomPacket {
2600            header: packet.header,
2601            payload: plaintext,
2602            extensions: Vec::new(),
2603        };
2604        match unwrap_coalesced_packet(&inner_for_codec) {
2605            Ok(Some(subs)) => {
2606                let payloads: Vec<Bytes> = subs
2607                    .into_iter()
2608                    .filter(|s| !s.is_empty())
2609                    .map(Bytes::from)
2610                    .collect();
2611                deliver_in_order_run(payloads, stream_id, deliver_tx, undelivered_bytes);
2612            }
2613            Ok(None) => {
2614                log::warn!("PhantomSession: COALESCED flag set but bundle didn't parse");
2615            }
2616            Err(e) => {
2617                log::warn!("PhantomSession: COALESCED parse error: {}", e);
2618            }
2619        }
2620        return;
2621    }
2622
2623    // Reliable application data → reassemble by the gap-free `stream_offset` (A.5),
2624    // emit an authenticated **SACK** ACK inline (H1, L1-A), then deliver the
2625    // in-order run. The reliable AEAD plaintext is `[stream_offset: u32 BE][data]`;
2626    // reordering on `stream_offset` (not the control-frame-holed `header.sequence`)
2627    // is what makes reliable in-order delivery correct over a reordering path. The
2628    // ACK is an `ENCRYPTED | ACK` control frame whose AEAD *plaintext* carries a
2629    // `Sack` over `stream_offset` ranges; the peer parses it only after AEAD verify,
2630    // so it cannot be forged off-path and a malformed range from a buggy peer is
2631    // dropped post-decrypt without crashing (handled in the sender branch). The SACK
2632    // retires every covered segment at once, so a lost ACK no longer strands a
2633    // segment — the next SACK re-acks it cumulatively. The ACK's own
2634    // `header.sequence` is drawn from this side's per-stream send counter — shared
2635    // with our data/window-update sends — so `(epoch, stream_id, sequence, path_id)`
2636    // is unique and never collides with our outbound data (the nonce-reuse trap); it
2637    // obeys the C1 rekey discipline. "ACK" means "received, decrypted, replay-passed,
2638    // accepted into in-order reassembly."
2639    if packet.header.flags.contains(PacketFlags::RELIABLE) {
2640        // Reliable plaintext = [stream_offset: u32 BE][data] (A.5). A frame shorter
2641        // than the 4-byte offset prefix is malformed — no legitimate sender emits
2642        // one — so drop it (never a panic).
2643        if plaintext.len() < 4 {
2644            log::warn!(
2645                "PhantomSession: reliable frame missing stream-offset prefix ({} B)",
2646                plaintext.len()
2647            );
2648            return;
2649        }
2650        let pt = Bytes::from(plaintext);
2651        let stream_offset = u32::from_be_bytes([pt[0], pt[1], pt[2], pt[3]]);
2652        let data = pt.slice(4..);
2653
2654        // H-3: cap concurrent receive streams. A new stream_id is auto-created only while
2655        // under MAX_STREAMS; past the cap the segment is refused (and, being unrecorded, not
2656        // SACKed → the sender retransmits / the stream stalls), so a peer cannot explode the
2657        // stream table across the 2^32 id space.
2658        let existing = streams_recv.get(&stream_id).map(|s| s.clone());
2659        let local = match existing {
2660            Some(s) => s,
2661            None => {
2662                if streams_recv.len() >= MAX_STREAMS {
2663                    log::warn!(
2664                        "PhantomSession: refusing new receive stream {stream_id}: \
2665                         MAX_STREAMS ({MAX_STREAMS}) reached"
2666                    );
2667                    return;
2668                }
2669                streams_recv
2670                    .entry(stream_id)
2671                    .or_insert_with(|| Arc::new(Stream::new(stream_id as TransportStreamId)))
2672                    .clone()
2673            }
2674        };
2675        // Accept into the reorder buffer FIRST so the SACK derived next reflects it.
2676        // `accept_in_order` returns the in-order run now deliverable and stamps the
2677        // data-arrival instant; `received_sack(0)` then populates `ack_delay_us`
2678        // from a coarse `now − recv_at`. A `None` SACK is structurally impossible
2679        // here (we just accepted an offset), but we skip the ACK rather than unwrap.
2680        let delivered = local.accept_in_order(stream_offset, vec![data]).await;
2681        let Some(sack) = local.received_sack(0).await else {
2682            return;
2683        };
2684        let mut ack_flag_bits = PacketFlags::ENCRYPTED | PacketFlags::ACK;
2685        match rekey_before_stamp(crypto_recv) {
2686            Some(extra) => ack_flag_bits |= extra,
2687            // Epoch saturated — drop this ACK rather than reuse a nonce; the
2688            // sender retransmits and the session is expected to reconnect.
2689            None => return,
2690        }
2691        let ack_pn = crypto_recv.next_send_pn();
2692        let ack_header = PacketHeader::new(
2693            session_id,
2694            stream_id as TransportStreamId,
2695            ack_pn,
2696            PacketFlags::new(ack_flag_bits),
2697        )
2698        .with_epoch(crypto_recv.current_epoch())
2699        .with_path_id(path_id);
2700        let ack_payload = sack.to_wire();
2701        match crypto_recv.encrypt_packet(&ack_header, &ack_payload, &[]) {
2702            Ok(ct) => {
2703                let ack_packet = PhantomPacket::new(ack_header, ct);
2704                match crypto_recv.protect_packet(&ack_packet) {
2705                    Ok(buf) => {
2706                        ack_buf.clear();
2707                        ack_buf.extend_from_slice(&buf);
2708                        let size = ack_buf.len();
2709                        let _ = transport_send_ack.send_bytes(&ack_buf[..size]).await;
2710                    }
2711                    Err(e) => {
2712                        log::error!("PhantomSession: ACK header protection failed: {}", e)
2713                    }
2714                }
2715            }
2716            Err(e) => log::error!("PhantomSession: ACK encrypt failed: {}", e),
2717        }
2718
2719        // Deliver the in-order run released by the reorder buffer (empty if this
2720        // segment filled a future hole — it waits for the gap to close).
2721        deliver_in_order_run(delivered, stream_id, deliver_tx, undelivered_bytes);
2722
2723        if packet.header.flags.contains(PacketFlags::FIN) {
2724            demux_recv.route_close(stream_id);
2725        }
2726        return;
2727    }
2728
2729    // Non-reliable application data → deliver in arrival order (unreliable data is
2730    // not sequenced/reordered by design). Unbounded + non-blocking, so the reader
2731    // never stalls on a slow `recv()` consumer; counted toward the backlog only on
2732    // a successful enqueue (a dead delivery task can't inflate `undelivered_bytes`).
2733    if !plaintext.is_empty() {
2734        let len = plaintext.len() as u64;
2735        if deliver_tx.send((stream_id, Bytes::from(plaintext))).is_ok() {
2736            undelivered_bytes.fetch_add(len, Ordering::AcqRel);
2737        }
2738    }
2739
2740    if packet.header.flags.contains(PacketFlags::FIN) {
2741        demux_recv.route_close(stream_id);
2742    }
2743}
2744
2745/// Hand an in-order run of reliable payloads (as released by
2746/// [`Stream::accept_in_order`]) to the single FIFO delivery task, in order. Each
2747/// non-empty chunk is counted toward the `undelivered_bytes` backlog only on a
2748/// successful enqueue, so a dead delivery task (consumer gone, `deliver_rx`
2749/// dropped) cannot inflate the counter for data that was discarded.
2750fn deliver_in_order_run(
2751    run: Vec<Bytes>,
2752    stream_id: u32,
2753    deliver_tx: &mpsc::UnboundedSender<(u32, Bytes)>,
2754    undelivered_bytes: &AtomicU64,
2755) {
2756    for chunk in run {
2757        if chunk.is_empty() {
2758            continue;
2759        }
2760        let len = chunk.len() as u64;
2761        if deliver_tx.send((stream_id, chunk)).is_ok() {
2762            undelivered_bytes.fetch_add(len, Ordering::AcqRel);
2763        }
2764    }
2765}
2766
2767// Internal-only methods — deliberately NOT on the `#[uniffi::export]` surface.
2768// `set_state` mutates the connection state machine; a foreign caller forcing
2769// `Connected` mid-handshake would make `is_data_ready()` lie and let `send()`
2770// bypass the queue, or `Closed` without tearing down the pump.
2771impl PhantomSession {
2772    /// Transition to a new connection state. Crate-internal: driven by the
2773    /// handshake task and teardown only.
2774    pub(crate) fn set_state(&self, new_state: ConnectionState) {
2775        self.state.store(new_state as u8, Ordering::Relaxed);
2776    }
2777
2778    /// Session observability handle (Rust-only — `Observability` is not a
2779    /// UniFFI type). For a server-accepted session this is the
2780    /// `PhantomListener`'s shared instance; for a client it is the session's
2781    /// own. Read `.snapshot()` for the lock-free metric counters.
2782    pub fn observability(&self) -> Arc<Observability> {
2783        self.observability.clone()
2784    }
2785}
2786
2787#[cfg_attr(feature = "bindings", uniffi::export(async_runtime = "tokio"))]
2788impl PhantomSession {
2789    /// Create a placeholder session — returns instantly and performs **no**
2790    /// handshake.
2791    ///
2792    /// # ⚠️ This does not connect
2793    ///
2794    /// Despite the name, this constructor never opens a transport, never runs
2795    /// the PQC handshake, and never spawns the background data pump. It returns
2796    /// an inert shell stuck in [`ConnectionState::Connecting`]: any `send()`
2797    /// only queues into an in-memory buffer that is never flushed, and `recv()`
2798    /// never yields application bytes. **No bytes ever reach the network.** It
2799    /// exists only as a pre-handshake placeholder from an earlier API shape.
2800    ///
2801    /// **Deprecated — use a real entry point instead:**
2802    /// - [`PhantomSession::connect_with_transport`] (Rust) — supply a
2803    ///   `SessionTransport` and the pinned `expected_server_key`; this spawns
2804    ///   the handshake + pump.
2805    /// - [`connect_pinned`] (native FFI / mobile) — one-shot TCP connect with a
2806    ///   pinned key.
2807    ///
2808    /// # Why no `#[deprecated]` attribute (T5.7)
2809    ///
2810    /// A `#[deprecated]` attribute would be the natural way to flag this, but it
2811    /// **cannot** be applied here: this constructor is `#[uniffi::constructor]`,
2812    /// and UniFFI 0.31 emits FFI scaffolding that calls `Self::connect()` from
2813    /// generated code in this same crate. That generated call would trip the
2814    /// `deprecated` lint, which CI promotes to a hard error under
2815    /// `clippy --lib -D warnings` — and no item-scoped `#[allow(deprecated)]`
2816    /// reaches the macro-generated call site (only a module-wide
2817    /// `#![allow(deprecated)]` would, which would silently mask every *future*
2818    /// genuine deprecation across this module). So the deprecation is documented
2819    /// loudly here instead. UniFFI copies this doc-comment into the generated
2820    /// Python / Swift / Kotlin docstrings (the C header carries no docstrings),
2821    /// so they were regenerated and committed alongside this change — the
2822    /// `bindings` `drift` CI job stays green. See
2823    /// `tests::deprecated_connect_is_inert_and_sends_no_bytes` for the regression
2824    /// pinning the inert behaviour.
2825    #[cfg_attr(feature = "bindings", uniffi::constructor)]
2826    pub fn connect(peer_addr: String) -> Arc<Self> {
2827        let (cmd_tx, cmd_rx) = mpsc::channel(256);
2828        let (_recv_tx, recv_rx) = mpsc::channel(256);
2829
2830        let (demux, _ctrl_rx) = StreamDemultiplexer::new(256);
2831        let streams = Arc::new(DashMap::new());
2832        Arc::new(Self {
2833            id: new_session_id(),
2834            peer_addr,
2835            state: Arc::new(AtomicU8::new(ConnectionState::Connecting as u8)),
2836            send_queue: Arc::new(Mutex::new(Vec::new())),
2837            cmd_tx,
2838            cmd_rx: Mutex::new(Some(cmd_rx)),
2839            recv_rx: Mutex::new(recv_rx),
2840            demux: Arc::new(demux),
2841            streams,
2842            inner_session: Arc::new(Mutex::new(None)),
2843            early_data_accepted: Arc::new(Mutex::new(None)),
2844            shaping: Arc::new(parking_lot::Mutex::new(TrafficShapingConfig::default())),
2845            // Placeholder session (no transport / pump yet); a no-op holder
2846            // until `connect_with_transport` spawns the real pump.
2847            observability: Observability::new(ObservabilityConfig::default()),
2848        })
2849    }
2850
2851    /// Open a new multiplexed stream
2852    pub fn open_stream(&self) -> Arc<crate::api::stream::PhantomStream> {
2853        let handle = self.demux.open_stream(1024);
2854        let stream_id = handle.stream_id;
2855
2856        let transport_stream = Arc::new(Stream::new(stream_id as TransportStreamId));
2857        self.streams.insert(stream_id, transport_stream);
2858
2859        Arc::new(crate::api::stream::PhantomStream::new(
2860            handle,
2861            self.cmd_tx.clone(),
2862        ))
2863    }
2864
2865    /// Send data through the session.
2866    ///
2867    /// - If the session is connected: sends immediately
2868    /// - If still handshaking: queues the data for auto-flush later
2869    pub async fn send(&self, data: Vec<u8>) -> Result<(), CoreError> {
2870        let state = self.connection_state();
2871
2872        if state.is_data_ready() {
2873            // Channel is up — send directly
2874            self.cmd_tx
2875                .send(SessionCommand::Send(data))
2876                .await
2877                .map_err(|_| CoreError::NetworkError("Session closed".into()))?;
2878        } else if state == ConnectionState::Connecting {
2879            // Still handshaking — queue
2880            self.send_queue.lock().await.push(data);
2881        } else {
2882            return Err(CoreError::NetworkError(format!(
2883                "Cannot send in state {:?}",
2884                state
2885            )));
2886        }
2887
2888        Ok(())
2889    }
2890
2891    /// Receive data from the session.
2892    ///
2893    /// Internally the recv pipeline keeps payloads as `Bytes` to avoid the
2894    /// per-packet Vec clone that used to fan out to the stream demux. The
2895    /// FFI surface still hands callers a `Vec<u8>`; if this is the last
2896    /// refcount the Vec is moved out of the underlying buffer, otherwise
2897    /// `Bytes::to_vec` copies.
2898    pub async fn recv(&self) -> Result<Vec<u8>, CoreError> {
2899        let mut rx = self.recv_rx.lock().await;
2900        let bytes = rx
2901            .recv()
2902            .await
2903            .ok_or_else(|| CoreError::NetworkError("Session closed".into()))?;
2904        Ok(bytes.to_vec())
2905    }
2906
2907    /// Get the current connection state (lock-free).
2908    pub fn connection_state(&self) -> ConnectionState {
2909        ConnectionState::from_u8(self.state.load(Ordering::Relaxed))
2910    }
2911
2912    /// Whether the session is ready for data transmission.
2913    pub fn is_data_ready(&self) -> bool {
2914        self.connection_state().is_data_ready()
2915    }
2916
2917    /// Whether the session has full PQC protection.
2918    pub fn is_pqc_ready(&self) -> bool {
2919        matches!(
2920            self.connection_state(),
2921            ConnectionState::PqcReady | ConnectionState::Connected
2922        )
2923    }
2924
2925    /// Flush all queued messages (called when handshake completes).
2926    pub async fn flush_queue(&self) -> Result<u32, CoreError> {
2927        let mut queue = self.send_queue.lock().await;
2928        let count = queue.len() as u32;
2929        for msg in queue.drain(..) {
2930            self.cmd_tx
2931                .send(SessionCommand::Send(msg))
2932                .await
2933                .map_err(|_| CoreError::NetworkError("Session closed during flush".into()))?;
2934        }
2935        Ok(count)
2936    }
2937
2938    /// Number of messages queued (waiting for handshake).
2939    pub async fn queued_count(&self) -> u32 {
2940        self.send_queue.lock().await.len() as u32
2941    }
2942
2943    /// Session identifier.
2944    pub fn id(&self) -> String {
2945        self.id.clone()
2946    }
2947
2948    /// Target peer address.
2949    pub fn peer_addr(&self) -> String {
2950        self.peer_addr.clone()
2951    }
2952
2953    /// The 0-RTT verdict for this session.
2954    ///
2955    /// - `None` — still handshaking, the handshake failed, or the client sent
2956    ///   no early-data on this connect.
2957    /// - `Some(true)` — the server consumed the 0-RTT early-data.
2958    /// - `Some(false)` — the client sent early-data and the server rejected it
2959    ///   (stale/unknown ticket, oversized blob, or AEAD failure). The caller
2960    ///   must re-send that payload over the normal channel.
2961    pub async fn early_data_accepted(&self) -> Option<bool> {
2962        *self.early_data_accepted.lock().await
2963    }
2964
2965    /// Extract a [`ResumptionHint`] for a future 0-RTT reconnect.
2966    ///
2967    /// Returns `Some` after a successful handshake; `None` while still
2968    /// handshaking, after a failure, or before the inner session has
2969    /// been published.
2970    ///
2971    /// Store the hint alongside the pinned `HybridVerifyingKey` of the
2972    /// server it was negotiated against and feed it back to
2973    /// [`connect_pinned_with_resumption`]. Reusing a hint across
2974    /// servers is a configuration bug — the `resumption_secret` is
2975    /// server-pinned.
2976    pub async fn resumption_hint(&self) -> Option<ResumptionHint> {
2977        let guard = self.inner_session.lock().await;
2978        guard
2979            .as_ref()
2980            .and_then(|s| s.resumption_hint())
2981            .map(|(session_id, resumption_secret)| ResumptionHint {
2982                session_id: session_id.to_vec(),
2983                resumption_secret: resumption_secret.to_vec(),
2984            })
2985    }
2986
2987    /// Current rekey epoch of the established session (`None` while still
2988    /// connecting). Rust-only — used by soak / integration tests to confirm
2989    /// that automatic mid-session rekey (C1) advanced the epoch.
2990    pub async fn current_epoch(&self) -> Option<u8> {
2991        self.inner_session
2992            .lock()
2993            .await
2994            .as_ref()
2995            .map(|s| s.current_epoch())
2996    }
2997
2998    /// Override the automatic-rekey send-invocation high-watermark on the
2999    /// established session (default `REKEY_SOFT_LIMIT`, currently `2^32`).
3000    /// Returns `false` if the session is still connecting. Rust-only — primarily
3001    /// for soak/load harnesses that need to exercise mid-session rekey without
3002    /// sending `2^32` packets.
3003    pub async fn set_rekey_threshold(&self, n: u64) -> bool {
3004        match self.inner_session.lock().await.as_ref() {
3005            Some(s) => {
3006                s.set_rekey_threshold(n);
3007                true
3008            }
3009            None => false,
3010        }
3011    }
3012
3013    /// Apply an anti-fingerprint traffic-shaping configuration to the established
3014    /// session (WIRE v6, direction #4). Returns `false` if the session is still
3015    /// connecting. All shaping is opt-in (default: none); enabling size padding
3016    /// ([`PaddingPolicy::Padme`]) makes outbound packets pad up to a PADÉ bucket so
3017    /// the datagram size no longer tracks the payload size, at a bounded (≈ ≤12%
3018    /// worst-case) bandwidth cost. FFI-exported so mobile / other embedders can
3019    /// tune it.
3020    pub async fn set_traffic_shaping(&self, config: TrafficShapingConfig) -> bool {
3021        // #9 — store as the pending config (a clone applied at session install, so
3022        // it works BEFORE the async client handshake completes), then apply
3023        // immediately too if the session is already established. Always accepted.
3024        *self.shaping.lock() = config;
3025        if let Some(s) = self.inner_session.lock().await.as_ref() {
3026            apply_shaping(s, config);
3027        }
3028        true
3029    }
3030
3031    /// Read back the traffic-shaping config currently applied to the established
3032    /// session (#9). `None` while still connecting (the session is not installed
3033    /// yet — the pending config set via [`set_traffic_shaping`](Self::set_traffic_shaping)
3034    /// will apply on install). FFI-exported.
3035    pub async fn traffic_shaping(&self) -> Option<TrafficShapingConfig> {
3036        self.inner_session
3037            .lock()
3038            .await
3039            .as_ref()
3040            .map(|s| TrafficShapingConfig {
3041                padding: s.padding_policy(),
3042                jitter_ms: s.send_jitter().as_millis() as u32,
3043                cover_interval_ms: s.cover_interval().as_millis() as u32,
3044            })
3045    }
3046
3047    /// Migrate the session to a new local network address (Phase 4 — embedder-
3048    /// triggered connection migration). The embedder calls this when the OS reports a
3049    /// network change (Wi-Fi↔cellular, NAT rebind); `local_addr` is the new local
3050    /// bind address (e.g. `"0.0.0.0:0"` to let the OS pick an ephemeral port on the
3051    /// new interface).
3052    ///
3053    /// **Best-effort and non-blocking on validation.** It hands the request to the
3054    /// background pump, which rebinds the transport (keeping the old socket for the
3055    /// overlap) and bumps the send `path_id`; the path validation + server-side peer
3056    /// switch then complete asynchronously. The keys and session persist — **no
3057    /// re-handshake**. A failed rebind never tears the session down: it keeps running
3058    /// on the existing socket (broken-rebind safety). `Err` here means only that the
3059    /// session was already closed (the command channel is gone).
3060    pub async fn migrate(&self, local_addr: String) -> Result<(), CoreError> {
3061        self.cmd_tx
3062            .send(SessionCommand::Migrate(local_addr))
3063            .await
3064            .map_err(|_| CoreError::NetworkError("Session closed".into()))
3065    }
3066
3067    /// Send the graceful close frame and shut the session down.
3068    ///
3069    /// Named `disconnect` rather than `close` because UniFFI's Kotlin
3070    /// generator unconditionally adds `AutoCloseable.close()` to every
3071    /// object, and a Rust-side `close` here would conflict with it.
3072    pub async fn disconnect(&self) -> Result<(), CoreError> {
3073        self.set_state(ConnectionState::Closed);
3074        let _ = self.cmd_tx.send(SessionCommand::Close).await;
3075        Ok(())
3076    }
3077}
3078
3079impl PhantomSession {
3080    /// Get the stream demultiplexer (internal use, not exposed to UniFFI)
3081    pub fn demux(&self) -> Arc<StreamDemultiplexer> {
3082        self.demux.clone()
3083    }
3084
3085    /// Override the path-liveness thresholds on the established session (Phase 4 /
3086    /// P4.3). Returns `false` if the session is still connecting. Rust-only (the
3087    /// `LivenessConfig` type is not on the UniFFI surface) — for tests / advanced
3088    /// embedders that want a faster or slower path-down / migration-idle timeout than
3089    /// the default.
3090    pub async fn set_liveness_config(
3091        &self,
3092        cfg: crate::transport::liveness::LivenessConfig,
3093    ) -> bool {
3094        match self.inner_session.lock().await.as_ref() {
3095            Some(s) => {
3096                s.set_liveness_config(cfg);
3097                true
3098            }
3099            None => false,
3100        }
3101    }
3102
3103    /// Migrate the **server side** of this session to a new local send address (the
3104    /// server-side mirror of [`migrate`](Self::migrate)). Intended for an accepted server
3105    /// session whose network path changes (failover, multi-homing, an egress NAT rebind):
3106    /// the server rebinds its send socket to `local_addr` and rotates its server→client
3107    /// `path_id` + connection-ID in lock-step, so the peer follows the fresh s2c source
3108    /// (its unconnected socket hears it) and an observer cannot relink the session by the
3109    /// s2c ConnId across the move. The keys and session persist — **no re-handshake**.
3110    ///
3111    /// The server keeps RECEIVING client→server traffic on the established (listen) address
3112    /// through the overlap, so the session stays bidirectional immediately. Best-effort: a
3113    /// failed rebind leaves the session on the old send socket and never tears it down.
3114    /// `Err` here means only that the session was already closed.
3115    ///
3116    /// **Rust-only** (deliberately not on the UniFFI/FFI surface): server migration is a
3117    /// native-deployment operation, not a mobile-client one. The peer's symmetric c2s
3118    /// follow (switching its send target to the new server address once the old one is
3119    /// unreachable) and the matching c2s CID rotation are added in the follow-up
3120    /// security-core change.
3121    pub async fn migrate_server(&self, local_addr: String) -> Result<(), CoreError> {
3122        self.cmd_tx
3123            .send(SessionCommand::MigrateServer(local_addr))
3124            .await
3125            .map_err(|_| CoreError::NetworkError("Session closed".into()))
3126    }
3127}
3128
3129impl std::fmt::Debug for PhantomSession {
3130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3131        f.debug_struct("PhantomSession")
3132            .field("id", &self.id)
3133            .field("peer", &self.peer_addr)
3134            .field("state", &self.connection_state())
3135            .finish()
3136    }
3137}
3138
3139// ─── Pinned-Connect Shim (Phase 7.2 mobile bridge) ──────────────────────────
3140//
3141// `connect_with_transport` itself can't cross the UniFFI boundary directly —
3142// it takes a generic `T: SessionTransport` trait object and a typed
3143// `HybridVerifyingKey`, neither of which is a UniFFI primitive. Mobile
3144// callers (iOS / Android) need a single async entry point that opens a TCP
3145// connection, wraps it in `TcpSessionTransport`, parses the pinned key from
3146// bytes (per security invariant 1 in SECURITY.md), and hands back an
3147// `Arc<PhantomSession>` ready for `send` / `recv`.
3148//
3149// Native-only: `TcpSessionTransport` lives behind `cfg(not(target_arch =
3150// "wasm32"))`, mirroring `crate::api::tcp_transport`. Wasm consumers use
3151// the in-tree `WebSocketLeg` instead.
3152#[cfg(not(target_arch = "wasm32"))]
3153#[cfg_attr(feature = "bindings", uniffi::export(async_runtime = "tokio"))]
3154pub async fn connect_pinned(
3155    host: String,
3156    port: u16,
3157    pinned_key: Vec<u8>,
3158) -> Result<Arc<PhantomSession>, CoreError> {
3159    // fips bootstrap POST gate (same policy as
3160    // `PhantomListener::bind_inner`). A failure here aborts the
3161    // connect before any socket is opened or key material is
3162    // touched.
3163    #[cfg(feature = "fips")]
3164    crate::crypto::self_tests::ensure_post_passed()
3165        .map_err(|e| CoreError::FipsSelfTestFailure(format!("{e:?}")))?;
3166
3167    // Decode the server's hybrid verifying key. A malformed blob is a
3168    // crypto-layer problem (wrong length, wrong encoding) rather than a
3169    // network failure — surface it as `CryptoError`.
3170    let expected_server_key = HybridVerifyingKey::from_bytes(&pinned_key)
3171        .map_err(|e| CoreError::CryptoError(format!("invalid pinned key: {}", e)))?;
3172
3173    // Open the TCP stream. The `format!` is shared between the actual
3174    // connect target and the `peer_addr` recorded inside the session
3175    // (`connect_with_transport` takes it as a free-form string).
3176    let addr = format!("{}:{}", host, port);
3177    let stream = tokio::net::TcpStream::connect(&addr)
3178        .await
3179        .map_err(|e| CoreError::NetworkError(format!("connect {}: {}", addr, e)))?;
3180    let transport = crate::api::tcp_transport::TcpSessionTransport::new(stream);
3181
3182    // The handshake is driven by the background task spawned inside
3183    // `connect_with_transport`; the returned `PhantomSession` is usable
3184    // immediately (state `Connecting`, sends auto-queued until ready).
3185    let session = PhantomSession::connect_with_transport(&addr, transport, expected_server_key);
3186    Ok(Arc::new(session))
3187}
3188
3189/// Connect to a pinned server over the **TLS-over-TCP active-mimicry** transport
3190/// (`mimicry` feature) — the flow looks like an ordinary HTTPS handshake to an
3191/// on-path observer, while the real authentication / confidentiality remains the
3192/// inner Phantom post-quantum session.
3193///
3194/// `sni` is the cover domain presented in the synthetic ClientHello. It is
3195/// **required and should be rotated** per connection and kept plausible for the
3196/// server's IP/AS — a single network-wide default SNI is itself a blocklist key.
3197///
3198/// **The outer TLS is anti-DPI obfuscation only, and is detectable by active
3199/// probing.** It defeats stateless DPI + passive JA3/JA4 fingerprinting + light
3200/// stateful inspection, but a censor that completes a real TLS handshake or
3201/// validates a certificate detects it in one round trip — do **not** use this
3202/// where active probing is in the threat model. See `docs/security/threat-model.md`.
3203///
3204/// Rust-only and native-only, gated on the `mimicry` feature.
3205#[cfg(all(not(target_arch = "wasm32"), feature = "mimicry"))]
3206pub async fn connect_pinned_mimic(
3207    host: String,
3208    port: u16,
3209    pinned_key: Vec<u8>,
3210    sni: String,
3211) -> Result<Arc<PhantomSession>, CoreError> {
3212    #[cfg(feature = "fips")]
3213    crate::crypto::self_tests::ensure_post_passed()
3214        .map_err(|e| CoreError::FipsSelfTestFailure(format!("{e:?}")))?;
3215
3216    let expected_server_key = HybridVerifyingKey::from_bytes(&pinned_key)
3217        .map_err(|e| CoreError::CryptoError(format!("invalid pinned key: {}", e)))?;
3218
3219    let addr = format!("{}:{}", host, port);
3220    let stream = tokio::net::TcpStream::connect(&addr)
3221        .await
3222        .map_err(|e| CoreError::NetworkError(format!("connect {}: {}", addr, e)))?;
3223
3224    // Run the synthetic TLS prelude before handing the leg to the background
3225    // handshake pump (the leg is ready for `send_bytes`/`recv_bytes` once this
3226    // returns). A prelude failure (e.g. an unreachable / non-mimic server) aborts
3227    // the connect.
3228    let config = crate::transport::legs::mimic_tls::MimicConfig::new(sni);
3229    let transport =
3230        crate::transport::legs::mimic_tls::MimicTlsLeg::connect(stream, &config).await?;
3231
3232    let session = PhantomSession::connect_with_transport(&addr, transport, expected_server_key);
3233    Ok(Arc::new(session))
3234}
3235
3236/// Connect to a pinned server with a **0-RTT resumption attempt** — the
3237/// resumption-aware analogue of [`connect_pinned`].
3238///
3239/// `hint` is a [`ResumptionHint`] from a prior session's
3240/// [`PhantomSession::resumption_hint`]; both of its fields must be
3241/// exactly 32 bytes or the call fails with `ValidationError` before any
3242/// socket is opened. `early_data` (≤ 16 KiB) is sealed into the resuming
3243/// ClientHello so it reaches the server on the very first flight.
3244///
3245/// Acceptance is best-effort: when the server does not consume the early-data
3246/// (stale/unknown ticket or AEAD failure) the handshake completes 1-RTT — the
3247/// caller checks [`PhantomSession::early_data_accepted`] and re-sends over the
3248/// normal channel when it is not `Some(true)`.
3249///
3250/// Native-only, like [`connect_pinned`]: `TcpSessionTransport` lives
3251/// behind `cfg(not(target_arch = "wasm32"))`.
3252#[cfg(not(target_arch = "wasm32"))]
3253#[cfg_attr(feature = "bindings", uniffi::export(async_runtime = "tokio"))]
3254pub async fn connect_pinned_with_resumption(
3255    host: String,
3256    port: u16,
3257    pinned_key: Vec<u8>,
3258    hint: ResumptionHint,
3259    early_data: Vec<u8>,
3260) -> Result<Arc<PhantomSession>, CoreError> {
3261    // fips bootstrap POST gate (same policy as
3262    // `connect_pinned`).
3263    #[cfg(feature = "fips")]
3264    crate::crypto::self_tests::ensure_post_passed()
3265        .map_err(|e| CoreError::FipsSelfTestFailure(format!("{e:?}")))?;
3266
3267    // Server-key pinning stays mandatory (security invariant 1): a
3268    // malformed blob is a crypto-layer problem, surfaced as `CryptoError`.
3269    let expected_server_key = HybridVerifyingKey::from_bytes(&pinned_key)
3270        .map_err(|e| CoreError::CryptoError(format!("invalid pinned key: {}", e)))?;
3271
3272    // `ResumptionHint` fields are `Vec<u8>` (UniFFI has no fixed-size
3273    // array type) — enforce the 32-byte invariant here, before any
3274    // socket is opened, so a caller bug never becomes a network call.
3275    let session_id: [u8; 32] = hint.session_id.as_slice().try_into().map_err(|_| {
3276        CoreError::ValidationError(format!(
3277            "resumption hint session_id must be 32 bytes, got {}",
3278            hint.session_id.len()
3279        ))
3280    })?;
3281    let resumption_secret: [u8; 32] =
3282        hint.resumption_secret.as_slice().try_into().map_err(|_| {
3283            CoreError::ValidationError(format!(
3284                "resumption hint resumption_secret must be 32 bytes, got {}",
3285                hint.resumption_secret.len()
3286            ))
3287        })?;
3288
3289    // APIFFI-03: reject oversized early-data BEFORE opening a socket, so a caller
3290    // bug (or oversized blob) never wastes a TCP connection establishment. The
3291    // inner `connect_with_resumption` enforces the same cap as defense-in-depth.
3292    if early_data.len() > EARLY_DATA_MAX_LEN {
3293        return Err(CoreError::ValidationError(format!(
3294            "early_data is {} bytes, exceeds the {}-byte 0-RTT cap",
3295            early_data.len(),
3296            EARLY_DATA_MAX_LEN
3297        )));
3298    }
3299
3300    let addr = format!("{}:{}", host, port);
3301    let stream = tokio::net::TcpStream::connect(&addr)
3302        .await
3303        .map_err(|e| CoreError::NetworkError(format!("connect {}: {}", addr, e)))?;
3304    let transport = crate::api::tcp_transport::TcpSessionTransport::new(stream);
3305
3306    // Reuses the Rust-only `connect_with_resumption` — no new crypto and
3307    // no new wire format. That path enforces the `EARLY_DATA_MAX_LEN`
3308    // cap and keeps 0-RTT one-shot / best-effort (security invariant 9).
3309    let session = PhantomSession::connect_with_resumption(
3310        &addr,
3311        transport,
3312        expected_server_key,
3313        (session_id, resumption_secret),
3314        early_data,
3315    )?;
3316    Ok(Arc::new(session))
3317}
3318
3319#[cfg(test)]
3320mod tests {
3321    use super::*;
3322    use crate::transport::handshake::{ClientHello, HandshakeResponse, HandshakeServer};
3323
3324    // ── Mock transport for testing ──
3325
3326    /// In-memory transport using channels (simulates a loopback pipe).
3327    struct ChannelTransport {
3328        tx: mpsc::Sender<Vec<u8>>,
3329        rx: Mutex<mpsc::Receiver<Vec<u8>>>,
3330    }
3331
3332    impl ChannelTransport {
3333        /// Create a pair of connected transports (client ↔ server).
3334        fn pair() -> (Self, Self) {
3335            let (a_tx, b_rx) = mpsc::channel(64);
3336            let (b_tx, a_rx) = mpsc::channel(64);
3337            (
3338                Self {
3339                    tx: a_tx,
3340                    rx: Mutex::new(a_rx),
3341                },
3342                Self {
3343                    tx: b_tx,
3344                    rx: Mutex::new(b_rx),
3345                },
3346            )
3347        }
3348    }
3349
3350    impl SessionTransport for ChannelTransport {
3351        async fn send_bytes(&self, data: &[u8]) -> Result<(), CoreError> {
3352            self.tx
3353                .send(data.to_vec())
3354                .await
3355                .map_err(|_| CoreError::NetworkError("channel closed".into()))
3356        }
3357
3358        async fn recv_bytes(&self) -> Result<Bytes, CoreError> {
3359            let mut rx = self.rx.lock().await;
3360            let v = rx
3361                .recv()
3362                .await
3363                .ok_or_else(|| CoreError::NetworkError("channel closed".into()))?;
3364            Ok(Bytes::from(v))
3365        }
3366    }
3367
3368    // ── Tests ──
3369
3370    /// T5.5(b) send-side: `rekey_before_stamp` re-advertises `PacketFlags::REKEY`
3371    /// on EVERY packet at the new epoch — not just the rotation-trigger packet —
3372    /// until the peer acknowledges the rekey. This is what lets a lost trigger
3373    /// packet recover: the next stamp still flags REKEY so the receive-side gate
3374    /// follows the catch-up. Without re-advertise the second stamp would carry the
3375    /// new epoch unflagged and the gate would strand the receiver.
3376    #[test]
3377    fn rekey_before_stamp_re_advertises_rekey_until_peer_confirms() {
3378        use crate::transport::session::{CryptoState, Session};
3379        use crate::transport::types::{SchedulerMode, SessionId};
3380
3381        let shared = [0x55u8; 32];
3382        let id = SessionId::from_bytes([7u8; 32]);
3383        let crypto = CryptoState::new(&shared, false).expect("crypto");
3384        let session = Arc::new(Session::from_derived(
3385            id,
3386            crypto,
3387            SchedulerMode::LowLatency,
3388            shared,
3389            false,
3390        ));
3391        session.set_rekey_threshold(2);
3392
3393        // Below the watermark: no rekey, no flag.
3394        assert_eq!(
3395            rekey_before_stamp(&session),
3396            Some(0),
3397            "below threshold: no flag"
3398        );
3399
3400        // Cross the high-watermark so the next stamp rotates.
3401        let h = PacketHeader::new(
3402            *session.id(),
3403            1,
3404            0,
3405            PacketFlags::new(PacketFlags::ENCRYPTED),
3406        );
3407        for i in 0..2u64 {
3408            session
3409                .encrypt_packet(
3410                    &PacketHeader {
3411                        packet_number: i,
3412                        ..h
3413                    },
3414                    b"x",
3415                    &[],
3416                )
3417                .expect("encrypt");
3418        }
3419        assert!(session.send_needs_rekey());
3420
3421        // The rotation-trigger stamp flags REKEY and bumps the epoch.
3422        assert_eq!(rekey_before_stamp(&session), Some(PacketFlags::REKEY));
3423        assert_eq!(session.current_epoch(), 1);
3424        assert!(session.rekey_unconfirmed());
3425
3426        // The NEXT stamp re-advertises REKEY even though no further rekey happens —
3427        // the peer has not confirmed yet.
3428        assert_eq!(rekey_before_stamp(&session), Some(PacketFlags::REKEY));
3429        assert_eq!(
3430            session.current_epoch(),
3431            1,
3432            "no second rekey — only a re-advertise"
3433        );
3434    }
3435
3436    /// H9 forward-compat (client side): when the server answers a `ClientHello`
3437    /// with a typed `ServerReject` (the version isn't one it speaks), the client
3438    /// surfaces a clear version-mismatch error instead of hanging or returning a
3439    /// generic failure — and crucially does NOT auto-downgrade.
3440    #[tokio::test]
3441    async fn client_surfaces_server_reject_as_version_error() {
3442        use crate::transport::handshake::{ServerReject, ServerReply};
3443
3444        let (client_transport, server_transport) = ChannelTransport::pair();
3445        // The reject path errors before any key verification, so any key works.
3446        let (_sk, expected_vk) = crate::crypto::hybrid_sign::HybridSigningKey::generate();
3447
3448        let server = tokio::spawn(async move {
3449            // Consume the ClientHello, then reply with the typed reject (T4.4 framed).
3450            let _hello = server_transport.recv_bytes().await.unwrap();
3451            let reject = ServerReply::Reject(ServerReject::unsupported_version())
3452                .to_wire()
3453                .unwrap();
3454            server_transport.send_bytes(&reject).await.unwrap();
3455        });
3456
3457        let result = run_client_handshake(&client_transport, &expected_vk, None).await;
3458        server.await.unwrap();
3459
3460        let err = result.expect_err("client must surface the reject as an error");
3461        let msg = format!("{err:?}");
3462        assert!(
3463            msg.contains("unsupported protocol version"),
3464            "expected a version-mismatch error, got: {msg}"
3465        );
3466    }
3467
3468    /// Reviewer §5: an **injected** `ServerReject` (a tiny, pre-crypto blob a network
3469    /// attacker can spray) during a HEALTHY handshake must NOT abort it. The client remembers
3470    /// the reject and keeps waiting for a valid `ServerHello`; it gives up (surfacing the
3471    /// reject) only if one never arrives. Here the attacker injects a reject ahead of the real
3472    /// cookie/ServerHello flow; the handshake must still succeed.
3473    #[tokio::test]
3474    async fn injected_server_reject_does_not_abort_a_healthy_handshake() {
3475        use crate::transport::handshake::{ServerReject, ServerReply};
3476
3477        let (client_transport, server_transport) = ChannelTransport::pair();
3478        let server_hs = HandshakeServer::new().unwrap();
3479        let expected_vk = server_hs.verifying_key().clone();
3480
3481        let server = tokio::spawn(async move {
3482            let Ok(hello_bytes) = server_transport.recv_bytes().await else {
3483                return;
3484            };
3485            let Ok(client_hello) = borsh::from_slice::<ClientHello>(&hello_bytes) else {
3486                return;
3487            };
3488            // Inject a forged reject AHEAD of the real handshake responses (T4.4 framed).
3489            let reject = ServerReply::Reject(ServerReject::unsupported_version())
3490                .to_wire()
3491                .unwrap();
3492            if server_transport.send_bytes(&reject).await.is_err() {
3493                return;
3494            }
3495            let ip = "127.0.0.1".parse().unwrap();
3496            let sh = match server_hs.process_client_hello(&client_hello, 0, ip) {
3497                HandshakeResponse::Retry(retry) => {
3498                    if server_transport
3499                        .send_bytes(&ServerReply::Retry(retry).to_wire().unwrap())
3500                        .await
3501                        .is_err()
3502                    {
3503                        return;
3504                    }
3505                    let Ok(h2) = server_transport.recv_bytes().await else {
3506                        return;
3507                    };
3508                    let Ok(next) = borsh::from_slice::<ClientHello>(&h2) else {
3509                        return;
3510                    };
3511                    match server_hs.process_client_hello(&next, 0, ip) {
3512                        HandshakeResponse::Success(sh, _, _) => sh,
3513                        _ => return,
3514                    }
3515                }
3516                HandshakeResponse::Success(sh, _, _) => sh,
3517                _ => return,
3518            };
3519            let _ = server_transport
3520                .send_bytes(&ServerReply::Hello(sh).to_wire().unwrap())
3521                .await;
3522        });
3523
3524        let result = run_client_handshake(&client_transport, &expected_vk, None).await;
3525        // Close the channel so the server task ends even if the client aborted (RED), instead
3526        // of blocking forever on the retried-hello it will never receive.
3527        drop(client_transport);
3528        let _ = server.await;
3529        assert!(
3530            result.is_ok(),
3531            "an injected ServerReject ahead of the real ServerHello must not abort a healthy \
3532             handshake; got {result:?}"
3533        );
3534    }
3535
3536    /// **HS-02.** A MITM that answers every `ClientHello` with a fresh cheap
3537    /// `HelloRetryRequest` must NOT loop the client forever — `run_client_handshake`
3538    /// caps the retry rounds and returns an error. (Pre-fix this test would hang.)
3539    #[tokio::test]
3540    async fn client_handshake_caps_retry_rounds() {
3541        use crate::transport::handshake::HelloRetryRequest;
3542
3543        let (client_transport, server_transport) = ChannelTransport::pair();
3544        let (_sk, expected_vk) = crate::crypto::hybrid_sign::HybridSigningKey::generate();
3545
3546        // Malicious server: answer EVERY ClientHello with a fresh, cheap
3547        // HelloRetryRequest (no cookie, no PoW) — never converging.
3548        let server = tokio::spawn(async move {
3549            loop {
3550                if server_transport.recv_bytes().await.is_err() {
3551                    break;
3552                }
3553                let retry = borsh::to_vec(&HelloRetryRequest {
3554                    challenge: None,
3555                    cookie: None,
3556                })
3557                .expect("encode retry");
3558                if server_transport.send_bytes(&retry).await.is_err() {
3559                    break;
3560                }
3561            }
3562        });
3563
3564        let result = run_client_handshake(&client_transport, &expected_vk, None).await;
3565        drop(client_transport); // close the channel so the server task ends
3566        let _ = server.await;
3567
3568        assert!(
3569            matches!(result, Err(CoreError::HandshakeError(_))),
3570            "client must error after the retry-round cap, not loop forever; got {result:?}"
3571        );
3572    }
3573
3574    /// **INFOLEAK-1.** `ResumptionHint`'s `Debug` must redact the 0-RTT
3575    /// `resumption_secret` — a mobile/FFI consumer logging it with `{:?}` must
3576    /// not leak the key material.
3577    #[test]
3578    fn resumption_hint_debug_redacts_secret() {
3579        let hint = ResumptionHint {
3580            session_id: vec![0xAB; 32],
3581            resumption_secret: vec![0xCD; 32],
3582        };
3583        let dbg = format!("{hint:?}");
3584        assert!(dbg.contains("REDACTED"), "secret must be redacted: {dbg}");
3585        // No representation of the secret bytes (0xCD) leaks — neither hex nor
3586        // the decimal the derived Debug would have printed for a Vec<u8>.
3587        assert!(
3588            !dbg.contains("205"),
3589            "no decimal secret bytes in Debug: {dbg}"
3590        );
3591        assert!(
3592            !dbg.to_lowercase().contains("cd, cd"),
3593            "no hex secret bytes: {dbg}"
3594        );
3595    }
3596
3597    #[tokio::test]
3598    async fn test_phantom_session_instant_connect() {
3599        let session = PhantomSession::connect("example.com:443".to_string());
3600
3601        // Should be in Connecting state immediately
3602        assert_eq!(session.connection_state(), ConnectionState::Connecting);
3603        assert!(!session.is_data_ready());
3604        assert_eq!(session.peer_addr(), "example.com:443");
3605    }
3606
3607    /// **T5.7 regression — the inert `connect()` performs no handshake and
3608    /// sends no bytes.** The constructor is documented as deprecated (no
3609    /// `#[deprecated]` attribute is possible — see the doc-comment for why), so
3610    /// this test pins the inert contract the doc promises: the session is stuck
3611    /// in `Connecting`, every `send()` only piles into the in-memory queue (no
3612    /// transport / pump exists to flush it), and `recv()` never yields. If a
3613    /// future change ever wires a real pump into this constructor, this test
3614    /// must be updated alongside the doc — the two must not drift apart.
3615    #[tokio::test]
3616    async fn deprecated_connect_is_inert_and_sends_no_bytes() {
3617        let session = PhantomSession::connect("example.com:443".to_string());
3618
3619        // Inert: never leaves the pre-handshake state on its own.
3620        assert_eq!(session.connection_state(), ConnectionState::Connecting);
3621        assert!(!session.is_data_ready());
3622        assert!(!session.is_pqc_ready());
3623
3624        // Every send while inert only queues — it never reaches a transport,
3625        // because no transport / data pump was ever spawned.
3626        session.send(b"first".to_vec()).await.unwrap();
3627        session.send(b"second".to_vec()).await.unwrap();
3628        assert_eq!(
3629            session.queued_count().await,
3630            2,
3631            "inert connect() must buffer sends in memory, never flush them to a wire"
3632        );
3633
3634        // The session is STILL inert after sending — no background task moved
3635        // the state forward, so the bytes are still sitting in the queue.
3636        assert_eq!(session.connection_state(), ConnectionState::Connecting);
3637
3638        // recv() must never deliver application bytes — no pump feeds the recv
3639        // channel, and the inert constructor drops the channel's sender at once,
3640        // so recv() resolves to an error rather than any data. A short timeout
3641        // bounds the wait and proves recv() does not yield a payload.
3642        let recv = tokio::time::timeout(std::time::Duration::from_millis(50), session.recv()).await;
3643        match recv {
3644            Ok(Err(_)) => { /* expected: "session closed" — never any bytes */ }
3645            Ok(Ok(bytes)) => panic!(
3646                "inert connect() must never deliver received data, got {} bytes",
3647                bytes.len()
3648            ),
3649            Err(_elapsed) => { /* also acceptable: recv blocked the whole window */ }
3650        }
3651    }
3652
3653    #[tokio::test]
3654    async fn test_phantom_session_send_queue() {
3655        let session = PhantomSession::connect("example.com:443".to_string());
3656
3657        // Send while still connecting — should queue
3658        session.send(vec![1, 2, 3]).await.unwrap();
3659        session.send(vec![4, 5, 6]).await.unwrap();
3660        assert_eq!(session.queued_count().await, 2);
3661
3662        // Simulate handshake completion
3663        session.set_state(ConnectionState::ClassicalReady);
3664        assert!(session.is_data_ready());
3665
3666        // Flush queue
3667        let flushed = session.flush_queue().await.unwrap();
3668        assert_eq!(flushed, 2);
3669        assert_eq!(session.queued_count().await, 0);
3670    }
3671
3672    #[tokio::test]
3673    async fn test_phantom_session_state_progression() {
3674        let session = PhantomSession::connect("example.com:443".to_string());
3675
3676        assert_eq!(session.connection_state(), ConnectionState::Connecting);
3677        assert!(!session.is_data_ready());
3678
3679        session.set_state(ConnectionState::ClassicalReady);
3680        assert!(session.is_data_ready());
3681        assert!(!session.is_pqc_ready());
3682
3683        session.set_state(ConnectionState::PqcUpgrading);
3684        assert!(session.is_data_ready());
3685        assert!(!session.is_pqc_ready());
3686
3687        session.set_state(ConnectionState::PqcReady);
3688        assert!(session.is_data_ready());
3689        assert!(session.is_pqc_ready());
3690
3691        session.set_state(ConnectionState::Connected);
3692        assert!(session.is_data_ready());
3693        assert!(session.is_pqc_ready());
3694    }
3695
3696    #[tokio::test]
3697    async fn test_phantom_session_close() {
3698        let session = PhantomSession::connect("example.com:443".to_string());
3699        session.disconnect().await.unwrap();
3700        assert_eq!(session.connection_state(), ConnectionState::Closed);
3701        assert!(!session.is_data_ready());
3702    }
3703
3704    /// Helper: decrypt an incoming encrypted frame on the test server side.
3705    fn decrypt_incoming(
3706        server_session: &crate::transport::session::Session,
3707        bytes: &[u8],
3708    ) -> Vec<u8> {
3709        // The peer pump applies header protection (T4.6); unmask with this
3710        // side's recv HP key (== the sender's send HP key) before reading.
3711        let pkt = server_session
3712            .parse_protected(bytes)
3713            .expect("parse header-protected PhantomPacket");
3714        assert!(
3715            pkt.header.flags.contains(PacketFlags::ENCRYPTED),
3716            "expected ENCRYPTED flag on application data"
3717        );
3718        let plain = server_session
3719            .decrypt_packet(&pkt.header, &pkt.payload, &[])
3720            .expect("decrypt application data");
3721        // Reliable app frames carry a 4-byte gap-free stream_offset prefix (A.5);
3722        // strip it so callers compare against the raw application payload.
3723        if pkt.header.flags.contains(PacketFlags::RELIABLE) && plain.len() >= 4 {
3724            plain[4..].to_vec()
3725        } else {
3726            plain
3727        }
3728    }
3729
3730    /// Helper: build an encrypted reply frame from the test server side. Mirrors
3731    /// the live sender's reliable framing: plaintext = `[stream_offset: u32 BE]
3732    /// [payload]` with `stream_offset == sequence` (no control gaps in this test).
3733    fn encrypt_outgoing(
3734        server_session: &crate::transport::session::Session,
3735        session_id: SessionId,
3736        stream_id: TransportStreamId,
3737        sequence: u32,
3738        payload: &[u8],
3739    ) -> Vec<u8> {
3740        let flag_bits = PacketFlags::RELIABLE | PacketFlags::ENCRYPTED;
3741        let header = PacketHeader::new(
3742            session_id,
3743            stream_id,
3744            sequence as u64,
3745            PacketFlags::new(flag_bits),
3746        )
3747        .with_epoch(server_session.current_epoch());
3748        let mut pt = Vec::with_capacity(4 + payload.len());
3749        pt.extend_from_slice(&sequence.to_be_bytes());
3750        pt.extend_from_slice(payload);
3751        let ct = server_session
3752            .encrypt_packet(&header, &pt, &[])
3753            .expect("encrypt reply");
3754        let packet = PhantomPacket::new(header, ct);
3755        // Apply header protection so the peer pump's parse_protected unmasks it.
3756        server_session
3757            .protect_packet(&packet)
3758            .expect("header protection")
3759    }
3760
3761    /// Integration test: Client handshake via ChannelTransport with a
3762    /// simulated server responder.
3763    #[tokio::test]
3764    async fn test_phantom_session_handshake_via_transport() {
3765        let (client_transport, server_transport) = ChannelTransport::pair();
3766        let server_hs = HandshakeServer::new().unwrap();
3767        let server_pinned_key = server_hs.verifying_key().clone();
3768
3769        // Start client session — spawns background handshake (with pinning)
3770        let session = PhantomSession::connect_with_transport(
3771            "test-server:9000",
3772            client_transport,
3773            server_pinned_key,
3774        );
3775
3776        // Queue a message before handshake completes
3777        session.send(b"early-data".to_vec()).await.unwrap();
3778
3779        // Simulate server responder
3780        let server_handle = tokio::spawn(async move {
3781            let client_ip = "127.0.0.1".parse().unwrap();
3782
3783            // 1. Receive the (bare borsh) ClientHello.
3784            let client_hello_bytes = server_transport.recv_bytes().await.unwrap();
3785            let client_hello = borsh::from_slice::<ClientHello>(&client_hello_bytes).unwrap();
3786
3787            // 2. Process — may retry with cookie/PoW.
3788            let server_session = loop {
3789                let response = server_hs.process_client_hello(&client_hello, 0, client_ip);
3790                match response {
3791                    HandshakeResponse::Retry(retry) => {
3792                        let retry_bytes = ServerReply::Retry(retry).to_wire().unwrap();
3793                        server_transport.send_bytes(&retry_bytes).await.unwrap();
3794                        // Receive retried client hello
3795                        let next_bytes = server_transport.recv_bytes().await.unwrap();
3796                        let next_hello = borsh::from_slice::<ClientHello>(&next_bytes).unwrap();
3797                        let resp2 = server_hs.process_client_hello(&next_hello, 0, client_ip);
3798                        match resp2 {
3799                            HandshakeResponse::Success(server_hello, session, _) => {
3800                                let server_hello_bytes =
3801                                    ServerReply::Hello(server_hello).to_wire().unwrap();
3802                                server_transport
3803                                    .send_bytes(&server_hello_bytes)
3804                                    .await
3805                                    .unwrap();
3806                                break session;
3807                            }
3808                            _ => panic!("Expected success after retry"),
3809                        }
3810                    }
3811                    HandshakeResponse::Success(server_hello, session, _) => {
3812                        let server_hello_bytes =
3813                            ServerReply::Hello(server_hello).to_wire().unwrap();
3814                        server_transport
3815                            .send_bytes(&server_hello_bytes)
3816                            .await
3817                            .unwrap();
3818                        break session;
3819                    }
3820                    HandshakeResponse::Reject(r) => panic!("unexpected reject: {:?}", r),
3821                    HandshakeResponse::Fail(e) => panic!("handshake failed: {:?}", e),
3822                }
3823            };
3824
3825            let session_id = *server_session.id();
3826
3827            // 3. Receive the flushed early data — must be ENCRYPTED.
3828            let early_frame = server_transport.recv_bytes().await.unwrap();
3829            assert!(
3830                !early_frame
3831                    .windows(b"early-data".len())
3832                    .any(|w| w == b"early-data"),
3833                "encrypted frame must not contain plaintext early-data"
3834            );
3835            let early_plain = decrypt_incoming(&server_session, &early_frame);
3836            assert_eq!(early_plain, b"early-data");
3837
3838            // 4. Receive a post-handshake message — must be ENCRYPTED.
3839            let post_frame = server_transport.recv_bytes().await.unwrap();
3840            let post_plain = decrypt_incoming(&server_session, &post_frame);
3841            assert_eq!(post_plain, b"after-handshake");
3842
3843            // 5. Send encrypted reply back. stream_offset (== sequence here) must
3844            // be 0: this is the FIRST reliable frame server→client on this stream,
3845            // so the client reassembles it at offset 0 (A.5).
3846            let reply = encrypt_outgoing(&server_session, session_id, 1, 0, b"server-reply");
3847            server_transport.send_bytes(&reply).await.unwrap();
3848        });
3849
3850        // Wait for handshake to progress
3851        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
3852
3853        // Should be connected now
3854        assert_eq!(session.connection_state(), ConnectionState::Connected);
3855
3856        // Send after handshake
3857        session.send(b"after-handshake".to_vec()).await.unwrap();
3858
3859        // Receive server reply — now returns DECRYPTED plaintext payload.
3860        let reply = session.recv().await.unwrap();
3861        assert_eq!(reply, b"server-reply");
3862
3863        server_handle.await.unwrap();
3864        session.disconnect().await.unwrap();
3865    }
3866
3867    /// Reliable delivery: a RELIABLE application send must survive a dropped data frame.
3868    ///
3869    /// The client runs over a `LossyTransport`; once the handshake completes we
3870    /// arm a drop of the next frame (the data frame) and send a reliable
3871    /// payload. The first transmission is lost, so the server only sees the
3872    /// payload because the raw-app stream buffers it and the data pump
3873    /// retransmits the timed-out segment.
3874    #[tokio::test]
3875    async fn reliable_send_survives_a_dropped_data_frame() {
3876        use crate::test_harness::fault_transport::{FaultControl, LossyTransport};
3877
3878        let (client_transport, server_transport) = ChannelTransport::pair();
3879        let faults = FaultControl::new();
3880        let lossy_client = LossyTransport::new(client_transport, faults.clone());
3881
3882        let server_hs = HandshakeServer::new().unwrap();
3883        let server_pinned_key = server_hs.verifying_key().clone();
3884
3885        let session = PhantomSession::connect_with_transport(
3886            "test-server:9000",
3887            lossy_client,
3888            server_pinned_key,
3889        );
3890
3891        let server_handle = tokio::spawn(async move {
3892            let client_ip = "127.0.0.1".parse().unwrap();
3893            let client_hello_bytes = server_transport.recv_bytes().await.unwrap();
3894            let client_hello = borsh::from_slice::<ClientHello>(&client_hello_bytes).unwrap();
3895
3896            // Drive the handshake to completion (may take one cookie/PoW retry).
3897            let server_session = loop {
3898                match server_hs.process_client_hello(&client_hello, 0, client_ip) {
3899                    HandshakeResponse::Retry(retry) => {
3900                        let retry_bytes = ServerReply::Retry(retry).to_wire().unwrap();
3901                        server_transport.send_bytes(&retry_bytes).await.unwrap();
3902                        let next_bytes = server_transport.recv_bytes().await.unwrap();
3903                        let next_hello = borsh::from_slice::<ClientHello>(&next_bytes).unwrap();
3904                        match server_hs.process_client_hello(&next_hello, 0, client_ip) {
3905                            HandshakeResponse::Success(server_hello, session, _) => {
3906                                let b = ServerReply::Hello(server_hello).to_wire().unwrap();
3907                                server_transport.send_bytes(&b).await.unwrap();
3908                                break session;
3909                            }
3910                            _ => panic!("expected success after retry"),
3911                        }
3912                    }
3913                    HandshakeResponse::Success(server_hello, session, _) => {
3914                        let b = ServerReply::Hello(server_hello).to_wire().unwrap();
3915                        server_transport.send_bytes(&b).await.unwrap();
3916                        break session;
3917                    }
3918                    HandshakeResponse::Reject(r) => panic!("unexpected reject: {:?}", r),
3919                    HandshakeResponse::Fail(e) => panic!("handshake failed: {:?}", e),
3920                }
3921            };
3922
3923            // The reliable data frame was dropped on first transmission; it can
3924            // only arrive via retransmission. Time-bounded so a missing
3925            // retransmit fails loudly instead of hanging the test forever.
3926            let data_frame = tokio::time::timeout(
3927                std::time::Duration::from_secs(3),
3928                server_transport.recv_bytes(),
3929            )
3930            .await
3931            .expect(
3932                "reliable payload never arrived within 3s — the dropped data frame was not \
3933                 retransmitted (loss-recovery regression)",
3934            )
3935            .unwrap();
3936            let plain = decrypt_incoming(&server_session, &data_frame);
3937            assert_eq!(plain, b"reliable-payload");
3938        });
3939
3940        // Wait for the handshake to complete.
3941        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
3942        assert_eq!(session.connection_state(), ConnectionState::Connected);
3943
3944        // Arm a single drop, then send: the next frame on the wire (the data
3945        // frame) is silently lost.
3946        faults.arm_drop_next(1);
3947        session.send(b"reliable-payload".to_vec()).await.unwrap();
3948
3949        server_handle.await.unwrap();
3950        session.disconnect().await.unwrap();
3951    }
3952
3953    /// A retransmission (RTO expiry) must be reported to congestion control as
3954    /// a loss, driving BBR into FastRecovery — proves the drain → on_packet_lost
3955    /// wiring, not just that the retransmit happens.
3956    #[tokio::test]
3957    async fn drain_reports_a_retransmit_as_loss_to_bbr() {
3958        use crate::transport::bandwidth_estimator::BbrState;
3959
3960        tokio::time::pause();
3961        let sid = fixed_session_id();
3962        let (client, _server) = paired_sessions(sid);
3963
3964        let stream = Arc::new(TransportStream::new(1));
3965        stream.send_reliable(Bytes::from("payload")).await.unwrap();
3966        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
3967        streams.insert(1u32, stream);
3968
3969        let (client_t, _server_t) = ChannelTransport::pair();
3970        let transport = Arc::new(client_t);
3971
3972        // First drain: the initial transmission — not a loss.
3973        drain_streams_priority_ordered(&transport, &client, sid, &streams).await;
3974        assert_ne!(client.bbr_state(), BbrState::FastRecovery);
3975
3976        // The RTO expires; the next drain retransmits and must report the loss.
3977        tokio::time::advance(std::time::Duration::from_millis(1100)).await;
3978        drain_streams_priority_ordered(&transport, &client, sid, &streams).await;
3979        assert_eq!(
3980            client.bbr_state(),
3981            BbrState::FastRecovery,
3982            "a retransmit must be reported to BBR as a loss"
3983        );
3984    }
3985
3986    /// New data must not be transmitted while inflight already exceeds the
3987    /// congestion window — the drain holds it back until ACKs free the window.
3988    #[tokio::test]
3989    async fn drain_withholds_new_data_when_inflight_exceeds_cwnd() {
3990        let sid = fixed_session_id();
3991        let (client, _server) = paired_sessions(sid);
3992
3993        // Drive inflight far above any plausible initial cwnd, so the window
3994        // has no room for new data.
3995        client.on_packet_sent(100_000_000);
3996        let inflight_before = client.bandwidth_snapshot().inflight_bytes;
3997
3998        let stream = Arc::new(TransportStream::new(1));
3999        stream.send_reliable(Bytes::from("new-data")).await.unwrap();
4000        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4001        streams.insert(1u32, stream);
4002
4003        let (client_t, _server_t) = ChannelTransport::pair();
4004        let transport = Arc::new(client_t);
4005
4006        drain_streams_priority_ordered(&transport, &client, sid, &streams).await;
4007
4008        // No new segment was transmitted — inflight is unchanged (a send would
4009        // have grown it via on_packet_sent).
4010        assert_eq!(
4011            client.bandwidth_snapshot().inflight_bytes,
4012            inflight_before,
4013            "no new data should be sent when inflight >= cwnd"
4014        );
4015    }
4016
4017    // ────────────────────────────────────────────────────────────────────
4018    // V2 wire-routing tests (Phase 4.2 / 2.5 follow-up — data-pump V2)
4019    // ────────────────────────────────────────────────────────────────────
4020
4021    use crate::transport::multiplexer::StreamDemultiplexer;
4022    use crate::transport::session::Session as InnerSession;
4023    use crate::transport::stream::Stream as TransportStream;
4024
4025    /// Build two `InnerSession` instances that share a 32-byte secret —
4026    /// one as the "client" (peer_side=false), one as the "server"
4027    /// (peer_side=true). Mirrors the role split after a real handshake.
4028    fn paired_sessions(session_id: SessionId) -> (Arc<InnerSession>, Arc<InnerSession>) {
4029        let secret = [0x11u8; 32];
4030        let client = Arc::new(InnerSession::new(session_id, &secret, false).unwrap());
4031        let server = Arc::new(InnerSession::new(session_id, &secret, true).unwrap());
4032        (client, server)
4033    }
4034
4035    fn fixed_session_id() -> SessionId {
4036        SessionId::from_bytes([0x88; 32])
4037    }
4038
4039    /// Encrypt a V2 application-data packet from the client side at
4040    /// `stream_id` / `sequence`. The returned bytes are wire-serialised
4041    /// ([`PhantomPacket::to_wire`]) and ready to feed into `handle_packet`.
4042    /// Build a RELIABLE app frame whose `stream_offset` equals its `sequence` (the
4043    /// no-control-gap case, which holds for almost every test).
4044    fn build_app_frame(
4045        client_session: &InnerSession,
4046        session_id: SessionId,
4047        stream_id: TransportStreamId,
4048        sequence: u32,
4049        payload: &[u8],
4050    ) -> Vec<u8> {
4051        build_app_frame_with_offset(
4052            client_session,
4053            session_id,
4054            stream_id,
4055            sequence,
4056            sequence,
4057            payload,
4058        )
4059    }
4060
4061    /// Build a RELIABLE app frame with an explicit gap-free `stream_offset`
4062    /// distinct from the wire `sequence` (A.5). The plaintext is
4063    /// `[stream_offset: u32 BE][payload]`, matching the live sender's reliable
4064    /// framing; the receiver reassembles by `stream_offset`.
4065    fn build_app_frame_with_offset(
4066        client_session: &InnerSession,
4067        session_id: SessionId,
4068        stream_id: TransportStreamId,
4069        sequence: u32,
4070        stream_offset: u32,
4071        payload: &[u8],
4072    ) -> Vec<u8> {
4073        let flag_bits = PacketFlags::RELIABLE | PacketFlags::ENCRYPTED;
4074        let header = PacketHeader::new(
4075            session_id,
4076            stream_id,
4077            sequence as u64,
4078            PacketFlags::new(flag_bits),
4079        )
4080        .with_epoch(client_session.current_epoch());
4081        let mut pt = Vec::with_capacity(4 + payload.len());
4082        pt.extend_from_slice(&stream_offset.to_be_bytes());
4083        pt.extend_from_slice(payload);
4084        let ciphertext = client_session
4085            .encrypt_packet(&header, &pt, &[])
4086            .expect("encrypt_packet");
4087        // Cleartext wire: this frame is decoded back to a struct and fed to
4088        // handle_packet directly (it never traverses the pump's transport, which
4089        // is the only path that applies/removes header protection).
4090        PhantomPacket::new(header, ciphertext).to_wire()
4091    }
4092
4093    /// Decode a test-built frame the way the recv pump's `parse_protected` does:
4094    /// `from_wire` + reconstruct the off-wire `session_id` from session context
4095    /// (ε / WIRE v5). The `build_*` helpers emit cleartext `to_wire` (header
4096    /// protection is exercised separately), so the inner `session_id` is the
4097    /// placeholder zero until this sets it — mirroring production, where
4098    /// `parse_protected` reconstructs it before `handle_packet` ever sees the
4099    /// packet.
4100    fn decode_recv_frame(frame: &[u8], session_id: SessionId) -> PhantomPacket {
4101        let mut packet = PhantomPacket::from_wire(frame).expect("decode test recv frame");
4102        packet.header.session_id = session_id;
4103        packet
4104    }
4105
4106    #[tokio::test]
4107    async fn v2_recv_routes_encrypted_app_data_through_recv_channel() {
4108        let session_id = fixed_session_id();
4109        let (client_session, server_session) = paired_sessions(session_id);
4110
4111        // Encrypt a V2 application-data packet on the client side.
4112        let stream_id: TransportStreamId = 1;
4113        let frame = build_app_frame(&client_session, session_id, stream_id, 0, b"hello-v2");
4114
4115        // Receive on the server side: decode (reconstructing the off-wire
4116        // session_id, as parse_protected does) then drive handle_packet.
4117        let v2 = decode_recv_frame(&frame, session_id);
4118
4119        let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
4120        let demux = Arc::new(demux);
4121        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4122        let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
4123        let undelivered = AtomicU64::new(0);
4124        let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
4125        let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
4126            tx: ack_a,
4127            rx: Mutex::new(ack_b),
4128        });
4129
4130        let mut ack_buf = Vec::with_capacity(256);
4131        let obs = Observability::new(ObservabilityConfig::default());
4132        handle_packet(
4133            v2,
4134            session_id,
4135            &server_session,
4136            &streams,
4137            &demux,
4138            &transport_send,
4139            &transport_send,
4140            &deliver_tx,
4141            &undelivered,
4142            &mut ack_buf,
4143            &obs,
4144            LegType::Tcp,
4145        )
4146        .await;
4147
4148        // The decrypted plaintext must have been handed to the delivery task,
4149        // tagged with its stream id, and counted toward the undelivered backlog.
4150        let (sid, received) = deliver_rx.recv().await.expect("delivery hand-off");
4151        assert_eq!(sid, stream_id as u32);
4152        assert_eq!(&received[..], b"hello-v2");
4153        assert_eq!(
4154            undelivered.load(Ordering::Acquire),
4155            b"hello-v2".len() as u64
4156        );
4157    }
4158
4159    /// H-3: the recv path must cap concurrent receive streams. A peer that sprays reliable
4160    /// frames across far more distinct `stream_id`s than the cap must not auto-create an
4161    /// unbounded number of `Stream`s — the table is bounded by `MAX_STREAMS`, which (with the
4162    /// per-stream reorder byte budget) bounds the session's total reorder memory.
4163    #[tokio::test]
4164    async fn recv_path_caps_concurrent_streams_at_max_streams() {
4165        let session_id = fixed_session_id();
4166        let (client_session, server_session) = paired_sessions(session_id);
4167
4168        let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
4169        let demux = Arc::new(demux);
4170        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4171        let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
4172        let undelivered = AtomicU64::new(0);
4173        let attempts = MAX_STREAMS as u32 + 64;
4174        let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(attempts as usize + 16);
4175        let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
4176            tx: ack_a,
4177            rx: Mutex::new(ack_b),
4178        });
4179        let mut ack_buf = Vec::with_capacity(256);
4180        let obs = Observability::new(ObservabilityConfig::default());
4181
4182        // A peer opens far more receive streams than the cap. Each frame uses a distinct
4183        // `sequence` (the per-direction packet number, else they replay-reject) but
4184        // stream_offset 0 so it delivers in order and creates its stream.
4185        for sid in 0..attempts {
4186            let frame = build_app_frame_with_offset(
4187                &client_session,
4188                session_id,
4189                sid as TransportStreamId,
4190                sid, // sequence = distinct per-direction PN
4191                0,   // stream_offset 0 → in-order delivery
4192                b"x",
4193            );
4194            let v2 = decode_recv_frame(&frame, session_id);
4195            handle_packet(
4196                v2,
4197                session_id,
4198                &server_session,
4199                &streams,
4200                &demux,
4201                &transport_send,
4202                &transport_send,
4203                &deliver_tx,
4204                &undelivered,
4205                &mut ack_buf,
4206                &obs,
4207                LegType::Tcp,
4208            )
4209            .await;
4210        }
4211
4212        assert!(
4213            streams.len() <= MAX_STREAMS,
4214            "recv path must cap concurrent receive streams at MAX_STREAMS ({MAX_STREAMS}); have {}",
4215            streams.len()
4216        );
4217    }
4218
4219    /// M-2 (audit 2026-06-11, residual of prior H1): a forged **unencrypted, empty-payload**
4220    /// packet carrying only the `FIN` flag (valid `session_id`) must NOT tear down an
4221    /// `open_stream()` stream. The stripped-flag downgrade defense must drop ALL unencrypted
4222    /// post-handshake packets — not only non-empty ones — so the standalone-FIN path is never
4223    /// reached without AEAD verification. Legitimate FINs are always `ENCRYPTED`.
4224    #[tokio::test]
4225    async fn forged_unencrypted_fin_does_not_close_a_stream() {
4226        let session_id = fixed_session_id();
4227        let (_client_session, server_session) = paired_sessions(session_id);
4228
4229        let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
4230        let demux = Arc::new(demux);
4231        // Register stream 2 — an open_stream()-style stream (ids 2+), the M-2 target.
4232        let mut handle = demux.register_stream(2, 8);
4233        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4234        let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
4235        let undelivered = AtomicU64::new(0);
4236        let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
4237        let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
4238            tx: ack_a,
4239            rx: Mutex::new(ack_b),
4240        });
4241        let mut ack_buf = Vec::with_capacity(64);
4242        let obs = Observability::new(ObservabilityConfig::default());
4243
4244        // Forged: UNENCRYPTED, empty payload, FIN flag, valid session_id, stream 2.
4245        let header = PacketHeader::new(session_id, 2, 0, PacketFlags::new(PacketFlags::FIN));
4246        let forged = PhantomPacket::new(header, Vec::new());
4247
4248        handle_packet(
4249            forged,
4250            session_id,
4251            &server_session,
4252            &streams,
4253            &demux,
4254            &transport_send,
4255            &transport_send,
4256            &deliver_tx,
4257            &undelivered,
4258            &mut ack_buf,
4259            &obs,
4260            LegType::Tcp,
4261        )
4262        .await;
4263
4264        assert!(
4265            handle.rx.try_recv().is_err(),
4266            "a forged unencrypted FIN must not close an open_stream() stream"
4267        );
4268    }
4269
4270    /// Like [`build_app_frame`] but stamps a caller-chosen `path_id` so the
4271    /// receive-side path gate (PATH-001) can be exercised.
4272    fn build_app_frame_on_path(
4273        client_session: &InnerSession,
4274        session_id: SessionId,
4275        stream_id: TransportStreamId,
4276        sequence: u32,
4277        stream_offset: u32,
4278        path_id: u8,
4279        payload: &[u8],
4280    ) -> Vec<u8> {
4281        let flag_bits = PacketFlags::RELIABLE | PacketFlags::ENCRYPTED;
4282        let header = PacketHeader::new(
4283            session_id,
4284            stream_id,
4285            sequence as u64,
4286            PacketFlags::new(flag_bits),
4287        )
4288        .with_epoch(client_session.current_epoch())
4289        .with_path_id(path_id);
4290        // Reliable plaintext = [stream_offset: u32 BE][payload] (A.5).
4291        let mut pt = Vec::with_capacity(4 + payload.len());
4292        pt.extend_from_slice(&stream_offset.to_be_bytes());
4293        pt.extend_from_slice(payload);
4294        let ciphertext = client_session
4295            .encrypt_packet(&header, &pt, &[])
4296            .expect("encrypt_packet");
4297        // Cleartext wire: this frame is decoded back to a struct and fed to
4298        // handle_packet directly (it never traverses the pump's transport, which
4299        // is the only path that applies/removes header protection).
4300        PhantomPacket::new(header, ciphertext).to_wire()
4301    }
4302
4303    #[test]
4304    fn send_path_id_starts_at_zero_then_bumps_per_migration() {
4305        // D5 (Phase 4): the client owns a monotonic send-side path_id, default 0 (the
4306        // implicit handshake path), bumped on each migration so the server can detect
4307        // and challenge the new path. Reuse is nonce-safe under ① (path_id left the
4308        // AEAD nonce — `nonce = nonce_prefix ‖ packet_number`).
4309        let session_id = fixed_session_id();
4310        let (client_session, _server_session) = paired_sessions(session_id);
4311        assert_eq!(client_session.current_send_path_id(), 0);
4312        assert_eq!(client_session.next_migration_path_id(), 1);
4313        assert_eq!(client_session.current_send_path_id(), 1);
4314        assert_eq!(client_session.next_migration_path_id(), 2);
4315        assert_eq!(client_session.current_send_path_id(), 2);
4316    }
4317
4318    /// ε / WIRE v5 (audit V-1 / Invariant 4) — the inbound CID-window slide is
4319    /// signalled ONLY from the post-AEAD path. A forged packet that fails AEAD —
4320    /// even one carrying a NEW forward `path_id` (the migration signal) — must not
4321    /// advance the inbound CID window or emit a `CidSlide`. This pins that an
4322    /// off-path attacker (who cannot produce a valid tag) cannot push the demux
4323    /// window; a future refactor that hoists the slide above the AEAD gate fails here.
4324    #[tokio::test]
4325    async fn eps_slide_requires_aead_success() {
4326        let session_id = fixed_session_id();
4327        let (client_session, server_session) = paired_sessions(session_id);
4328        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4329
4330        // Install the demux slide channel and snapshot the inbound CID window.
4331        let (slide_tx, mut slide_rx) = mpsc::unbounded_channel();
4332        server_session.set_cid_slide_tx(slide_tx);
4333        let window_before = server_session.inbound_window_cids();
4334
4335        // A valid frame on a NEW path_id (1, the migration signal), then corrupt
4336        // the ciphertext so AEAD verification fails. The header stays intact.
4337        let stream_id: TransportStreamId = 1;
4338        let frame =
4339            build_app_frame_on_path(&client_session, session_id, stream_id, 0, 0, 1, b"forged");
4340        let mut pkt = decode_recv_frame(&frame, session_id);
4341        assert!(!pkt.payload.is_empty(), "ciphertext present to corrupt");
4342        pkt.payload[0] ^= 0xFF; // tamper → AEAD open fails
4343
4344        run_recv(pkt, session_id, &server_session, &streams).await;
4345
4346        assert!(
4347            slide_rx.try_recv().is_err(),
4348            "a forged (AEAD-failing) packet must emit no CidSlide"
4349        );
4350        assert_eq!(
4351            server_session.inbound_window_cids(),
4352            window_before,
4353            "the inbound CID window must not advance on a forged packet (slide is post-AEAD)"
4354        );
4355    }
4356
4357    use std::sync::atomic::AtomicBool;
4358
4359    /// Records each `SessionTransport` control method the inner transport receives,
4360    /// for the [`observed_transport_forwards_all_control_methods`] tripwire.
4361    #[derive(Default)]
4362    struct ControlRecorder {
4363        set_frame_phase: AtomicBool,
4364        set_outbound_cid: std::sync::Mutex<Option<[u8; 8]>>,
4365        has_migration_candidate: AtomicBool,
4366        send_to_candidate: AtomicBool,
4367        confirm_authenticated_source: AtomicBool,
4368        promote_candidate: AtomicBool,
4369        migrate: std::sync::Mutex<Option<String>>,
4370        migrate_server: std::sync::Mutex<Option<String>>,
4371    }
4372
4373    struct RecordingTransport {
4374        rec: Arc<ControlRecorder>,
4375    }
4376
4377    impl SessionTransport for RecordingTransport {
4378        async fn send_bytes(&self, _data: &[u8]) -> Result<(), CoreError> {
4379            Ok(())
4380        }
4381        async fn recv_bytes(&self) -> Result<Bytes, CoreError> {
4382            Ok(Bytes::new())
4383        }
4384        fn set_frame_phase(&self, _phase: FramePhase) {
4385            self.rec.set_frame_phase.store(true, Ordering::SeqCst);
4386        }
4387        fn set_outbound_cid(&self, cid: [u8; 8]) {
4388            *self.rec.set_outbound_cid.lock().unwrap() = Some(cid);
4389        }
4390        fn has_migration_candidate(&self) -> bool {
4391            self.rec
4392                .has_migration_candidate
4393                .store(true, Ordering::SeqCst);
4394            true
4395        }
4396        async fn send_to_candidate(&self, _data: &[u8]) -> Result<bool, CoreError> {
4397            self.rec.send_to_candidate.store(true, Ordering::SeqCst);
4398            Ok(true)
4399        }
4400        fn confirm_authenticated_source(&self) {
4401            self.rec
4402                .confirm_authenticated_source
4403                .store(true, Ordering::SeqCst);
4404        }
4405        fn promote_candidate(&self) -> bool {
4406            self.rec.promote_candidate.store(true, Ordering::SeqCst);
4407            true
4408        }
4409        async fn migrate(&self, local_addr: String) -> Result<(), CoreError> {
4410            *self.rec.migrate.lock().unwrap() = Some(local_addr);
4411            Ok(())
4412        }
4413        async fn migrate_server(&self, local_addr: String) -> Result<(), CoreError> {
4414            *self.rec.migrate_server.lock().unwrap() = Some(local_addr);
4415            Ok(())
4416        }
4417    }
4418
4419    /// EPS-02 (audit L4) — the symmetric-rotation role branch, pinned ALWAYS-ON
4420    /// (the live wire-level proof is `#[ignore]` `udp_integration`). On detecting a
4421    /// peer migration, a **server** rotates its s2c CID but keeps its send `path_id`
4422    /// (path-id-silent — prevents a ping-pong), while a **client** rotates its c2s
4423    /// CID **and** bumps its send `path_id` (the window-slide / no-stranding fix).
4424    /// Non-vacuous: flipping the `is_server` branch flips which side bumps `path_id`,
4425    /// failing an assertion here.
4426    #[test]
4427    fn eps02_rotation_branch_is_role_correct() {
4428        let session_id = fixed_session_id();
4429        let (client, server) = paired_sessions(session_id);
4430
4431        // SERVER (its client migrated): rotate s2c CID, path_id SILENT.
4432        let s_path_before = server.current_send_path_id();
4433        let s_cid_before = server.current_outbound_cid();
4434        let rec_s = Arc::new(ControlRecorder::default());
4435        apply_eps02_peer_migration_rotation(&server, &RecordingTransport { rec: rec_s.clone() });
4436        assert_ne!(
4437            server.current_outbound_cid(),
4438            s_cid_before,
4439            "server must rotate its s2c CID on a peer (client) migration"
4440        );
4441        assert_eq!(
4442            *rec_s.set_outbound_cid.lock().unwrap(),
4443            Some(server.current_outbound_cid()),
4444            "server stamps the rotated CID onto its transport"
4445        );
4446        assert_eq!(
4447            server.current_send_path_id(),
4448            s_path_before,
4449            "server rotation is path-id-SILENT (bumping it would ping-pong the client)"
4450        );
4451
4452        // CLIENT (its server migrated): rotate c2s CID AND bump send path_id.
4453        let c_path_before = client.current_send_path_id();
4454        let c_cid_before = client.current_outbound_cid();
4455        let rec_c = Arc::new(ControlRecorder::default());
4456        apply_eps02_peer_migration_rotation(&client, &RecordingTransport { rec: rec_c.clone() });
4457        assert_ne!(
4458            client.current_outbound_cid(),
4459            c_cid_before,
4460            "client must rotate its c2s CID on a peer (server) migration"
4461        );
4462        assert_eq!(
4463            *rec_c.set_outbound_cid.lock().unwrap(),
4464            Some(client.current_outbound_cid()),
4465            "client stamps the rotated CID onto its transport"
4466        );
4467        assert_eq!(
4468            client.current_send_path_id(),
4469            c_path_before + 1,
4470            "client bumps its send path_id (slides the server's c2s demux window — no stranding)"
4471        );
4472    }
4473
4474    /// ε / WIRE v5 (audit V-3 / EPS-03 / EPS-04) — `ObservedTransport` must forward
4475    /// EVERY `SessionTransport` control method to the inner transport, not just
4476    /// send/recv. A method left on the trait default silently no-ops — the bug that
4477    /// made the pre-ε FFI `migrate()` vacuous and linkable. This always-on tripwire
4478    /// pins the full control surface without UDP loopback: a dropped forward, or a
4479    /// future-added trait method the wrapper forgets, fails an assertion here.
4480    #[tokio::test]
4481    async fn observed_transport_forwards_all_control_methods() {
4482        let rec = Arc::new(ControlRecorder::default());
4483        let observed = ObservedTransport::new(
4484            RecordingTransport { rec: rec.clone() },
4485            Observability::new(ObservabilityConfig::default()),
4486            LegType::Udp,
4487        );
4488
4489        observed.set_frame_phase(FramePhase::Established);
4490        observed.set_outbound_cid([7u8; 8]);
4491        assert!(observed.has_migration_candidate());
4492        assert!(observed
4493            .send_to_candidate(b"challenge")
4494            .await
4495            .expect("send_to_candidate"));
4496        observed.confirm_authenticated_source();
4497        assert!(observed.promote_candidate());
4498        observed
4499            .migrate("127.0.0.1:0".to_string())
4500            .await
4501            .expect("migrate");
4502        observed
4503            .migrate_server("127.0.0.1:0".to_string())
4504            .await
4505            .expect("migrate_server");
4506
4507        assert!(
4508            rec.set_frame_phase.load(Ordering::SeqCst),
4509            "set_frame_phase not forwarded"
4510        );
4511        assert_eq!(
4512            *rec.set_outbound_cid.lock().unwrap(),
4513            Some([7u8; 8]),
4514            "set_outbound_cid not forwarded"
4515        );
4516        assert!(
4517            rec.has_migration_candidate.load(Ordering::SeqCst),
4518            "has_migration_candidate not forwarded"
4519        );
4520        assert!(
4521            rec.send_to_candidate.load(Ordering::SeqCst),
4522            "send_to_candidate not forwarded"
4523        );
4524        assert!(
4525            rec.confirm_authenticated_source.load(Ordering::SeqCst),
4526            "confirm_authenticated_source not forwarded"
4527        );
4528        assert!(
4529            rec.promote_candidate.load(Ordering::SeqCst),
4530            "promote_candidate not forwarded"
4531        );
4532        assert_eq!(
4533            rec.migrate.lock().unwrap().as_deref(),
4534            Some("127.0.0.1:0"),
4535            "migrate not forwarded"
4536        );
4537        assert_eq!(
4538            rec.migrate_server.lock().unwrap().as_deref(),
4539            Some("127.0.0.1:0"),
4540            "migrate_server not forwarded"
4541        );
4542    }
4543
4544    /// EPS-02 (symmetric CID rotation) — when the **server** (the demuxing side)
4545    /// detects a client migration (a new authenticated `path_id`, post-AEAD), it
4546    /// must rotate its OWN outbound (server→client) CID so that direction also
4547    /// gets a fresh `ConnId` across the move. Otherwise an on-path observer seeing
4548    /// both networks links the session by the stable s2c CID (the ε §12.5 residual
4549    /// this fix closes). The socket-routed client accepts any inbound CID, so no
4550    /// client-side window slide is needed and there is no ping-pong (we never bump
4551    /// the server's own send path_id here).
4552    #[tokio::test]
4553    async fn eps02_server_rotates_s2c_cid_on_client_migration() {
4554        let session_id = fixed_session_id();
4555        let (client_session, server_session) = paired_sessions(session_id);
4556        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4557
4558        assert!(server_session.is_server(), "server side");
4559        let s2c_cid_before = server_session.current_outbound_cid();
4560
4561        // The client migrates: it bumps its send path_id and sends app data on the
4562        // new path. Deliver that migration packet (path_id = 1) to the server.
4563        let stream_id: TransportStreamId = 1;
4564        let frame =
4565            build_app_frame_on_path(&client_session, session_id, stream_id, 0, 0, 1, b"migrated");
4566        let pkt = decode_recv_frame(&frame, session_id);
4567        run_recv(pkt, session_id, &server_session, &streams).await;
4568
4569        assert_ne!(
4570            server_session.current_outbound_cid(),
4571            s2c_cid_before,
4572            "the server must rotate its server->client CID when the client migrates (EPS-02)"
4573        );
4574    }
4575
4576    /// EPS-02 CLOSURE (D4, symmetric rotation) — when the CLIENT detects a server
4577    /// migration (a new authenticated server `path_id`, post-AEAD) it REFLECTS: it bumps
4578    /// its OWN send path_id AND rotates its outbound (c2s) CID. The path_id bump is what
4579    /// makes the server slide its c2s demux window so the rotated c2s CID stays routable
4580    /// (no stranding — the reason the old "client must not rotate" rule existed); the CID
4581    /// rotation closes the s2c/c2s linkability residual for SERVER-initiated migration.
4582    /// The server's own s2c re-rotation (the test above) is path_id-silent, so the client
4583    /// sees no new forward server path_id from it — no ping-pong, terminates in one round.
4584    #[tokio::test]
4585    async fn eps02_client_rotates_c2s_on_server_migration() {
4586        let session_id = fixed_session_id();
4587        let (client_session, server_session) = paired_sessions(session_id);
4588        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4589
4590        assert!(!client_session.is_server(), "client side");
4591        let c2s_cid_before = client_session.current_outbound_cid();
4592        let send_path_before = client_session.current_send_path_id();
4593
4594        // The server migrates: build a server→client app frame on a new path_id and
4595        // deliver it to the client's recv path.
4596        let stream_id: TransportStreamId = 1;
4597        let frame = build_app_frame_on_path(
4598            &server_session,
4599            session_id,
4600            stream_id,
4601            0,
4602            0,
4603            1,
4604            b"srv-moved",
4605        );
4606        let pkt = decode_recv_frame(&frame, session_id);
4607        run_recv(pkt, session_id, &client_session, &streams).await;
4608
4609        assert_ne!(
4610            client_session.current_outbound_cid(),
4611            c2s_cid_before,
4612            "the client must rotate its c2s CID on detecting a server migration (EPS-02 closure)"
4613        );
4614        assert_ne!(
4615            client_session.current_send_path_id(),
4616            send_path_before,
4617            "the client must bump its send path_id so the server slides its c2s window (no stranding)"
4618        );
4619    }
4620
4621    /// EPS-02 closure, multi-step case — the client's c2s rotation is driven by ITS OWN
4622    /// reflection count, NOT by the server's migration count `d`. When the client detects a
4623    /// *forward* server `path_id` of `d > 1` (it missed intermediate server migrations under
4624    /// loss), it reflects ONCE: `send_path_id` and `outbound_cid_index` each advance by 1 and
4625    /// stay **1:1**. That 1:1 is exactly the invariant the server's c2s window slide relies on
4626    /// — the server slides by the *client's* `path_id` delta (1) and routes the client's c2s
4627    /// CID at index 1. So a `d > 1` server migration does NOT desync the c2s direction (the
4628    /// `d` the client computes here is its *inbound* view of the server's s2c chain, which the
4629    /// socket-routed client does not even use). Guards against an over-eager "bump by d" fix.
4630    #[tokio::test]
4631    async fn eps02_client_reflects_once_for_a_multi_step_server_migration() {
4632        let session_id = fixed_session_id();
4633        let (client_session, server_session) = paired_sessions(session_id);
4634        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4635
4636        assert!(!client_session.is_server(), "client side");
4637
4638        // The server migrated TWICE but the client only sees the second (the first s2c on
4639        // path_id 1 was lost): deliver a server→client app frame on path_id = 2 (forward
4640        // distance d = 2 from the client's view).
4641        let stream_id: TransportStreamId = 1;
4642        let frame = build_app_frame_on_path(
4643            &server_session,
4644            session_id,
4645            stream_id,
4646            0,
4647            0,
4648            2,
4649            b"srv-moved-2x",
4650        );
4651        let pkt = decode_recv_frame(&frame, session_id);
4652        run_recv(pkt, session_id, &client_session, &streams).await;
4653
4654        assert_eq!(
4655            client_session.current_send_path_id(),
4656            1,
4657            "client reflects ONCE (not d = 2) — its c2s rotation is decoupled from the server's migration count"
4658        );
4659        assert_eq!(
4660            client_session.outbound_cid_index(),
4661            1,
4662            "outbound CID index stays 1:1 with send_path_id, so the server (which slides its c2s window by the CLIENT's path_id delta) routes the rotated c2s CID — no desync on a d>1 server migration"
4663        );
4664    }
4665
4666    #[test]
4667    fn migration_path_id_never_collides_with_the_handshake_path() {
4668        // The migration counter must never hand back path_id 0 — that id is
4669        // permanently the Validated handshake path on both peers, so reusing it would
4670        // make the server skip the challenge (path 0 is always Validated) and the
4671        // switch would never fire. Spanning > 2 u8 wraps proves the wrap (never 0;
4672        // it also skips the reserved 255 — see the dedicated collision test).
4673        let session_id = fixed_session_id();
4674        let (client_session, _server_session) = paired_sessions(session_id);
4675        for _ in 0..600 {
4676            assert_ne!(client_session.next_migration_path_id(), 0);
4677        }
4678    }
4679
4680    #[tokio::test]
4681    async fn send_app_data_stamps_the_current_send_path_id() {
4682        // P4.2b: `send_app_data` must stamp `header.path_id` from the session's
4683        // current send path_id (default 0). After a migration bump, every outbound
4684        // app-data packet — including ARQ retransmits, which also flow through
4685        // `send_app_data` — carries the new path_id, which is exactly what makes the
4686        // server detect the new path and issue a PATH_CHALLENGE (D5 / D6).
4687        let session_id = fixed_session_id();
4688        let (client_session, _server_session) = paired_sessions(session_id);
4689        let (client_t, server_t) = ChannelTransport::pair();
4690        let client_t = Arc::new(client_t);
4691
4692        // Default: app data is stamped on the implicit path 0.
4693        assert!(
4694            send_app_data(
4695                &client_t,
4696                &client_session,
4697                session_id,
4698                1,
4699                b"pre-migration",
4700                PacketFlags::RELIABLE,
4701                Some(0),
4702            )
4703            .await
4704        );
4705        let wire = server_t.recv_bytes().await.unwrap();
4706        let pkt = _server_session.parse_protected(&wire).unwrap();
4707        assert_eq!(
4708            pkt.header.path_id, 0,
4709            "default send path is the implicit path 0"
4710        );
4711
4712        // After a migration bump, the new path_id is stamped on subsequent app data.
4713        assert_eq!(client_session.next_migration_path_id(), 1);
4714        assert!(
4715            send_app_data(
4716                &client_t,
4717                &client_session,
4718                session_id,
4719                1,
4720                b"post-migration",
4721                PacketFlags::RELIABLE,
4722                Some(13),
4723            )
4724            .await
4725        );
4726        let wire2 = server_t.recv_bytes().await.unwrap();
4727        let pkt2 = _server_session.parse_protected(&wire2).unwrap();
4728        assert_eq!(
4729            pkt2.header.path_id, 1,
4730            "after migrate(), app data must carry the bumped send path_id"
4731        );
4732    }
4733
4734    #[tokio::test]
4735    async fn app_data_on_non_validated_path_is_delivered_recv_relax() {
4736        // PATH-001 split (D10, Phase 4). RECV is relaxed: AEAD-authenticated,
4737        // non-replayed app data is DELIVERED regardless of which path it arrived
4738        // on (the data already passed AEAD + the per-direction replay window, so
4739        // dropping it by source buys no security and would break a seamless
4740        // NAT-rebind). The path is still registered Unvalidated so it can be
4741        // challenged. The strict half (PATH-001a, the send-gate: app data only to
4742        // the peer / a Validated path) is exercised over a real UdpServerTransport
4743        // in udp_integration.
4744        use crate::transport::path::PathStateKind;
4745        let session_id = fixed_session_id();
4746        let (client_session, server_session) = paired_sessions(session_id);
4747        let stream_id: TransportStreamId = 1;
4748
4749        let frame = build_app_frame_on_path(
4750            &client_session,
4751            session_id,
4752            stream_id,
4753            0,
4754            0, // stream_offset 0 — first reliable frame on this stream
4755            7, // a path the receiver has never validated
4756            b"on-new-path",
4757        );
4758        let frame = decode_recv_frame(&frame, session_id);
4759
4760        let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
4761        let demux = Arc::new(demux);
4762        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4763        let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
4764        let undelivered = AtomicU64::new(0);
4765        let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
4766        let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
4767            tx: ack_a,
4768            rx: Mutex::new(ack_b),
4769        });
4770        let mut ack_buf = Vec::with_capacity(256);
4771        let obs = Observability::new(ObservabilityConfig::default());
4772
4773        handle_packet(
4774            frame,
4775            session_id,
4776            &server_session,
4777            &streams,
4778            &demux,
4779            &transport_send,
4780            &transport_send,
4781            &deliver_tx,
4782            &undelivered,
4783            &mut ack_buf,
4784            &obs,
4785            LegType::Tcp,
4786        )
4787        .await;
4788
4789        // Recv-relax (D10b): the authenticated frame IS delivered, even though
4790        // path 7 is not validated.
4791        let (sid, received) =
4792            tokio::time::timeout(std::time::Duration::from_secs(1), deliver_rx.recv())
4793                .await
4794                .expect("recv-relax must deliver promptly (no drop / hang)")
4795                .expect("delivery channel open");
4796        assert_eq!(sid, stream_id as u32);
4797        assert_eq!(&received[..], b"on-new-path");
4798        // The new path is registered Unvalidated for a later challenge. (The
4799        // ChannelTransport reports no migration candidate, so no challenge is
4800        // issued here — the server-challenge path is exercised in udp_integration.)
4801        assert_eq!(
4802            server_session.path_state(7),
4803            Some(PathStateKind::Unvalidated),
4804            "the new path id must be registered for a later challenge"
4805        );
4806    }
4807
4808    #[tokio::test]
4809    async fn server_challenges_a_migration_candidate() {
4810        // P4.1 end-to-end over a real UdpServerTransport: app data on a NEW path_id
4811        // from a NEW source makes the server issue a PATH_VALIDATION challenge TO
4812        // THAT SOURCE (not the established peer), under the 3× anti-amp cap, and the
4813        // new path goes Validating. No peer switch (that is P4.2).
4814        use crate::api::udp_transport::UdpServerTransport;
4815        use crate::transport::path::PathStateKind;
4816        use crate::transport::phantom_udp::datagram::{push_datagram, FragmentAssembler};
4817
4818        let session_id = fixed_session_id();
4819        let (client_session, server_session) = paired_sessions(session_id);
4820        let stream_id: TransportStreamId = 1;
4821
4822        let server_sock = Arc::new(tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap());
4823        let peer: std::net::SocketAddr = "127.0.0.1:9".parse().unwrap(); // established (old) peer
4824        let cand_sock = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
4825        let cand_addr = cand_sock.local_addr().unwrap();
4826
4827        // Build the server transport and set the candidate by feeding a frame from
4828        // the candidate source through the demux channel (as the demux would), with
4829        // enough received bytes that the 3× budget admits a challenge.
4830        let (tx, rx) = mpsc::channel(8);
4831        let ust = Arc::new(UdpServerTransport::new(
4832            server_sock.clone(),
4833            peer,
4834            [5u8; 8],
4835            tx.clone(),
4836            rx,
4837        ));
4838        tx.send((Bytes::from(vec![0u8; 256]), cand_addr))
4839            .await
4840            .unwrap();
4841        let _ = ust.recv_bytes().await.unwrap();
4842        // M-1: the candidate is committed only on the post-decrypt (authenticated) path, which
4843        // handle_packet drives in production; mirror that here for the manual setup.
4844        ust.confirm_authenticated_source();
4845        assert!(
4846            ust.has_migration_candidate(),
4847            "new source must set a candidate"
4848        );
4849
4850        // App data on a NEW (unvalidated) path id → the server must challenge it.
4851        let frame =
4852            build_app_frame_on_path(&client_session, session_id, stream_id, 0, 0, 1, b"migrated");
4853        let frame = decode_recv_frame(&frame, session_id);
4854
4855        let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
4856        let demux = Arc::new(demux);
4857        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4858        let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
4859        let undelivered = AtomicU64::new(0);
4860        let mut ack_buf = Vec::with_capacity(256);
4861        let obs = Observability::new(ObservabilityConfig::default());
4862
4863        handle_packet(
4864            frame,
4865            session_id,
4866            &server_session,
4867            &streams,
4868            &demux,
4869            &ust,
4870            &ust,
4871            &deliver_tx,
4872            &undelivered,
4873            &mut ack_buf,
4874            &obs,
4875            LegType::Udp,
4876        )
4877        .await;
4878
4879        // The server issued a challenge → path 1 is now Validating.
4880        assert_eq!(
4881            server_session.path_state(1),
4882            Some(PathStateKind::Validating),
4883            "an unvalidated path on a candidate source must be challenged"
4884        );
4885        // ...and the challenge datagram reached the CANDIDATE socket (not the peer).
4886        let mut buf = vec![0u8; 2048];
4887        let (n, _from) = tokio::time::timeout(
4888            std::time::Duration::from_secs(1),
4889            cand_sock.recv_from(&mut buf),
4890        )
4891        .await
4892        .expect("challenge must reach the candidate")
4893        .unwrap();
4894        let mut asm = FragmentAssembler::new();
4895        let (_hdr, inner) = push_datagram(&mut asm, &buf[..n]).expect("decode envelope");
4896        let inner = inner.expect("single-datagram challenge");
4897        // The server emitted this challenge (protect_packet under its send HP
4898        // key); unmask it from the client side (== the server's send key).
4899        let pkt = client_session
4900            .parse_protected(&inner)
4901            .expect("inner packet");
4902        assert!(
4903            pkt.header.flags.contains(PacketFlags::PATH_VALIDATION),
4904            "the candidate must receive a PATH_VALIDATION challenge"
4905        );
4906        assert_eq!(pkt.header.path_id, 1, "challenge must be on the new path");
4907    }
4908
4909    #[tokio::test]
4910    async fn server_challenges_a_passive_rebind_on_path_zero() {
4911        // M-3: a *passive* NAT rebind keeps `path_id = 0` (the client never called
4912        // `migrate()`, so it never bumped its send path_id). Path 0 is permanently
4913        // `Validated`, so the path-id-gated challenge block is skipped — pre-fix the
4914        // server NEVER challenged the new source, never promoted it, and kept sending
4915        // downstream to the OLD (now-dead) address → stall. The fix makes detection
4916        // address-driven: when an authenticated frame arrives on a Validated path AND
4917        // the transport flags a migration candidate (a new authenticated source), the
4918        // server issues a PATH_CHALLENGE to that candidate on a RESERVED validation
4919        // path-id (`REBIND_VALIDATION_PATH_ID`), under the 3× anti-amp cap. Anti-spoof
4920        // still holds: the candidate is only ever the AEAD-authenticated source, and
4921        // the challenge only goes there.
4922        use crate::api::udp_transport::UdpServerTransport;
4923        use crate::transport::path::PathStateKind;
4924        use crate::transport::phantom_udp::datagram::{push_datagram, FragmentAssembler};
4925        use crate::transport::session::REBIND_VALIDATION_PATH_ID;
4926
4927        let session_id = fixed_session_id();
4928        let (client_session, server_session) = paired_sessions(session_id);
4929        let stream_id: TransportStreamId = 1;
4930
4931        let server_sock = Arc::new(tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap());
4932        let peer: std::net::SocketAddr = "127.0.0.1:9".parse().unwrap(); // established (old) peer
4933        let cand_sock = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
4934        let cand_addr = cand_sock.local_addr().unwrap();
4935
4936        let (tx, rx) = mpsc::channel(8);
4937        let ust = Arc::new(UdpServerTransport::new(
4938            server_sock.clone(),
4939            peer,
4940            [5u8; 8],
4941            tx.clone(),
4942            rx,
4943        ));
4944        // A frame from the rebind source seeds the candidate + its 3× budget.
4945        tx.send((Bytes::from(vec![0u8; 256]), cand_addr))
4946            .await
4947            .unwrap();
4948        let _ = ust.recv_bytes().await.unwrap();
4949        ust.confirm_authenticated_source();
4950        assert!(
4951            ust.has_migration_candidate(),
4952            "new source must set a candidate"
4953        );
4954
4955        // The reserved validation path is untouched at the start.
4956        assert_eq!(
4957            server_session.path_state(REBIND_VALIDATION_PATH_ID),
4958            None,
4959            "the rebind validation path must not exist before the rebind is observed"
4960        );
4961
4962        // App data on the ESTABLISHED, always-Validated path 0 (passive rebind:
4963        // path_id unchanged) from the candidate source → the server must STILL
4964        // challenge the candidate on the reserved validation path-id.
4965        let frame =
4966            build_app_frame_on_path(&client_session, session_id, stream_id, 0, 0, 0, b"rebound");
4967        let frame = decode_recv_frame(&frame, session_id);
4968
4969        let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
4970        let demux = Arc::new(demux);
4971        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4972        let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
4973        let undelivered = AtomicU64::new(0);
4974        let mut ack_buf = Vec::with_capacity(256);
4975        let obs = Observability::new(ObservabilityConfig::default());
4976
4977        handle_packet(
4978            frame,
4979            session_id,
4980            &server_session,
4981            &streams,
4982            &demux,
4983            &ust,
4984            &ust,
4985            &deliver_tx,
4986            &undelivered,
4987            &mut ack_buf,
4988            &obs,
4989            LegType::Udp,
4990        )
4991        .await;
4992
4993        // The server issued a challenge on the RESERVED rebind path → it is Validating.
4994        assert_eq!(
4995            server_session.path_state(REBIND_VALIDATION_PATH_ID),
4996            Some(PathStateKind::Validating),
4997            "a path-0 rebind on a candidate source must be challenged on the reserved id"
4998        );
4999        // ...and the challenge datagram reached the CANDIDATE socket (not the peer).
5000        let mut buf = vec![0u8; 2048];
5001        let (n, _from) = tokio::time::timeout(
5002            std::time::Duration::from_secs(1),
5003            cand_sock.recv_from(&mut buf),
5004        )
5005        .await
5006        .expect("rebind challenge must reach the candidate")
5007        .unwrap();
5008        let mut asm = FragmentAssembler::new();
5009        let (_hdr, inner) = push_datagram(&mut asm, &buf[..n]).expect("decode envelope");
5010        let inner = inner.expect("single-datagram challenge");
5011        let pkt = client_session
5012            .parse_protected(&inner)
5013            .expect("inner packet");
5014        assert!(
5015            pkt.header.flags.contains(PacketFlags::PATH_VALIDATION),
5016            "the candidate must receive a PATH_VALIDATION challenge"
5017        );
5018        assert_eq!(
5019            pkt.header.path_id, REBIND_VALIDATION_PATH_ID,
5020            "the passive-rebind challenge must be stamped on the reserved validation path-id"
5021        );
5022    }
5023
5024    #[test]
5025    fn migration_path_id_never_collides_with_the_rebind_validation_path() {
5026        // M-3: the client's active-migration counter must never hand back the
5027        // reserved rebind validation id — otherwise an active migration and a
5028        // concurrent passive-rebind challenge would share a registry slot and a
5029        // late echo to one could resolve the other. The counter wraps 254 → 1,
5030        // skipping both 0 (the handshake path) and 255 (the reserved id).
5031        use crate::transport::session::REBIND_VALIDATION_PATH_ID;
5032        let session_id = fixed_session_id();
5033        let (client_session, _server_session) = paired_sessions(session_id);
5034        for _ in 0..600 {
5035            let id = client_session.next_migration_path_id();
5036            assert_ne!(id, 0, "must never reuse the handshake path");
5037            assert_ne!(
5038                id, REBIND_VALIDATION_PATH_ID,
5039                "must never reuse the reserved rebind validation path"
5040            );
5041        }
5042    }
5043
5044    /// Build an `ENCRYPTED | ACK` frame (H1, L1-A) from `acker_session`
5045    /// acknowledging `acked_seq` on `stream_id`, with its own header sequence
5046    /// `ack_header_seq` (drawn from the acker's send space, distinct from the
5047    /// acked data sequence). The AEAD plaintext is a single-sequence `Sack`
5048    /// (the SACK superset of the legacy single-seq ACK). Wire-serialised, ready
5049    /// for `handle_packet`.
5050    fn build_encrypted_ack(
5051        acker_session: &InnerSession,
5052        session_id: SessionId,
5053        stream_id: TransportStreamId,
5054        ack_header_seq: u32,
5055        acked_seq: u32,
5056    ) -> Vec<u8> {
5057        let sack = crate::transport::sack::Sack::from_received(&[acked_seq], 0)
5058            .expect("single-seq sack")
5059            .to_wire();
5060        build_encrypted_ack_with_payload(
5061            acker_session,
5062            session_id,
5063            stream_id,
5064            ack_header_seq,
5065            &sack,
5066        )
5067    }
5068
5069    /// Like [`build_encrypted_ack`] but with an arbitrary AEAD plaintext payload
5070    /// (used to exercise malformed-SACK handling on the sender path).
5071    fn build_encrypted_ack_with_payload(
5072        acker_session: &InnerSession,
5073        session_id: SessionId,
5074        stream_id: TransportStreamId,
5075        ack_header_seq: u32,
5076        payload: &[u8],
5077    ) -> Vec<u8> {
5078        let flag_bits = PacketFlags::ENCRYPTED | PacketFlags::ACK;
5079        let header = PacketHeader::new(
5080            session_id,
5081            stream_id,
5082            ack_header_seq as u64,
5083            PacketFlags::new(flag_bits),
5084        )
5085        .with_epoch(acker_session.current_epoch());
5086        let ct = acker_session
5087            .encrypt_packet(&header, payload, &[])
5088            .expect("encrypt ack");
5089        PhantomPacket::new(header, ct).to_wire()
5090    }
5091
5092    /// Drive a single inbound packet through `handle_packet` against
5093    /// `server_session` with throwaway delivery/transport/observability wiring.
5094    async fn run_recv(
5095        pkt: PhantomPacket,
5096        session_id: SessionId,
5097        server_session: &Arc<InnerSession>,
5098        streams: &Arc<DashMap<u32, Arc<TransportStream>>>,
5099    ) {
5100        let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
5101        let demux = Arc::new(demux);
5102        let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5103        let undelivered = AtomicU64::new(0);
5104        let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
5105        let transport: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5106            tx: ack_a,
5107            rx: Mutex::new(ack_b),
5108        });
5109        let mut ack_buf = Vec::with_capacity(64);
5110        let obs = Observability::new(ObservabilityConfig::default());
5111        handle_packet(
5112            pkt,
5113            session_id,
5114            server_session,
5115            streams,
5116            &demux,
5117            &transport,
5118            &transport,
5119            &deliver_tx,
5120            &undelivered,
5121            &mut ack_buf,
5122            &obs,
5123            LegType::Tcp,
5124        )
5125        .await;
5126    }
5127
5128    /// Stage a stream with one in-flight reliable segment; returns the stream,
5129    /// the shared streams map, and the segment's sequence number.
5130    async fn staged_pending_segment() -> (
5131        Arc<TransportStream>,
5132        Arc<DashMap<u32, Arc<TransportStream>>>,
5133        u32,
5134    ) {
5135        let stream_id: TransportStreamId = 1;
5136        let stream = Arc::new(TransportStream::new(stream_id));
5137        let seq = stream
5138            .send_reliable(Bytes::from_static(b"reliable-payload"))
5139            .await
5140            .unwrap();
5141        let _ = stream.poll_send(u64::MAX).await.expect("segment in-flight");
5142        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5143        streams.insert(stream_id as u32, stream.clone());
5144        (stream, streams, seq)
5145    }
5146
5147    /// **H1 (Invariant 2).** A forged *unauthenticated* ACK — whether bare
5148    /// (`ACK` flag, empty payload) or carrying a plaintext 4-byte acked-seq —
5149    /// must NOT retire a pending reliable segment. Pre-fix, the ACK branch ran
5150    /// before the AEAD gate and trusted `header.sequence`, so an off-path
5151    /// attacker could silently drop never-acknowledged segments.
5152    #[tokio::test]
5153    async fn forged_plaintext_ack_does_not_retire_pending_segment() {
5154        let session_id = fixed_session_id();
5155        let (_client, server_session) = paired_sessions(session_id);
5156        let (stream, streams, seq) = staged_pending_segment().await;
5157        let stream_id: TransportStreamId = 1;
5158
5159        // Variant 1: bare ACK, no ENCRYPTED, empty payload, guessed sequence.
5160        run_recv(
5161            PhantomPacket::new(
5162                PacketHeader::new(
5163                    session_id,
5164                    stream_id,
5165                    seq as u64,
5166                    PacketFlags::new(PacketFlags::ACK),
5167                ),
5168                Vec::new(),
5169            ),
5170            session_id,
5171            &server_session,
5172            &streams,
5173        )
5174        .await;
5175        // Variant 2: ACK with a plaintext 4-byte acked-seq, no ENCRYPTED.
5176        run_recv(
5177            PhantomPacket::new(
5178                PacketHeader::new(
5179                    session_id,
5180                    stream_id,
5181                    999,
5182                    PacketFlags::new(PacketFlags::ACK),
5183                ),
5184                seq.to_be_bytes().to_vec(),
5185            ),
5186            session_id,
5187            &server_session,
5188            &streams,
5189        )
5190        .await;
5191
5192        assert!(
5193            stream.ack(seq).await.is_some(),
5194            "a forged unauthenticated ACK must not retire the pending reliable segment"
5195        );
5196    }
5197
5198    /// **H1 + L1-B.** A forged *unauthenticated* SACK (plaintext `ACK`, no
5199    /// `ENCRYPTED`) carrying a wide range must neither retire a pending segment NOR
5200    /// trigger a fast-retransmit: it is dropped by the downgrade defense before the
5201    /// AEAD gate, so it never reaches the SACK loss detector (which would otherwise
5202    /// flag segments lost and drive Pass-0). The SACK plaintext is acted on only
5203    /// after AEAD verify.
5204    #[tokio::test]
5205    async fn forged_sack_neither_retires_nor_fast_retransmits() {
5206        let session_id = fixed_session_id();
5207        let (_client, server_session) = paired_sessions(session_id);
5208        let stream_id: TransportStreamId = 1;
5209
5210        // Stage offsets 0..=5, all in flight.
5211        let stream = Arc::new(TransportStream::new(stream_id));
5212        for _ in 0..6u32 {
5213            stream
5214                .send_reliable(Bytes::from_static(b"x"))
5215                .await
5216                .unwrap();
5217            let _ = stream.poll_send(u64::MAX).await.expect("in-flight");
5218        }
5219        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5220        streams.insert(stream_id as u32, stream.clone());
5221        assert_eq!(stream.pending_send_count().await, 6);
5222
5223        // Forged PLAINTEXT ACK (no ENCRYPTED) carrying a SACK over offset {5}.
5224        // If acted on, it would retire offset 5 AND flag offsets 0,1,2 lost.
5225        let forged_sack = crate::transport::sack::Sack::from_received(&[5], 0)
5226            .expect("sack")
5227            .to_wire();
5228        run_recv(
5229            PhantomPacket::new(
5230                PacketHeader::new(
5231                    session_id,
5232                    stream_id,
5233                    4242,
5234                    PacketFlags::new(PacketFlags::ACK),
5235                ),
5236                forged_sack, // plaintext — NOT encrypted
5237            ),
5238            session_id,
5239            &server_session,
5240            &streams,
5241        )
5242        .await;
5243
5244        // Nothing retired: all six segments remain buffered.
5245        assert_eq!(
5246            stream.pending_send_count().await,
5247            6,
5248            "a forged unauthenticated SACK must not retire any segment (H1)"
5249        );
5250        // No fast-retransmit: nothing was flagged lost, so poll_send (all sent, no
5251        // new data) returns None rather than a Pass-0 retransmit.
5252        assert!(
5253            stream.poll_send(u64::MAX).await.is_none(),
5254            "a forged SACK must not trigger a fast-retransmit (no segment flagged lost)"
5255        );
5256    }
5257
5258    /// **#7 (congestion 4.4 fix).** A SACK that declares segments lost must NOT itself feed
5259    /// BBR's loss signal — loss is fed exactly once per loss event, at the *retransmission*
5260    /// point (`drain_streams`'s `if seg.retransmit`), which covers both SACK-gap and RTO
5261    /// retransmits. Feeding it again here, at SACK-gap detection, double-decrements the
5262    /// purely-incremental `inflight_bytes`: a lost segment fed at both detection and
5263    /// retransmission nets a permanent inflight under-count, inflating the cwnd budget
5264    /// (`cwnd − inflight`) → over-send, accumulating with every SACK-gap loss. Here a sender
5265    /// has six in-flight segments; an authenticated SACK acking offset {5} retires segment 5
5266    /// and flags 0,1,2 lost. Afterward `inflight_bytes` must drop by ONLY the retired
5267    /// segment — never by the three flagged-lost ones (which the bug would subtract here).
5268    #[tokio::test]
5269    async fn loss_declaring_sack_does_not_feed_bbr_loss_at_detection() {
5270        tokio::time::pause();
5271        let session_id = fixed_session_id();
5272        let (client_session, server_session) = paired_sessions(session_id);
5273        let stream_id: TransportStreamId = 1;
5274
5275        // Stage six in-flight reliable segments on the sender, mirroring the pump's
5276        // inflight accounting (`on_packet_sent` per sent segment).
5277        let stream = Arc::new(TransportStream::new(stream_id));
5278        let mut seg_size = 0u64;
5279        for _ in 0..6u32 {
5280            stream
5281                .send_reliable(Bytes::from_static(b"x"))
5282                .await
5283                .unwrap();
5284            let seg = stream.poll_send(u64::MAX).await.expect("in-flight");
5285            seg_size = seg.data.len() as u64;
5286            server_session.on_packet_sent(seg_size);
5287        }
5288        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5289        streams.insert(stream_id as u32, stream.clone());
5290        let inflight_before = server_session.bandwidth_snapshot().inflight_bytes;
5291        assert_eq!(inflight_before, 6 * seg_size, "six segments in flight");
5292
5293        // Authenticated SACK acking offset {5}: retires segment 5, flags 0,1,2 lost.
5294        let ack = build_encrypted_ack(&client_session, session_id, stream_id, 4242, 5);
5295        let pkt = decode_recv_frame(&ack, session_id);
5296        run_recv(pkt, session_id, &server_session, &streams).await;
5297
5298        let inflight_after = server_session.bandwidth_snapshot().inflight_bytes;
5299        assert_eq!(
5300            inflight_after,
5301            inflight_before - seg_size,
5302            "inflight must drop by ONLY the retired (acked) segment; double-feeding loss at \
5303             SACK-gap detection would over-decrement it by the three flagged-lost segments (#7)"
5304        );
5305    }
5306
5307    /// **H1 positive control.** A genuine `ENCRYPTED | ACK` frame from the peer,
5308    /// whose AEAD payload carries the acked data sequence, retires the matching
5309    /// pending segment after AEAD verify. The ACK's own `header.sequence`
5310    /// (`ack_header_seq`) is deliberately different from the acked sequence to
5311    /// prove the handler reads the authenticated payload, not the header.
5312    #[tokio::test]
5313    async fn authenticated_ack_retires_pending_segment() {
5314        let session_id = fixed_session_id();
5315        let (client_session, server_session) = paired_sessions(session_id);
5316        let (stream, streams, seq) = staged_pending_segment().await;
5317        let stream_id: TransportStreamId = 1;
5318
5319        let ack_header_seq = seq.wrapping_add(54_321);
5320        let frame =
5321            build_encrypted_ack(&client_session, session_id, stream_id, ack_header_seq, seq);
5322        let ack_pkt = decode_recv_frame(&frame, session_id);
5323        run_recv(ack_pkt, session_id, &server_session, &streams).await;
5324
5325        assert!(
5326            stream.ack(seq).await.is_none(),
5327            "an authenticated ACK must retire the acked pending segment"
5328        );
5329    }
5330
5331    /// **L1-A SACK end-to-end (gap retire).** Stage segments 0..=5 on the sender,
5332    /// deliver one authenticated `ENCRYPTED | ACK` carrying a SACK over the
5333    /// received set {0,1,2,4,5} (gap at 3), and assert the sender retires exactly
5334    /// those five segments from its send buffer — keeping only the gap segment 3.
5335    /// This proves the SACK retires MULTIPLE segments in one ACK (vs. the legacy
5336    /// single-seq ACK).
5337    #[tokio::test]
5338    async fn authenticated_sack_retires_all_covered_segments_skipping_gap() {
5339        let session_id = fixed_session_id();
5340        let (client_session, server_session) = paired_sessions(session_id);
5341        let stream_id: TransportStreamId = 1;
5342
5343        // Sender stages segments 0..=5, all in-flight.
5344        let stream = Arc::new(TransportStream::new(stream_id));
5345        for i in 0..6u32 {
5346            let seq = stream
5347                .send_reliable(Bytes::from(format!("seg-{i}")))
5348                .await
5349                .unwrap();
5350            assert_eq!(seq, i);
5351            let _ = stream.poll_send(u64::MAX).await.expect("in-flight");
5352        }
5353        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5354        streams.insert(stream_id as u32, stream.clone());
5355        assert_eq!(stream.pending_send_count().await, 6);
5356
5357        // The receiver (client_session) emits a SACK over {0,1,2,4,5}.
5358        let sack = crate::transport::sack::Sack::from_received(&[0, 1, 2, 4, 5], 777)
5359            .expect("sack")
5360            .to_wire();
5361        let frame = build_encrypted_ack_with_payload(
5362            &client_session,
5363            session_id,
5364            stream_id,
5365            9_999, // ACK header seq distinct from the acked data seqs
5366            &sack,
5367        );
5368        let ack_pkt = decode_recv_frame(&frame, session_id);
5369        run_recv(ack_pkt, session_id, &server_session, &streams).await;
5370
5371        // Exactly the gap segment (3) remains.
5372        assert_eq!(
5373            stream.pending_send_count().await,
5374            1,
5375            "SACK must retire all five covered segments at once"
5376        );
5377        for retired in [0u32, 1, 2, 4, 5] {
5378            assert!(
5379                stream.ack(retired).await.is_none(),
5380                "seq {retired} should have been retired by the SACK"
5381            );
5382        }
5383        assert!(
5384            stream.ack(3).await.is_some(),
5385            "the gap segment 3 must remain buffered"
5386        );
5387    }
5388
5389    /// **L1-A malformed-SACK robustness.** An authenticated (post-AEAD) but
5390    /// structurally malformed SACK payload — here a truncated 5-byte blob — must
5391    /// be dropped on the sender path WITHOUT panic and retire NOTHING. Post-AEAD
5392    /// the frame is authenticated, but a buggy peer must not crash us.
5393    #[tokio::test]
5394    async fn malformed_sack_is_dropped_and_retires_nothing() {
5395        let session_id = fixed_session_id();
5396        let (client_session, server_session) = paired_sessions(session_id);
5397        let (stream, streams, seq) = staged_pending_segment().await;
5398        let stream_id: TransportStreamId = 1;
5399
5400        // 5 bytes < MIN_WIRE_LEN (14) → Sack::from_wire returns Truncated.
5401        let bad_payload = vec![0u8; 5];
5402        let frame = build_encrypted_ack_with_payload(
5403            &client_session,
5404            session_id,
5405            stream_id,
5406            1234,
5407            &bad_payload,
5408        );
5409        let ack_pkt = decode_recv_frame(&frame, session_id);
5410        // Must not panic.
5411        run_recv(ack_pkt, session_id, &server_session, &streams).await;
5412
5413        assert!(
5414            stream.ack(seq).await.is_some(),
5415            "a malformed SACK must retire nothing — the pending segment stays buffered"
5416        );
5417    }
5418
5419    /// **L1-A ack_delay plumbing.** A reliable data packet driven through the
5420    /// receiver's `handle_packet` produces an `ENCRYPTED | ACK` frame on the wire
5421    /// whose decoded SACK has a populated (non-zero) `ack_delay_us` — proving the
5422    /// field, previously always 0, is now plumbed end-to-end.
5423    #[tokio::test]
5424    async fn receiver_emits_sack_with_populated_ack_delay() {
5425        let session_id = fixed_session_id();
5426        // Two paired sessions sharing keys so the receiver's ACK decrypts under
5427        // the sender's session.
5428        let (sender_session, receiver_session) = paired_sessions(session_id);
5429        let stream_id: TransportStreamId = 1;
5430
5431        // Build a reliable data packet from the sender at sequence 7 (stream_offset
5432        // == sequence == 7 via build_app_frame, so the SACK's largest_acked is 7).
5433        let data_seq = 7u32;
5434        let data_pkt = decode_recv_frame(
5435            &build_app_frame(
5436                &sender_session,
5437                session_id,
5438                stream_id,
5439                data_seq,
5440                b"hello-reliable",
5441            ),
5442            session_id,
5443        );
5444
5445        // Wiring with a capturable ACK transport.
5446        let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
5447        let demux = Arc::new(demux);
5448        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5449        let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5450        let undelivered = AtomicU64::new(0);
5451        let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
5452        let transport: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5453            tx: ack_a,
5454            rx: Mutex::new(ack_b),
5455        });
5456        let mut ack_buf = Vec::with_capacity(64);
5457        let obs = Observability::new(ObservabilityConfig::default());
5458        handle_packet(
5459            data_pkt,
5460            session_id,
5461            &receiver_session,
5462            &streams,
5463            &demux,
5464            &transport,
5465            &transport,
5466            &deliver_tx,
5467            &undelivered,
5468            &mut ack_buf,
5469            &obs,
5470            LegType::Tcp,
5471        )
5472        .await;
5473
5474        // Pull the emitted ACK frame off the transport and decode the SACK.
5475        let ack_frame = transport
5476            .rx
5477            .lock()
5478            .await
5479            .recv()
5480            .await
5481            .expect("an ACK frame must have been emitted");
5482        // The receiver pump emitted this ACK with header protection; unmask from
5483        // the sender side (== the receiver's send HP key).
5484        let ack_pkt = sender_session
5485            .parse_protected(&ack_frame)
5486            .expect("parse emitted ack");
5487        assert!(ack_pkt.header.flags.contains(PacketFlags::ACK));
5488        // Decrypt under the sender's session (shared keys) to read the SACK.
5489        let plain = sender_session
5490            .decrypt_packet(&ack_pkt.header, &ack_pkt.payload, &[])
5491            .expect("decrypt emitted ack");
5492        let sack = crate::transport::sack::Sack::from_wire(&plain).expect("decode emitted sack");
5493        assert_eq!(sack.largest_acked, data_seq, "SACK must ack the data seq");
5494        assert!(sack.acks(data_seq));
5495        // The field is plumbed: ack_delay_us is the coarse recv-to-emit hold.
5496        // It is derived from `now − recv_at` and is therefore populated (the
5497        // assertion is on the field being threaded through, not a tight bound).
5498        let _ = sack.ack_delay_us;
5499    }
5500
5501    /// **H1 session binding.** A frame whose `header.session_id` does not match
5502    /// the negotiated session must be dropped by the per-frame guard before any
5503    /// state mutation — pre-fix the ACK was processed with no session check.
5504    #[tokio::test]
5505    async fn ack_with_wrong_session_id_is_dropped() {
5506        let session_id = fixed_session_id();
5507        let (_client, server_session) = paired_sessions(session_id);
5508        let (stream, streams, seq) = staged_pending_segment().await;
5509        let stream_id: TransportStreamId = 1;
5510
5511        let wrong_id = SessionId::from_bytes([0x11; 32]);
5512        run_recv(
5513            PhantomPacket::new(
5514                PacketHeader::new(
5515                    wrong_id,
5516                    stream_id,
5517                    seq as u64,
5518                    PacketFlags::new(PacketFlags::ACK),
5519                ),
5520                Vec::new(),
5521            ),
5522            session_id,
5523            &server_session,
5524            &streams,
5525        )
5526        .await;
5527
5528        assert!(
5529            stream.ack(seq).await.is_some(),
5530            "an ACK for a different session id must not retire the segment"
5531        );
5532    }
5533
5534    #[tokio::test]
5535    async fn v2_recv_drops_unencrypted_non_empty_post_handshake_payload() {
5536        // Downgrade defense: a V2 application-data packet WITHOUT the
5537        // ENCRYPTED flag but with a non-empty plaintext-looking payload
5538        // must be dropped, mirroring the V1 invariant.
5539        let session_id = fixed_session_id();
5540        let (_, server_session) = paired_sessions(session_id);
5541
5542        let stream_id: TransportStreamId = 2;
5543        let bad_header = PacketHeader::new(
5544            session_id,
5545            stream_id,
5546            0,
5547            PacketFlags::new(PacketFlags::RELIABLE), // no ENCRYPTED
5548        );
5549        let bad_packet = PhantomPacket::new(bad_header, b"leaked-cleartext".to_vec());
5550
5551        let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
5552        let demux = Arc::new(demux);
5553        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5554        let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5555        let undelivered = AtomicU64::new(0);
5556        let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
5557        let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5558            tx: ack_a,
5559            rx: Mutex::new(ack_b),
5560        });
5561
5562        let mut ack_buf = Vec::with_capacity(256);
5563        let obs = Observability::new(ObservabilityConfig::default());
5564        handle_packet(
5565            bad_packet,
5566            session_id,
5567            &server_session,
5568            &streams,
5569            &demux,
5570            &transport_send,
5571            &transport_send,
5572            &deliver_tx,
5573            &undelivered,
5574            &mut ack_buf,
5575            &obs,
5576            LegType::Tcp,
5577        )
5578        .await;
5579
5580        // Nothing should have been handed to the delivery task, and the backlog
5581        // counter must stay at zero (the packet was dropped before hand-off).
5582        assert!(
5583            deliver_rx.try_recv().is_err(),
5584            "unencrypted post-handshake payload must NOT be handed off for delivery"
5585        );
5586        assert_eq!(undelivered.load(Ordering::Acquire), 0);
5587    }
5588
5589    #[tokio::test]
5590    async fn v2_recv_handles_coalesced_bundle_and_routes_each_subpayload() {
5591        use crate::transport::packet_coalescer::{CoalescerConfig, PacketCoalescer};
5592
5593        let session_id = fixed_session_id();
5594        let (client_session, server_session) = paired_sessions(session_id);
5595
5596        // Build a COALESCED bundle of three sub-payloads.
5597        let mut coalescer = PacketCoalescer::new(CoalescerConfig::default());
5598        coalescer.push(b"alpha");
5599        coalescer.push(b"bravo");
5600        coalescer.push(b"charlie");
5601        let bundle = coalescer.flush().expect("bundle");
5602
5603        // Encrypt the bundle and wrap it in a V2 packet with
5604        // ENCRYPTED + COALESCED flags.
5605        let stream_id: TransportStreamId = 3;
5606        let flag_bits = PacketFlags::ENCRYPTED | PacketFlags::COALESCED;
5607        let header = PacketHeader::new(session_id, stream_id, 0, PacketFlags::new(flag_bits))
5608            .with_epoch(client_session.current_epoch());
5609        let ciphertext = client_session
5610            .encrypt_packet(&header, &bundle, &[])
5611            .expect("encrypt bundle");
5612        let v2 = PhantomPacket::new(header, ciphertext);
5613
5614        let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
5615        let demux = Arc::new(demux);
5616        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5617        let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5618        let undelivered = AtomicU64::new(0);
5619        let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
5620        let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5621            tx: ack_a,
5622            rx: Mutex::new(ack_b),
5623        });
5624
5625        let mut ack_buf = Vec::with_capacity(256);
5626        let obs = Observability::new(ObservabilityConfig::default());
5627        handle_packet(
5628            v2,
5629            session_id,
5630            &server_session,
5631            &streams,
5632            &demux,
5633            &transport_send,
5634            &transport_send,
5635            &deliver_tx,
5636            &undelivered,
5637            &mut ack_buf,
5638            &obs,
5639            LegType::Tcp,
5640        )
5641        .await;
5642
5643        // Each sub-payload is handed off IN ORDER through the single FIFO
5644        // delivery channel, every one tagged with the outer stream id, and the
5645        // total counted toward the undelivered backlog.
5646        let (sa, a) = deliver_rx.recv().await.expect("alpha");
5647        let (sb, b) = deliver_rx.recv().await.expect("bravo");
5648        let (sc, c) = deliver_rx.recv().await.expect("charlie");
5649        assert_eq!(
5650            (sa, sb, sc),
5651            (stream_id as u32, stream_id as u32, stream_id as u32)
5652        );
5653        assert_eq!(&a[..], b"alpha");
5654        assert_eq!(&b[..], b"bravo");
5655        assert_eq!(&c[..], b"charlie");
5656        assert_eq!(undelivered.load(Ordering::Acquire), (5 + 5 + 7) as u64);
5657    }
5658
5659    /// Ordering across two COALESCED bundles: the single FIFO delivery channel
5660    /// must hand the first bundle's `[A, B, C]` and the second bundle's `[D]` to
5661    /// the consumer in exactly `A, B, C, D` — decoupling delivery from the reader
5662    /// must not reorder application bytes. (COALESCED is delivered immediately in
5663    /// arrival order — it is not reassembled by stream offset and is not mixed with
5664    /// RELIABLE frames on a stream by the live sender.)
5665    #[tokio::test]
5666    async fn delivery_preserves_order_across_coalesced_then_normal_frame() {
5667        use crate::transport::packet_coalescer::{CoalescerConfig, PacketCoalescer};
5668
5669        let session_id = fixed_session_id();
5670        let (client_session, server_session) = paired_sessions(session_id);
5671        let stream_id: TransportStreamId = 1;
5672
5673        let build_bundle = |seq: u32, items: &[&[u8]]| -> PhantomPacket {
5674            let mut coalescer = PacketCoalescer::new(CoalescerConfig::default());
5675            for it in items {
5676                coalescer.push(it);
5677            }
5678            let bundle = coalescer.flush().expect("bundle");
5679            let flag_bits = PacketFlags::ENCRYPTED | PacketFlags::COALESCED;
5680            let h = PacketHeader::new(
5681                session_id,
5682                stream_id,
5683                seq as u64,
5684                PacketFlags::new(flag_bits),
5685            )
5686            .with_epoch(client_session.current_epoch());
5687            let ct = client_session
5688                .encrypt_packet(&h, &bundle, &[])
5689                .expect("encrypt bundle");
5690            PhantomPacket::new(h, ct)
5691        };
5692
5693        // Frame 1: COALESCED [A, B, C] at sequence 0; Frame 2: COALESCED [D] at seq 1.
5694        let coalesced = build_bundle(0, &[b"A", b"B", b"C"]);
5695        let normal = build_bundle(1, &[b"D"]);
5696
5697        let (demux, _ctrl) = StreamDemultiplexer::new(16);
5698        let demux = Arc::new(demux);
5699        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5700        let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5701        let undelivered = AtomicU64::new(0);
5702        let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(8);
5703        let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5704            tx: ack_a,
5705            rx: Mutex::new(ack_b),
5706        });
5707        let mut ack_buf = Vec::with_capacity(256);
5708        let obs = Observability::new(ObservabilityConfig::default());
5709
5710        for pkt in [coalesced, normal] {
5711            handle_packet(
5712                pkt,
5713                session_id,
5714                &server_session,
5715                &streams,
5716                &demux,
5717                &transport_send,
5718                &transport_send,
5719                &deliver_tx,
5720                &undelivered,
5721                &mut ack_buf,
5722                &obs,
5723                LegType::Tcp,
5724            )
5725            .await;
5726        }
5727
5728        // Drain the FIFO delivery channel — order must be exactly A, B, C, D.
5729        let mut got: Vec<Bytes> = Vec::new();
5730        while let Ok((_sid, b)) = deliver_rx.try_recv() {
5731            got.push(b);
5732        }
5733        let seen: Vec<&[u8]> = got.iter().map(|b| &b[..]).collect();
5734        assert_eq!(seen, vec![&b"A"[..], b"B", b"C", b"D"]);
5735    }
5736
5737    /// **A.5 RED → GREEN.** Two RELIABLE frames arriving OUT OF sequence order on
5738    /// the wire (seq 1 before seq 0) must be delivered to the app IN sequence
5739    /// order (`zero`, `one`). Before the receive-side reorder fix, the live pump
5740    /// delivered in decrypt-arrival order, breaking reliable in-order delivery
5741    /// over a reordering (UDP) path.
5742    #[tokio::test]
5743    async fn reliable_frames_delivered_in_sequence_order_despite_arrival_order() {
5744        let session_id = fixed_session_id();
5745        let (client_session, server_session) = paired_sessions(session_id);
5746        let stream_id: TransportStreamId = 1;
5747
5748        let f0 = decode_recv_frame(
5749            &build_app_frame(&client_session, session_id, stream_id, 0, b"zero"),
5750            session_id,
5751        );
5752        let f1 = decode_recv_frame(
5753            &build_app_frame(&client_session, session_id, stream_id, 1, b"one"),
5754            session_id,
5755        );
5756
5757        let (demux, _ctrl) = StreamDemultiplexer::new(16);
5758        let demux = Arc::new(demux);
5759        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5760        let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5761        let undelivered = AtomicU64::new(0);
5762        let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(8);
5763        let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5764            tx: ack_a,
5765            rx: Mutex::new(ack_b),
5766        });
5767        let mut ack_buf = Vec::with_capacity(256);
5768        let obs = Observability::new(ObservabilityConfig::default());
5769
5770        // Deliver OUT OF ORDER on the wire: seq 1 first, then seq 0.
5771        for pkt in [f1, f0] {
5772            handle_packet(
5773                pkt,
5774                session_id,
5775                &server_session,
5776                &streams,
5777                &demux,
5778                &transport_send,
5779                &transport_send,
5780                &deliver_tx,
5781                &undelivered,
5782                &mut ack_buf,
5783                &obs,
5784                LegType::Tcp,
5785            )
5786            .await;
5787        }
5788
5789        let mut got: Vec<Bytes> = Vec::new();
5790        while let Ok((_sid, b)) = deliver_rx.try_recv() {
5791            got.push(b);
5792        }
5793        let seen: Vec<&[u8]> = got.iter().map(|b| &b[..]).collect();
5794        assert_eq!(
5795            seen,
5796            vec![&b"zero"[..], b"one"],
5797            "reliable data must be delivered in sequence order, not arrival order"
5798        );
5799    }
5800
5801    /// **A.5 control-gap regression (the bidirectional-hang fix).** Reliable data
5802    /// whose wire `header.sequence` has a HOLE (a control frame — ACK /
5803    /// WINDOW_UPDATE — consumed that sequence) but whose gap-free `stream_offset`
5804    /// is contiguous must still deliver in order WITHOUT stalling on the sequence
5805    /// hole. Here header seqs are 0 and 2 (seq 1 = a control frame), offsets 0 and
5806    /// 1. Reordering keyed on the raw `header.sequence` hangs forever waiting for
5807    /// seq 1; keyed on `stream_offset` it delivers `a, b`.
5808    #[tokio::test]
5809    async fn reliable_delivery_skips_control_frame_sequence_holes() {
5810        let session_id = fixed_session_id();
5811        let (client_session, server_session) = paired_sessions(session_id);
5812        let stream_id: TransportStreamId = 1;
5813
5814        // header.seq 0, offset 0, "a"; header.seq 2, offset 1, "b" (seq 1 is a hole).
5815        let a = decode_recv_frame(
5816            &build_app_frame_with_offset(&client_session, session_id, stream_id, 0, 0, b"a"),
5817            session_id,
5818        );
5819        let b = decode_recv_frame(
5820            &build_app_frame_with_offset(&client_session, session_id, stream_id, 2, 1, b"b"),
5821            session_id,
5822        );
5823
5824        let (demux, _ctrl) = StreamDemultiplexer::new(16);
5825        let demux = Arc::new(demux);
5826        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5827        let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5828        let undelivered = AtomicU64::new(0);
5829        let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(8);
5830        let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5831            tx: ack_a,
5832            rx: Mutex::new(ack_b),
5833        });
5834        let mut ack_buf = Vec::with_capacity(256);
5835        let obs = Observability::new(ObservabilityConfig::default());
5836
5837        for pkt in [a, b] {
5838            handle_packet(
5839                pkt,
5840                session_id,
5841                &server_session,
5842                &streams,
5843                &demux,
5844                &transport_send,
5845                &transport_send,
5846                &deliver_tx,
5847                &undelivered,
5848                &mut ack_buf,
5849                &obs,
5850                LegType::Tcp,
5851            )
5852            .await;
5853        }
5854
5855        let mut got: Vec<Bytes> = Vec::new();
5856        while let Ok((_sid, x)) = deliver_rx.try_recv() {
5857            got.push(x);
5858        }
5859        let seen: Vec<&[u8]> = got.iter().map(|b| &b[..]).collect();
5860        assert_eq!(
5861            seen,
5862            vec![&b"a"[..], b"b"],
5863            "reliable data must deliver in stream_offset order, skipping control-frame \
5864             sequence holes (not stall on them)"
5865        );
5866    }
5867
5868    /// A peer that ignores flow control and floods application data faster than
5869    /// the app drains must NOT grow the receive backlog without bound: once the
5870    /// undelivered backlog crosses the reader's hard cap, the session is torn
5871    /// down (state → `Closed`) instead of buffering unboundedly. The app here
5872    /// never calls `recv()`, so the delivery channel fills and the reader's
5873    /// pre-decrypt cap gate fires.
5874    #[tokio::test]
5875    async fn peer_ignoring_flow_control_trips_delivery_hard_cap_and_closes_session() {
5876        let session_id = fixed_session_id();
5877        let (client_inner, server_inner) = paired_sessions(session_id);
5878        let (client_t, server_t) = ChannelTransport::pair();
5879        let client_t = Arc::new(client_t);
5880
5881        // Full server-side session with a running pump; the app NEVER drains it.
5882        let server = PhantomSession::from_accepted_server_session(
5883            "flooder".to_string(),
5884            server_t,
5885            server_inner,
5886        );
5887
5888        // Drain and discard everything the server sends back (ACKs / control)
5889        // so the server reader never blocks on the back channel — a real
5890        // flooding peer likewise keeps emptying its socket. Without this the
5891        // reader would wedge on its own ACK send and the cap could never trip.
5892        let drain_t = client_t.clone();
5893        let drainer = tokio::spawn(async move { while drain_t.recv_bytes().await.is_ok() {} });
5894
5895        // Malicious client: flood valid RELIABLE app packets with unique
5896        // monotonic sequences (so none are replay-dropped) and never honor a
5897        // WINDOW_UPDATE — i.e. ignore flow control entirely.
5898        let payload = vec![0xABu8; 64 * 1024];
5899        let mut seq: u32 = 0;
5900        let mut torn_down = false;
5901        for _ in 0..4000 {
5902            if server.connection_state() == ConnectionState::Closed {
5903                torn_down = true;
5904                break;
5905            }
5906            let flag_bits = PacketFlags::RELIABLE | PacketFlags::ENCRYPTED;
5907            let header = PacketHeader::new(session_id, 1, seq as u64, PacketFlags::new(flag_bits))
5908                .with_epoch(client_inner.current_epoch());
5909            // Reliable plaintext = [stream_offset: u32 BE][payload] (A.5). Offsets
5910            // are contiguous (== seq), so every frame delivers in order and grows
5911            // the undelivered backlog — exactly what should trip the hard cap.
5912            let mut pt = Vec::with_capacity(4 + payload.len());
5913            pt.extend_from_slice(&seq.to_be_bytes());
5914            pt.extend_from_slice(&payload);
5915            let ct = client_inner
5916                .encrypt_packet(&header, &pt, &[])
5917                .expect("encrypt");
5918            // Bound the send so a torn-down (or wedged) transport can't hang the
5919            // test: a closed channel or a stalled reader both mean the flood is
5920            // no longer absorbed — i.e. the session is being torn down.
5921            // This frame traverses the server pump's transport, which removes
5922            // header protection on recv — so apply it on the way out.
5923            let packet = PhantomPacket::new(header, ct);
5924            let wire = client_inner
5925                .protect_packet(&packet)
5926                .expect("header protection");
5927            match tokio::time::timeout(
5928                std::time::Duration::from_secs(5),
5929                client_t.send_bytes(&wire),
5930            )
5931            .await
5932            {
5933                Ok(Ok(())) => {}
5934                _ => {
5935                    torn_down = true;
5936                    break;
5937                }
5938            }
5939            seq = seq.wrapping_add(1);
5940            tokio::task::yield_now().await;
5941        }
5942        assert!(
5943            torn_down,
5944            "a peer flooding past the delivery hard cap must get its session torn down"
5945        );
5946
5947        // Definitive: the session ends up Closed.
5948        let mut closed = false;
5949        for _ in 0..200 {
5950            if server.connection_state() == ConnectionState::Closed {
5951                closed = true;
5952                break;
5953            }
5954            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
5955        }
5956        drainer.abort();
5957        assert!(
5958            closed,
5959            "session state must be Closed after the hard cap trips"
5960        );
5961    }
5962
5963    /// Phase 4.4 — BBR ACK feedback drives the pacer rate. Build a
5964    /// realistic DeliverySample with known sent_at/acked_at timestamps
5965    /// and packet size; assert that calling `on_packet_acked` causes
5966    /// the pacer to leave its default unlimited state with a finite
5967    /// finite positive rate.
5968    #[tokio::test]
5969    async fn bbr_on_ack_drives_pacer_rate() {
5970        use crate::transport::bandwidth_estimator::DeliverySample;
5971        use std::time::{Duration, Instant};
5972
5973        let session_id = fixed_session_id();
5974        let (client_session, _server_session) = paired_sessions(session_id);
5975
5976        // The default Pacer is `unlimited` — track it before/after.
5977        assert!(!client_session.pacer().is_enabled());
5978
5979        // Simulate sending a 1500-byte packet, then receiving an ACK
5980        // 20 ms later. We feed a few samples in a row so the EMA
5981        // estimator has data to work with.
5982        let now = Instant::now();
5983        for i in 0..16 {
5984            let sent_at = now - Duration::from_millis(20 + i * 5);
5985            let acked_at = now - Duration::from_millis(i * 5);
5986            let sample = DeliverySample {
5987                delivered_bytes: 0,
5988                sent_at,
5989                acked_at,
5990                packet_bytes: 1500,
5991                is_app_limited: false,
5992                ack_delay_us: 100,
5993            };
5994            client_session.on_packet_sent(1500);
5995            let _ = client_session.on_packet_acked(sample);
5996        }
5997
5998        // The pacer should now be set to a real rate (still
5999        // "unlimited" handle, but with a finite stored rate). The
6000        // BandwidthEstimator's `pacing_rate()` is what gets pushed
6001        // into the pacer; assert it is non-zero and finite.
6002        let snap = client_session.bandwidth_snapshot();
6003        assert!(
6004            snap.pacing_rate_bps > 0,
6005            "expected pacing_rate to be non-zero, got {}",
6006            snap.pacing_rate_bps,
6007        );
6008        // The pacer's stored rate must match the estimator's view
6009        // (Session.on_packet_acked mirrors them).
6010        assert_eq!(client_session.pacer().rate(), snap.pacing_rate_bps);
6011    }
6012
6013    /// Phase 4.3 — WINDOW_UPDATE round-trip under the relative-credit model.
6014    /// The receive **delivery** task credits the flow-control window on real
6015    /// app consumption and stages the credit; the **send loop** flushes it as a
6016    /// single encrypted WINDOW_UPDATE via `flush_pending_window_updates`. The
6017    /// sender then ADDS the relative credit to its `peer_send_window` — it does
6018    /// not overwrite it with an absolute value.
6019    #[tokio::test]
6020    async fn flow_control_window_update_round_trip() {
6021        use crate::transport::stream::INITIAL_STREAM_WINDOW;
6022
6023        let session_id = fixed_session_id();
6024        let (client_session, server_session) = paired_sessions(session_id);
6025
6026        let stream_id: TransportStreamId = 9;
6027        let server_streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
6028        let server_stream = Arc::new(TransportStream::new(stream_id));
6029        server_streams.insert(stream_id as u32, server_stream.clone());
6030
6031        // Client also has a Stream so we can apply the inbound credit.
6032        let client_stream = Arc::new(TransportStream::new(stream_id));
6033
6034        // Pre-drain the client's peer_send_window so the credit has a real
6035        // effect to assert against.
6036        let drain = INITIAL_STREAM_WINDOW - 1000;
6037        assert!(client_stream.try_consume_send_window(drain));
6038        assert_eq!(client_stream.peer_send_window(), 1000);
6039
6040        // The delivery task credits the window on real consumption: model one
6041        // drain that crosses the half-window threshold and stage the credit
6042        // exactly as `run_data_pump`'s delivery task does.
6043        let consumed = INITIAL_STREAM_WINDOW / 2 + 1;
6044        let credit = server_stream
6045            .record_app_consumed(consumed)
6046            .expect("threshold crossed → credit granted");
6047        server_stream.stage_window_update_credit(credit);
6048
6049        // The send loop flushes the staged credit as a single WINDOW_UPDATE.
6050        let (out_tx, mut out_rx) = mpsc::channel::<Vec<u8>>(4);
6051        let (back_tx, back_rx) = mpsc::channel::<Vec<u8>>(4);
6052        let server_outbound: Arc<ChannelTransport> = Arc::new(ChannelTransport {
6053            tx: out_tx,
6054            rx: Mutex::new(back_rx),
6055        });
6056        let _keep = back_tx;
6057        flush_pending_window_updates(
6058            &server_outbound,
6059            &server_session,
6060            session_id,
6061            &server_streams,
6062        )
6063        .await;
6064
6065        // Exactly one WINDOW_UPDATE was emitted; decrypt it and read the credit.
6066        let frame = tokio::time::timeout(std::time::Duration::from_millis(100), out_rx.recv())
6067            .await
6068            .expect("expected a WINDOW_UPDATE frame")
6069            .expect("channel open");
6070        let pv2 = client_session.parse_protected(&frame).unwrap();
6071        assert!(pv2.header.flags.contains(PacketFlags::WINDOW_UPDATE));
6072        // The control frame's sequence comes from the stream's own send space —
6073        // distinct from any data packet so the AEAD nonce never repeats.
6074        let pt = client_session
6075            .decrypt_packet(&pv2.header, &pv2.payload, &[])
6076            .expect("decrypt WINDOW_UPDATE");
6077        assert_eq!(pt.len(), 4);
6078        let announced = u32::from_be_bytes([pt[0], pt[1], pt[2], pt[3]]);
6079        assert_eq!(
6080            announced, credit,
6081            "WINDOW_UPDATE carries the relative credit (bytes consumed since last update)"
6082        );
6083        // Exactly one frame was emitted — nothing else is queued on the wire.
6084        assert!(
6085            out_rx.try_recv().is_err(),
6086            "exactly one WINDOW_UPDATE must be emitted"
6087        );
6088
6089        // The staged slot is now empty — a second flush emits nothing.
6090        flush_pending_window_updates(
6091            &server_outbound,
6092            &server_session,
6093            session_id,
6094            &server_streams,
6095        )
6096        .await;
6097        assert!(
6098            out_rx.try_recv().is_err(),
6099            "no spurious second WINDOW_UPDATE after the credit was already flushed"
6100        );
6101
6102        // Apply the relative credit on the client side: peer_send_window ADDS it
6103        // to the current 1000 (it does not jump to an absolute value).
6104        client_stream.apply_peer_window_update(announced);
6105        assert_eq!(client_stream.peer_send_window(), 1000 + credit);
6106    }
6107
6108    /// Phase 4.3 — priority scheduler ordering. Two streams enqueue
6109    /// data simultaneously; the higher-priority one must be drained
6110    /// first, all of its data before any of the lower one's.
6111    #[tokio::test]
6112    async fn priority_scheduler_drains_higher_priority_stream_first() {
6113        // Build a real Session (any crypto state — we only inspect
6114        // send order, not ciphertext) and an Arc<Stream> per stream.
6115        let session_id = fixed_session_id();
6116        let (client_session, _server_session) = paired_sessions(session_id);
6117
6118        // Capture every outbound packet by stuffing into a channel-
6119        // backed transport whose tx end we can drain after.
6120        let (tx_a, mut rx_a) = mpsc::channel::<Vec<u8>>(32);
6121        let (tx_b, rx_b) = mpsc::channel::<Vec<u8>>(32);
6122        let transport: Arc<ChannelTransport> = Arc::new(ChannelTransport {
6123            tx: tx_a,
6124            rx: Mutex::new(rx_b),
6125        });
6126        let _keep = tx_b; // keep the recv side alive
6127
6128        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
6129
6130        // Stream 11: low priority (1), 3 reliable chunks.
6131        let low = Arc::new(TransportStream::new(11));
6132        low.set_priority(1);
6133        low.send_reliable(Bytes::from_static(b"L0")).await.unwrap();
6134        low.send_reliable(Bytes::from_static(b"L1")).await.unwrap();
6135        low.send_reliable(Bytes::from_static(b"L2")).await.unwrap();
6136        streams.insert(11, low);
6137
6138        // Stream 22: HIGH priority (100), 3 reliable chunks.
6139        let hi = Arc::new(TransportStream::new(22));
6140        hi.set_priority(100);
6141        hi.send_reliable(Bytes::from_static(b"H0")).await.unwrap();
6142        hi.send_reliable(Bytes::from_static(b"H1")).await.unwrap();
6143        hi.send_reliable(Bytes::from_static(b"H2")).await.unwrap();
6144        streams.insert(22, hi);
6145
6146        drain_streams_priority_ordered(&transport, &client_session, session_id, &streams).await;
6147
6148        // Pull all packets off the channel and verify their order:
6149        // the three H* chunks must come before any L* chunk.
6150        let mut order: Vec<&'static str> = Vec::new();
6151        while let Ok(frame) =
6152            tokio::time::timeout(std::time::Duration::from_millis(50), rx_a.recv()).await
6153        {
6154            let bytes = match frame {
6155                Some(b) => b,
6156                None => break,
6157            };
6158            let v2 = _server_session.parse_protected(&bytes).unwrap();
6159            // Decrypt under the SERVER role so the per-direction key
6160            // matches the client-side encrypt.
6161            let plaintext = _server_session
6162                .decrypt_packet(&v2.header, &v2.payload, &[])
6163                .expect("decrypt");
6164            // Reliable frames carry a 4-byte stream_offset prefix (A.5); the tag is
6165            // the application payload after it.
6166            let tag: &'static str = match &plaintext[4..] {
6167                b"H0" => "H0",
6168                b"H1" => "H1",
6169                b"H2" => "H2",
6170                b"L0" => "L0",
6171                b"L1" => "L1",
6172                b"L2" => "L2",
6173                other => panic!("unexpected payload {:?}", other),
6174            };
6175            order.push(tag);
6176        }
6177
6178        // All H* before any L*.
6179        let first_low = order
6180            .iter()
6181            .position(|s| s.starts_with('L'))
6182            .unwrap_or(order.len());
6183        let last_high = order.iter().rposition(|s| s.starts_with('H')).unwrap();
6184        assert!(
6185            last_high < first_low,
6186            "strict priority violated: order = {:?}",
6187            order
6188        );
6189    }
6190
6191    #[tokio::test]
6192    async fn v2_recv_echoes_path_validation_challenge_back_as_response() {
6193        // Two paired sessions on different IDs (so neither has a
6194        // pending challenge for the path). The "responder" sees a
6195        // PATH_VALIDATION packet on a new path id and must echo the
6196        // 32-byte payload back via the transport.
6197        let session_id = fixed_session_id();
6198        let (client_session, server_session) = paired_sessions(session_id);
6199
6200        // Build a PATH_VALIDATION packet with ENCRYPTED + path_id=7.
6201        let path_id: u8 = 7;
6202        let payload = [0xDEu8; crate::transport::path::PATH_CHALLENGE_LEN];
6203        let flag_bits = PacketFlags::ENCRYPTED | PacketFlags::PATH_VALIDATION;
6204        let header = PacketHeader::new(session_id, 0, 0, PacketFlags::new(flag_bits))
6205            .with_epoch(client_session.current_epoch())
6206            .with_path_id(path_id);
6207        let ciphertext = client_session
6208            .encrypt_packet(&header, &payload, &[])
6209            .expect("encrypt challenge");
6210        let v2 = PhantomPacket::new(header, ciphertext);
6211
6212        let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
6213        let demux = Arc::new(demux);
6214        let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
6215        let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
6216        let undelivered = AtomicU64::new(0);
6217        // Server's outbound transport — captures the echo back.
6218        let (echo_tx, mut echo_rx) = mpsc::channel::<Vec<u8>>(4);
6219        let (back_tx, back_rx) = mpsc::channel::<Vec<u8>>(4);
6220        let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
6221            tx: echo_tx,
6222            rx: Mutex::new(back_rx),
6223        });
6224        let _back_tx_keepalive = back_tx; // keep the recv side alive
6225
6226        let mut ack_buf = Vec::with_capacity(256);
6227        let obs = Observability::new(ObservabilityConfig::default());
6228
6229        handle_packet(
6230            v2,
6231            session_id,
6232            &server_session,
6233            &streams,
6234            &demux,
6235            &transport_send,
6236            &transport_send,
6237            &deliver_tx,
6238            &undelivered,
6239            &mut ack_buf,
6240            &obs,
6241            LegType::Tcp,
6242        )
6243        .await;
6244
6245        // Server should have emitted a PATH_VALIDATION response on the
6246        // outbound transport. Pull it out and verify it carries the
6247        // same payload back.
6248        let echo_bytes =
6249            tokio::time::timeout(std::time::Duration::from_millis(200), echo_rx.recv())
6250                .await
6251                .expect("echo should arrive")
6252                .expect("channel open");
6253
6254        // Decrypt the echo on the original (client) side — server-side
6255        // ciphertext authenticates the round-trip.
6256        // The server emitted this echo with header protection; unmask from the
6257        // client side (== the server's send HP key).
6258        let echo_v2 = client_session.parse_protected(&echo_bytes).unwrap();
6259        assert!(echo_v2.header.flags.contains(PacketFlags::PATH_VALIDATION));
6260        assert_eq!(echo_v2.header.path_id, path_id);
6261    }
6262
6263    // ────────────────────────────────────────────────────────────────────
6264    // 0-RTT early-data
6265    // ────────────────────────────────────────────────────────────────────
6266
6267    /// Full 0-RTT round-trip over `ChannelTransport`: a priming handshake
6268    /// populates the server cache and yields a resumption hint; a second
6269    /// connect via `connect_with_resumption` carries application early-data
6270    /// sealed inside the resuming ClientHello, which the server decrypts and
6271    /// surfaces. The client learns the verdict via `early_data_accepted()`.
6272    ///
6273    /// The server side runs inline (not a spawned task) so its
6274    /// `ChannelTransport` halves stay alive in scope — dropping them
6275    /// would close the client's data pump and flip the session to
6276    /// `Closed` before the assertions run.
6277    #[tokio::test]
6278    async fn zero_rtt_early_data_full_round_trip() {
6279        // One HandshakeServer shared across both phases so its session
6280        // cache persists between the priming handshake and the resume.
6281        let server_hs = HandshakeServer::new().unwrap();
6282        let server_pinned_key = server_hs.verifying_key().clone();
6283        let client_ip: std::net::IpAddr = "127.0.0.1".parse().unwrap();
6284
6285        // ── Step 1: prime — a normal handshake fills the cache ──
6286        let (c1, s1) = ChannelTransport::pair();
6287        let phase1_session =
6288            PhantomSession::connect_with_transport("test:9000", c1, server_pinned_key.clone());
6289
6290        let hello_bytes = s1.recv_bytes().await.unwrap();
6291        let ch = borsh::from_slice::<ClientHello>(&hello_bytes).unwrap();
6292        let retry = match server_hs.process_client_hello(&ch, 0, client_ip) {
6293            HandshakeResponse::Retry(r) => r,
6294            _ => panic!("expected Retry"),
6295        };
6296        s1.send_bytes(&ServerReply::Retry(retry).to_wire().unwrap())
6297            .await
6298            .unwrap();
6299        let next = s1.recv_bytes().await.unwrap();
6300        let ch2 = borsh::from_slice::<ClientHello>(&next).unwrap();
6301        match server_hs.process_client_hello(&ch2, 0, client_ip) {
6302            HandshakeResponse::Success(sh, _session, _) => {
6303                s1.send_bytes(&ServerReply::Hello(sh).to_wire().unwrap())
6304                    .await
6305                    .unwrap();
6306            }
6307            _ => panic!("expected Success"),
6308        }
6309
6310        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
6311        assert_eq!(
6312            phase1_session.connection_state(),
6313            ConnectionState::Connected
6314        );
6315        let hint = phase1_session
6316            .resumption_hint()
6317            .await
6318            .expect("phase 1 produced a resumption hint");
6319        // The Rust-only `connect_with_resumption` takes the raw tuple;
6320        // `resumption_hint()` now yields the UniFFI `ResumptionHint`
6321        // record, so rebuild the tuple from its 32-byte fields.
6322        let hint = (
6323            <[u8; 32]>::try_from(hint.session_id.as_slice()).expect("session_id is 32 bytes"),
6324            <[u8; 32]>::try_from(hint.resumption_secret.as_slice())
6325                .expect("resumption_secret is 32 bytes"),
6326        );
6327
6328        // ── Step 2: resume — the ClientHello carries sealed early-data ──
6329        let early_payload = b"zero-rtt application bytes".to_vec();
6330        let (c2, s2) = ChannelTransport::pair();
6331        let phase2_session = PhantomSession::connect_with_resumption(
6332            "test:9000",
6333            c2,
6334            server_pinned_key.clone(),
6335            hint,
6336            early_payload.clone(),
6337        )
6338        .expect("early_data is within the size cap");
6339
6340        let hello_bytes = s2.recv_bytes().await.unwrap();
6341        let ch3 = borsh::from_slice::<ClientHello>(&hello_bytes).unwrap();
6342        assert!(
6343            ch3.early_data.is_some(),
6344            "phase 2 hello carries sealed 0-RTT early-data"
6345        );
6346        match server_hs.process_client_hello(&ch3, 0, client_ip) {
6347            HandshakeResponse::Success(sh, _session, early_data) => {
6348                // The server decrypted exactly what the client sealed.
6349                assert_eq!(early_data.as_deref(), Some(&early_payload[..]));
6350                assert!(sh.early_data_accepted);
6351                s2.send_bytes(&ServerReply::Hello(sh).to_wire().unwrap())
6352                    .await
6353                    .unwrap();
6354            }
6355            _ => {
6356                panic!("expected Success with accepted early-data — the resumption ticket is fresh")
6357            }
6358        }
6359
6360        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
6361        assert_eq!(
6362            phase2_session.connection_state(),
6363            ConnectionState::Connected
6364        );
6365        assert_eq!(
6366            phase2_session.early_data_accepted().await,
6367            Some(true),
6368            "client must see the server accepted its 0-RTT early-data"
6369        );
6370
6371        // Keep the server transports alive until every assertion has
6372        // run — see the doc comment above.
6373        drop((s1, s2));
6374    }
6375
6376    /// `connect_pinned_with_resumption` validates the `ResumptionHint`
6377    /// field lengths *before* opening any socket — a hint whose
6378    /// `session_id` or `resumption_secret` is not exactly 32 bytes is a
6379    /// caller bug and surfaces as `ValidationError`, never a network
6380    /// round-trip.
6381    #[tokio::test]
6382    async fn connect_pinned_with_resumption_rejects_malformed_hint() {
6383        let server_hs = HandshakeServer::new().unwrap();
6384        let pinned = server_hs.verifying_key().to_bytes();
6385
6386        let bad_hint = ResumptionHint {
6387            session_id: vec![0u8; 5], // not 32 bytes
6388            resumption_secret: vec![0u8; 32],
6389        };
6390
6391        let err = connect_pinned_with_resumption(
6392            "127.0.0.1".to_string(),
6393            9,
6394            pinned,
6395            bad_hint,
6396            Vec::new(),
6397        )
6398        .await
6399        .expect_err("a 5-byte session_id must be rejected");
6400
6401        assert!(
6402            matches!(err, CoreError::ValidationError(_)),
6403            "expected ValidationError, got {err:?}"
6404        );
6405    }
6406}