Skip to main content

phantom_protocol/transport/legs/embedded/
mod.rs

1//! `EmbeddedLeg` — a `SessionTransport` over `embedded-io-async` byte streams,
2//! for UART / serial and other embedded byte transports (Phase 3.4).
3//!
4//! ## Shape
5//!
6//! `EmbeddedLeg<R, W, const N: usize>` is **passive** — it holds the read/
7//! write halves of a pre-split transport behind two `async_lock` mutexes and
8//! exposes inherent generic `async fn` send/recv methods (with the framing
9//! logic from [`framing`]). The `SessionTransport` trait `impl` is
10//! **per-concrete (`R`, `W`)** rather than one generic blanket:
11//! `embedded-io-async`'s async-fn-in-trait futures are not `Send`-bounded, so
12//! a generic `impl<R, W>` cannot satisfy `SessionTransport`'s `+ Send` future
13//! bound (un-expressible in stable Rust without `return_type_notation`). With
14//! concrete `R`/`W` the compiler sees the HAL's actual future and proves
15//! `Send` directly. The `impl_embedded_session_transport!` macro
16//! generates the per-pair impl in one line.
17//!
18//! Behind the `embedded` cargo feature. no_std + alloc-clean — this module
19//! only depends on `core`/`alloc`, `bytes`, `async_lock`, and `embedded-io-
20//! async`.
21//!
22//! Phase 3.6 (no-std foundation): production code in this module compiles
23//! without `std`. `format!` / `String` / `Vec` come from `alloc`, pulled in by
24//! the crate-level `extern crate alloc;` in `lib.rs`. The `#[cfg(test)]` block
25//! below intentionally stays std-bound — host tests lean on `tokio`,
26//! `std::collections::VecDeque`, `std::sync::Arc`, etc.
27
28#[cfg(not(feature = "std"))]
29use alloc::format;
30
31pub mod framing;
32
33use crate::errors::CoreError;
34use async_lock::Mutex;
35use bytes::Bytes;
36use embedded_io_async::{Error, Read, Write};
37
38/// Length-prefix transport over `embedded-io-async` byte streams.
39///
40/// See the [module docs](self) for the `SessionTransport` hook-up story.
41pub struct EmbeddedLeg<R, W, const N: usize> {
42    rx: Mutex<(R, [u8; N])>,
43    tx: Mutex<W>,
44}
45
46impl<R, W, const N: usize> EmbeddedLeg<R, W, N> {
47    /// Wrap a pre-split `(reader, writer)` pair. Most embassy UART/USB HALs
48    /// offer a `.split()` that produces compatible halves; a non-splittable
49    /// shared bus needs a caller-side wrapper.
50    pub fn new(reader: R, writer: W) -> Self {
51        Self {
52            rx: Mutex::new((reader, [0u8; N])),
53            tx: Mutex::new(writer),
54        }
55    }
56
57    /// Recover the inner `(reader, writer)`; consumes the leg.
58    pub fn into_inner(self) -> (R, W) {
59        let (r, _buf) = self.rx.into_inner();
60        let w = self.tx.into_inner();
61        (r, w)
62    }
63}
64
65impl<R, W, const N: usize> EmbeddedLeg<R, W, N>
66where
67    R: Read,
68    W: Write,
69{
70    /// Send one framed message: 4-byte big-endian length prefix + payload.
71    /// Errors if `data.len()` exceeds the leg's buffer `N` or `u32::MAX`, or
72    /// on any transport error from `W`.
73    pub async fn send_frame(&self, data: &[u8]) -> Result<(), CoreError> {
74        let header = framing::encode_header(data.len(), N)
75            .map_err(|e| CoreError::NetworkError(format!("framing: {:?}", e)))?;
76        let mut w = self.tx.lock().await;
77        w.write_all(&header)
78            .await
79            .map_err(|e| CoreError::NetworkError(format!("write header: {:?}", e.kind())))?;
80        w.write_all(data)
81            .await
82            .map_err(|e| CoreError::NetworkError(format!("write payload: {:?}", e.kind())))?;
83        w.flush()
84            .await
85            .map_err(|e| CoreError::NetworkError(format!("flush: {:?}", e.kind())))?;
86        Ok(())
87    }
88
89    /// Receive one framed message. Returns the payload as a fresh `Bytes`.
90    /// Returns `CoreError::ConnectionClosed` on EOF; `CoreError::NetworkError`
91    /// on framing errors or transport errors.
92    pub async fn recv_frame(&self) -> Result<Bytes, CoreError> {
93        let mut header = [0u8; framing::HEADER_LEN];
94        let mut guard = self.rx.lock().await;
95        let (r, buf) = &mut *guard;
96        r.read_exact(&mut header)
97            .await
98            .map_err(|_| CoreError::NetworkError("read header".into()))?;
99        let len = framing::decode_header(&header, N)
100            .map_err(|e| CoreError::NetworkError(format!("framing: {:?}", e)))?;
101        r.read_exact(&mut buf[..len])
102            .await
103            .map_err(|_| CoreError::NetworkError("read payload".into()))?;
104        Ok(Bytes::copy_from_slice(&buf[..len]))
105    }
106}
107
108/// Generate a [`SessionTransport`] impl for a concrete
109/// `EmbeddedLeg<$reader, $writer, $n>`.
110///
111/// `embedded-io-async`'s `Read::read` / `Write::write` are async-fn-in-trait
112/// methods whose returned futures are **not** `Send`-bounded. A generic blanket
113/// `impl<R, W> SessionTransport for EmbeddedLeg<R, W, N>` therefore cannot
114/// satisfy [`SessionTransport`]'s `+ Send` constraint without the unstable
115/// `return_type_notation` feature. By emitting the impl with *concrete*
116/// `$reader` / `$writer` types, the compiler sees the HAL's actual future at
117/// the use site and can prove `Send` directly.
118///
119/// Downstream HAL adapters call this once per `(reader, writer, N)` triple
120/// they expose:
121///
122/// ```ignore
123/// use phantom_protocol::impl_embedded_session_transport;
124/// impl_embedded_session_transport!(MyUartRx, MyUartTx, 1024);
125/// ```
126///
127/// [`SessionTransport`]: crate::transport::session_transport::SessionTransport
128#[macro_export]
129macro_rules! impl_embedded_session_transport {
130    ($reader:ty, $writer:ty, $n:expr) => {
131        impl $crate::transport::session_transport::SessionTransport
132            for $crate::transport::legs::embedded::EmbeddedLeg<$reader, $writer, $n>
133        {
134            fn send_bytes(
135                &self,
136                data: &[u8],
137            ) -> impl core::future::Future<Output = Result<(), $crate::errors::CoreError>> + Send
138            {
139                self.send_frame(data)
140            }
141            fn recv_bytes(
142                &self,
143            ) -> impl core::future::Future<
144                Output = Result<bytes::Bytes, $crate::errors::CoreError>,
145            > + Send {
146                self.recv_frame()
147            }
148        }
149    };
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::transport::session_transport::SessionTransport;
156    use core::convert::Infallible;
157    use std::collections::VecDeque;
158    use std::sync::Arc;
159    use std::time::Duration;
160    use tokio::sync::{Mutex as TokioMutex, Notify};
161
162    // Install the `SessionTransport` impl for the test mock pair. Lives inside
163    // the test module so the macro's `+ Send` future bound is genuinely
164    // exercised on the `MockReader` / `MockWriter` future types; if either
165    // were `!Send`, this invocation would fail to compile.
166    crate::impl_embedded_session_transport!(MockReader, MockWriter, 1024);
167
168    // ── Mock duplex over `embedded-io-async` ────────────────────────────
169    //
170    // One-direction byte pipe shared between paired halves. `duplex_pair`
171    // returns two `(MockReader, MockWriter)` duplexes cross-connected:
172    // A_writer's bytes appear in B_reader's stream, and vice versa.
173
174    struct Pipe {
175        buf: VecDeque<u8>,
176        closed: bool,
177    }
178
179    struct MockReader {
180        read_from: Arc<TokioMutex<Pipe>>,
181        read_notify: Arc<Notify>,
182        max_read: usize,
183    }
184
185    struct MockWriter {
186        write_to: Arc<TokioMutex<Pipe>>,
187        write_notify: Arc<Notify>,
188    }
189
190    fn duplex_pair() -> ((MockReader, MockWriter), (MockReader, MockWriter)) {
191        duplex_pair_with_chunk(usize::MAX)
192    }
193
194    fn duplex_pair_with_chunk(
195        max_read: usize,
196    ) -> ((MockReader, MockWriter), (MockReader, MockWriter)) {
197        let ab = Arc::new(TokioMutex::new(Pipe {
198            buf: VecDeque::new(),
199            closed: false,
200        }));
201        let ba = Arc::new(TokioMutex::new(Pipe {
202            buf: VecDeque::new(),
203            closed: false,
204        }));
205        let n_ab = Arc::new(Notify::new());
206        let n_ba = Arc::new(Notify::new());
207        let a = (
208            MockReader {
209                read_from: ba.clone(),
210                read_notify: n_ba.clone(),
211                max_read,
212            },
213            MockWriter {
214                write_to: ab.clone(),
215                write_notify: n_ab.clone(),
216            },
217        );
218        let b = (
219            MockReader {
220                read_from: ab,
221                read_notify: n_ab,
222                max_read,
223            },
224            MockWriter {
225                write_to: ba,
226                write_notify: n_ba,
227            },
228        );
229        (a, b)
230    }
231
232    impl embedded_io_async::ErrorType for MockReader {
233        type Error = Infallible;
234    }
235    impl embedded_io_async::ErrorType for MockWriter {
236        type Error = Infallible;
237    }
238
239    impl Read for MockReader {
240        async fn read(&mut self, out: &mut [u8]) -> Result<usize, Infallible> {
241            if out.is_empty() {
242                return Ok(0);
243            }
244            loop {
245                // Arm a Notified BEFORE the lock+check, so any notify between
246                // releasing the lock and awaiting the wakeup is not lost.
247                let notified = self.read_notify.notified();
248                tokio::pin!(notified);
249                notified.as_mut().enable();
250                {
251                    let mut p = self.read_from.lock().await;
252                    if !p.buf.is_empty() {
253                        let n = out.len().min(p.buf.len()).min(self.max_read);
254                        for slot in out.iter_mut().take(n) {
255                            *slot = p.buf.pop_front().expect("checked non-empty");
256                        }
257                        return Ok(n);
258                    }
259                    if p.closed {
260                        return Ok(0);
261                    }
262                }
263                notified.await;
264            }
265        }
266    }
267
268    impl Write for MockWriter {
269        async fn write(&mut self, data: &[u8]) -> Result<usize, Infallible> {
270            let mut p = self.write_to.lock().await;
271            p.buf.extend(data.iter().copied());
272            drop(p);
273            self.write_notify.notify_waiters();
274            Ok(data.len())
275        }
276        // `flush` defaults to `Ok(())` — keep the default.
277    }
278
279    // ── Tests ───────────────────────────────────────────────────────────
280
281    /// `send_frame` writes the 4-byte big-endian length prefix followed by
282    /// the payload, byte-identical to `TcpSessionTransport`'s wire format.
283    #[tokio::test]
284    async fn send_frame_writes_length_prefixed_payload() {
285        let ((a_r, a_w), (mut b_r, _b_w)) = duplex_pair();
286        let leg: EmbeddedLeg<MockReader, MockWriter, 1024> = EmbeddedLeg::new(a_r, a_w);
287
288        leg.send_frame(b"hello").await.expect("send_frame");
289
290        let mut buf = vec![0u8; 4 + 5];
291        tokio::time::timeout(Duration::from_secs(1), b_r.read_exact(&mut buf))
292            .await
293            .expect("peer read should not hang")
294            .expect("peer read_exact");
295
296        assert_eq!(&buf[..4], &[0x00, 0x00, 0x00, 0x05], "length prefix");
297        assert_eq!(&buf[4..], b"hello", "payload");
298    }
299
300    /// `recv_frame` reads a 4-byte big-endian length prefix and returns the
301    /// payload as `Bytes`.
302    #[tokio::test]
303    async fn recv_frame_reads_length_prefixed_payload() {
304        let ((a_r, a_w), (_b_r, mut b_w)) = duplex_pair();
305        let leg: EmbeddedLeg<MockReader, MockWriter, 1024> = EmbeddedLeg::new(a_r, a_w);
306
307        b_w.write_all(&[0x00, 0x00, 0x00, 0x05]).await.unwrap();
308        b_w.write_all(b"world").await.unwrap();
309
310        let frame = tokio::time::timeout(Duration::from_secs(1), leg.recv_frame())
311            .await
312            .expect("recv should not hang")
313            .expect("recv_frame");
314
315        assert_eq!(&frame[..], b"world");
316    }
317
318    /// Even when the underlying byte stream hands out one byte at a time
319    /// (worst-case UART with a 1-byte FIFO), `recv_frame` must drain
320    /// `HEADER_LEN + payload` bytes via the internal `read_exact` loop and
321    /// reassemble the original frame.
322    #[tokio::test]
323    async fn recv_frame_reassembles_under_adversarial_chunking() {
324        let ((a_r, a_w), (_b_r, mut b_w)) = duplex_pair_with_chunk(1);
325        let leg: EmbeddedLeg<MockReader, MockWriter, 1024> = EmbeddedLeg::new(a_r, a_w);
326
327        // Spawn a writer that dribbles the header + payload one byte at a
328        // time, with a yield in between so the receiver gets a chance to
329        // observe each step.
330        let writer = tokio::spawn(async move {
331            for &b in &[0x00, 0x00, 0x00, 0x05] {
332                b_w.write_all(&[b]).await.expect("write header byte");
333                tokio::task::yield_now().await;
334            }
335            for &b in b"abcde" {
336                b_w.write_all(&[b]).await.expect("write payload byte");
337                tokio::task::yield_now().await;
338            }
339        });
340
341        let frame = tokio::time::timeout(Duration::from_secs(1), leg.recv_frame())
342            .await
343            .expect("recv should not hang under 1-byte chunking")
344            .expect("recv_frame");
345
346        writer.await.expect("writer task");
347        assert_eq!(&frame[..], b"abcde");
348    }
349
350    /// A header announcing a length that exceeds the leg's buffer capacity
351    /// `N` must be rejected at the framing layer **before** any payload bytes
352    /// are pulled off the wire. This guards a remote peer (or attacker) from
353    /// forcing an `N`-bounded receiver to read megabytes.
354    #[tokio::test]
355    async fn recv_frame_rejects_oversized_header() {
356        // N = 8, peer claims a 16-byte payload and writes no payload bytes.
357        // If the implementation tried to drain the announced payload, the
358        // recv would hang and the timeout would fire instead of an error.
359        let ((a_r, a_w), (_b_r, mut b_w)) = duplex_pair();
360        let leg: EmbeddedLeg<MockReader, MockWriter, 8> = EmbeddedLeg::new(a_r, a_w);
361
362        b_w.write_all(&[0x00, 0x00, 0x00, 0x10])
363            .await
364            .expect("write bogus header");
365        // Deliberately no payload bytes follow.
366
367        let err = tokio::time::timeout(Duration::from_secs(1), leg.recv_frame())
368            .await
369            .expect("recv should error fast, not hang on payload");
370        match err {
371            Err(CoreError::NetworkError(msg)) => {
372                assert!(
373                    msg.contains("framing"),
374                    "expected framing error, got: {msg}"
375                );
376            }
377            other => panic!("expected NetworkError(framing), got {other:?}"),
378        }
379    }
380
381    /// If the peer closes the pipe mid-header (fewer than 4 bytes delivered),
382    /// `read_exact` returns `UnexpectedEof`, which `recv_frame` maps to a
383    /// `NetworkError("read header")`. Pinning this prevents a silent stall
384    /// or a misleading "read payload" error from a future refactor.
385    #[tokio::test]
386    async fn recv_frame_returns_error_on_eof_mid_header() {
387        let ((a_r, a_w), (_b_r, b_w)) = duplex_pair();
388        let leg: EmbeddedLeg<MockReader, MockWriter, 1024> = EmbeddedLeg::new(a_r, a_w);
389
390        // Reach into the peer-side `MockWriter` to push 2 header bytes, then
391        // mark the pipe closed and wake the reader.
392        let target = b_w.write_to.clone();
393        let notify = b_w.write_notify.clone();
394        {
395            let mut p = target.lock().await;
396            p.buf.extend([0x00u8, 0x00]);
397            p.closed = true;
398        }
399        notify.notify_waiters();
400
401        let err = tokio::time::timeout(Duration::from_secs(1), leg.recv_frame())
402            .await
403            .expect("recv should error fast on EOF");
404        match err {
405            Err(CoreError::NetworkError(msg)) => {
406                assert!(
407                    msg.contains("read header"),
408                    "expected `read header` in error msg, got: {msg}"
409                );
410            }
411            other => panic!("expected NetworkError(read header), got {other:?}"),
412        }
413    }
414
415    /// `send_frame` and `recv_frame` take distinct `Mutex`es (`tx` vs `rx`),
416    /// so a send on one side and a recv on the other can race without one
417    /// blocking the other. Spawning both concurrently and asserting both
418    /// finish within a 1-second window confirms the lock-split design.
419    #[tokio::test]
420    async fn send_recv_run_concurrently_without_blocking() {
421        let ((a_r, a_w), (b_r, b_w)) = duplex_pair();
422        let leg_a: Arc<EmbeddedLeg<MockReader, MockWriter, 1024>> =
423            Arc::new(EmbeddedLeg::new(a_r, a_w));
424        let leg_b: Arc<EmbeddedLeg<MockReader, MockWriter, 1024>> =
425            Arc::new(EmbeddedLeg::new(b_r, b_w));
426
427        let leg_a_send = Arc::clone(&leg_a);
428        let send = tokio::spawn(async move { leg_a_send.send_frame(b"ping").await });
429        let leg_b_recv = Arc::clone(&leg_b);
430        let recv = tokio::spawn(async move { leg_b_recv.recv_frame().await });
431
432        tokio::time::timeout(Duration::from_secs(1), async {
433            send.await.expect("send task").expect("send_frame result");
434            let frame = recv.await.expect("recv task").expect("recv_frame result");
435            assert_eq!(&frame[..], b"ping");
436        })
437        .await
438        .expect("concurrent send+recv should complete within 1s");
439    }
440
441    /// Round-trip a payload through the `SessionTransport` trait surface
442    /// (`send_bytes` / `recv_bytes`). This is the proof that the
443    /// `impl_embedded_session_transport!` macro invocation above actually
444    /// produces a usable impl — without it, the `.send_bytes` and
445    /// `.recv_bytes` calls would not resolve.
446    #[tokio::test]
447    async fn session_transport_round_trip() {
448        let ((a_r, a_w), (b_r, b_w)) = duplex_pair();
449        let leg_a: EmbeddedLeg<MockReader, MockWriter, 1024> = EmbeddedLeg::new(a_r, a_w);
450        let leg_b: EmbeddedLeg<MockReader, MockWriter, 1024> = EmbeddedLeg::new(b_r, b_w);
451
452        tokio::time::timeout(Duration::from_secs(1), async {
453            <EmbeddedLeg<MockReader, MockWriter, 1024> as SessionTransport>::send_bytes(
454                &leg_a,
455                b"hello-trait",
456            )
457            .await
458            .expect("send_bytes");
459            let frame =
460                <EmbeddedLeg<MockReader, MockWriter, 1024> as SessionTransport>::recv_bytes(&leg_b)
461                    .await
462                    .expect("recv_bytes");
463            assert_eq!(&frame[..], b"hello-trait");
464        })
465        .await
466        .expect("trait round-trip should complete within 1s");
467    }
468
469    // ── End-to-end handshake mirror (Phase 3.4.6 deferred e2e) ──────────
470    //
471    // Mirrors the canonical `test_phantom_session_handshake_via_transport`
472    // (in `crate::api::session::tests`) but swaps `ChannelTransport::pair()`
473    // for two `EmbeddedLeg<MockReader, MockWriter, 1024>` instances cross-
474    // connected via `duplex_pair()`. The decrypt / encrypt helpers are
475    // duplicated locally rather than promoted — keeps file-overlap surface
476    // at zero so parallel agents in adjacent modules can't conflict.
477
478    use crate::api::session::{ConnectionState, PhantomSession};
479    use crate::transport::handshake::{
480        ClientHello, HandshakeResponse, HandshakeServer, ServerReply,
481    };
482    use crate::transport::types::{
483        PacketFlags, PacketHeader, PhantomPacket, SessionId, StreamId as TransportStreamId,
484    };
485
486    /// Decrypt an incoming encrypted frame on the test server side.
487    /// Local duplicate of the helper in
488    /// `api::session::tests` — see module comment above.
489    fn decrypt_incoming_local(
490        server_session: &crate::transport::session::Session,
491        bytes: &[u8],
492    ) -> Vec<u8> {
493        // The peer pump applies header protection (T4.6); unmask with this side's
494        // recv HP key (== the sender's send HP key) before reading.
495        let pkt = server_session
496            .parse_protected(bytes)
497            .expect("parse header-protected PhantomPacket");
498        assert!(
499            pkt.header.flags.contains(PacketFlags::ENCRYPTED),
500            "expected ENCRYPTED flag on application data"
501        );
502        let plain = server_session
503            .decrypt_packet(&pkt.header, &pkt.payload, &[])
504            .expect("decrypt application data");
505        // Reliable app frames carry a 4-byte gap-free stream_offset prefix (A.5);
506        // strip it so callers compare against the raw application payload.
507        if pkt.header.flags.contains(PacketFlags::RELIABLE) && plain.len() >= 4 {
508            plain[4..].to_vec()
509        } else {
510            plain
511        }
512    }
513
514    /// Build an encrypted reply frame from the test server side.
515    /// Local duplicate of the helper in
516    /// `api::session::tests` — see module comment above. Mirrors the live sender's
517    /// reliable framing: plaintext = `[stream_offset: u32 BE][payload]` with
518    /// `stream_offset == sequence` (no control gaps in this test).
519    fn encrypt_outgoing_local(
520        server_session: &crate::transport::session::Session,
521        session_id: SessionId,
522        stream_id: TransportStreamId,
523        sequence: u32,
524        payload: &[u8],
525    ) -> Vec<u8> {
526        let flag_bits = PacketFlags::RELIABLE | PacketFlags::ENCRYPTED;
527        let header = PacketHeader::new(
528            session_id,
529            stream_id,
530            sequence as u64,
531            PacketFlags::new(flag_bits),
532        )
533        .with_epoch(server_session.current_epoch());
534        let mut pt = Vec::with_capacity(4 + payload.len());
535        pt.extend_from_slice(&sequence.to_be_bytes());
536        pt.extend_from_slice(payload);
537        let ct = server_session
538            .encrypt_packet(&header, &pt, &[])
539            .expect("encrypt reply");
540        let packet = PhantomPacket::new(header, ct);
541        // Apply header protection so the peer pump's parse_protected unmasks it.
542        server_session
543            .protect_packet(&packet)
544            .expect("header protection")
545    }
546
547    /// `MockWriter` wrapper that tees every byte through to a side recorder
548    /// before forwarding to the underlying pipe. Used to sniff the wire and
549    /// assert plaintext like `b"early-data"` never appears in the framed
550    /// bytes the client writes.
551    struct TeeWriter {
552        inner: MockWriter,
553        recorder: Arc<TokioMutex<Vec<u8>>>,
554    }
555
556    impl embedded_io_async::ErrorType for TeeWriter {
557        type Error = Infallible;
558    }
559
560    impl Write for TeeWriter {
561        async fn write(&mut self, data: &[u8]) -> Result<usize, Infallible> {
562            self.recorder.lock().await.extend_from_slice(data);
563            self.inner.write(data).await
564        }
565    }
566
567    // The macro generates the `SessionTransport` impl for this concrete
568    // `(MockReader, TeeWriter, 16384)` triple — same `+ Send` future bound
569    // as the production embedded triple. A real PQ ClientHello / ServerHello
570    // (ML-KEM-768 1184-byte ek + ML-DSA-65 ~3.3 KiB signature + envelope)
571    // exceeds the 1024-byte capacity used by the unit framing tests above,
572    // so the e2e test uses a 16 KiB buffer.
573    crate::impl_embedded_session_transport!(MockReader, TeeWriter, 16384);
574    crate::impl_embedded_session_transport!(MockReader, MockWriter, 16384);
575
576    /// End-to-end handshake + encrypted data exchange over `EmbeddedLeg`.
577    /// Mirrors `test_phantom_session_handshake_via_transport` from
578    /// `api::session::tests`, with a `TeeWriter` on the client side so the
579    /// negative assertion (plaintext never on the wire) can read the actual
580    /// bytes the client emitted.
581    #[tokio::test]
582    async fn test_phantom_session_handshake_via_embedded_leg() {
583        // Client leg uses TeeWriter to record every transmitted byte.
584        // Server leg is a plain MockReader/MockWriter pair.
585        let ((client_r, client_w_inner), (server_r, server_w)) = duplex_pair();
586        let client_wire_recorder: Arc<TokioMutex<Vec<u8>>> = Arc::new(TokioMutex::new(Vec::new()));
587        let client_w = TeeWriter {
588            inner: client_w_inner,
589            recorder: Arc::clone(&client_wire_recorder),
590        };
591
592        let client_leg: EmbeddedLeg<MockReader, TeeWriter, 16384> =
593            EmbeddedLeg::new(client_r, client_w);
594        let server_leg: EmbeddedLeg<MockReader, MockWriter, 16384> =
595            EmbeddedLeg::new(server_r, server_w);
596
597        let server_hs = HandshakeServer::new().expect("HandshakeServer::new");
598        let server_pinned_key = server_hs.verifying_key().clone();
599
600        // Spawn the client session — kicks off the background handshake.
601        let session = PhantomSession::connect_with_transport(
602            "test-server:9000",
603            client_leg,
604            server_pinned_key,
605        );
606
607        // Queue an early message before the handshake completes.
608        session
609            .send(b"early-data".to_vec())
610            .await
611            .expect("queue early-data");
612
613        // Server responder task: handles ClientHello (+ optional retry),
614        // emits ServerHello, receives the flushed early-data + a post-
615        // handshake message, then sends an encrypted reply.
616        let server_handle = tokio::spawn(async move {
617            let client_ip = "127.0.0.1".parse().expect("parse loopback IP");
618
619            // 1. Receive the first ClientHello (carries version == PROTOCOL_VERSION).
620            let client_hello_bytes =
621                tokio::time::timeout(Duration::from_secs(5), server_leg.recv_frame())
622                    .await
623                    .expect("recv ClientHello within 5s")
624                    .expect("recv ClientHello frame");
625            let client_hello = borsh::from_slice::<ClientHello>(&client_hello_bytes)
626                .expect("deserialize ClientHello");
627
628            // 2. Process — may retry with a cookie/PoW challenge.
629            let server_session = loop {
630                let response = server_hs.process_client_hello(&client_hello, 0, client_ip);
631                match response {
632                    HandshakeResponse::Retry(retry) => {
633                        // T4.4: the client dispatches on the ServerReply discriminant
634                        // byte, so the server must frame its reply the same way.
635                        let retry_bytes = ServerReply::Retry(retry)
636                            .to_wire()
637                            .expect("serialize retry");
638                        tokio::time::timeout(
639                            Duration::from_secs(5),
640                            server_leg.send_frame(&retry_bytes),
641                        )
642                        .await
643                        .expect("send retry within 5s")
644                        .expect("send retry frame");
645
646                        let next_bytes =
647                            tokio::time::timeout(Duration::from_secs(5), server_leg.recv_frame())
648                                .await
649                                .expect("recv retried ClientHello within 5s")
650                                .expect("recv retried ClientHello frame");
651                        let next_hello = borsh::from_slice::<ClientHello>(&next_bytes)
652                            .expect("deserialize retried ClientHello");
653                        let resp2 = server_hs.process_client_hello(&next_hello, 0, client_ip);
654                        match resp2 {
655                            HandshakeResponse::Success(server_hello, session, _) => {
656                                let server_hello_bytes = ServerReply::Hello(server_hello)
657                                    .to_wire()
658                                    .expect("serialize ServerHello");
659                                tokio::time::timeout(
660                                    Duration::from_secs(5),
661                                    server_leg.send_frame(&server_hello_bytes),
662                                )
663                                .await
664                                .expect("send ServerHello within 5s")
665                                .expect("send ServerHello frame");
666                                break session;
667                            }
668                            other => panic!("expected success after retry, got {other:?}"),
669                        }
670                    }
671                    HandshakeResponse::Success(server_hello, session, _) => {
672                        let server_hello_bytes = ServerReply::Hello(server_hello)
673                            .to_wire()
674                            .expect("serialize ServerHello");
675                        tokio::time::timeout(
676                            Duration::from_secs(5),
677                            server_leg.send_frame(&server_hello_bytes),
678                        )
679                        .await
680                        .expect("send ServerHello within 5s")
681                        .expect("send ServerHello frame");
682                        break session;
683                    }
684                    HandshakeResponse::Reject(r) => panic!("unexpected reject: {r:?}"),
685                    HandshakeResponse::Fail(e) => panic!("handshake failed: {e:?}"),
686                }
687            };
688
689            let session_id = *server_session.id();
690
691            // 3. Receive the flushed early-data frame and decrypt it.
692            let early_frame = tokio::time::timeout(Duration::from_secs(5), server_leg.recv_frame())
693                .await
694                .expect("recv early-data within 5s")
695                .expect("recv early-data frame");
696            let early_plain = decrypt_incoming_local(&server_session, &early_frame);
697            assert_eq!(early_plain, b"early-data");
698
699            // 4. Receive the post-handshake message.
700            let post_frame = tokio::time::timeout(Duration::from_secs(5), server_leg.recv_frame())
701                .await
702                .expect("recv after-handshake within 5s")
703                .expect("recv after-handshake frame");
704            let post_plain = decrypt_incoming_local(&server_session, &post_frame);
705            assert_eq!(post_plain, b"after-handshake");
706
707            // 5. Send an encrypted reply.
708            // stream_offset (== sequence) must be 0: first reliable frame
709            // server→client, so the client reassembles it at offset 0 (A.5).
710            let reply = encrypt_outgoing_local(&server_session, session_id, 1, 0, b"server-reply");
711            tokio::time::timeout(Duration::from_secs(5), server_leg.send_frame(&reply))
712                .await
713                .expect("send reply within 5s")
714                .expect("send reply frame");
715        });
716
717        // Give the handshake time to progress to Connected.
718        tokio::time::sleep(Duration::from_millis(500)).await;
719        assert_eq!(session.connection_state(), ConnectionState::Connected);
720
721        // Send the post-handshake message.
722        session
723            .send(b"after-handshake".to_vec())
724            .await
725            .expect("send after-handshake");
726
727        // Receive the (decrypted) server reply.
728        let reply = tokio::time::timeout(Duration::from_secs(5), session.recv())
729            .await
730            .expect("recv reply within 5s")
731            .expect("recv server-reply");
732        assert_eq!(reply, b"server-reply");
733
734        tokio::time::timeout(Duration::from_secs(5), server_handle)
735            .await
736            .expect("server task within 5s")
737            .expect("server task joined");
738        session.disconnect().await.expect("close session");
739
740        // Negative assertion: plaintext like "early-data" / "after-handshake"
741        // must never appear in the bytes the client emitted on the wire.
742        // Mirrors the canonical test's confidentiality check.
743        let wire = client_wire_recorder.lock().await;
744        assert!(
745            !wire
746                .windows(b"early-data".len())
747                .any(|w| w == b"early-data"),
748            "plaintext early-data leaked onto the embedded wire"
749        );
750        assert!(
751            !wire
752                .windows(b"after-handshake".len())
753                .any(|w| w == b"after-handshake"),
754            "plaintext after-handshake leaked onto the embedded wire"
755        );
756    }
757}