Skip to main content

dig_download/
source.rs

1//! [`RangeTransport`] — fetch one byte range (or an availability answer) from one provider — plus
2//! per-source health tracking and the real dig-nat-backed implementation.
3//!
4//! The orchestrator fans byte ranges across providers by calling [`RangeTransport::fetch_range`]
5//! concurrently, one future per (provider, range). The trait abstracts the peer transport so the
6//! scheduler is tested over an in-memory mock (see [`crate::testkit`]); the real
7//! [`NatRangeTransport`] rides dig-nat (`dig.getAvailability` + `dig.fetchRange` over an mTLS mux
8//! stream). A provider that fails or serves a bad range is penalized via [`SourceTracker`] so the
9//! scheduler stops leaning on it.
10
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14
15use async_trait::async_trait;
16use dig_dht::ProviderRecord;
17use dig_nat::{
18    AvailabilityItem, AvailabilityResponse, ChunkLensAssembler, ChunkLensError, RangeFrame,
19    RangeRequest,
20};
21use dig_peer::DigPeer;
22use tokio::io::{AsyncRead, AsyncReadExt};
23
24use crate::error::DownloadError;
25
26/// The verification metadata a range's frames carry (L7 §9): the whole-resource shape a downloader
27/// uses to establish or check the [`ResourceCommitment`](crate::verify::ResourceCommitment).
28///
29/// # Read this as "the stream's declared identity", not "frame 1's fields"
30///
31/// [`total_length`](Self::total_length), [`chunk_lens`](Self::chunk_lens),
32/// [`chunk_count`](Self::chunk_count) and [`root`](Self::root) describe the WHOLE resource, so every
33/// frame of a conforming stream repeats the same values. [`assemble_range_stream`] therefore captures
34/// them from the first frame and RE-CHECKS every later frame against them — a holder that revises its
35/// declared shape mid-stream is rejected rather than silently believed on whichever frame arrived
36/// first. [`chunk_index`](Self::chunk_index) is the one field that legitimately varies per frame; see
37/// its own note.
38///
39/// `#[non_exhaustive]`: the L7 range preamble gains optional fields as the wire grows (`chunk_count`
40/// and the paged prologue are recent additions), and each one arrives here. Build one with
41/// [`from_frame`](Self::from_frame) or from [`Default`] plus the `declaring_*` setters.
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct RangeMeta {
45    /// The full resource ciphertext length.
46    pub total_length: Option<u64>,
47    /// Per-chunk ciphertext lengths of the whole resource, in order. For a resource whose layout is
48    /// too large to state on one frame this is one PAGE of the array rather than all of it — which is
49    /// why [`chunk_count`](Self::chunk_count) exists to say how many entries the whole array has.
50    pub chunk_lens: Option<Vec<u64>>,
51    /// How many entries the whole resource's `chunk_lens` array has.
52    ///
53    /// Present so a reader can tell a COMPLETE single-frame layout from one page of a paged prologue:
54    /// `chunk_lens.len() < chunk_count` means the array continues on later frames. Absent means the
55    /// holder declared no count, and `chunk_lens` is taken to be the whole array (the pre-paging
56    /// shape, which stays readable — §5.1).
57    pub chunk_count: Option<u64>,
58    /// Index into `chunk_lens` of the first chunk in THIS frame.
59    ///
60    /// Unlike the other fields this is per-frame, not per-resource: a chunk-aligned continuation frame
61    /// states where it begins. Frames arrive in ascending byte offset, so the declared index is
62    /// non-decreasing across a conforming stream.
63    pub chunk_index: Option<u64>,
64    /// The chain-anchored generation root (64-hex).
65    pub root: Option<String>,
66    /// The whole-resource merkle inclusion proof (base64), or `None` for a capsule.
67    pub inclusion_proof: Option<String>,
68}
69
70impl RangeMeta {
71    /// The verification metadata one [`RangeFrame`] declares.
72    pub fn from_frame(frame: &RangeFrame) -> Self {
73        RangeMeta {
74            total_length: frame.total_length,
75            chunk_lens: frame.chunk_lens.clone(),
76            chunk_count: frame.chunk_count,
77            chunk_index: frame.chunk_index,
78            root: frame.root.clone(),
79            inclusion_proof: frame.inclusion_proof.clone(),
80        }
81    }
82
83    /// Declare the whole-resource shape (`total_length` + `chunk_lens` + the array's `chunk_count`).
84    /// The `#[non_exhaustive]` construction path for a test fixture or a non-dig-nat transport.
85    pub fn declaring_layout(
86        mut self,
87        total_length: u64,
88        chunk_lens: Vec<u64>,
89        chunk_count: u64,
90    ) -> Self {
91        self.total_length = Some(total_length);
92        self.chunk_count = Some(chunk_count);
93        self.chunk_lens = Some(chunk_lens);
94        self
95    }
96
97    /// Declare the chain anchor (`root` + the whole-resource `inclusion_proof`).
98    pub fn declaring_anchor(mut self, root: String, inclusion_proof: Option<String>) -> Self {
99        self.root = Some(root);
100        self.inclusion_proof = inclusion_proof;
101        self
102    }
103
104    /// Declare which chunk of the resource this frame's bytes begin at.
105    pub fn declaring_chunk_index(mut self, chunk_index: u64) -> Self {
106        self.chunk_index = Some(chunk_index);
107        self
108    }
109}
110
111/// A fetched, reassembled byte range: the assembled ciphertext for the requested `[offset, offset+len)`
112/// plus the first-frame verification metadata. The orchestrator verifies this against the resource
113/// commitment, then writes `bytes` at `request_offset` in the sink.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct FetchedRange {
116    /// The absolute resource offset the range was requested at (== [`RangeRequest::offset`]).
117    pub request_offset: u64,
118    /// The reassembled range ciphertext.
119    pub bytes: Vec<u8>,
120    /// The first-frame verification metadata for this range.
121    pub meta: RangeMeta,
122}
123
124/// Fetch content ranges + availability from providers. The one network capability the orchestrator
125/// needs, abstracted for testability (mock in [`crate::testkit`]; real [`NatRangeTransport`]).
126#[async_trait]
127pub trait RangeTransport: Send + Sync {
128    /// Ask `provider` which of `items` it holds (`dig.getAvailability`) — the pre-check before fanning
129    /// ranges. The answer's `total_length` / `chunk_count` also seed range planning.
130    async fn query_availability(
131        &self,
132        provider: &ProviderRecord,
133        items: Vec<AvailabilityItem>,
134    ) -> Result<AvailabilityResponse, DownloadError>;
135
136    /// Fetch the byte range described by `req` from `provider` (`dig.fetchRange`), streaming +
137    /// reassembling the frames into a [`FetchedRange`]. A transport failure (connect/stream error) is
138    /// a recoverable [`DownloadError::Transport`] — the orchestrator retries the range elsewhere.
139    async fn fetch_range(
140        &self,
141        provider: &ProviderRecord,
142        req: &RangeRequest,
143    ) -> Result<FetchedRange, DownloadError>;
144}
145
146/// Health of one provider as a range source — failure count + a backoff window during which the
147/// scheduler avoids it.
148#[derive(Debug, Clone, Default)]
149pub struct SourceHealth {
150    /// Consecutive failures (reset on success).
151    pub failures: u32,
152    /// Total ranges this source has successfully served (for rebalancing / diagnostics).
153    pub served: u64,
154    /// Do not schedule this source again until this instant (set on failure, capped-exponential).
155    pub backoff_until: Option<Instant>,
156}
157
158/// Tracks per-provider [`SourceHealth`] so the scheduler prefers healthy sources and backs off failed
159/// ones (bounded exponential backoff), without ever permanently banning a source that might recover.
160#[derive(Debug, Default)]
161pub struct SourceTracker {
162    health: HashMap<String, SourceHealth>,
163    base_backoff: Duration,
164    max_backoff: Duration,
165}
166
167impl SourceTracker {
168    /// A tracker with the given base + max backoff (backoff doubles per consecutive failure, capped).
169    pub fn new(base_backoff: Duration, max_backoff: Duration) -> Self {
170        SourceTracker {
171            health: HashMap::new(),
172            base_backoff,
173            max_backoff,
174        }
175    }
176
177    /// Whether `peer_id` is schedulable at `now` (not inside a backoff window).
178    pub fn is_available(&self, peer_id: &str, now: Instant) -> bool {
179        match self.health.get(peer_id) {
180            Some(h) => match h.backoff_until {
181                Some(until) => now >= until,
182                None => true,
183            },
184            None => true,
185        }
186    }
187
188    /// Record a successful range served by `peer_id` (clears failures + backoff).
189    pub fn record_success(&mut self, peer_id: &str) {
190        let h = self.health.entry(peer_id.to_string()).or_default();
191        h.failures = 0;
192        h.served += 1;
193        h.backoff_until = None;
194    }
195
196    /// Record a failure by `peer_id` at `now` and set its (capped-exponential) backoff window.
197    pub fn record_failure(&mut self, peer_id: &str, now: Instant) {
198        let base = self.base_backoff;
199        let max = self.max_backoff;
200        let h = self.health.entry(peer_id.to_string()).or_default();
201        h.failures = h.failures.saturating_add(1);
202        let shift = h.failures.saturating_sub(1).min(16);
203        let backoff = base.checked_mul(1u32 << shift).unwrap_or(max).min(max);
204        h.backoff_until = Some(now + backoff);
205    }
206
207    /// The number of successfully-served ranges recorded for `peer_id`.
208    pub fn served(&self, peer_id: &str) -> u64 {
209        self.health.get(peer_id).map(|h| h.served).unwrap_or(0)
210    }
211
212    /// The consecutive-failure count recorded for `peer_id`.
213    pub fn failures(&self, peer_id: &str) -> u32 {
214        self.health.get(peer_id).map(|h| h.failures).unwrap_or(0)
215    }
216}
217
218/// The whole-resource identity a range stream declared on its FIRST frame, enforced against every
219/// later frame of the same stream.
220///
221/// # Why a later frame has to be checked at all
222///
223/// The L7 frame contract splits its optional fields in two (dig-nat `mux.rs`, `SPEC.md` §5.1.1):
224/// `total_length` / `root` / `chunk_count` / `chunk_index` are **identity — every frame**, while
225/// `chunk_lens` / `inclusion_proof` are **prologue — once per stream** and MUST NOT be repeated.
226/// Reading only frame 1 and discarding the rest, as this reader used to, means a holder can declare an
227/// honest shape on the frame the commitment binds to and a different one on every frame after it, and
228/// be believed on the first. Nothing downstream recovers that: the commitment was already adopted.
229///
230/// # The rule is over the class, not over one behaviour
231///
232/// A revision is a revision whichever direction it goes and whichever field carries it, so this rejects
233/// **any** disagreement with frame 1 — a changed value, and equally a value APPEARING on a later frame
234/// that frame 1 left unstated, since the reader binds to frame 1 and a late arrival is information it
235/// deliberately withheld from the frame that mattered. A later frame that simply OMITS an identity field
236/// is allowed: it asserts nothing, so there is nothing to disbelieve, and refusing it would reject a
237/// holder whose only fault is terseness while gaining no property.
238#[derive(Debug, Default)]
239struct StreamIdentity {
240    root: Option<String>,
241    total_length: Option<u64>,
242    chunk_count: Option<u64>,
243    /// The highest `chunk_index` declared so far. Frames arrive in ascending byte offset and this names
244    /// the frame's own first chunk, so it may ADVANCE but never rewind — the one identity-adjacent
245    /// field that legitimately differs per frame.
246    highest_chunk_index: Option<u64>,
247    /// One past the last `chunk_lens` entry any accepted page has filled — the boundary that separates a
248    /// conforming NEXT page from a restatement of ground already covered.
249    prologue_frontier: u64,
250}
251
252/// What a conforming LATER frame turned out to be.
253#[derive(Debug, PartialEq, Eq)]
254enum LaterFrame {
255    /// A continuation carrying bytes and no prologue — the ordinary case.
256    Continuation,
257    /// A NEW `chunk_lens` page: conforming, and the next installment of a paged prologue.
258    NewProloguePage {
259        /// The entry the page begins at.
260        offset: u64,
261        /// How many entries it carries.
262        entries: usize,
263    },
264}
265
266impl StreamIdentity {
267    /// Capture the identity the stream's first frame declares.
268    fn from_first_frame(frame: &RangeFrame) -> Self {
269        let page_entries = frame.chunk_lens.as_ref().map_or(0, Vec::len) as u64;
270        StreamIdentity {
271            root: frame.root.clone(),
272            total_length: frame.total_length,
273            chunk_count: frame.chunk_count,
274            highest_chunk_index: frame.chunk_index,
275            prologue_frontier: frame
276                .chunk_lens_offset
277                .unwrap_or(0)
278                .saturating_add(page_entries),
279        }
280    }
281
282    /// Check one LATER frame against the captured identity, advancing the chunk-index and prologue
283    /// frontiers, and classify what the frame is.
284    ///
285    /// `Err` names the field and both values, because "the holder revised its declared shape" is only
286    /// actionable if the reader can say which declaration moved.
287    fn check_later_frame(&mut self, frame: &RangeFrame) -> Result<LaterFrame, String> {
288        check_unrevised("root", self.root.as_deref(), frame.root.as_deref())?;
289        check_unrevised("total_length", self.total_length, frame.total_length)?;
290        check_unrevised("chunk_count", self.chunk_count, frame.chunk_count)?;
291
292        // `inclusion_proof` is once-per-stream with NO paged form — there is only ever one proof — so any
293        // later frame carrying it is restating, whether or not it agrees. Refusing the restatement rather
294        // than comparing it is what makes "frame 1 said A, frame 5 said B" inexpressible instead of
295        // merely unpersuasive.
296        if frame.inclusion_proof.is_some() {
297            return Err("a later frame restates inclusion_proof, which is once-per-stream".into());
298        }
299
300        if let Some(index) = frame.chunk_index {
301            if let Some(highest) = self.highest_chunk_index {
302                if index < highest {
303                    return Err(format!(
304                        "chunk_index {index} rewinds below {highest}; frames arrive in ascending offset"
305                    ));
306                }
307            }
308            self.highest_chunk_index = Some(index);
309        }
310
311        // `chunk_lens` is the one prologue field WITH a paged form, so "MUST NOT be repeated" cannot be
312        // read as "only the first frame may carry it" — the wire contract explicitly has successive frames
313        // each carry a page, stamped with the offset it starts at. What is forbidden is restating ground
314        // already covered; what is prescribed is advancing past it. The field that tells those apart is
315        // `chunk_lens_offset`, so the rule is stated over it.
316        //
317        // Reading the ban as first-frame-only would hand a CONFORMING paging holder a protocol-violation
318        // verdict, and the paging work would then have to delete this branch outright. Classifying the
319        // frame instead means that work only changes what the CALLER does with a new page.
320        let Some(page) = &frame.chunk_lens else {
321            return Ok(LaterFrame::Continuation);
322        };
323        // Absent means "begins at 0" per the wire contract, which the first frame's page already covered,
324        // so an unstamped later page lands below the frontier and is caught here as the restatement it is.
325        let offset = frame.chunk_lens_offset.unwrap_or(0);
326        if offset < self.prologue_frontier {
327            return Err(format!(
328                "a later frame's chunk_lens page at offset {offset} re-covers entries below {}, which an \
329                 earlier page already filled; a paged prologue must ADVANCE, never restate",
330                self.prologue_frontier
331            ));
332        }
333        self.prologue_frontier = offset.saturating_add(page.len() as u64);
334        Ok(LaterFrame::NewProloguePage {
335            offset,
336            entries: page.len(),
337        })
338    }
339
340    /// How many `chunk_lens` entries the stream has delivered so far.
341    fn entries_delivered(&self) -> u64 {
342        self.prologue_frontier
343    }
344}
345
346/// Reject a later frame's `declared` value for an identity `field` unless it agrees with what the
347/// first frame `committed` — including the case where the first frame committed nothing at all.
348fn check_unrevised<T: PartialEq + std::fmt::Debug>(
349    field: &str,
350    committed: Option<T>,
351    declared: Option<T>,
352) -> Result<(), String> {
353    match (committed, declared) {
354        (_, None) => Ok(()), // asserts nothing
355        (Some(committed), Some(declared)) if committed == declared => Ok(()),
356        (Some(committed), Some(declared)) => Err(format!(
357            "{field} changed mid-stream: first frame declared {committed:?}, a later frame {declared:?}"
358        )),
359        (None, Some(declared)) => Err(format!(
360            "{field} {declared:?} appears only on a later frame; the first frame left it unstated"
361        )),
362    }
363}
364
365/// Reassemble a `dig.fetchRange` frame stream into `(bytes, meta)`: read [`RangeFrame`]s in ascending
366/// offset order, placing each frame's bytes at its (range-relative) offset and capturing the
367/// stream's declared verification metadata. Stops on the frame marked `complete` or clean
368/// end-of-stream.
369///
370/// Bounded by `max_len` (the expected range length) so a misbehaving peer cannot stream unbounded
371/// bytes into memory: a frame that overshoots the window is CLIPPED to it (servers answer at chunk
372/// granularity, so a 1-byte metadata probe is legitimately served a whole chunk), assembly stops as
373/// soon as the window is full, and only a frame starting at or beyond `max_len` is an error.
374///
375/// The returned [`RangeMeta`] is the FIRST frame's declaration, and every later frame is checked
376/// against it — a holder that revises `root` / `total_length` / `chunk_count`
377/// mid-stream, or restates the once-per-stream prologue, fails the whole fetch instead of being
378/// believed on frame 1. Nothing beneath this reader performs that check.
379///
380/// # Paged prologue
381///
382/// A resource whose `chunk_lens` array is too large to state on one frame is served as a **paged
383/// prologue**: the first frame declares the whole array's `chunk_count` but carries only its first
384/// page, and successive frames each carry another page stamped with the entry `chunk_lens_offset` it
385/// begins at (dig-nat `SPEC.md` §5.1.1). This reader reassembles those pages into one array via
386/// [`ChunkLensAssembler`], and sets [`RangeMeta::chunk_lens`] to the FULL array only once every page
387/// has landed. The reassembly is **fail-closed**: `chunk_lens` is a decrypt input (per-chunk
388/// AES-GCM-SIV needs the whole array, whose entries must sum to `total_length`), so a stream whose
389/// prologue ends short — or whose page is misaligned, duplicated, or overshoots — yields NO layout at
390/// all rather than a partial one, and the holder is skipped (a RECOVERABLE error) rather than believed.
391///
392/// This is the pure, network-free core of [`NatRangeTransport::fetch_range`] and is
393/// unit-tested by feeding encoded frames through an in-memory reader.
394pub async fn assemble_range_stream<R: AsyncRead + Unpin>(
395    reader: &mut R,
396    max_len: u64,
397) -> Result<(Vec<u8>, RangeMeta), DownloadError> {
398    let mut buf: Vec<u8> = Vec::new();
399    let mut meta = RangeMeta::default();
400    let mut identity: Option<StreamIdentity> = None;
401    // Reassembles the `chunk_lens` array of a paged prologue across frames. Built lazily on the first
402    // frame that declares more entries than it carries, and `None` for the ordinary single-frame layout.
403    let mut assembler: Option<ChunkLensAssembler> = None;
404    // One past the furthest byte any frame has contributed — the progress the termination guard at the
405    // bottom of the loop requires each non-final frame to advance.
406    let mut byte_frontier: u64 = 0;
407    loop {
408        let frame = RangeFrame::decode(reader)
409            .await
410            .map_err(|e| DownloadError::Transport {
411                provider: String::new(),
412                reason: format!("range frame decode: {e}"),
413            })?;
414        let Some(frame) = frame else {
415            break; // clean end-of-stream
416        };
417        // Whether THIS frame advanced the paged prologue. A prologue page may carry zero data bytes, so
418        // the termination guard must count an accepted page as progress or a legitimately data-less page
419        // would look like a stalled stream.
420        let mut accepted_page = false;
421        match identity.as_mut() {
422            None => {
423                meta = RangeMeta::from_frame(&frame);
424                identity = Some(StreamIdentity::from_first_frame(&frame));
425                // A layout too large for one frame is paged: the first frame declares the whole array's
426                // `chunk_count` while carrying only its first page. Begin reassembly so the pages that
427                // arrive on later frames land in one array. `ChunkLensAssembler::new` refuses a
428                // `chunk_count` above `MAX_RESOURCE_CHUNK_COUNT` before it allocates.
429                if let Some(chunk_count) = frame.chunk_count {
430                    let delivered = frame.chunk_lens.as_ref().map_or(0, Vec::len) as u64;
431                    if chunk_count > delivered {
432                        let mut asm = ChunkLensAssembler::new(chunk_count as usize)
433                            .map_err(chunk_lens_error)?;
434                        if let Some(page) = &frame.chunk_lens {
435                            asm.accept_page(frame.chunk_lens_offset.unwrap_or(0), page)
436                                .map_err(chunk_lens_error)?;
437                            accepted_page = true;
438                        }
439                        assembler = Some(asm);
440                    }
441                }
442            }
443            Some(identity) => {
444                let verdict = identity.check_later_frame(&frame).map_err(|reason| {
445                    DownloadError::Transport {
446                        provider: String::new(),
447                        reason,
448                    }
449                })?;
450                if let LaterFrame::NewProloguePage { offset, .. } = verdict {
451                    // The identity guard confirmed this page ADVANCES the prologue; the assembler applies
452                    // the finer placement rules (alignment, exact page length, no duplicate slot) that
453                    // dig-nat owns. A hostile page is a RECOVERABLE rejection that skips THIS holder.
454                    let page = frame.chunk_lens.as_ref().expect(
455                        "a NewProloguePage verdict is only produced for a frame with chunk_lens",
456                    );
457                    match assembler.as_mut() {
458                        Some(asm) => {
459                            asm.accept_page(offset, page).map_err(chunk_lens_error)?;
460                            accepted_page = true;
461                        }
462                        // The first frame declared no multi-page layout, yet a later frame pages one: the
463                        // frames disagree about the resource's shape. Refuse fail-closed rather than adopt
464                        // a page for an array this reader never sized.
465                        None => {
466                            return Err(DownloadError::PagedPrologueUnsupported {
467                                provider: String::new(),
468                                chunk_count: identity.chunk_count.unwrap_or_default(),
469                                delivered: identity.entries_delivered(),
470                            });
471                        }
472                    }
473                }
474            }
475        }
476        // The paged prologue is stream metadata that must fully drain even when the window is already
477        // full or the request wanted metadata only — so every early loop exit waits on it.
478        let prologue_drained = assembler
479            .as_ref()
480            .map_or(true, ChunkLensAssembler::is_complete);
481        // A zero-length request asks for metadata ONLY (there is no window to place bytes in). The paged
482        // prologue IS that metadata, so drain it before stopping; a page that carries no data bytes is
483        // handled here rather than falling through to byte placement.
484        if max_len == 0 {
485            if prologue_drained {
486                break;
487            }
488            // A non-final frame that advanced nothing (no accepted page) cannot progress the prologue —
489            // stop it looping forever. Mirrors the byte-window termination guard below.
490            if !accepted_page {
491                return Err(DownloadError::Transport {
492                    provider: String::new(),
493                    reason: "a metadata stream ended its prologue short without progressing".into(),
494                });
495            }
496            continue;
497        }
498        // A frame that starts at or past the end of the requested window carries bytes that can never
499        // belong to this range — a real protocol violation, not a granularity mismatch.
500        if frame.offset >= max_len {
501            return Err(DownloadError::Transport {
502                provider: String::new(),
503                reason: format!(
504                    "range frame at offset {} starts beyond expected length {max_len}",
505                    frame.offset
506                ),
507            });
508        }
509        // CLIP an over-long frame instead of rejecting it: a server legitimately answers at CHUNK
510        // granularity, so a 1-byte metadata probe is served a whole chunk (#836). Taking only the
511        // requested window keeps memory bounded by `max_len` AND keeps such a holder usable.
512        let start = frame.offset as usize;
513        let take = frame.bytes.len().min((max_len - frame.offset) as usize);
514        let end = start + take;
515        if buf.len() < end {
516            // FALLIBLE growth. `max_len` is derived from a peer-DECLARED chunk length, so even bounded
517            // by the commitment's ceiling it can exceed what this host can hold — and an infallible
518            // `resize` aborts the process through the uncatchable `handle_alloc_error` (#1608). A frame
519            // sparse in the window (`offset` near `max_len`, a few bytes of payload) makes that
520            // reachable from ONE small frame, so exhaustion must be an ordinary recoverable error the
521            // scheduler routes around, not a death.
522            buf.try_reserve(end - buf.len())
523                .map_err(|e| DownloadError::Transport {
524                    provider: String::new(),
525                    reason: format!("cannot allocate a {end}-byte range assembly buffer: {e}"),
526                })?;
527            buf.resize(end, 0); // within the reservation above — no further allocation
528        }
529        buf[start..end].copy_from_slice(&frame.bytes[..take]);
530        // The window filling is NOT sufficient to stop while a paged prologue is still draining: the
531        // layout is stream metadata the reader must hold in full, and the probe that requested one byte
532        // is exactly the read that must keep going until the last page lands.
533        if frame.complete || (prologue_drained && buf.len() as u64 >= max_len) {
534            break;
535        }
536
537        // TERMINATION. Every loop exit above depends on the window filling or the holder saying it is
538        // done, so a frame that does neither and advances nothing lets the holder stream forever: a
539        // `{ offset: 0, bytes: [], complete: false }` frame with all identity omitted satisfies every
540        // check — omission is conforming by design — while `take` is 0 and `buf` never grows. A holder
541        // re-sending the SAME low offset does the same thing with non-empty bytes. Either one hangs the
542        // job on a few dozen bytes per frame, holding the staging claim that this crate's own comment
543        // calls the denial primitive the claim exists to prevent.
544        //
545        // The guard is over the CLASS — a frame that does not extend the assembled prefix — rather than
546        // over the empty-payload instance of it, so the re-send variant is caught by the same rule.
547        // Bytes arrive in ascending offset, so a conforming continuation always extends past the frontier.
548        // A frame that placed no new bytes but ADVANCED the paged prologue is progress too: prologue-only
549        // pages legitimately carry zero data, so they are exempt from the byte-frontier rule.
550        if end as u64 <= byte_frontier && !accepted_page {
551            return Err(DownloadError::Transport {
552                provider: String::new(),
553                reason: format!(
554                    "a frame at offset {} extends the range to {end}, past nothing (already at \
555                     {byte_frontier}), and does not complete it; the stream cannot progress",
556                    frame.offset
557                ),
558            });
559        }
560        byte_frontier = byte_frontier.max(end as u64);
561    }
562
563    // The layout is adopted ONLY as a COMPLETE array. An assembler that never saw its last page yields
564    // nothing (fail-closed): a partial `chunk_lens` sums short of `total_length` and would decrypt every
565    // chunk to garbage, so the holder is skipped (a recoverable refusal) rather than believed.
566    if let Some(asm) = assembler {
567        match asm.into_chunk_lens() {
568            Ok(full) => meta.chunk_lens = Some(full),
569            Err(_) => {
570                return Err(DownloadError::PagedPrologueUnsupported {
571                    provider: String::new(),
572                    chunk_count: meta.chunk_count.unwrap_or_default(),
573                    delivered: identity
574                        .as_ref()
575                        .map_or(0, StreamIdentity::entries_delivered),
576                });
577            }
578        }
579    }
580    Ok((buf, meta))
581}
582
583/// Map a [`ChunkLensError`] from the paged-prologue assembler to a RECOVERABLE [`DownloadError`], so a
584/// hostile or short prologue skips the offending holder rather than failing the whole download. The
585/// `provider` is left empty for the transport layer to stamp (see [`DownloadError::attributed_to`]).
586fn chunk_lens_error(e: ChunkLensError) -> DownloadError {
587    DownloadError::Transport {
588        provider: String::new(),
589        reason: format!("chunk_lens prologue rejected: {e}"),
590    }
591}
592
593/// The maximum number of trailer bytes drained from a range stream after the complete/last frame,
594/// before the mux stream is closed. A well-behaved peer sends nothing (or a tiny framing tail) after
595/// the last frame, so this bound is generous; it exists solely to close off a malicious peer that
596/// holds the stream open and streams arbitrary filler (see [`drain_trailer_bounded`]).
597const MAX_TRAILER_DRAIN: u64 = 64 * 1024;
598
599/// Drain and DISCARD up to `cap` trailer bytes from `reader` (the leftover after a range's last
600/// frame), so the mux stream closes cleanly WITHOUT buffering an unbounded trailer into memory.
601///
602/// A previous implementation did `stream.read_to_end(&mut Vec::new())`, which has no length bound: a
603/// peer that serves a valid complete range then keeps the stream open and streams filler forces the
604/// client to buffer all of it until OOM (MEDIUM #179). This reads into a small fixed scratch buffer
605/// and stops once `cap` bytes have been seen (or at EOF / error), never growing an unbounded `Vec`.
606/// Returns the number of trailer bytes drained (capped at `cap`).
607pub async fn drain_trailer_bounded<R: AsyncRead + Unpin>(reader: &mut R, cap: u64) -> u64 {
608    let mut scratch = [0u8; 4096];
609    let mut drained: u64 = 0;
610    while drained < cap {
611        let want = ((cap - drained) as usize).min(scratch.len());
612        match reader.read(&mut scratch[..want]).await {
613            Ok(0) => break, // EOF — stream ended cleanly
614            Ok(n) => drained += n as u64,
615            Err(_) => break, // treat a read error as end-of-drain (stream will be dropped)
616        }
617    }
618    drained
619}
620
621/// A pooled per-peer [`DigPeer`] client, shared behind a mutex so many range fetches to the SAME peer
622/// reuse ONE mTLS session (opening a cheap fresh yamux stream each) instead of re-handshaking per
623/// request. The `&mut self` [`DigPeer`] RPC receivers are serialized by the mutex.
624type PooledConn = Arc<tokio::sync::Mutex<DigPeer>>;
625
626/// The real [`RangeTransport`] over [`dig-peer`](dig_peer): connects to a provider as a
627/// [`DigPeer`] — the one DIG Network peer client — over the FULL NAT-traversal ladder (direct →
628/// UPnP/NAT-PMP/PCP → hole-punch → relay, IPv6-first), **reuses the client via a per-peer pool**, and
629/// runs `dig.getAvailability` / `dig.fetchRange` over the mux'd mTLS session.
630///
631/// # Why DigPeer (#1283)
632///
633/// dig-download talks to peers through the shared [`DigPeer`] client rather than driving
634/// [`dig_nat`] directly, so the whole ecosystem reaches peers ONE way. Every connection is
635/// established through a [`PeerTarget`](dig_nat::PeerTarget) carrying the provider's `peer_id`, which
636/// [`DigPeer::connect`] PINS the mTLS handshake to: a caller that means to reach provider X cannot be
637/// answered by a different CA-valid peer (the impersonation footgun). The availability + range calls
638/// are public-read (merkle-verified content), so they ride the mTLS channel unsealed (§5.4 exemption);
639/// this transport therefore configures no [`SealingIdentity`](dig_peer::SealingIdentity).
640///
641/// # The NAT ladder on the fetch leg (#1305)
642///
643/// Discovery (dig-dht lookups) already rides the full ladder via a live [`dig_nat::NatRuntime`]; the
644/// content byte-download must too, or a fully-NAT'd peer would DISCOVER a provider it can never FETCH
645/// from (a non-Direct-reachable holder reachable only via hole-punch/relay). This transport connects
646/// via [`DigPeer::connect_with_runtime`], composing exactly the tiers whose live handles the injected
647/// [`NatRuntime`](dig_nat::NatRuntime) carries: an empty runtime ([`new`](Self::new)) is Direct-only; a node's real runtime
648/// ([`new_with_runtime`](Self::new_with_runtime)) unlocks hole-punch + relay. dig-node builds the SAME
649/// shared `NatRuntime` it uses for the DHT-side dial and hands it here.
650///
651/// A download fans many ranges across a few providers; without pooling every range fetch paid a full
652/// NAT-traversal + mTLS handshake (LOW #179). The pool keeps one [`DigPeer`] per `peer_id` and opens a
653/// new mux stream per request over the reused mTLS session; a client that errors is evicted so the
654/// next request re-dials. For `fetch_range` the per-peer lock is held only while opening the (owned)
655/// range stream, then released before the bytes are read, so concurrent ranges to the same peer still
656/// stream in parallel.
657///
658/// The network dial is the only part not exercised by the in-memory tests (it needs real sockets +
659/// certs); the reassembly + provider→target mapping are pure and unit-tested. dig-node constructs one
660/// of these with its [`NodeCert`](dig_nat::NodeCert) (its CA-signed mTLS identity, minted by dig-tls's
661/// `NodeCert::load_or_generate`) + [`NatConfig`](dig_nat::NatConfig) + its live [`NatRuntime`](dig_nat::NatRuntime) and
662/// hands it to the [`Downloader`](crate::Downloader) — see the implementers' note in the crate docs.
663pub struct NatRangeTransport {
664    node: std::sync::Arc<dig_nat::NodeCert>,
665    config: dig_nat::NatConfig,
666    network_id: String,
667    /// The live traversal handles (relay reservation / hole-punch coordinator / mapped port) the
668    /// full-ladder dial composes each connect from. An empty runtime yields a Direct-only dial; a
669    /// node's real runtime unlocks the hole-punch + relay tiers (#1305). Shared (`Arc`) so it can be
670    /// the SAME runtime the node's DHT-side dial uses.
671    runtime: Arc<dig_nat::NatRuntime>,
672    /// Per-peer connection pool keyed by provider `peer_id` (the 64-hex string).
673    pool: tokio::sync::Mutex<HashMap<String, PooledConn>>,
674}
675
676impl NatRangeTransport {
677    /// Build a transport that dials providers on `network_id`, presenting `node` (this peer's
678    /// CA-signed mTLS identity) and using `config` to select the traversal methods + timeouts.
679    ///
680    /// This uses an EMPTY [`NatRuntime`](dig_nat::NatRuntime), so the dial composes the **Direct** tier only — suitable for
681    /// a fully-reachable node or a test. A NAT'd node that must reach non-Direct providers over
682    /// hole-punch/relay MUST use [`new_with_runtime`](Self::new_with_runtime) with its live runtime.
683    pub fn new(
684        node: std::sync::Arc<dig_nat::NodeCert>,
685        config: dig_nat::NatConfig,
686        network_id: impl Into<String>,
687    ) -> Self {
688        Self::new_with_runtime(
689            node,
690            config,
691            network_id,
692            Arc::new(dig_nat::NatRuntime::default()),
693        )
694    }
695
696    /// Build a transport that dials over the **FULL** NAT-traversal ladder using the live handles in
697    /// `runtime` (#1305). Mirrors the node's DHT-side [`dig_nat::connect_with_runtime`] path so the
698    /// content-fetch leg reaches providers via hole-punch + relay, not just direct. dig-node passes the
699    /// SAME shared [`NatRuntime`](dig_nat::NatRuntime) it built for its DHT transport.
700    pub fn new_with_runtime(
701        node: std::sync::Arc<dig_nat::NodeCert>,
702        config: dig_nat::NatConfig,
703        network_id: impl Into<String>,
704        runtime: Arc<dig_nat::NatRuntime>,
705    ) -> Self {
706        NatRangeTransport {
707            node,
708            config,
709            network_id: network_id.into(),
710            runtime,
711            pool: tokio::sync::Mutex::new(HashMap::new()),
712        }
713    }
714
715    /// Every way to reach `provider`, in dial order: each resolvable candidate address as a
716    /// [`dig_nat::PeerTarget`] — **IPv6 first, then IPv4** (§5.2) — followed by a relay-only target
717    /// reached purely by identity.
718    ///
719    /// Each entry carries the candidate's rendered address so a failed dial can name WHICH address it
720    /// tried. Unresolvable candidates are logged and skipped rather than aborting the provider: one
721    /// malformed v6 candidate must never hide a working v4 one (#836).
722    pub fn provider_dial_targets(
723        &self,
724        provider: &ProviderRecord,
725    ) -> Result<Vec<(String, dig_nat::PeerTarget)>, DownloadError> {
726        let peer_id = provider.provider_peer_id().ok_or_else(|| {
727            DownloadError::transport(&provider.provider_peer_id, "malformed provider peer_id")
728        })?;
729        let mut targets = Vec::new();
730        for candidate in crate::addr::dial_candidates(provider) {
731            match crate::addr::candidate_socket(candidate) {
732                Ok(socket) => targets.push((
733                    socket.to_string(),
734                    dig_nat::PeerTarget::with_addr(peer_id, socket, self.network_id.clone()),
735                )),
736                Err(e) => tracing::warn!(
737                    peer = %crate::error::hex64_or_sentinel(&provider.provider_peer_id, "peer-id"),
738                    candidate = %crate::addr::display(candidate),
739                    error = %e,
740                    "skipping unusable provider candidate address"
741                ),
742            }
743        }
744        targets.push((
745            "relay-only".to_string(),
746            dig_nat::PeerTarget::relay_only(peer_id, self.network_id.clone()),
747        ));
748        Ok(targets)
749    }
750
751    /// Build a [`dig_nat::PeerTarget`] from a provider record: its `peer_id` + the most-direct
752    /// dialable candidate address (falling back to relay-only reachability by identity).
753    ///
754    /// This is the FIRST of [`provider_dial_targets`](Self::provider_dial_targets); dialing uses the
755    /// full ordered list so a failing candidate falls through to the next.
756    pub fn provider_to_target(
757        &self,
758        provider: &ProviderRecord,
759    ) -> Result<dig_nat::PeerTarget, DownloadError> {
760        let (_, target) = self
761            .provider_dial_targets(provider)?
762            .into_iter()
763            .next()
764            .expect("dial targets always include the relay-only fallback");
765        Ok(target)
766    }
767
768    /// Connect to a provider as a [`DigPeer`] (fresh `peer_id`-pinned mTLS connection over the FULL
769    /// NAT-traversal ladder). Composes exactly the tiers whose live handles this transport's
770    /// [`NatRuntime`](dig_nat::NatRuntime) carries — Direct always, plus hole-punch/relay when the node
771    /// injected them (#1305). The [`PeerTarget`](dig_nat::PeerTarget) carries the provider's `peer_id`,
772    /// which [`DigPeer::connect_with_runtime`] pins so a different CA-valid peer cannot impersonate the
773    /// intended provider (#1283).
774    ///
775    /// Every candidate address is tried in order (IPv6 first, then IPv4, then relay-only, §5.2) and
776    /// each failure is logged with the address that produced it, so an unreachable v6 candidate falls
777    /// through to a working v4 one instead of failing the whole holder (#836).
778    async fn connect(&self, provider: &ProviderRecord) -> Result<DigPeer, DownloadError> {
779        let mut last_error = None;
780        for (addr, target) in self.provider_dial_targets(provider)? {
781            match DigPeer::connect_with_runtime(&target, &self.node, &self.config, &self.runtime)
782                .await
783            {
784                Ok(peer) => return Ok(peer),
785                Err(e) => {
786                    tracing::debug!(
787                        peer = %crate::error::hex64_or_sentinel(&provider.provider_peer_id, "peer-id"),
788                        candidate = %addr,
789                        error = %e,
790                        "provider dial candidate failed; trying the next address"
791                    );
792                    last_error = Some(format!("dial {addr}: {e}"));
793                }
794            }
795        }
796        Err(DownloadError::transport(
797            &provider.provider_peer_id,
798            last_error.unwrap_or_else(|| "no dialable candidate address".to_string()),
799        ))
800    }
801
802    /// Get the pooled connection for `provider`, dialing (and caching) a fresh one if none is pooled.
803    /// Reuses the existing mTLS session across requests; a broken connection is evicted via
804    /// [`evict`](Self::evict) so the next call re-dials.
805    async fn pooled_conn(&self, provider: &ProviderRecord) -> Result<PooledConn, DownloadError> {
806        let key = provider.provider_peer_id.clone();
807        if let Some(conn) = self.pool.lock().await.get(&key).cloned() {
808            return Ok(conn);
809        }
810        // Dial OUTSIDE the pool lock (a handshake can be slow); race-insert, reusing a connection a
811        // concurrent caller may have inserted first so we never hold two sessions to one peer.
812        let fresh = Arc::new(tokio::sync::Mutex::new(self.connect(provider).await?));
813        let mut pool = self.pool.lock().await;
814        Ok(pool.entry(key).or_insert(fresh).clone())
815    }
816
817    /// Drop `provider`'s pooled connection so the next request re-dials (called after a stream error).
818    async fn evict(&self, provider: &ProviderRecord) {
819        self.pool.lock().await.remove(&provider.provider_peer_id);
820    }
821}
822
823#[async_trait]
824impl RangeTransport for NatRangeTransport {
825    async fn query_availability(
826        &self,
827        provider: &ProviderRecord,
828        items: Vec<AvailabilityItem>,
829    ) -> Result<AvailabilityResponse, DownloadError> {
830        let conn = self.pooled_conn(provider).await?;
831        let res = {
832            let mut guard = conn.lock().await;
833            guard.get_availability(items).await
834        };
835        match res {
836            Ok(resp) => Ok(resp),
837            Err(e) => {
838                // The pooled session is suspect — drop it so the next request re-dials.
839                self.evict(provider).await;
840                Err(DownloadError::transport(&provider.provider_peer_id, e))
841            }
842        }
843    }
844
845    async fn fetch_range(
846        &self,
847        provider: &ProviderRecord,
848        req: &RangeRequest,
849    ) -> Result<FetchedRange, DownloadError> {
850        let conn = self.pooled_conn(provider).await?;
851        // Hold the per-peer lock ONLY to open the (owned) range stream over the reused mTLS session;
852        // release it before reading frames so concurrent ranges to the same peer stream in parallel.
853        let stream = {
854            let mut guard = conn.lock().await;
855            guard.fetch_range(req).await
856        };
857        let mut stream = match stream {
858            Ok(s) => s,
859            Err(e) => {
860                self.evict(provider).await;
861                return Err(DownloadError::transport(&provider.provider_peer_id, e));
862            }
863        };
864        let (bytes, meta) = assemble_range_stream(&mut stream, req.length)
865            .await
866            // ATTRIBUTE the reassembly error to this provider rather than WRAPPING it in a fresh
867            // `Transport`. Wrapping flattened every typed variant the reassembler raises deliberately —
868            // including `PagedPrologueUnsupported` — so a caller could never observe one, and the
869            // `is_recoverable` arm for it was unreachable.
870            .map_err(|e| e.attributed_to(&provider.provider_peer_id))?;
871        // Drain any trailer so the mux stream closes cleanly — BOUNDED, so a peer that keeps the
872        // stream open and streams filler after the last frame cannot exhaust our memory (MEDIUM
873        // #179). Never read_to_end into an unbounded Vec.
874        let _ = drain_trailer_bounded(&mut stream, MAX_TRAILER_DRAIN).await;
875        Ok(FetchedRange {
876            request_offset: req.offset,
877            bytes,
878            meta,
879        })
880    }
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886    use dig_dht::{CandidateAddr, ProviderRecord};
887    use dig_nat::PeerId;
888
889    /// The generation root every conforming fixture frame is stamped with (64-hex, as the wire
890    /// requires). Identity travels on EVERY frame since dig-nat 0.13, so it is named once here rather
891    /// than re-spelled per fixture.
892    fn test_root() -> String {
893        "aa".repeat(32)
894    }
895
896    /// Encode a fixture frame, surfacing the dig-nat framing-ceiling refusal as a test failure.
897    ///
898    /// `RangeFrame::encode` became FALLIBLE in 0.13 (#1640): the encode side now refuses a frame a
899    /// conforming decoder would have to reject. A fixture that trips it is a fixture bug, so the
900    /// panic names the ceiling instead of silently disappearing into a `Result` nobody inspects.
901    fn encode(frame: &RangeFrame) -> Vec<u8> {
902        frame
903            .encode()
904            .expect("fixture frame must be within the dig-nat framing ceilings")
905    }
906
907    fn provider(peer: u8, host: &str, port: u16) -> ProviderRecord {
908        ProviderRecord::new(
909            &dig_dht::Key::from_bytes([0xAB; 32]),
910            &PeerId::from_bytes([peer; 32]),
911            vec![CandidateAddr::direct(host, port)],
912            u64::MAX,
913        )
914    }
915
916    #[test]
917    fn provider_to_target_uses_direct_address() {
918        let t = NatRangeTransport::new(
919            fake_node_cert(),
920            dig_nat::NatConfig::default(),
921            "DIG_MAINNET",
922        );
923        let p = provider(1, "203.0.113.7", 9444);
924        let target = t.provider_to_target(&p).unwrap();
925        assert_eq!(
926            target.direct_addr().unwrap().to_string(),
927            "203.0.113.7:9444"
928        );
929        assert_eq!(target.network_id, "DIG_MAINNET");
930    }
931
932    #[test]
933    fn new_with_runtime_builds_a_full_ladder_transport() {
934        // #1305: the fetch leg must be constructible with a live NatRuntime (the same handle carrier
935        // the node's DHT dial uses) so hole-punch/relay tiers compose. The dial itself needs real
936        // sockets, so here we assert the runtime-injecting constructor yields a working transport
937        // whose pure provider→target mapping is identical to the Direct-only `new`.
938        let runtime = std::sync::Arc::new(dig_nat::NatRuntime::default());
939        let t = NatRangeTransport::new_with_runtime(
940            fake_node_cert(),
941            dig_nat::NatConfig::default(),
942            "DIG_MAINNET",
943            runtime,
944        );
945        let p = provider(1, "203.0.113.7", 9444);
946        let target = t.provider_to_target(&p).unwrap();
947        assert_eq!(
948            target.direct_addr().unwrap().to_string(),
949            "203.0.113.7:9444"
950        );
951        assert_eq!(target.network_id, "DIG_MAINNET");
952    }
953
954    #[test]
955    fn provider_to_target_accepts_v4_mapped_v6_host() {
956        // #836 regression: the e2e read leg died with "addr: invalid socket address syntax" because
957        // the host+port were STRING-formatted before parsing, and an IPv6 literal needs brackets.
958        let t = NatRangeTransport::new(
959            fake_node_cert(),
960            dig_nat::NatConfig::default(),
961            "DIG_MAINNET",
962        );
963        let p = provider(1, "::ffff:172.31.79.22", 9444);
964        let target = t
965            .provider_to_target(&p)
966            .expect("v4-mapped v6 host must resolve");
967        assert_eq!(
968            target.direct_addr().unwrap(),
969            std::net::SocketAddr::new("::ffff:172.31.79.22".parse().unwrap(), 9444)
970        );
971    }
972
973    #[test]
974    fn provider_to_target_accepts_plain_v6_host() {
975        let t = NatRangeTransport::new(
976            fake_node_cert(),
977            dig_nat::NatConfig::default(),
978            "DIG_MAINNET",
979        );
980        let p = provider(1, "2001:db8::1", 9444);
981        let target = t.provider_to_target(&p).expect("v6 host must resolve");
982        assert_eq!(
983            target.direct_addr().unwrap(),
984            std::net::SocketAddr::new("2001:db8::1".parse().unwrap(), 9444)
985        );
986    }
987
988    #[test]
989    fn unusable_first_candidate_falls_through_to_the_ipv4_one() {
990        // #836 / §5.2: IPv6-first with IPv4 FALLBACK. A provider whose leading candidate is unusable
991        // must still be dialed on its valid v4 candidate — previously the record's FIRST address was
992        // the only one considered, so one bad candidate condemned the holder.
993        let t = NatRangeTransport::new(
994            fake_node_cert(),
995            dig_nat::NatConfig::default(),
996            "DIG_MAINNET",
997        );
998        let p = ProviderRecord::new(
999            &dig_dht::Key::from_bytes([0xAB; 32]),
1000            &PeerId::from_bytes([3; 32]),
1001            vec![
1002                CandidateAddr::direct("not-an-ip-literal", 9444),
1003                CandidateAddr::direct("10.0.0.1", 9444),
1004            ],
1005            u64::MAX,
1006        );
1007        let target = t
1008            .provider_to_target(&p)
1009            .expect("the v4 candidate is dialable");
1010        assert_eq!(
1011            target.direct_addr().unwrap(),
1012            "10.0.0.1:9444".parse::<std::net::SocketAddr>().unwrap()
1013        );
1014    }
1015
1016    #[test]
1017    fn dial_targets_order_v6_then_v4_then_relay() {
1018        let t = NatRangeTransport::new(
1019            fake_node_cert(),
1020            dig_nat::NatConfig::default(),
1021            "DIG_MAINNET",
1022        );
1023        let p = ProviderRecord::new(
1024            &dig_dht::Key::from_bytes([0xAB; 32]),
1025            &PeerId::from_bytes([4; 32]),
1026            vec![
1027                CandidateAddr::direct("172.31.79.22", 9444),
1028                CandidateAddr::direct("::ffff:172.31.79.22", 9444),
1029            ],
1030            u64::MAX,
1031        );
1032        let addrs: Vec<String> = t
1033            .provider_dial_targets(&p)
1034            .unwrap()
1035            .into_iter()
1036            .map(|(addr, _)| addr)
1037            .collect();
1038        assert_eq!(
1039            addrs,
1040            vec![
1041                "[::ffff:172.31.79.22]:9444",
1042                "172.31.79.22:9444",
1043                "relay-only"
1044            ]
1045        );
1046    }
1047
1048    #[tokio::test]
1049    async fn connect_tries_every_candidate_before_failing() {
1050        // Both candidates are closed loopback ports: the dial must walk the whole list (v6 then v4
1051        // then relay-only) and report the LAST attempt, proving no early give-up.
1052        let t = NatRangeTransport::new(
1053            fake_node_cert(),
1054            dig_nat::NatConfig::default(),
1055            "DIG_MAINNET",
1056        );
1057        let p = ProviderRecord::new(
1058            &dig_dht::Key::from_bytes([0xAB; 32]),
1059            &PeerId::from_bytes([5; 32]),
1060            vec![
1061                CandidateAddr::direct("::1", 1),
1062                CandidateAddr::direct("127.0.0.1", 1),
1063            ],
1064            u64::MAX,
1065        );
1066        let err = t.connect(&p).await.expect_err("no listener is up");
1067        let reason = err.to_string();
1068        assert!(
1069            reason.contains("relay-only"),
1070            "the last attempt must be named: {reason}"
1071        );
1072    }
1073
1074    #[test]
1075    fn provider_to_target_relay_only_without_address() {
1076        let t = NatRangeTransport::new(
1077            fake_node_cert(),
1078            dig_nat::NatConfig::default(),
1079            "DIG_MAINNET",
1080        );
1081        let p = ProviderRecord::new(
1082            &dig_dht::Key::from_bytes([0xAB; 32]),
1083            &PeerId::from_bytes([2; 32]),
1084            vec![CandidateAddr::relay_marker()],
1085            u64::MAX,
1086        );
1087        let target = t.provider_to_target(&p).unwrap();
1088        assert!(target.direct_addr().is_none());
1089    }
1090
1091    #[tokio::test]
1092    async fn assemble_reassembles_ordered_frames() {
1093        // Two frames tiling a 6-byte range; first frame carries the metadata.
1094        let f0 = RangeFrame::data(0, b"ABC".to_vec())
1095            .with_identity(test_root(), 6, 2)
1096            .with_chunk_lens_page(0, vec![3, 3])
1097            .with_chunk_index(0)
1098            .with_inclusion_proof("proof");
1099        // The continuation frame starts on a chunk boundary, so it RESTATES the fixed-size identity
1100        // set + its own `chunk_index` and omits the once-per-stream prologue.
1101        let f1 = RangeFrame::data(3, b"DEF".to_vec())
1102            .with_complete(true)
1103            .with_identity(test_root(), 6, 2)
1104            .with_chunk_index(1);
1105        let mut wire = encode(&f0);
1106        wire.extend_from_slice(&encode(&f1));
1107        let mut cur = std::io::Cursor::new(wire);
1108        let (bytes, meta) = assemble_range_stream(&mut cur, 6).await.unwrap();
1109        assert_eq!(bytes, b"ABCDEF");
1110        assert_eq!(meta.total_length, Some(6));
1111        assert_eq!(meta.chunk_lens, Some(vec![3, 3]));
1112        assert_eq!(meta.chunk_index, Some(0));
1113        assert_eq!(meta.root, Some("aa".repeat(32)));
1114        assert_eq!(meta.inclusion_proof, Some("proof".into()));
1115    }
1116
1117    /// A frame that STARTS beyond the requested window is a real protocol violation (its bytes can
1118    /// never belong to the range) and stays an error.
1119    #[tokio::test]
1120    async fn assemble_rejects_frame_starting_beyond_window() {
1121        // Deliberately IDENTITY-FREE: the frame is refused on its offset alone, before any metadata
1122        // is consulted, so attaching identity here would only obscure which field the rejection reads.
1123        let f = RangeFrame::data(8, vec![0u8; 4]).with_complete(true);
1124        let mut cur = std::io::Cursor::new(encode(&f));
1125        let err = assemble_range_stream(&mut cur, 5).await;
1126        assert!(matches!(err, Err(DownloadError::Transport { .. })));
1127    }
1128
1129    /// The #836 metadata probe: `establish_commitment` asks for `length = 1` purely to obtain the
1130    /// first-frame metadata, and a chunk-granular server answers with a WHOLE chunk. The assembler
1131    /// must clip to the requested window and keep the metadata — erroring here discarded every
1132    /// holder and turned a healthy read into a 404.
1133    #[tokio::test]
1134    async fn assemble_clips_chunk_granular_frame_to_one_byte_probe() {
1135        let chunk = vec![0x5Au8; 4096];
1136        let f = RangeFrame::data(0, chunk)
1137            .with_complete(true)
1138            .with_identity(test_root(), 1_048_576, 256)
1139            .with_chunk_lens_page(0, vec![4096; 256])
1140            .with_chunk_index(0)
1141            .with_inclusion_proof("proof");
1142        let mut cur = std::io::Cursor::new(encode(&f));
1143        let (bytes, meta) = assemble_range_stream(&mut cur, 1).await.unwrap();
1144        assert_eq!(
1145            bytes,
1146            vec![0x5Au8],
1147            "clipped to exactly the requested window"
1148        );
1149        assert_eq!(meta.total_length, Some(1_048_576));
1150        assert_eq!(meta.chunk_lens, Some(vec![4096; 256]));
1151        assert_eq!(meta.chunk_index, Some(0));
1152        assert_eq!(meta.root, Some("aa".repeat(32)));
1153        assert_eq!(meta.inclusion_proof, Some("proof".into()));
1154    }
1155
1156    /// Only the OVERSHOOTING tail is clipped: every earlier frame's bytes survive, in order.
1157    #[tokio::test]
1158    async fn assemble_clips_only_the_overshooting_last_frame() {
1159        let f0 = RangeFrame::data(0, b"ABC".to_vec())
1160            .with_identity(test_root(), 9, 2)
1161            .with_chunk_lens_page(0, vec![3, 6])
1162            .with_chunk_index(0);
1163        // Chunk-aligned continuation: identity restated, prologue not repeated.
1164        let f1 = RangeFrame::data(3, b"DEFGHI".to_vec())
1165            .with_complete(true)
1166            .with_identity(test_root(), 9, 2)
1167            .with_chunk_index(1);
1168        let mut wire = encode(&f0);
1169        wire.extend_from_slice(&encode(&f1));
1170        let mut cur = std::io::Cursor::new(wire);
1171        let (bytes, meta) = assemble_range_stream(&mut cur, 5).await.unwrap();
1172        assert_eq!(bytes, b"ABCDE");
1173        assert_eq!(meta.total_length, Some(9));
1174    }
1175
1176    /// Once the requested window is full the assembler stops reading, even without a `complete`
1177    /// frame — it never buffers past `max_len`.
1178    #[tokio::test]
1179    async fn assemble_stops_once_the_window_is_full() {
1180        let f0 = RangeFrame::data(0, b"WXYZ".to_vec())
1181            .with_identity(test_root(), 8, 2)
1182            .with_chunk_lens_page(0, vec![4, 4])
1183            .with_chunk_index(0);
1184        // Chunk-aligned continuation: identity restated, prologue not repeated.
1185        let f1 = RangeFrame::data(4, b"nope".to_vec())
1186            .with_complete(true)
1187            .with_identity(test_root(), 8, 2)
1188            .with_chunk_index(1);
1189        let mut wire = encode(&f0);
1190        wire.extend_from_slice(&encode(&f1));
1191        let mut cur = std::io::Cursor::new(wire);
1192        let (bytes, _) = assemble_range_stream(&mut cur, 4).await.unwrap();
1193        assert_eq!(bytes, b"WXYZ");
1194    }
1195
1196    #[tokio::test]
1197    async fn drain_trailer_is_bounded_by_cap() {
1198        // A "peer" that streams far more trailer than the cap: the drain must stop at the cap, never
1199        // buffering the whole thing (MEDIUM #179 — no unbounded read_to_end).
1200        let flood = vec![0u8; 1_000_000];
1201        let mut cur = std::io::Cursor::new(flood);
1202        let drained = drain_trailer_bounded(&mut cur, 64 * 1024).await;
1203        assert_eq!(drained, 64 * 1024, "drain must stop exactly at the cap");
1204        // The cursor still has bytes left (we did NOT read to end).
1205        assert!((cur.position() as usize) < 1_000_000);
1206    }
1207
1208    #[tokio::test]
1209    async fn drain_trailer_stops_at_eof_below_cap() {
1210        // A well-behaved peer with a small (or empty) trailer: drain returns the actual count and
1211        // stops at EOF without waiting for the cap.
1212        let mut cur = std::io::Cursor::new(vec![0u8; 100]);
1213        assert_eq!(drain_trailer_bounded(&mut cur, 64 * 1024).await, 100);
1214        let mut empty = std::io::Cursor::new(Vec::<u8>::new());
1215        assert_eq!(drain_trailer_bounded(&mut empty, 64 * 1024).await, 0);
1216    }
1217
1218    #[tokio::test]
1219    async fn assemble_stops_on_clean_eof() {
1220        // A single non-complete frame followed by EOF still yields the bytes.
1221        let f = RangeFrame::data(0, b"hi".to_vec())
1222            .with_identity(test_root(), 2, 1)
1223            .with_chunk_lens_page(0, vec![2])
1224            .with_chunk_index(0);
1225        let mut cur = std::io::Cursor::new(encode(&f));
1226        let (bytes, meta) = assemble_range_stream(&mut cur, 2).await.unwrap();
1227        assert_eq!(bytes, b"hi");
1228        assert_eq!(meta.total_length, Some(2));
1229    }
1230
1231    /// #1640, from BOTH sides of the bound. A payload at exactly [`MAX_RANGE_FRAME_PAYLOAD`] is legal
1232    /// and must survive the real encode → decode → assemble path; one byte over must be REFUSED at the
1233    /// encode site rather than emitted for a decoder that is required to reject it.
1234    ///
1235    /// The fixture size is taken FROM the protocol constant, deliberately. #1640 hid for as long as it
1236    /// did because every fixture that touched this path was far below the ceiling — an 8-byte in-process
1237    /// mock and 20 KB / 27 KB e2e content — and a fixture that cannot exceed a bound can never detect an
1238    /// unbounded encoder. Testing only the at-bound case would be the same mistake in miniature: it
1239    /// confirms the ceiling is reachable without showing that anything stops one byte past it.
1240    ///
1241    /// Scope of the proof, stated honestly: the over-bound half is load-bearing against dig-nat 0.11,
1242    /// where `encode` returned a bare `Vec<u8>` and no ceiling existed at all. It does NOT distinguish
1243    /// 0.12 from 0.13 — the payload ceiling landed in 0.12.0 — so `dependency_tree.rs` carries the
1244    /// assertion that the resolved line is not a pre-0.12 one.
1245    #[tokio::test]
1246    async fn a_payload_at_the_ceiling_round_trips_and_one_byte_over_is_refused() {
1247        let ceiling = dig_nat::MAX_RANGE_FRAME_PAYLOAD;
1248        let at_ceiling = vec![0x7Eu8; ceiling];
1249
1250        let f = RangeFrame::data(0, at_ceiling.clone())
1251            .with_complete(true)
1252            .with_identity(test_root(), ceiling as u64, 1)
1253            .with_chunk_lens_page(0, vec![ceiling as u64])
1254            .with_chunk_index(0);
1255        let wire = f
1256            .encode()
1257            .expect("a payload AT MAX_RANGE_FRAME_PAYLOAD is conforming and must encode");
1258
1259        let mut cur = std::io::Cursor::new(wire);
1260        let (bytes, meta) = assemble_range_stream(&mut cur, ceiling as u64)
1261            .await
1262            .expect("a ceiling-sized frame decodes and assembles");
1263        assert_eq!(
1264            bytes, at_ceiling,
1265            "every byte of a ceiling-sized window survives the round trip"
1266        );
1267        assert_eq!(meta.total_length, Some(ceiling as u64));
1268        assert_eq!(meta.chunk_index, Some(0));
1269
1270        let over = RangeFrame::data(0, vec![0x7Eu8; ceiling + 1]).with_complete(true);
1271        let err = over
1272            .encode()
1273            .expect_err("one byte past the ceiling has no conforming frame and must be refused");
1274        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1275    }
1276
1277    /// A holder that declares a paged `chunk_count` but sends only its FIRST page then marks the stream
1278    /// complete has delivered an INCOMPLETE layout. The reader refuses it fail-closed at the assembler —
1279    /// it never surfaces the lone page as if it were the whole array — because `chunk_lens` is a DECRYPT
1280    /// input: a truncated array is not a degraded layout, it is one that decrypts every chunk to garbage.
1281    ///
1282    /// The fixture's `chunk_count` sits above [`MAX_CHUNK_LENS_PER_FRAME`], the sender's own paging
1283    /// threshold, so this is the genuinely-paged shape rather than a large-looking array that still fits
1284    /// one frame.
1285    #[tokio::test]
1286    async fn a_single_page_of_a_paged_prologue_is_refused_not_surfaced_as_the_whole_array() {
1287        let chunk_count = dig_nat::MAX_CHUNK_LENS_PER_FRAME + 952;
1288        let chunk_lens: Vec<u64> = (0..chunk_count).map(|i| 64 + (i as u64 % 7)).collect();
1289        let total_length: u64 = chunk_lens.iter().sum();
1290        let page0 = chunk_lens[..dig_nat::MAX_CHUNK_LENS_PER_FRAME].to_vec();
1291
1292        let f = RangeFrame::data(0, b"AB".to_vec())
1293            .with_complete(true)
1294            .with_identity(test_root(), total_length, chunk_count as u64)
1295            .with_chunk_lens_page(0, page0)
1296            .with_chunk_index(0);
1297        let wire = f.encode().expect(
1298            "a first page of MAX_CHUNK_LENS_PER_FRAME entries is within the framing ceiling",
1299        );
1300
1301        let mut cur = std::io::Cursor::new(wire);
1302        let err = assemble_range_stream(&mut cur, 2)
1303            .await
1304            .expect_err("a lone page of a paged prologue is not a complete layout");
1305        assert!(
1306            matches!(err, DownloadError::PagedPrologueUnsupported { .. }),
1307            "an incomplete prologue is refused fail-closed, never adopted; got {err:?}"
1308        );
1309        assert!(
1310            err.is_recoverable(),
1311            "the holder is skipped, not the download"
1312        );
1313    }
1314
1315    #[test]
1316    fn source_tracker_backoff_and_recovery() {
1317        let mut t = SourceTracker::new(Duration::from_millis(100), Duration::from_secs(10));
1318        let now = Instant::now();
1319        assert!(t.is_available("p", now));
1320        t.record_failure("p", now);
1321        assert!(!t.is_available("p", now)); // inside backoff
1322        assert_eq!(t.failures("p"), 1);
1323        // After the backoff window it is schedulable again.
1324        assert!(t.is_available("p", now + Duration::from_millis(101)));
1325        // Success clears failures + backoff and counts a served range.
1326        t.record_success("p");
1327        assert!(t.is_available("p", now));
1328        assert_eq!(t.failures("p"), 0);
1329        assert_eq!(t.served("p"), 1);
1330    }
1331
1332    #[test]
1333    fn source_tracker_backoff_is_exponential_and_capped() {
1334        let mut t = SourceTracker::new(Duration::from_millis(100), Duration::from_millis(250));
1335        let now = Instant::now();
1336        t.record_failure("p", now); // 100ms
1337        assert!(t.is_available("p", now + Duration::from_millis(150)));
1338        t.record_failure("p", now); // 200ms
1339        assert!(!t.is_available("p", now + Duration::from_millis(150)));
1340        t.record_failure("p", now); // 400ms → capped to 250ms
1341        assert!(t.is_available("p", now + Duration::from_millis(260)));
1342    }
1343
1344    /// A real (but disposable) CA-signed [`dig_nat::NodeCert`] for the pure helpers under test — they
1345    /// never dial, so any validly-minted cert works. `NodeCert` has no public fields (only
1346    /// `generate_signed`/`load_or_generate`/`from_pem`), so it is minted from a BLS secret key
1347    /// deterministically derived from a fixed label (never a literal keypair — keeps CodeQL's
1348    /// hard-coded-crypto-value scan happy, matches dig-tls's own test convention).
1349    fn fake_node_cert() -> std::sync::Arc<dig_nat::NodeCert> {
1350        use sha2::{Digest, Sha256};
1351        let seed: [u8; 32] = Sha256::digest(b"dig-download/tests/fake-node-cert").into();
1352        let bls_sk = dig_tls::bls::SecretKey::from_seed(&seed);
1353        std::sync::Arc::new(dig_nat::NodeCert::generate_signed(&bls_sk).unwrap())
1354    }
1355
1356    /// #1608 — the range assembly buffer is sized by a peer-DECLARED length, so its growth must be
1357    /// FALLIBLE: `Vec::resize` aborts the process through the uncatchable `handle_alloc_error`, which a
1358    /// peer must never be able to trigger. A frame that is SPARSE in a huge window (a high `offset`,
1359    /// a few payload bytes) reaches that path from ONE small frame.
1360    ///
1361    /// An ~18 EiB reservation fails on every host without touching a page, so this is deterministic
1362    /// rather than dependent on the CI host's memory or overcommit policy.
1363    #[tokio::test]
1364    async fn an_unsatisfiable_assembly_buffer_is_a_recoverable_error_not_an_abort() {
1365        // Deliberately IDENTITY-FREE: the reservation is sized from `offset + bytes.len()` against
1366        // `max_len`, so no metadata field participates. Stating identity here would suggest the
1367        // refusal depends on a declared length it does not read.
1368        let f = RangeFrame::data(u64::MAX - 4, vec![0xAB; 2]);
1369        let mut cur = std::io::Cursor::new(encode(&f));
1370        let err = assemble_range_stream(&mut cur, u64::MAX)
1371            .await
1372            .expect_err("an unsatisfiable window allocation is refused, not fatal");
1373        assert!(
1374            err.is_recoverable(),
1375            "and it is RECOVERABLE, so the scheduler re-fetches the range elsewhere: {err}"
1376        );
1377    }
1378    // ---- mid-stream identity revision (Obligation 2 of #1668) ----------------------------------
1379    //
1380    // The truthful CONTROL for this whole group is `assemble_reassembles_ordered_frames` above: a
1381    // conforming two-frame stream that RESTATES the identity set on its continuation frame and is
1382    // accepted. Without it a guard that rejected every multi-frame stream would satisfy every
1383    // rejection test below while breaking the reader outright.
1384
1385    /// A conforming two-frame stream, and the ONE later-frame field each test varies from it.
1386    ///
1387    /// Built as a pair so every rejection differs from an ACCEPTED stream by exactly one field. A
1388    /// fixture assembled independently per test drifts, and then a rejection can no longer be
1389    /// attributed to the field under test.
1390    fn identity_pair() -> (RangeFrame, RangeFrame) {
1391        let first = RangeFrame::data(0, b"ABC".to_vec())
1392            .with_identity(test_root(), 6, 2)
1393            .with_chunk_lens_page(0, vec![3, 3])
1394            .with_chunk_index(0)
1395            .with_inclusion_proof("proof");
1396        let second = RangeFrame::data(3, b"DEF".to_vec())
1397            .with_complete(true)
1398            .with_identity(test_root(), 6, 2)
1399            .with_chunk_index(1);
1400        (first, second)
1401    }
1402
1403    /// Assemble a two-frame stream and return the rejection reason, panicking if it was ACCEPTED.
1404    async fn reject_reason(first: &RangeFrame, second: &RangeFrame) -> String {
1405        let mut wire = encode(first);
1406        wire.extend_from_slice(&encode(second));
1407        let mut cur = std::io::Cursor::new(wire);
1408        match assemble_range_stream(&mut cur, 6).await {
1409            Err(DownloadError::Transport { reason, .. }) => reason,
1410            other => panic!("a revised identity must be rejected; got {other:?}"),
1411        }
1412    }
1413
1414    #[tokio::test]
1415    async fn a_later_frame_revising_the_root_is_rejected() {
1416        let (first, mut second) = identity_pair();
1417        second.root = Some("bb".repeat(32));
1418        let reason = reject_reason(&first, &second).await;
1419        assert!(
1420            reason.contains("root changed mid-stream"),
1421            "must name the revised field, not fail generically; got {reason}"
1422        );
1423    }
1424
1425    #[tokio::test]
1426    async fn a_later_frame_revising_the_total_length_is_rejected() {
1427        let (first, mut second) = identity_pair();
1428        second.total_length = Some(7);
1429        let reason = reject_reason(&first, &second).await;
1430        assert!(
1431            reason.contains("total_length changed mid-stream"),
1432            "got {reason}"
1433        );
1434    }
1435
1436    /// A revised `chunk_count` is the case NOTHING beneath this reader catches: dig-nat's
1437    /// `ChunkLensAssembler` is constructed with one count and never sees a later frame's declaration,
1438    /// so if this check is absent the revision is simply invisible.
1439    #[tokio::test]
1440    async fn a_later_frame_revising_the_chunk_count_is_rejected() {
1441        let (first, mut second) = identity_pair();
1442        second.chunk_count = Some(3);
1443        let reason = reject_reason(&first, &second).await;
1444        assert!(
1445            reason.contains("chunk_count changed mid-stream"),
1446            "got {reason}"
1447        );
1448    }
1449
1450    /// Revising DOWNWARD, to pin the guard from BOTH sides.
1451    ///
1452    /// A check written as "the count may not grow" passes every test above and is bypassed by this one.
1453    /// The property is that the declared shape may not CHANGE, in either direction.
1454    #[tokio::test]
1455    async fn a_later_frame_revising_the_chunk_count_downward_is_also_rejected() {
1456        let (first, mut second) = identity_pair();
1457        second.chunk_count = Some(1);
1458        let reason = reject_reason(&first, &second).await;
1459        assert!(
1460            reason.contains("chunk_count changed mid-stream"),
1461            "a revision is a revision in EITHER direction; got {reason}"
1462        );
1463    }
1464
1465    /// An identity field the first frame left UNSTATED, arriving later.
1466    ///
1467    /// The commitment binds to the first frame, so a holder that withholds a value from that frame and
1468    /// supplies it afterwards has revised the declaration the reader actually bound to. A guard written
1469    /// only as "the values must match" accepts this, because there is nothing to compare against.
1470    #[tokio::test]
1471    async fn an_identity_field_appearing_only_on_a_later_frame_is_rejected() {
1472        let (mut first, second) = identity_pair();
1473        first.chunk_count = None;
1474        let reason = reject_reason(&first, &second).await;
1475        assert!(
1476            reason.contains("appears only on a later frame"),
1477            "got {reason}"
1478        );
1479    }
1480
1481    /// A `chunk_lens` page RESTATING entries an earlier page already filled — rejected whether or not it
1482    /// agrees.
1483    ///
1484    /// The guard is stated over `chunk_lens_offset`, not over "is this the first frame". The reader never
1485    /// compares a restated page, it refuses the restatement, so "frame 1 said A, frame 5 said B" cannot be
1486    /// expressed at all rather than merely being unpersuasive. The page HERE is byte-identical to the
1487    /// first frame's, so agreement cannot be what saves it.
1488    #[tokio::test]
1489    async fn a_later_frame_restating_an_identical_chunk_lens_page_is_rejected() {
1490        let (first, second) = identity_pair();
1491        let second = second.with_chunk_lens_page(0, vec![3, 3]);
1492        let reason = reject_reason(&first, &second).await;
1493        assert!(
1494            reason.contains("must ADVANCE, never restate"),
1495            "an identical restatement is still a restatement; got {reason}"
1496        );
1497    }
1498
1499    /// An UNSTAMPED later page. Absent `chunk_lens_offset` means "begins at 0" per the wire contract, so
1500    /// this restates ground the first frame's page already covered.
1501    ///
1502    /// Separate from the test above because the two reach the rule by different routes: that one states an
1503    /// offset, this one omits it. A guard that only compared a PRESENT offset would let this through.
1504    #[tokio::test]
1505    async fn a_later_frame_carrying_an_unstamped_chunk_lens_page_is_rejected() {
1506        let (first, mut second) = identity_pair();
1507        second.chunk_lens = Some(vec![3, 3]);
1508        second.chunk_lens_offset = None;
1509        let reason = reject_reason(&first, &second).await;
1510        assert!(
1511            reason.contains("offset 0 re-covers entries below 2"),
1512            "an unstamped page begins at 0, which is already filled; got {reason}"
1513        );
1514    }
1515
1516    /// A page that OVERLAPS rather than exactly repeating — the off-by-one variant of the same class.
1517    ///
1518    /// A guard written as "reject a page at an offset already seen" passes both tests above and is
1519    /// bypassed here: offset 1 was never itself the start of a page, yet entry 1 is already filled. The
1520    /// rule compares against the frontier, so partial overlap is caught the same way a duplicate is.
1521    #[tokio::test]
1522    async fn a_later_frame_whose_chunk_lens_page_partially_overlaps_is_rejected() {
1523        let (first, second) = identity_pair();
1524        let second = second.with_chunk_lens_page(1, vec![9]);
1525        let reason = reject_reason(&first, &second).await;
1526        assert!(
1527            reason.contains("offset 1 re-covers entries below 2"),
1528            "a partially overlapping page is a restatement too; got {reason}"
1529        );
1530    }
1531
1532    // ---- paged-prologue reassembly (#1668) ------------------------------------------------------
1533
1534    /// The `chunk_lens` entries of a `count`-entry resource, DISTINCT per index (`64 + i%7`).
1535    ///
1536    /// A uniform `vec![64; count]` would hide a page placed at the wrong offset or a page truncated by
1537    /// one entry — every slot looks identical — so the ceiling test that #1640 taught us to write needs
1538    /// entries that differ, and a reassembled array that equals this one proves each page landed exactly.
1539    fn distinct_lens(count: usize) -> Vec<u64> {
1540        (0..count).map(|i| 64 + (i % 7) as u64).collect()
1541    }
1542
1543    /// The three frames of a paged prologue for a resource ABOVE the single-frame ceiling.
1544    ///
1545    /// `chunk_count = 2*2048 + 1 = 4097` needs exactly three pages — [0,2048), [2048,4096), [4096,4097)
1546    /// — so it exercises full pages AND a short final page, and sits above `MAX_CHUNK_LENS_PER_FRAME`
1547    /// where a reader that snapshots the layout from frame 1 alone could never read it. The first frame
1548    /// carries the only data bytes (`b"ABC"`); the two later frames are prologue-only (zero data), which
1549    /// is what makes the termination guard's "an accepted page is progress" exemption load-bearing.
1550    fn paged_prologue_frames() -> (Vec<u64>, RangeFrame, RangeFrame, RangeFrame) {
1551        let full = distinct_lens(4097);
1552        let total: u64 = full.iter().sum();
1553        let first = RangeFrame::data(0, b"ABC".to_vec())
1554            .with_identity(test_root(), total, 4097)
1555            .with_chunk_lens_page(0, full[0..2048].to_vec())
1556            .with_chunk_index(0)
1557            .with_inclusion_proof("proof");
1558        let second = RangeFrame::data(0, Vec::new())
1559            .with_identity(test_root(), total, 4097)
1560            .with_chunk_lens_page(2048, full[2048..4096].to_vec());
1561        let third = RangeFrame::data(0, Vec::new())
1562            .with_complete(true)
1563            .with_identity(test_root(), total, 4097)
1564            .with_chunk_lens_page(4096, full[4096..4097].to_vec());
1565        (full, first, second, third)
1566    }
1567
1568    /// A paged prologue spanning THREE frames is reassembled into one array, and adopted only once the
1569    /// last page has landed. This is the capability #1668 adds: a resource above the single-frame layout
1570    /// ceiling now reads end-to-end instead of being refused.
1571    #[tokio::test]
1572    async fn a_paged_prologue_is_reassembled_into_the_full_chunk_lens_array() {
1573        let (full, first, second, third) = paged_prologue_frames();
1574        let mut wire = encode(&first);
1575        wire.extend_from_slice(&encode(&second));
1576        wire.extend_from_slice(&encode(&third));
1577        let mut cur = std::io::Cursor::new(wire);
1578
1579        // A one-byte window (the metadata probe) MUST keep reading until every prologue page lands,
1580        // even though the byte window fills on the first frame.
1581        let (bytes, meta) = assemble_range_stream(&mut cur, 3)
1582            .await
1583            .expect("a conforming paged prologue reassembles");
1584        assert_eq!(bytes, b"ABC", "the data window is clipped and preserved");
1585        assert_eq!(meta.chunk_count, Some(4097));
1586        assert_eq!(
1587            meta.chunk_lens,
1588            Some(full),
1589            "the reassembled array equals the full, per-entry-distinct layout"
1590        );
1591    }
1592
1593    /// FAIL-CLOSED: the SAME stream missing its last page yields NO layout. A partial `chunk_lens` sums
1594    /// short of `total_length` and would decrypt every chunk to garbage, so an incomplete prologue is a
1595    /// RECOVERABLE refusal (the holder is skipped) — never an adopted partial array (SPEC.md §2.2).
1596    #[tokio::test]
1597    async fn an_incomplete_paged_prologue_is_refused_not_adopted() {
1598        let (_full, first, mut second, _third) = paged_prologue_frames();
1599        // End the stream after the SECOND page (2 of 3 pages) by marking it complete.
1600        second.complete = true;
1601        let mut wire = encode(&first);
1602        wire.extend_from_slice(&encode(&second));
1603        let mut cur = std::io::Cursor::new(wire);
1604
1605        let err = assemble_range_stream(&mut cur, 3)
1606            .await
1607            .expect_err("a prologue short of chunk_count must not be adopted");
1608        assert!(
1609            matches!(
1610                err,
1611                DownloadError::PagedPrologueUnsupported {
1612                    chunk_count: 4097,
1613                    ..
1614                }
1615            ),
1616            "an incomplete layout is refused fail-closed; got {err:?}"
1617        );
1618        assert!(
1619            err.is_recoverable(),
1620            "one holder's short prologue skips the holder, not the download"
1621        );
1622    }
1623
1624    /// A declared `chunk_count` above `MAX_RESOURCE_CHUNK_COUNT` is refused BEFORE the assembler
1625    /// allocates its array — a peer-declared count is never allowed to become an allocation this host
1626    /// cannot survive. The refusal is recoverable, so the scheduler routes around the hostile holder.
1627    #[tokio::test]
1628    async fn a_chunk_count_above_the_resource_ceiling_is_refused_before_allocation() {
1629        let oversized = dig_nat::MAX_RESOURCE_CHUNK_COUNT as u64 + 1;
1630        let first = RangeFrame::data(0, b"A".to_vec())
1631            .with_identity(test_root(), 64, oversized)
1632            .with_chunk_lens_page(0, vec![64; 2048])
1633            .with_chunk_index(0);
1634        let mut cur = std::io::Cursor::new(encode(&first));
1635
1636        let err = assemble_range_stream(&mut cur, 1)
1637            .await
1638            .expect_err("an over-ceiling chunk_count is refused pre-allocation");
1639        assert!(
1640            err.is_recoverable(),
1641            "a refused-before-allocation layout skips the holder, not the download; got {err:?}"
1642        );
1643    }
1644
1645    /// A MISALIGNED later page (offset not a multiple of `MAX_CHUNK_LENS_PER_FRAME`) is rejected by the
1646    /// assembler's own placement rules — defense in depth beyond the identity frontier guard. Recoverable,
1647    /// so the holder is skipped.
1648    #[tokio::test]
1649    async fn a_misaligned_prologue_page_is_rejected() {
1650        let full = distinct_lens(4097);
1651        let total: u64 = full.iter().sum();
1652        let first = RangeFrame::data(0, b"ABC".to_vec())
1653            .with_identity(test_root(), total, 4097)
1654            .with_chunk_lens_page(0, full[0..2048].to_vec())
1655            .with_chunk_index(0);
1656        // Offset 2049 is not a page-aligned multiple of 2048, so the assembler refuses it even though it
1657        // advances the identity frontier past 2048.
1658        let second = RangeFrame::data(0, Vec::new())
1659            .with_complete(true)
1660            .with_identity(test_root(), total, 4097)
1661            .with_chunk_lens_page(2049, full[2049..4097].to_vec());
1662        let mut wire = encode(&first);
1663        wire.extend_from_slice(&encode(&second));
1664        let mut cur = std::io::Cursor::new(wire);
1665
1666        let err = assemble_range_stream(&mut cur, 3)
1667            .await
1668            .expect_err("a misaligned page must be rejected");
1669        assert!(
1670            matches!(&err, DownloadError::Transport { reason, .. } if reason.contains("chunk_lens prologue rejected")),
1671            "the assembler's placement rule names the rejection; got {err:?}"
1672        );
1673        assert!(err.is_recoverable(), "a hostile page skips the holder");
1674    }
1675
1676    // ---- termination against a holder that streams without progressing --------------------------
1677
1678    /// A holder streaming EMPTY non-final frames must be refused, not read forever.
1679    ///
1680    /// Every loop exit depends on the window filling or the holder setting `complete`, so a frame that
1681    /// contributes no bytes and sets neither satisfies every other check — including the identity
1682    /// re-check, because omitting identity is conforming by design — and advances nothing. Sustained on a
1683    /// few dozen bytes per frame it pins the job while it still holds the staging claim, which makes the
1684    /// staging path permanently GC-exempt and permanently un-downloadable.
1685    ///
1686    /// The test is bounded so a REGRESSION fails instead of hanging the suite: an unbounded reader would
1687    /// otherwise consume the fixture and block, and a test that hangs reports nothing.
1688    #[tokio::test]
1689    async fn a_holder_streaming_empty_non_final_frames_is_refused() {
1690        let first = RangeFrame::data(0, b"AB".to_vec())
1691            .with_identity(test_root(), 64, 2)
1692            .with_chunk_lens_page(0, vec![32, 32])
1693            .with_chunk_index(0);
1694        // A frame carrying nothing, declaring nothing, and not completing — every field a hostile holder
1695        // is free to omit.
1696        let empty = RangeFrame::data(0, Vec::new());
1697        let mut wire = encode(&first);
1698        for _ in 0..64 {
1699            wire.extend_from_slice(&encode(&empty));
1700        }
1701        let mut cur = std::io::Cursor::new(wire);
1702
1703        let outcome = tokio::time::timeout(
1704            std::time::Duration::from_secs(5),
1705            assemble_range_stream(&mut cur, 64),
1706        )
1707        .await
1708        .expect("the reader must REFUSE a non-progressing stream, not consume it");
1709        let Err(DownloadError::Transport { reason, .. }) = outcome else {
1710            panic!("a stream that cannot progress must be an error; got {outcome:?}");
1711        };
1712        assert!(
1713            reason.contains("cannot progress"),
1714            "and must say why; got {reason}"
1715        );
1716    }
1717
1718    /// The same guard, reached by a holder RE-SENDING bytes it already sent.
1719    ///
1720    /// This is the variant that slips past a rule aimed at the empty payload: the frame carries real bytes,
1721    /// so a check on `bytes.is_empty()` accepts it, yet re-writing an already-written prefix advances the
1722    /// assembled length by nothing and loops just as forever. The rule is therefore stated over the
1723    /// frontier — the CLASS of frame that does not extend the prefix — not over the empty instance of it.
1724    #[tokio::test]
1725    async fn a_holder_resending_an_already_written_prefix_is_refused() {
1726        let first = RangeFrame::data(0, b"AB".to_vec())
1727            .with_identity(test_root(), 64, 2)
1728            .with_chunk_lens_page(0, vec![32, 32])
1729            .with_chunk_index(0);
1730        let resend = RangeFrame::data(0, b"AB".to_vec());
1731        let mut wire = encode(&first);
1732        for _ in 0..64 {
1733            wire.extend_from_slice(&encode(&resend));
1734        }
1735        let mut cur = std::io::Cursor::new(wire);
1736
1737        let outcome = tokio::time::timeout(
1738            std::time::Duration::from_secs(5),
1739            assemble_range_stream(&mut cur, 64),
1740        )
1741        .await
1742        .expect("a re-sent prefix advances nothing and must be refused, not read forever");
1743        assert!(
1744            matches!(outcome, Err(DownloadError::Transport { .. })),
1745            "got {outcome:?}"
1746        );
1747    }
1748
1749    /// The at-bound side: a frame that advances by ONE byte is progress and must be accepted.
1750    ///
1751    /// Without this the guard could be "reject any frame that does not fill the window" and both tests
1752    /// above would still pass, while every real chunk-granular multi-frame holder broke. Progress is
1753    /// progress however small.
1754    #[tokio::test]
1755    async fn a_frame_advancing_the_window_by_one_byte_is_accepted() {
1756        let first = RangeFrame::data(0, b"A".to_vec())
1757            .with_identity(test_root(), 3, 1)
1758            .with_chunk_lens_page(0, vec![3])
1759            .with_chunk_index(0);
1760        let second = RangeFrame::data(1, b"B".to_vec());
1761        let third = RangeFrame::data(2, b"C".to_vec()).with_complete(true);
1762        let mut wire = encode(&first);
1763        wire.extend_from_slice(&encode(&second));
1764        wire.extend_from_slice(&encode(&third));
1765        let mut cur = std::io::Cursor::new(wire);
1766
1767        let (bytes, _) = assemble_range_stream(&mut cur, 3)
1768            .await
1769            .expect("one byte at a time is slow, not hostile");
1770        assert_eq!(bytes, b"ABC");
1771    }
1772
1773    /// ATTRIBUTING and WRAPPING are not interchangeable: only one preserves the variant.
1774    ///
1775    /// `fetch_range` chooses between these two on a single line, and that call site is NOT covered by a
1776    /// test — it needs real sockets and certificates, so it is one of the few genuinely untestable spots
1777    /// in this crate. What is pinned here instead is the DIFFERENCE the choice makes, so a future edit that
1778    /// swaps back to wrapping has a test stating exactly what it destroys.
1779    #[tokio::test]
1780    async fn wrapping_a_typed_error_loses_the_variant_that_attributing_keeps() {
1781        let peer = "ab".repeat(32);
1782        // Built twice rather than cloned: `DownloadError` is not `Clone` (the derive existed only for the
1783        // removed re-adoption retry), and the two calls need separate owned values.
1784        let typed = || DownloadError::PagedPrologueUnsupported {
1785            provider: String::new(),
1786            chunk_count: 4,
1787            delivered: 4,
1788        };
1789
1790        // Both halves matter and a weaker assertion misses one: dropping the variant's arm from
1791        // `attributed_to` leaves it falling through to the catch-all, which PRESERVES the variant while
1792        // silently failing to stamp the peer. Asserting only the variant would stay green on that.
1793        match typed().attributed_to(&peer) {
1794            DownloadError::PagedPrologueUnsupported { provider, .. } => assert_eq!(
1795                provider, peer,
1796                "attribution must fill the provider in, not merely keep the variant"
1797            ),
1798            other => panic!("attribution must leave the variant alone; got {other:?}"),
1799        }
1800        assert!(
1801            matches!(
1802                DownloadError::transport(&peer, typed()),
1803                DownloadError::Transport { .. }
1804            ),
1805            "wrapping flattens it to Transport, so `is_recoverable` can no longer tell it apart and the \
1806             stable error catalogue promises a variant no caller can ever match"
1807        );
1808    }
1809
1810    // ---- omit-tolerance: a TERSE holder is conforming --------------------------------------------
1811    //
1812    // `SPEC.md` 2.2 states normatively that a later frame OMITTING an identity field asserts nothing and
1813    // must be accepted. Nothing tested it: `assemble_reassembles_ordered_frames` and both `identity_pair()`
1814    // frames all call `.with_identity(...)`, so no fixture anywhere fed a later frame that leaves one out.
1815    // Inverting the tolerant arm to an `Err` therefore left the whole suite green — vacuous, and exactly
1816    // the one-sided pinning the rewind rule already avoids.
1817
1818    /// Assemble a two-frame stream whose continuation omits ONE identity field, and require success.
1819    ///
1820    /// Takes the field out of an otherwise-conforming frame, so the only difference from the accepted
1821    /// control is the omission under test.
1822    async fn assemble_with_terse_continuation(
1823        strip: impl FnOnce(&mut RangeFrame),
1824    ) -> Result<(Vec<u8>, RangeMeta), DownloadError> {
1825        let (first, mut second) = identity_pair();
1826        strip(&mut second);
1827        let mut wire = encode(&first);
1828        wire.extend_from_slice(&encode(&second));
1829        let mut cur = std::io::Cursor::new(wire);
1830        assemble_range_stream(&mut cur, 6).await
1831    }
1832
1833    #[tokio::test]
1834    async fn a_later_frame_omitting_the_root_is_accepted() {
1835        let (bytes, meta) = assemble_with_terse_continuation(|f| f.root = None)
1836            .await
1837            .expect("a terse continuation asserts nothing and must be accepted");
1838        assert_eq!(bytes, b"ABCDEF", "and its bytes still land in the window");
1839        assert_eq!(
1840            meta.root,
1841            Some(test_root()),
1842            "the stream's identity stays the FIRST frame's declaration"
1843        );
1844    }
1845
1846    #[tokio::test]
1847    async fn a_later_frame_omitting_the_total_length_is_accepted() {
1848        let (bytes, meta) = assemble_with_terse_continuation(|f| f.total_length = None)
1849            .await
1850            .expect("a terse continuation asserts nothing and must be accepted");
1851        assert_eq!(bytes, b"ABCDEF");
1852        assert_eq!(meta.total_length, Some(6));
1853    }
1854
1855    #[tokio::test]
1856    async fn a_later_frame_omitting_the_chunk_count_is_accepted() {
1857        let (bytes, meta) = assemble_with_terse_continuation(|f| f.chunk_count = None)
1858            .await
1859            .expect("a terse continuation asserts nothing and must be accepted");
1860        assert_eq!(bytes, b"ABCDEF");
1861        assert_eq!(meta.chunk_count, Some(2));
1862    }
1863
1864    /// A continuation frame carrying NO metadata at all — the maximally terse conforming holder.
1865    ///
1866    /// The three tests above each omit one field; this omits every one at once, which is what a holder
1867    /// that treats the identity set as first-frame-only actually sends. A guard that tolerated single
1868    /// omissions but tripped on the combination would pass all three and fail here.
1869    #[tokio::test]
1870    async fn a_later_frame_omitting_every_identity_field_is_accepted() {
1871        let (bytes, _) = assemble_with_terse_continuation(|f| {
1872            f.root = None;
1873            f.total_length = None;
1874            f.chunk_count = None;
1875            f.chunk_index = None;
1876        })
1877        .await
1878        .expect("a bare continuation frame is conforming");
1879        assert_eq!(bytes, b"ABCDEF");
1880    }
1881
1882    #[tokio::test]
1883    async fn a_later_frame_restating_the_inclusion_proof_is_rejected() {
1884        let (first, second) = identity_pair();
1885        let second = second.with_inclusion_proof("proof");
1886        let reason = reject_reason(&first, &second).await;
1887        assert!(reason.contains("restates inclusion_proof"), "got {reason}");
1888    }
1889
1890    /// `chunk_index` is per-FRAME, not per-resource, so it may advance but never rewind — frames arrive
1891    /// in ascending byte offset. Treating it as invariant would reject every conforming multi-frame
1892    /// stream, which is why the control test above matters.
1893    #[tokio::test]
1894    async fn a_later_frame_rewinding_the_chunk_index_is_rejected() {
1895        let (mut first, mut second) = identity_pair();
1896        first.chunk_index = Some(1);
1897        second.chunk_index = Some(0);
1898        let reason = reject_reason(&first, &second).await;
1899        assert!(reason.contains("rewinds below"), "got {reason}");
1900    }
1901
1902    /// The other side of the rewind rule: an index that does NOT go backwards is accepted.
1903    ///
1904    /// A guard written as "reject any later chunk_index" passes the rewind test above and is caught only
1905    /// here, so the bound is pinned from both sides. A repeated index is legal — a frame that does not
1906    /// begin a new chunk restates the chunk it is inside.
1907    #[tokio::test]
1908    async fn a_later_frame_repeating_its_chunk_index_is_accepted() {
1909        let (first, mut second) = identity_pair();
1910        second.chunk_index = Some(0);
1911        let mut wire = encode(&first);
1912        wire.extend_from_slice(&encode(&second));
1913        let mut cur = std::io::Cursor::new(wire);
1914        let (bytes, _) = assemble_range_stream(&mut cur, 6)
1915            .await
1916            .expect("an equal chunk_index is not a rewind");
1917        assert_eq!(bytes, b"ABCDEF");
1918    }
1919}