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}