Skip to main content

phantom_protocol/api/
session.rs

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