Skip to main content

dig_nat/
mux.rs

1//! Stream multiplexing + byte-range streams over a single established peer connection.
2//!
3//! Every traversal tier (direct / UPnP / NAT-PMP / PCP / hole-punch / relayed) yields the SAME
4//! thing: one mTLS byte stream to the peer. On top of that single stream this module layers
5//! **[`yamux`]** multiplexing so the content/download layer can open **many cheap concurrent logical
6//! streams** to the peer with no head-of-line blocking — the transport is **streaming-first**, never
7//! "send request, buffer the whole response in memory".
8//!
9//! Two capabilities:
10//!
11//! 1. **Multiplexing** — [`PeerSession::open_stream`] opens an independent bidirectional
12//!    [`PeerStream`] (a tokio [`AsyncRead`] + [`AsyncWrite`]); open N of them concurrently and read
13//!    each incrementally with natural backpressure (yamux windows).
14//! 2. **Byte-range streams** — [`PeerSession::open_range_stream`] opens a stream scoped to a
15//!    `[offset, offset+len)` range of a named resource by writing a small [`RangeRequest`] preamble,
16//!    then hands back the stream so the caller reads exactly those bytes as they arrive. A downloader
17//!    opens range streams to DIFFERENT peers in parallel and reassembles — multi-source parallel
18//!    download falls out of "streams are cheap + multiplexed + range-scoped".
19//!
20//! The uniform abstraction holds regardless of how the connection was established, and regardless of
21//! whether the underlying byte stream is direct or (tier-6) relay-proxied.
22//!
23//! ## Wire alignment (normative)
24//!
25//! The control + range types here conform to the published **L7 peer-network spec** (docs.dig.net
26//! "L7 · DIG Node peer network", §8 streaming, §9 byte-range fetch + availability). The shapes are
27//! the `dig.getAvailability` / `dig.fetchRange` request/response and the streamed `RangeFrame`
28//! (`{offset, length, bytes, complete}`, first frame adding `total_length` + `chunk_lens` +
29//! `chunk_index` + `inclusion_proof` + `root`). Per-chunk integrity (split by `chunk_lens`, verify
30//! the whole-resource inclusion proof vs the chain-anchored `root`, AES-256-GCM-SIV-open) is done by
31//! the CONTENT layer above dig-nat; dig-nat carries these frames faithfully over the mux transport.
32
33use std::io;
34use std::sync::atomic::{AtomicBool, Ordering};
35use std::sync::Arc;
36
37use serde::{Deserialize, Serialize};
38use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
39use tokio::sync::Notify;
40use tokio_util::compat::{Compat, FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt};
41
42/// One logical, bidirectional stream to the peer — a tokio [`AsyncRead`] + [`AsyncWrite`]. Reads
43/// deliver bytes incrementally as they arrive (streaming, with yamux-window backpressure); many
44/// [`PeerStream`]s coexist on one [`PeerSession`] without head-of-line blocking.
45///
46/// yamux streams are `futures` streams; this is the tokio-trait view via `tokio-util` compat.
47pub type PeerStream = Compat<yamux::Stream>;
48
49/// One item in a [`dig.getAvailability`](AvailabilityRequest) batch — a resource key at store, root,
50/// or capsule/resource granularity (inferred from which fields are present, per the L7 spec §9):
51/// `store_id` only → *has_store*; `+ root` → *has_root* (the capsule `store_id:root`); `+
52/// retrieval_key` → *has_resource*. Hashes are 64-hex.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct AvailabilityItem {
55    /// The store id (64-hex). Always present.
56    pub store_id: String,
57    /// The generation root (64-hex). Present for root/resource granularity.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub root: Option<String>,
60    /// The resource retrieval key (64-hex). Present for resource granularity.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub retrieval_key: Option<String>,
63}
64
65/// The **availability pre-check** (`dig.getAvailability`, L7 spec §9) — asked BEFORE any range fetch.
66/// A multi-source download batches candidate peers × items in one call each and only fans byte-range
67/// requests at peers that answer *available* — never opening range streams to peers that may not hold
68/// the content. A message-style control call over the mux'd mTLS connection.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct AvailabilityRequest {
71    /// The items to check, batched.
72    pub items: Vec<AvailabilityItem>,
73}
74
75/// One answer in an [`AvailabilityResponse`], positionally aligned with the request `items`.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct AvailabilityAnswer {
78    /// Whether the peer holds the queried item. Always present.
79    pub available: bool,
80    /// (store granularity) generation roots the peer holds for the store, newest-first.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub roots: Option<Vec<String>>,
83    /// (root/resource granularity) the ciphertext length — lets the caller plan its ranges.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub total_length: Option<u64>,
86    /// (root/resource granularity) the chunk count.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub chunk_count: Option<u64>,
89    /// Whether the peer holds the FULL resource/capsule (`true`) or only part (`false`).
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub complete: Option<bool>,
92}
93
94/// The peer's answer to an [`AvailabilityRequest`]: one [`AvailabilityAnswer`] per queried item,
95/// positionally aligned with the request's `items`.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct AvailabilityResponse {
98    /// One answer per queried item, in request order.
99    pub items: Vec<AvailabilityAnswer>,
100}
101
102/// A byte-range request (`dig.fetchRange`, L7 spec §9) written at the start of a range-scoped stream.
103/// Identifies a resource (`store_id` + `retrieval_key` [+ `root`]) or a whole capsule
104/// (`capsule: true`, identified by `store_id` [+ `root`]) and the `[offset, offset+length)` range.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct RangeRequest {
107    /// The store id (64-hex).
108    pub store_id: String,
109    /// The resource retrieval key (64-hex). Omitted when `capsule` is true.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub retrieval_key: Option<String>,
112    /// The generation root (64-hex). Optional — defaults to the chain-anchored tip.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub root: Option<String>,
115    /// Fetch a whole capsule / `.dig` (identified by `store_id` [+ `root`]) rather than one resource.
116    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
117    pub capsule: bool,
118    /// Start offset (bytes) into the resource ciphertext. Default `0`.
119    #[serde(default)]
120    pub offset: u64,
121    /// Length (bytes) to return (widened to whole-chunk boundaries; clamped to the node window).
122    pub length: u64,
123}
124
125/// One streamed `dig.fetchRange` frame (L7 spec §8 framing). Frames arrive in ascending `offset`
126/// order and tile the requested range exactly; the caller reassembles by `offset` and stops on
127/// `complete`. The **first frame** additionally carries the per-range verification metadata so a
128/// single-peer range is independently verifiable against the chain-anchored `root`.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct RangeFrame {
131    /// This frame's start offset within the requested range.
132    pub offset: u64,
133    /// This frame's byte length.
134    pub length: u64,
135    /// The raw ciphertext bytes. On the wire they are **base64** (`base64_bytes`) — the canonical
136    /// `dig.fetchRange` frame encoding every producer emits.
137    #[serde(with = "base64_bytes")]
138    pub bytes: Vec<u8>,
139    /// Whether this is the final frame of the range.
140    pub complete: bool,
141    /// (first frame only) the full resource ciphertext length.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub total_length: Option<u64>,
144    /// (first frame only) per-chunk ciphertext lengths of the whole resource, in order.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub chunk_lens: Option<Vec<u64>>,
147    /// (first frame only) index into `chunk_lens` of the first chunk in this frame.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub chunk_index: Option<u64>,
150    /// (first frame only) merkle inclusion proof of the whole resource vs the generation `root`
151    /// (base64, relayed verbatim); `null`/absent for `capsule: true` (self-verifying on install).
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub inclusion_proof: Option<String>,
154    /// (first frame only) the generation root (64-hex) the inclusion proof is against.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub root: Option<String>,
157}
158
159/// Serde for [`RangeFrame::bytes`]: **base64** on the wire, raw `Vec<u8>` in Rust.
160///
161/// The `dig.fetchRange` frame is JSON, and the canonical wire type
162/// (`dig_rpc_protocol::types::RangeFrame`, "this window's ciphertext, base64") — and every real
163/// producer, including the dig-node peer serve path — encodes the window as a base64 STRING. Reading
164/// it with `serde_bytes` instead yielded the string's literal characters, so a served window arrived
165/// as its own base64 text and the reassembler rejected the frame (#1586, the read-leg blocker).
166///
167/// Deserialization is tolerant: a base64 string (canonical) OR a byte array (what an older dig-nat
168/// emitted) both decode, so a mixed-version peer is never dropped.
169mod base64_bytes {
170    use base64::Engine as _;
171    use serde::de::{SeqAccess, Visitor};
172    use serde::{Deserializer, Serializer};
173    use std::fmt;
174
175    pub fn serialize<S: Serializer>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error> {
176        s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(bytes))
177    }
178
179    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
180        d.deserialize_any(Base64OrArray)
181    }
182
183    struct Base64OrArray;
184
185    impl<'de> Visitor<'de> for Base64OrArray {
186        type Value = Vec<u8>;
187
188        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189            f.write_str("base64-encoded ciphertext (or a legacy byte array)")
190        }
191
192        fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
193            base64::engine::general_purpose::STANDARD
194                .decode(v)
195                .map_err(|e| E::custom(format!("range frame bytes are not valid base64: {e}")))
196        }
197
198        fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<Self::Value, E> {
199            Ok(v.to_vec())
200        }
201
202        fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
203            let mut out = Vec::with_capacity(seq.size_hint().unwrap_or_default());
204            while let Some(b) = seq.next_element::<u8>()? {
205                out.push(b);
206            }
207            Ok(out)
208        }
209    }
210}
211
212impl AvailabilityRequest {
213    /// Serialize as a `u32` big-endian length prefix + JSON body (the uniform control framing).
214    pub fn encode(&self) -> io::Result<Vec<u8>> {
215        encode_framed(self)
216    }
217    /// Read + decode an [`AvailabilityRequest`] from `r` (the peer/serving side).
218    pub async fn decode<R: AsyncRead + Unpin>(r: &mut R) -> io::Result<Self> {
219        decode_framed(r).await
220    }
221}
222
223impl AvailabilityResponse {
224    /// Serialize as a `u32` big-endian length prefix + JSON body.
225    pub fn encode(&self) -> io::Result<Vec<u8>> {
226        encode_framed(self)
227    }
228    /// Read + decode an [`AvailabilityResponse`] from `r` (the requesting side).
229    pub async fn decode<R: AsyncRead + Unpin>(r: &mut R) -> io::Result<Self> {
230        decode_framed(r).await
231    }
232}
233
234impl RangeRequest {
235    /// A range request for a content resource (`store_id` + `retrieval_key`).
236    pub fn resource(
237        store_id: impl Into<String>,
238        retrieval_key: impl Into<String>,
239        offset: u64,
240        length: u64,
241    ) -> Self {
242        RangeRequest {
243            store_id: store_id.into(),
244            retrieval_key: Some(retrieval_key.into()),
245            root: None,
246            capsule: false,
247            offset,
248            length,
249        }
250    }
251
252    /// Serialize as a `u32` big-endian length prefix + JSON body — the preamble a peer reads to learn
253    /// the resource + range before streaming the frames.
254    pub fn encode(&self) -> io::Result<Vec<u8>> {
255        encode_framed(self)
256    }
257    /// Read + decode a [`RangeRequest`] preamble from `r` (the serving side of a range stream).
258    pub async fn decode<R: AsyncRead + Unpin>(r: &mut R) -> io::Result<Self> {
259        decode_framed(r).await
260    }
261}
262
263impl RangeFrame {
264    /// Serialize as a `u32` big-endian length prefix + JSON body (one framed frame on the stream).
265    ///
266    /// Fails with [`io::ErrorKind::InvalidData`] if [`bytes`](Self::bytes) exceeds
267    /// [`MAX_RANGE_FRAME_PAYLOAD`], or if the serialized body exceeds [`MAX_FRAMED_BODY`] for any
268    /// other reason (an unusually large `chunk_lens` table or proof). A serving peer therefore cannot
269    /// emit a frame [`decode`](Self::decode) is required to reject: it splits its resource on
270    /// [`MAX_RANGE_FRAME_PAYLOAD`] or it learns about it here, at the send site, with the ceiling
271    /// named in the error.
272    ///
273    /// The payload is checked separately from the body because the body check alone is too weak: a
274    /// payload well over the ceiling still fits in [`MAX_FRAMED_BODY`] once base64'd when the frame
275    /// carries no metadata, so it would encode here and then overflow the moment the same span rode a
276    /// FIRST frame with a chunk table attached — a size-dependent, intermittent failure. One explicit
277    /// ceiling on `bytes` makes the limit the same for every frame.
278    pub fn encode(&self) -> io::Result<Vec<u8>> {
279        if self.bytes.len() > MAX_RANGE_FRAME_PAYLOAD {
280            return Err(io::Error::new(
281                io::ErrorKind::InvalidData,
282                format!(
283                    "RangeFrame payload {} exceeds MAX_RANGE_FRAME_PAYLOAD {MAX_RANGE_FRAME_PAYLOAD}; \
284                     split the range into ceiling-sized frames",
285                    self.bytes.len()
286                ),
287            ));
288        }
289        encode_framed(self)
290    }
291    /// Read + decode one [`RangeFrame`] from `r`. Returns `Ok(None)` at clean end-of-stream (the
292    /// reader hit EOF on a frame boundary), so a consumer loops until `None` or `complete`.
293    pub async fn decode<R: AsyncRead + Unpin>(r: &mut R) -> io::Result<Option<Self>> {
294        decode_framed_opt(r).await
295    }
296}
297
298/// Maximum length-prefixed frame BODY, in bytes — the one number both sides of the framing contract
299/// obey. A decoder rejects a longer body (guarding against a malicious length prefix forcing a huge
300/// allocation) and, since #1640, an encoder refuses to produce one, so a sender can never emit a
301/// frame a conforming receiver is required to reject.
302///
303/// This is a **shared byte-identical wire constant**: any second implementation of DIG peer framing
304/// — including `dig-node`'s own `write_framed`/`read_framed` — MUST use this exact value.
305pub const MAX_FRAMED_BODY: usize = 64 * 1024;
306
307/// Maximum raw [`RangeFrame::bytes`] length, in bytes, that a single frame may carry — the number a
308/// serving peer splits a resource on. **32 KiB.**
309///
310/// ## Why it is not ~48 KiB
311///
312/// `bytes` travels base64 (4 output bytes per 3 input bytes), so [`MAX_FRAMED_BODY`] of body would
313/// hold at most ~48 KiB of raw payload *if the payload were the only thing in the frame*. It is not.
314/// The frame is JSON and, per #1577, the FIRST frame of every range additionally carries `root`,
315/// `total_length`, `chunk_index`, a base64 `inclusion_proof`, and **the entire `chunk_lens` array of
316/// the whole resource** — whose size is driven by the RESOURCE, not by the frame.
317///
318/// So the ceiling is deliberately CONSERVATIVE rather than exact-fit:
319///
320/// | component | budget |
321/// |---|---|
322/// | base64 of 32 KiB of `bytes` | 43,692 B |
323/// | remaining allowance for JSON keys + `chunk_lens` + proof + root | 21,844 B |
324/// | **total** | **65,536 B** = [`MAX_FRAMED_BODY`] |
325///
326/// **Resist tightening it.** An exact-fit constant satisfies a naive round-trip test and then
327/// overflows in production on the first resource with a large chunk table — which is precisely the
328/// class of defect #1640 was.
329///
330/// ## The allowance does NOT cover every permitted resource
331///
332/// It is bounded, and the bound is [`MAX_FIRST_FRAME_CHUNK_LENS`] — read that before assuming this
333/// ceiling makes any legal range answerable. It does not.
334pub const MAX_RANGE_FRAME_PAYLOAD: usize = 32 * 1024;
335
336/// The largest `chunk_lens` array, in entries, that is GUARANTEED to fit on a first frame: **2,486**.
337///
338/// ## The four maxima this is derived against — all of them, simultaneously
339///
340/// Read these before using or changing the number. The same budget has been derived wrong three times,
341/// each time because ONE of these stayed implicit, and each time in the UNSAFE direction — too
342/// generous, which lets a conforming sender emit a frame the receiver must reject, i.e. exactly the
343/// defect #1640 exists to close.
344///
345/// 1. **Payload at its cap** — [`MAX_RANGE_FRAME_PAYLOAD`] (32,768 B raw → 43,692 B base64).
346/// 2. **Inclusion proof at its cap** — `MAX_INCLUSION_PROOF_B64` = 4,096 B. *(The 2,891 figure this
347///    constant replaces held the proof at ~1,400 B, so it only fit a small-proof frame: at 2,891
348///    entries the body is 64,199 B with no proof, 65,223 B at a 1,024 B proof, and 68,295 B at the
349///    real cap.)*
350/// 3. **Entries at their maximum decimal WIDTH** — a chunk may legally be 256 KiB, and `chunk_lens` is
351///    JSON decimal, so a worst-case entry is SIX digits. *(The 3,373 figure assumed five, from a wrong
352///    premise that the chunker targets 256 KiB; it is FastCDC target **64 KiB**, min 16 KiB, max
353///    256 KiB — `digs crates/digstore-chunker/src/config.rs`.)*
354/// 4. **Every `u64` scalar at max width, including the 0.13.0 ones** — `offset`, `length`,
355///    `total_length`, `chunk_index`,
356///    `chunk_count`, `chunk_lens_offset`, each at 20 digits. Deliberately stricter than the
357///    protocol-tight widths (`length` ≤ 32,768 is 5 digits, `chunk_index` and `chunk_count`
358///    ≤ 1,048,576 are 7). That slack is given up on purpose: a bound that depends on a scalar
359///    happening to be small is a bound with a fifth implicit premise. Holding `chunk_count` at 7
360///    digits rather than 20 is worth 13 B — very nearly two entries — which is exactly the size of
361///    mistake this rule exists to prevent.
362///
363/// Derived on the **0.13.0 field set** — including the `chunk_count` + `chunk_lens_offset` prologue
364/// fields, which cost a measured 76 B — so the number does not have to move when they land. On today's
365/// 0.12.0 fields the same maxima allow 2,496; publishing the smaller figure keeps it valid across both.
366///
367/// ## Measured, never argued
368///
369/// Every figure here comes from serializing the real struct. Bodies at the four maxima, by entry
370/// width, on the 0.13.0 field set:
371///
372/// | `chunk_lens` entries | 5-digit entries | 6-digit entries |
373/// |---|---|---|
374/// | 2,048 | 60,422 B | 62,470 B |
375/// | **2,486** | 63,050 B | **65,536 B — the bound, exactly at the cap** |
376/// | 2,487 | 63,056 B | 65,543 B (over by 7) |
377/// | 2,495 | 63,104 B | 65,599 B (over by 63) |
378/// | 2,891 | 65,480 B | 68,371 B (over by 2,835) |
379/// | 4,096 | 72,710 B | 76,806 B |
380///
381/// At five-digit entries 2,900 would fit, but that is a property of the DATA, not of the protocol —
382/// never rely on it.
383///
384/// In resource terms 2,486 entries is about **155 MiB** at the 64 KiB target chunk size and about
385/// **38 MiB** at the 16 KiB minimum.
386///
387/// ## Not the same number as the sender's paging threshold
388///
389/// `MAX_CHUNK_LENS_PER_FRAME` = 2,048 is the 0.13.0 SENDER threshold — where a serve path starts
390/// paging the prologue (62,470 B at these same maxima). This constant is the hard ARITHMETIC ceiling.
391/// Two numbers doing two different jobs, and **the gap between them is deliberate margin**; do not
392/// collapse one into the other.
393///
394/// ## Known limitation — a permitted resource can have NO conforming first frame (#1640)
395///
396/// The ecosystem already permits resources past this bound: `digstore-host` sets
397/// `MAX_MODULE_BYTES = 256 MiB` (~4,096 chunks at the 64 KiB target — the last row above), and
398/// dig-download accepts up to `MAX_MODULE_CHUNK_COUNT = 1,048,576`. For such a resource a holder that
399/// splits on [`MAX_RANGE_FRAME_PAYLOAD`] produces a first frame [`RangeFrame::encode`] must refuse —
400/// and **surrendering the payload entirely stops helping at 8,727 entries**, where the metadata ALONE
401/// fills the body (measured at these same maxima, on the 0.13.0 field set).
402///
403/// So this is NOT a constant to re-tune: no value of [`MAX_RANGE_FRAME_PAYLOAD`] fixes it. The
404/// resolution is a wire-shape change — the resource-scaling metadata (`chunk_lens`, `inclusion_proof`)
405/// moves off every frame and onto the first frame or a paged prologue sent once per range stream — and
406/// **that shape lands in 0.13.0**. Until then a range past this bound hard-fails at the SENDER rather
407/// than corrupting a read at the receiver, which is the correct half of the trade.
408///
409/// Do NOT work around it locally: raising [`MAX_FRAMED_BODY`] is a RECEIVER bound (no sender may exceed
410/// it until every receiver is deployed, and no finite cap holds `MAX_MODULE_CHUNK_COUNT` = 1,048,576
411/// entries without abandoning the bounded-allocation property the cap exists for), and truncating
412/// `chunk_lens` is not an option either — it is a DECRYPT input, since per-chunk AES-256-GCM-SIV needs
413/// the whole array and a reader rejects any array whose sum differs from `total_length`.
414pub const MAX_FIRST_FRAME_CHUNK_LENS: usize = 2_486;
415
416/// Serialize `value` as a `u32` big-endian length prefix + JSON body — the uniform framing for every
417/// control message on a stream (availability + range preambles, and the range frames themselves).
418///
419/// Fails with [`io::ErrorKind::InvalidData`] if the body would exceed [`MAX_FRAMED_BODY`]. That check
420/// is the whole point: the decoders below MUST reject such a body, so producing one is a bug at the
421/// SENDER and belongs at the sender's call site — not as an opaque `InvalidData` surfacing on some
422/// remote peer's read.
423fn encode_framed<T: Serialize>(value: &T) -> io::Result<Vec<u8>> {
424    let body =
425        serde_json::to_vec(value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
426    if body.len() > MAX_FRAMED_BODY {
427        return Err(io::Error::new(
428            io::ErrorKind::InvalidData,
429            format!(
430                "framed body {} exceeds MAX_FRAMED_BODY {MAX_FRAMED_BODY}; split the payload on \
431                 MAX_RANGE_FRAME_PAYLOAD ({MAX_RANGE_FRAME_PAYLOAD})",
432                body.len()
433            ),
434        ));
435    }
436    let mut out = Vec::with_capacity(4 + body.len());
437    out.extend_from_slice(&(body.len() as u32).to_be_bytes());
438    out.extend_from_slice(&body);
439    Ok(out)
440}
441
442/// Read + decode a length-prefixed JSON control message from `r`, bounded by [`MAX_FRAMED_BODY`].
443async fn decode_framed<T: for<'de> Deserialize<'de>, R: AsyncRead + Unpin>(
444    r: &mut R,
445) -> io::Result<T> {
446    let mut len_buf = [0u8; 4];
447    r.read_exact(&mut len_buf).await?;
448    let len = u32::from_be_bytes(len_buf) as usize;
449    if len > MAX_FRAMED_BODY {
450        return Err(io::Error::new(
451            io::ErrorKind::InvalidData,
452            "control message too large",
453        ));
454    }
455    let mut body = vec![0u8; len];
456    r.read_exact(&mut body).await?;
457    serde_json::from_slice(&body).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
458}
459
460/// Like [`decode_framed`] but returns `Ok(None)` on a CLEAN end-of-stream at a frame boundary (the
461/// length prefix read hits immediate EOF), so a streaming consumer can loop until the stream ends.
462async fn decode_framed_opt<T: for<'de> Deserialize<'de>, R: AsyncRead + Unpin>(
463    r: &mut R,
464) -> io::Result<Option<T>> {
465    let mut len_buf = [0u8; 4];
466    match r.read_exact(&mut len_buf).await {
467        Ok(_) => {}
468        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
469        Err(e) => return Err(e),
470    }
471    let len = u32::from_be_bytes(len_buf) as usize;
472    if len > MAX_FRAMED_BODY {
473        return Err(io::Error::new(
474            io::ErrorKind::InvalidData,
475            "control message too large",
476        ));
477    }
478    let mut body = vec![0u8; len];
479    r.read_exact(&mut body).await?;
480    serde_json::from_slice(&body)
481        .map(Some)
482        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
483}
484
485/// One command to the yamux driver task. yamux 0.13 has no `Control` handle, so we drive the
486/// [`yamux::Connection`] in a task and talk to it over this channel.
487enum MuxCommand {
488    /// Open a new outbound stream; the resulting [`yamux::Stream`] (or error) comes back on the sender.
489    OpenOutbound(tokio::sync::oneshot::Sender<Result<yamux::Stream, String>>),
490}
491
492/// A multiplexed session over one peer connection: open many concurrent logical [`PeerStream`]s.
493///
494/// yamux 0.13 exposes a poll-based [`yamux::Connection`] (no `Control` handle), so a background
495/// driver task owns the connection and serves open-stream requests over a command channel; inbound
496/// streams are surfaced on [`Self::inbound_rx`] for a serving node. Dropping the session closes the
497/// command channel, which ends the driver and tears down the underlying byte stream.
498pub struct PeerSession {
499    cmd_tx: tokio::sync::mpsc::Sender<MuxCommand>,
500    /// Inbound streams opened BY the peer (server role / bidirectional use). A pure client can
501    /// ignore this; a serving node reads accepted range-request streams from here.
502    inbound_rx: tokio::sync::mpsc::Receiver<PeerStream>,
503    /// Set + notified when the driver task ends (the underlying byte stream closed — a clean close or
504    /// a transport error). Observed via [`Self::closed_handle`] so fast-connect can detect a transport
505    /// dying and fall back without holding the session lock.
506    closed_flag: Arc<AtomicBool>,
507    closed_notify: Arc<Notify>,
508}
509
510/// A cheap, cloneable observer of a [`PeerSession`]'s underlying byte stream closing (the mux driver
511/// task ending — a clean close OR a transport error). Fast-connect's promotion guard holds one to
512/// detect the active transport dying and fall back to another tier, without locking the session.
513#[derive(Clone)]
514pub struct ClosedHandle {
515    flag: Arc<AtomicBool>,
516    notify: Arc<Notify>,
517}
518
519impl ClosedHandle {
520    /// Whether the session's transport has already closed.
521    pub fn is_closed(&self) -> bool {
522        self.flag.load(Ordering::Acquire)
523    }
524
525    /// Resolve once the session's transport has closed (returns immediately if already closed).
526    pub async fn closed(&self) {
527        loop {
528            if self.flag.load(Ordering::Acquire) {
529                return;
530            }
531            // Arm the wait, then re-check the flag: the driver sets the flag BEFORE
532            // `notify_waiters`, so a close that races this arming is caught by the recheck (no lost
533            // wakeup).
534            let notified = self.notify.notified();
535            if self.flag.load(Ordering::Acquire) {
536                return;
537            }
538            notified.await;
539        }
540    }
541}
542
543impl std::fmt::Debug for PeerSession {
544    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545        f.debug_struct("PeerSession").finish_non_exhaustive()
546    }
547}
548
549impl PeerSession {
550    /// Wrap an established mTLS byte stream in yamux as the **client** (outbound-stream opener) and
551    /// spawn the driver. `io` is any tokio duplex stream (the mTLS [`tokio_rustls::client::TlsStream`]
552    /// or, in tests, a loopback stream). Returns the session; open streams with
553    /// [`Self::open_stream`] / [`Self::open_range_stream`].
554    pub fn client<S>(io: S) -> Self
555    where
556        S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
557    {
558        Self::new(io, yamux::Mode::Client)
559    }
560
561    /// Wrap an established byte stream in yamux as the **server** (accepts inbound streams). Inbound
562    /// streams the peer opens are delivered via [`Self::accept_stream`]. Provided for symmetry + the
563    /// serving side of tests.
564    pub fn server<S>(io: S) -> Self
565    where
566        S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
567    {
568        Self::new(io, yamux::Mode::Server)
569    }
570
571    fn new<S>(io: S, mode: yamux::Mode) -> Self
572    where
573        S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
574    {
575        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel::<MuxCommand>(64);
576        let (inbound_tx, inbound_rx) = tokio::sync::mpsc::channel::<PeerStream>(64);
577        let conn = yamux::Connection::new(io.compat(), yamux::Config::default(), mode);
578        let closed_flag = Arc::new(AtomicBool::new(false));
579        let closed_notify = Arc::new(Notify::new());
580        tokio::spawn(drive_connection(
581            conn,
582            cmd_rx,
583            inbound_tx,
584            Arc::clone(&closed_flag),
585            Arc::clone(&closed_notify),
586        ));
587        PeerSession {
588            cmd_tx,
589            inbound_rx,
590            closed_flag,
591            closed_notify,
592        }
593    }
594
595    /// A cloneable observer of this session's transport closing — see [`ClosedHandle`].
596    pub fn closed_handle(&self) -> ClosedHandle {
597        ClosedHandle {
598            flag: Arc::clone(&self.closed_flag),
599            notify: Arc::clone(&self.closed_notify),
600        }
601    }
602
603    /// Open a new outbound logical stream to the peer. Cheap — open as many as you need to run
604    /// concurrent transfers without head-of-line blocking.
605    pub async fn open_stream(&mut self) -> io::Result<PeerStream> {
606        let (tx, rx) = tokio::sync::oneshot::channel();
607        self.cmd_tx
608            .send(MuxCommand::OpenOutbound(tx))
609            .await
610            .map_err(|_| io::Error::other("mux driver closed"))?;
611        let stream = rx
612            .await
613            .map_err(|_| io::Error::other("mux driver dropped request"))?
614            .map_err(io::Error::other)?;
615        Ok(stream.compat())
616    }
617
618    /// Accept the next inbound logical stream the peer opened (server side). Returns `None` when the
619    /// connection has closed. A pure client never calls this.
620    pub async fn accept_stream(&mut self) -> Option<PeerStream> {
621        self.inbound_rx.recv().await
622    }
623
624    /// Open a `dig.fetchRange` stream for `req`: opens a fresh logical stream, writes the
625    /// [`RangeRequest`] preamble, and returns the stream for the caller to read [`RangeFrame`]s from
626    /// (via [`RangeFrame::decode`]) as they arrive. The building block for multi-source parallel
627    /// range downloads — open one of these per (peer, range) and read them concurrently.
628    pub async fn open_range_stream(&mut self, req: &RangeRequest) -> io::Result<PeerStream> {
629        let mut stream = self.open_stream().await?;
630        stream.write_all(&req.encode()?).await?;
631        stream.flush().await?;
632        Ok(stream)
633    }
634
635    /// **Availability pre-check** (`dig.getAvailability`) — ask the peer which of `items` it holds,
636    /// BEFORE opening any range streams. Opens a short-lived control stream, writes the batched
637    /// [`AvailabilityRequest`], reads the [`AvailabilityResponse`]. A multi-source downloader runs
638    /// this against candidate peers and only range-fetches from holders — the normative flow is:
639    /// discover peers → `query_availability` (batch) → fan byte-ranges across holders → verify each
640    /// vs the chain-anchored root → retry a bad range from another holder → reassemble.
641    pub async fn query_availability(
642        &mut self,
643        items: Vec<AvailabilityItem>,
644    ) -> io::Result<AvailabilityResponse> {
645        let req = AvailabilityRequest { items };
646        let mut stream = self.open_stream().await?;
647        stream.write_all(&req.encode()?).await?;
648        stream.flush().await?;
649        AvailabilityResponse::decode(&mut stream).await
650    }
651}
652
653/// Drive one yamux [`Connection`](yamux::Connection): concurrently service open-outbound commands
654/// and surface inbound streams, until the command channel closes (session dropped) or the connection
655/// errors. This is the task that replaces yamux 0.12's `Control`.
656///
657/// `T` is the futures-io view of the byte stream (a `tokio-util` [`Compat`] of the tokio mTLS
658/// stream), since yamux operates on `futures::AsyncRead + AsyncWrite`.
659async fn drive_connection<T>(
660    mut conn: yamux::Connection<T>,
661    mut cmd_rx: tokio::sync::mpsc::Receiver<MuxCommand>,
662    inbound_tx: tokio::sync::mpsc::Sender<PeerStream>,
663    closed_flag: Arc<AtomicBool>,
664    closed_notify: Arc<Notify>,
665) where
666    T: futures::AsyncRead + futures::AsyncWrite + Send + Unpin + 'static,
667{
668    use std::future::poll_fn;
669
670    loop {
671        tokio::select! {
672            // An open-outbound request from the session.
673            cmd = cmd_rx.recv() => {
674                match cmd {
675                    Some(MuxCommand::OpenOutbound(reply)) => {
676                        let res = poll_fn(|cx| conn.poll_new_outbound(cx)).await;
677                        let _ = reply.send(res.map_err(|e| e.to_string()));
678                    }
679                    None => {
680                        // Session dropped — close the connection and end the driver.
681                        let _ = poll_fn(|cx| conn.poll_close(cx)).await;
682                        break;
683                    }
684                }
685            }
686            // An inbound stream opened by the peer.
687            inbound = poll_fn(|cx| conn.poll_next_inbound(cx)) => {
688                match inbound {
689                    Some(Ok(stream)) => {
690                        // Deliver to a serving node; if no one is accepting, the stream is dropped.
691                        let _ = inbound_tx.try_send(stream.compat());
692                    }
693                    Some(Err(_)) | None => {
694                        // Connection closed / errored — end the driver.
695                        break;
696                    }
697                }
698            }
699        }
700    }
701
702    // The transport is gone: publish closure (flag BEFORE notify, so `ClosedHandle::closed`'s
703    // arm-then-recheck can never miss the wakeup).
704    closed_flag.store(true, Ordering::Release);
705    closed_notify.notify_waiters();
706}