phantom_protocol/transport/session_transport.rs
1//! The `SessionTransport` byte-pipe abstraction — the boundary between
2//! `PhantomSession`'s background data pump and a concrete physical transport
3//! (`TcpSessionTransport`, `WebSocketLeg`, `EmbeddedLeg`, ...).
4//!
5//! Defined with **native async fn in trait** (AFIT, stable since Rust 1.75)
6//! rather than the `#[async_trait]` macro: method calls do not allocate a
7//! boxed future, and `Send`-ness of the returned futures is checked at the
8//! *use* site (the concrete impl type), not at the trait-impl site. The
9//! latter is what allows `EmbeddedLeg<R, W, ...>` to compose with
10//! `embedded-io-async`, whose async-fn-in-trait futures are not `Send`-
11//! bounded — `#[async_trait]` here would have failed to prove `Send`-ness
12//! at the generic impl site.
13//!
14//! Dependency-light (only `bytes` + `CoreError`) so it can compile in a
15//! future `no_std + alloc` build ahead of the rest of the crate.
16//! Re-exported from [`crate::api::session`] so the historical import path
17//! stays stable.
18//!
19//! Phase 3.6 (no-std foundation): module compiles without `std` because the
20//! implementation uses only `core::future::Future` and pulls nothing from
21//! `std::*`. The crate-level `#![cfg_attr(not(feature = "std"), no_std)]` in
22//! `lib.rs` drives the no_std switch; this module needs no extra attribute.
23
24use crate::errors::CoreError;
25use bytes::Bytes;
26// `String` is in the std prelude on std builds; under `no_std` it comes from `alloc`
27// (the migration address crosses this boundary as a `String` to keep the trait
28// `SocketAddr`-free — `std::net::SocketAddr` does not exist in `core`/`alloc`).
29#[cfg(not(feature = "std"))]
30use alloc::string::String;
31
32/// Lifecycle phase of a [`SessionTransport`], used to bound the receive frame
33/// size differently before vs. after the handshake (WIRE-001). During the
34/// unauthenticated handshake a peer can open a connection and declare a large
35/// frame; capping the receive size tightly there bounds the memory a single
36/// unauthenticated peer can make the server buffer. After establishment the cap
37/// rises to the steady-state application limit.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum FramePhase {
40 /// Pre-establishment: only small handshake messages are expected.
41 Handshake,
42 /// Post-establishment: full-size application frames are allowed.
43 Established,
44}
45
46/// Async transport trait for PhantomSession.
47///
48/// Abstractions over UDP, TCP, FakeTLS, etc.
49/// Used by the background handshake task for I/O.
50///
51/// `recv_bytes` returns `Bytes` (Phase 2.8) so the recv pipeline can
52/// fan out the same buffer to multiple consumers via cheap refcount
53/// clones — no `Vec → Bytes` conversion at the trait boundary.
54/// `send_bytes` keeps `&[u8]` because the caller routinely sends a
55/// borrowed slice of an already-allocated send buffer.
56///
57/// **Wrapper contract (audit EPS-04):** every method below the two I/O methods
58/// has a *silently-succeeding* default (`{}` / `false` / `Ok(())`). Those are
59/// correct for transports without migration, but they make a **partial wrapper**
60/// (a newtype that forwards only `send_bytes` / `recv_bytes`) compile with no
61/// warning while every control call no-ops on the default — the exact bug that
62/// made the pre-ε FFI `migrate()` vacuous through `ObservedTransport`. Any wrapper
63/// over a `SessionTransport` **MUST forward the full surface** (all the control
64/// methods, not just I/O); the `observed_transport_forwards_all_control_methods`
65/// tripwire test guards this for the in-crate wrappers.
66pub trait SessionTransport: Send + Sync + 'static {
67 /// Send raw bytes to the peer.
68 ///
69 /// Desugared form (`fn -> impl Future + Send`) rather than `async fn`
70 /// so the `+ Send` bound on the returned future is explicit. This is
71 /// what lets the data pump spawn its task generically over any
72 /// `T: SessionTransport` without an AFIT `return_type_notation` hack.
73 fn send_bytes(
74 &self,
75 data: &[u8],
76 ) -> impl core::future::Future<Output = Result<(), CoreError>> + Send;
77 /// Receive the next message from the peer. The returned `Bytes` is
78 /// a refcounted view over an opaque buffer; subsequent `clone()`s
79 /// are cheap.
80 fn recv_bytes(&self) -> impl core::future::Future<Output = Result<Bytes, CoreError>> + Send;
81
82 /// Move the transport to a new [`FramePhase`], adjusting the receive
83 /// frame-size cap (WIRE-001). Default no-op — transports that do not
84 /// length-prefix / buffer, or are inherently bounded, need not implement it.
85 /// Called once by the session machinery at the handshake → data-pump
86 /// boundary. Adding this defaulted method is source-compatible for every
87 /// existing impl.
88 fn set_frame_phase(&self, _phase: FramePhase) {}
89
90 /// Stamp this transport's outbound routing CID (ε / WIRE v5). Called once by
91 /// the session machinery at the handshake → data-pump boundary with the
92 /// session's `current_outbound_cid()` (the rotating `CID_0`), switching the
93 /// transport off the bootstrap ConnId onto the chain. Default no-op —
94 /// socket-routed transports (TCP / WebSocket / WASI / Embedded) carry no
95 /// on-wire CID and ignore it. Source-compatible for every existing impl.
96 fn set_outbound_cid(&self, _cid: [u8; 8]) {}
97
98 /// Whether the transport has observed an unvalidated **candidate** source for
99 /// this session — a connection-migration signal (Phase 4). Only an
100 /// address-aware transport (the UDP server) ever returns `true`; stream
101 /// transports (TCP / WebSocket / WASI / Embedded) have no migration and use
102 /// the default `false`. Deliberately **SocketAddr-free** so the generic data
103 /// pump that calls it stays no_std-clean (`std::net::SocketAddr` does not
104 /// exist in `core`/`alloc`); the candidate address is held inside the
105 /// concrete transport.
106 fn has_migration_candidate(&self) -> bool {
107 false
108 }
109
110 /// Send `data` — an already-encrypted `PATH_VALIDATION` challenge built by the
111 /// session — to the transport's internally-tracked candidate source, distinct
112 /// from the established peer, under the transport's own anti-amplification cap
113 /// (RFC 9000 §8.2). Returns `Ok(true)` if a candidate existed and the send was
114 /// within budget, `Ok(false)` otherwise (no candidate, or the 3× cap was hit).
115 /// Default (non-address transports): a no-op `Ok(false)`. The candidate
116 /// address never crosses this boundary, keeping the trait no_std-safe.
117 fn send_to_candidate(
118 &self,
119 _data: &[u8],
120 ) -> impl core::future::Future<Output = Result<bool, CoreError>> + Send {
121 async { Ok(false) }
122 }
123
124 /// Commit the most-recently-received frame's source as the migration candidate — call ONLY
125 /// from the post-decrypt path, i.e. once that frame has been AEAD-verified (M-1). On the
126 /// address-aware UDP server this is the migration-integrity fix: the candidate (and hence
127 /// the server's `PATH_CHALLENGE` target) is only ever an AEAD-authenticated source, so a
128 /// spoofed CID-matched datagram (which never decrypts) cannot clobber the candidate slot
129 /// and misdirect / stall a legitimate migration. SocketAddr-free — the address stays inside
130 /// the concrete transport. Default no-op for transports without migration.
131 fn confirm_authenticated_source(&self) {}
132
133 /// Promote the migration candidate to the established peer (Phase 4, the
134 /// switch): after the candidate's path validates, subsequent app data and
135 /// retransmits go to it instead of the old peer. Returns `true` if a candidate
136 /// was promoted. Default no-op (`false`) for transports without migration.
137 /// SocketAddr-free — the address is internal to the concrete transport.
138 fn promote_candidate(&self) -> bool {
139 false
140 }
141
142 /// Migrate this transport to a new local address (Phase 4 / P4.2c — embedder-
143 /// triggered connection migration). The address crosses as a `String` (parsed
144 /// inside the concrete native transport) so the
145 /// trait stays `SocketAddr`-free and no_std-clean — `std::net::SocketAddr` does
146 /// not exist in `core`/`alloc`. Best-effort: a parse / bind / connect failure
147 /// returns `Err` and the session is expected to keep running on its existing
148 /// socket — migration never tears it down. Default no-op `Ok(())` for transports
149 /// without migration (TCP / WebSocket / WASI / Embedded / the in-memory test
150 /// pipe); only the native UDP client implements it.
151 fn migrate(
152 &self,
153 _local_addr: String,
154 ) -> impl core::future::Future<Output = Result<(), CoreError>> + Send {
155 async { Ok(()) }
156 }
157
158 /// Migrate the **server side** of this transport to a new local send address — the
159 /// mirror of [`migrate`](Self::migrate) for the accepting peer. The server binds a
160 /// fresh local socket, sends subsequent server→client datagrams from it (so the client
161 /// sees a new s2c source and follows it), and receives on it too (so once the client
162 /// switches its send target the c2s frames are delivered transparently). The address
163 /// crosses as a `String` to keep the trait `SocketAddr`-free / no_std-clean. Best-effort:
164 /// a parse / bind failure returns `Err` and the session keeps running on the old socket.
165 /// Default no-op `Ok(())` — only the native UDP server implements it. Kept distinct from
166 /// [`migrate`](Self::migrate) so the FFI-exported client `migrate()` cannot trigger a
167 /// server migration.
168 fn migrate_server(
169 &self,
170 _local_addr: String,
171 ) -> impl core::future::Future<Output = Result<(), CoreError>> + Send {
172 async { Ok(()) }
173 }
174}