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 compiles in the
15//! shipped `no_std + alloc` subset (Phase 3.6) alongside `legs::embedded`.
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/// The byte-pipe abstraction over every concrete physical transport — PhantomUDP
49/// (`UdpClientTransport` / `UdpServerTransport`), TCP (`TcpSessionTransport`),
50/// WebSocket, WASI, Embedded, and the off-by-default TLS-mimicry leg. Used by the
51/// background handshake + data-pump task for all message-oriented I/O.
52///
53/// `recv_bytes` returns `Bytes` (Phase 2.8) so the recv pipeline can
54/// fan out the same buffer to multiple consumers via cheap refcount
55/// clones — no `Vec → Bytes` conversion at the trait boundary.
56/// `send_bytes` keeps `&[u8]` because the caller routinely sends a
57/// borrowed slice of an already-allocated send buffer.
58///
59/// **Wrapper contract (audit EPS-04):** every method below the two I/O methods
60/// has a *silently-succeeding* default (`{}` / `false` / `Ok(())`). Those are
61/// correct for transports without migration, but they make a **partial wrapper**
62/// (a newtype that forwards only `send_bytes` / `recv_bytes`) compile with no
63/// warning while every control call no-ops on the default — the exact bug that
64/// made the pre-ε FFI `migrate()` vacuous through `ObservedTransport`. Any wrapper
65/// over a `SessionTransport` **MUST forward the full surface** (all the control
66/// methods, not just I/O); the `observed_transport_forwards_all_control_methods`
67/// tripwire test guards this for the in-crate wrappers.
68pub trait SessionTransport: Send + Sync + 'static {
69 /// Send raw bytes to the peer.
70 ///
71 /// Desugared form (`fn -> impl Future + Send`) rather than `async fn`
72 /// so the `+ Send` bound on the returned future is explicit. This is
73 /// what lets the data pump spawn its task generically over any
74 /// `T: SessionTransport` without an AFIT `return_type_notation` hack.
75 fn send_bytes(
76 &self,
77 data: &[u8],
78 ) -> impl core::future::Future<Output = Result<(), CoreError>> + Send;
79 /// Receive the next message from the peer. The returned `Bytes` is
80 /// a refcounted view over an opaque buffer; subsequent `clone()`s
81 /// are cheap.
82 fn recv_bytes(&self) -> impl core::future::Future<Output = Result<Bytes, CoreError>> + Send;
83
84 /// Move the transport to a new [`FramePhase`], adjusting the receive
85 /// frame-size cap (WIRE-001). Default no-op — transports that do not
86 /// length-prefix / buffer, or are inherently bounded, need not implement it.
87 /// Called once by the session machinery at the handshake → data-pump
88 /// boundary. Adding this defaulted method is source-compatible for every
89 /// existing impl.
90 fn set_frame_phase(&self, _phase: FramePhase) {}
91
92 /// Stamp this transport's outbound routing CID (ε / WIRE v5). Called once by
93 /// the session machinery at the handshake → data-pump boundary with the
94 /// session's `current_outbound_cid()` (the rotating `CID_0`), switching the
95 /// transport off the bootstrap ConnId onto the chain. Default no-op —
96 /// socket-routed transports (TCP / WebSocket / WASI / Embedded) carry no
97 /// on-wire CID and ignore it. Source-compatible for every existing impl.
98 fn set_outbound_cid(&self, _cid: [u8; 8]) {}
99
100 /// Whether the transport has observed an unvalidated **candidate** source for
101 /// this session — a connection-migration signal (Phase 4). Only an
102 /// address-aware transport (the UDP server) ever returns `true`; stream
103 /// transports (TCP / WebSocket / WASI / Embedded) have no migration and use
104 /// the default `false`. Deliberately **SocketAddr-free** so the generic data
105 /// pump that calls it stays no_std-clean (`std::net::SocketAddr` does not
106 /// exist in `core`/`alloc`); the candidate address is held inside the
107 /// concrete transport.
108 fn has_migration_candidate(&self) -> bool {
109 false
110 }
111
112 /// Send `data` — an already-encrypted `PATH_VALIDATION` challenge built by the
113 /// session — to the transport's internally-tracked candidate source, distinct
114 /// from the established peer, under the transport's own anti-amplification cap
115 /// (RFC 9000 §8.2). Returns `Ok(true)` if a candidate existed and the send was
116 /// within budget, `Ok(false)` otherwise (no candidate, or the 3× cap was hit).
117 /// Default (non-address transports): a no-op `Ok(false)`. The candidate
118 /// address never crosses this boundary, keeping the trait no_std-safe.
119 fn send_to_candidate(
120 &self,
121 _data: &[u8],
122 ) -> impl core::future::Future<Output = Result<bool, CoreError>> + Send {
123 async { Ok(false) }
124 }
125
126 /// Commit the most-recently-received frame's source as the migration candidate — call ONLY
127 /// from the post-decrypt path, i.e. once that frame has been AEAD-verified (M-1). On the
128 /// address-aware UDP server this is the migration-integrity fix: the candidate (and hence
129 /// the server's `PATH_CHALLENGE` target) is only ever an AEAD-authenticated source, so a
130 /// spoofed CID-matched datagram (which never decrypts) cannot clobber the candidate slot
131 /// and misdirect / stall a legitimate migration. SocketAddr-free — the address stays inside
132 /// the concrete transport. Default no-op for transports without migration.
133 fn confirm_authenticated_source(&self) {}
134
135 /// Promote the migration candidate to the established peer (Phase 4, the
136 /// switch): after the candidate's path validates, subsequent app data and
137 /// retransmits go to it instead of the old peer. Returns `true` if a candidate
138 /// was promoted. Default no-op (`false`) for transports without migration.
139 /// SocketAddr-free — the address is internal to the concrete transport.
140 fn promote_candidate(&self) -> bool {
141 false
142 }
143
144 /// Migrate this transport to a new local address (Phase 4 / P4.2c — embedder-
145 /// triggered connection migration). The address crosses as a `String` (parsed
146 /// inside the concrete native transport) so the
147 /// trait stays `SocketAddr`-free and no_std-clean — `std::net::SocketAddr` does
148 /// not exist in `core`/`alloc`. Best-effort: a parse / bind / connect failure
149 /// returns `Err` and the session is expected to keep running on its existing
150 /// socket — migration never tears it down. Default no-op `Ok(())` for transports
151 /// without migration (TCP / WebSocket / WASI / Embedded / the in-memory test
152 /// pipe); only the native UDP client implements it.
153 fn migrate(
154 &self,
155 _local_addr: String,
156 ) -> impl core::future::Future<Output = Result<(), CoreError>> + Send {
157 async { Ok(()) }
158 }
159
160 /// Migrate the **server side** of this transport to a new local send address — the
161 /// mirror of [`migrate`](Self::migrate) for the accepting peer. The server binds a
162 /// fresh local socket, sends subsequent server→client datagrams from it (so the client
163 /// sees a new s2c source and follows it), and receives on it too (so once the client
164 /// switches its send target the c2s frames are delivered transparently). The address
165 /// crosses as a `String` to keep the trait `SocketAddr`-free / no_std-clean. Best-effort:
166 /// a parse / bind failure returns `Err` and the session keeps running on the old socket.
167 /// Default no-op `Ok(())` — only the native UDP server implements it. Kept distinct from
168 /// [`migrate`](Self::migrate) so the FFI-exported client `migrate()` cannot trigger a
169 /// server migration.
170 fn migrate_server(
171 &self,
172 _local_addr: String,
173 ) -> impl core::future::Future<Output = Result<(), CoreError>> + Send {
174 async { Ok(()) }
175 }
176}