Skip to main content

dig_download/
error.rs

1//! [`DownloadError`] — the crate's top-level error, and [`VerifyError`] — why a fetched range or a
2//! reassembled resource failed integrity.
3//!
4//! The orchestrator treats most errors as **recoverable per range**: a transport failure or a
5//! [`VerifyError`] on one range marks that source suspect and re-queues the range to another
6//! provider (it is never fatal to the whole download). Only the terminal conditions —
7//! [`DownloadError::NoProviders`] (nowhere left to fetch a still-missing range) and
8//! [`DownloadError::Cancelled`] — end a download.
9
10use thiserror::Error;
11
12/// The character bound applied to a foreign error `reason` when it is rendered (#1603). Long enough
13/// for a nested dial/transport chain, short enough that a hostile peer cannot flood a log line.
14pub const MAX_ERROR_REASON_CHARS: usize = 512;
15
16/// The character bound applied to a rendered failure CONTEXT (a per-holder reason list), which is
17/// legitimately longer than one reason but still bounded.
18pub const MAX_ERROR_CONTEXT_CHARS: usize = 4096;
19
20/// Render an untrusted identifier for a log or an error message: the lowercase 64-hex value if it IS
21/// canonical 64-hex, else `<non-canonical-{label}>`.
22///
23/// Peer ids and a descriptor's hashes are peer-supplied free-form strings. Echoing one verbatim lets a
24/// hostile holder inject newlines, markup, or forged log lines into the node's own diagnostics — so a
25/// non-canonical value never reaches the output, and a log an attacker can write is never mistaken for
26/// evidence (#1603). Applied in [`DownloadError`]'s own `Display`, so the raw id is UNREPRESENTABLE in
27/// an error string however the error was constructed.
28pub fn hex64_or_sentinel(value: &str, label: &str) -> String {
29    let canonical = value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit());
30    if canonical {
31        value.to_ascii_lowercase()
32    } else {
33        format!("<non-canonical-{label}>")
34    }
35}
36
37/// Render free-form foreign TEXT (a remote error message, a returned status line) safely: control
38/// characters are escaped rather than emitted, and the result is bounded to `max_chars`.
39///
40/// A peer-supplied reason lands inside error messages consumers LOG, so an un-escaped newline lets a
41/// holder forge whole log lines — the same #1603 class as an un-sentinelled peer id, through a
42/// different door. Escaping (not deleting) keeps the reason diagnosable while making it exactly ONE
43/// line.
44pub fn sanitize_untrusted_text(text: &str, max_chars: usize) -> String {
45    let mut out = String::with_capacity(text.len().min(max_chars));
46    for (count, ch) in text.chars().enumerate() {
47        if count == max_chars {
48            out.push_str("…<truncated>");
49            break;
50        }
51        if ch.is_control() || is_bidi_control(ch) {
52            out.extend(ch.escape_debug());
53        } else {
54            out.push(ch);
55        }
56    }
57    out
58}
59
60/// Whether `ch` is a Unicode bidirectional-formatting character (the LRE/RLE/PDF/LRO/RLO overrides,
61/// the isolate set, and the LRM/RLM/ALM marks).
62///
63/// These are category-`Cf`, NOT control characters, so `is_control` misses them — yet they visually
64/// REORDER the text around them, which is enough to make a rendered log line read as something other
65/// than what it says (the classic `…exe.txt` / `…txt.exe` swap). Escaped, not deleted, like every other
66/// untrusted byte here.
67fn is_bidi_control(ch: char) -> bool {
68    matches!(
69        ch,
70        '\u{200E}' | '\u{200F}' | '\u{061C}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
71    )
72}
73
74/// An error from a download operation.
75#[derive(Error)]
76pub enum DownloadError {
77    /// A transport-level failure fetching from one provider (connect failed, stream dropped,
78    /// availability/range RPC errored, timeout). Carries the reason as text. **Recoverable**: the
79    /// orchestrator marks the provider suspect and re-queues the range to another holder.
80    #[error(
81        "transport error from provider {}: {}",
82        hex64_or_sentinel(provider, "peer-id"),
83        sanitize_untrusted_text(reason, MAX_ERROR_REASON_CHARS)
84    )]
85    Transport {
86        /// The provider `peer_id` (64-hex) the failure came from.
87        provider: String,
88        /// The underlying reason (stable, greppable text).
89        reason: String,
90    },
91
92    /// A range fetch exceeded the configured per-range timeout (`DownloadConfig::range_timeout`) — a
93    /// too-slow or stalled source. **Recoverable**: the range is re-queued to another holder and the
94    /// slow source is backed off (its `TimedOut` outcome is reported to the selector).
95    #[error(
96        "range fetch from provider {} timed out",
97        hex64_or_sentinel(provider, "peer-id")
98    )]
99    Timeout {
100        /// The provider `peer_id` (64-hex) whose fetch timed out.
101        provider: String,
102    },
103
104    /// A fetched range failed integrity verification. **Recoverable**: the bad range is discarded and
105    /// re-fetched from a different provider, and the serving provider is penalized.
106    ///
107    /// The wrapped reason is SANITIZED here for the same reason a transport reason is: a verify failure
108    /// routinely quotes peer-reported metadata (a first-frame `root`, a declared length), so it is
109    /// untrusted text arriving through a different door.
110    #[error(
111        "integrity failure: {}",
112        sanitize_untrusted_text(&.0.to_string(), MAX_ERROR_REASON_CHARS)
113    )]
114    Verify(#[from] VerifyError),
115
116    /// A still-needed range has no live provider left to fetch it from — every known holder has been
117    /// tried + failed and a fresh `find_providers` discovered no more. This is terminal for the
118    /// download (there is nowhere left to get the missing bytes).
119    #[error("no providers left holding the content (needed {needed} more range(s))")]
120    NoProviders {
121        /// How many ranges were still missing when the provider set was exhausted.
122        needed: usize,
123    },
124
125    /// The content could not be fetched at all — either `find_providers` returned no holders, or
126    /// no located holder could answer the metadata probe. Terminal.
127    ///
128    /// `content` names the content id AND which of those two steps failed: the message must never
129    /// blame discovery for a probe failure (that ambiguity cost four #1586 investigations).
130    #[error(
131        "content not found: {}",
132        sanitize_untrusted_text(content, MAX_ERROR_CONTEXT_CHARS)
133    )]
134    NotFound {
135        /// The content id that could not be fetched, plus the step that failed.
136        content: String,
137    },
138
139    /// Holders WERE located and confirmed, but not one of them could seed the resource layout. Terminal.
140    ///
141    /// This is the named replacement for reporting that outcome as a generic
142    /// [`NotFound`](Self::NotFound). The two failures are not the same event and must not read the
143    /// same: "nobody has this content" is a discovery result, while "several holders have it and every
144    /// one of them served metadata this reader cannot use" is a compatibility or hostility result, and
145    /// only the second is actionable by whoever operates the holders. Collapsing them cost four separate
146    /// #1586 investigations.
147    ///
148    /// `reasons` carries the per-holder cause, which was previously written to a `tracing::debug!` and
149    /// then dropped — so the one fact that identifies the fault survived only if debug logging happened
150    /// to be on.
151    #[error(
152        "no confirmed holder could seed the resource layout for {} — probed {holders}, all failed: {}",
153        sanitize_untrusted_text(content, MAX_ERROR_CONTEXT_CHARS),
154        sanitize_untrusted_text(&reasons.join("; "), MAX_ERROR_REASON_CHARS)
155    )]
156    MetadataProbeFailed {
157        /// The content id whose layout could not be established.
158        content: String,
159        /// How many confirmed holders were probed.
160        holders: usize,
161        /// One `peer_id: reason` line per probed holder, in probe order.
162        reasons: Vec<String>,
163    },
164
165    /// The resource's `chunk_lens` **paged prologue** could not be COMPLETED — the stream ended short of
166    /// the declared `chunk_count`, or a first frame that declared no multi-page layout was followed by a
167    /// frame paging one. **Recoverable per holder**: the range is retried elsewhere and the adoption path
168    /// probes the next holder.
169    ///
170    /// # Fail-closed, not a reader limitation
171    ///
172    /// This reader DOES reassemble a paged prologue (`SPEC.md` §2.2), so a conforming multi-page holder
173    /// reads end-to-end. The error is raised only when the layout stays INCOMPLETE — a decrypt-input array
174    /// short of `chunk_count` would decrypt every chunk to garbage, so it is refused rather than adopted
175    /// partial. It reports the declared count and the entries delivered so a short prologue is
176    /// distinguishable from "nobody holds this content". (A page that violates a placement rule —
177    /// misaligned, duplicated, overshooting — surfaces instead as a recoverable [`Transport`](Self::Transport)
178    /// naming the broken rule.)
179    #[error(
180        "provider {} served a {chunk_count}-entry chunk_lens paged prologue that ended incomplete \
181         ({delivered} of {chunk_count} entries)",
182        hex64_or_sentinel(provider, "peer-id")
183    )]
184    PagedPrologueUnsupported {
185        /// The provider `peer_id` (64-hex) whose layout could not be assembled.
186        provider: String,
187        /// The whole array's declared entry count.
188        chunk_count: u64,
189        /// How many entries had been delivered when the reader gave up.
190        delivered: u64,
191    },
192
193    /// The download was cancelled via [`DownloadHandle::cancel`](crate::DownloadHandle::cancel).
194    /// Terminal (by request).
195    #[error("download cancelled")]
196    Cancelled,
197
198    /// Persisting or loading resume state failed. Carries the reason.
199    #[error("state store error: {0}")]
200    State(String),
201
202    /// The sink (store-write path) rejected a write. Carries the reason.
203    #[error("sink write error: {0}")]
204    Sink(String),
205
206    /// The requested content id cannot be downloaded as a byte stream — a bare store id names a
207    /// whole store (many capsules), not a single resource/capsule to fetch. Supply a root/capsule or
208    /// resource content id.
209    #[error("content id is not directly downloadable (needs a root/capsule or resource, got a bare store id)")]
210    NotDownloadable,
211
212    /// The orchestrator task ended unexpectedly (its channel closed before a terminal result). This
213    /// indicates a bug or an aborted runtime, not a normal download outcome.
214    #[error("download task ended without a result")]
215    TaskEnded,
216}
217
218/// `Debug` delegates to the SANITIZING [`Display`](std::fmt::Display) rather than printing raw fields.
219///
220/// `Debug` is not a developer-only rendering in practice: `tracing`'s `?field`, a `{:?}` in a log line,
221/// and every `unwrap`/`expect` panic message emit it. A derived `Debug` would print the untrusted
222/// `provider` / `reason` / verify text verbatim — unbounded, with markup intact — bypassing the very
223/// sanitization `Display` applies. One rendering, one door.
224impl std::fmt::Debug for DownloadError {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        write!(f, "DownloadError({self})")
227    }
228}
229
230impl DownloadError {
231    /// Build a [`DownloadError::Transport`] for `provider` from anything displayable.
232    pub fn transport(provider: impl Into<String>, reason: impl std::fmt::Display) -> Self {
233        DownloadError::Transport {
234            provider: provider.into(),
235            reason: reason.to_string(),
236        }
237    }
238
239    /// Build a [`DownloadError::Sink`] from anything displayable.
240    pub fn sink(reason: impl std::fmt::Display) -> Self {
241        DownloadError::Sink(reason.to_string())
242    }
243
244    /// Build a [`DownloadError::State`] from anything displayable.
245    pub fn state(reason: impl std::fmt::Display) -> Self {
246        DownloadError::State(reason.to_string())
247    }
248
249    /// Whether this error is **recoverable per range** (the download can continue by retrying the
250    /// range elsewhere) rather than terminal for the whole download.
251    ///
252    /// [`PagedPrologueUnsupported`](Self::PagedPrologueUnsupported) is recoverable for the same reason a
253    /// transport failure is: it rules out ONE holder's stream, not the content. The other new variants
254    /// are terminal — each already reports that every holder was tried.
255    pub fn is_recoverable(&self) -> bool {
256        matches!(
257            self,
258            DownloadError::Transport { .. }
259                | DownloadError::Verify(_)
260                | DownloadError::Timeout { .. }
261                | DownloadError::PagedPrologueUnsupported { .. }
262        )
263    }
264
265    /// Fill in an as-yet-unattributed `provider` with the peer the failure came from, leaving every other
266    /// variant untouched.
267    ///
268    /// The pure reassembly core cannot know which peer it is reading, so it raises errors with an empty
269    /// `provider` for the transport layer to stamp. Stamping must not be done by WRAPPING, which is what
270    /// this replaced: re-wrapping every reassembly failure into a [`Transport`](Self::Transport) flattened
271    /// the typed ones, so a variant the reassembler raised deliberately — and that
272    /// [`is_recoverable`](Self::is_recoverable) treats specially — could never be observed by a caller.
273    ///
274    /// An already-attributed error is returned unchanged, so stamping twice cannot relabel a failure onto
275    /// the wrong peer.
276    pub fn attributed_to(self, peer_id: &str) -> Self {
277        match self {
278            DownloadError::Transport { provider, reason } if provider.is_empty() => {
279                DownloadError::Transport {
280                    provider: peer_id.to_string(),
281                    reason,
282                }
283            }
284            DownloadError::PagedPrologueUnsupported {
285                provider,
286                chunk_count,
287                delivered,
288            } if provider.is_empty() => DownloadError::PagedPrologueUnsupported {
289                provider: peer_id.to_string(),
290                chunk_count,
291                delivered,
292            },
293            other => other,
294        }
295    }
296}
297
298/// Why a fetched range or a reassembled resource failed integrity — the checks of L7 §9
299/// "per-range integrity". A [`VerifyError`] on a range marks its source suspect + re-fetches.
300#[derive(Error, Clone, PartialEq, Eq)]
301pub enum VerifyError {
302    /// A returned range's byte length did not match the sum of the `chunk_lens` for the chunk(s) it
303    /// was supposed to cover — the cheapest, per-range detection of a bad/truncated source.
304    #[error("range length mismatch: expected {expected} bytes for chunks, got {actual}")]
305    Length {
306        /// The length the `chunk_lens` say the range should be.
307        expected: u64,
308        /// The length actually delivered.
309        actual: u64,
310    },
311
312    /// A range's first-frame metadata was inconsistent with the resource commitment already
313    /// established (a differing `chunk_lens`, `total_length`, or generation `root`) — a source
314    /// serving a different/forged generation.
315    #[error("range metadata mismatch with the resource commitment: {0}")]
316    Metadata(String),
317
318    /// A range was not aligned to whole chunk boundaries (offset/length did not start/end on a chunk
319    /// edge per `chunk_lens`), so it cannot be a verifiable unit.
320    #[error("range is not chunk-aligned: {0}")]
321    Alignment(String),
322
323    /// The reassembled whole resource's `resource_leaf` (= SHA-256 of its concatenated chunk
324    /// ciphertexts) was not committed under the chain-anchored generation `root` — the on-chain
325    /// integrity check. Either the assembled bytes are corrupt or the inclusion proof does not verify.
326    #[error("resource does not verify against the chain-anchored root")]
327    Root,
328
329    /// The first frame of a range was missing the verification metadata (`total_length` / `chunk_lens`
330    /// / `root`) required to establish or check the commitment.
331    #[error("first frame is missing verification metadata ({0})")]
332    MissingMetadata(String),
333}
334
335/// `Debug` sanitizes the wrapped text the same way [`DownloadError::Verify`]'s `Display` does, rather
336/// than printing the derived struct fields verbatim.
337///
338/// `Metadata` / `Alignment` / `MissingMetadata` carry peer-reported text (a first-frame `root`, a
339/// declared length) — the same untrusted-text class [`DownloadError`]'s own manual `Debug` guards.
340/// `DownloadError::Verify`'s `Display` already sanitizes a WRAPPED `VerifyError`, but a bare one
341/// reaches `{:?}` too (an `unwrap`/`expect` panic, a raw `tracing::error!(?e)`) — this closes that
342/// second door with the same [`sanitize_untrusted_text`] the wrapping site uses.
343impl std::fmt::Debug for VerifyError {
344    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345        write!(
346            f,
347            "VerifyError({})",
348            sanitize_untrusted_text(&self.to_string(), MAX_ERROR_REASON_CHARS)
349        )
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    /// A canonical 64-hex peer id is a real identifier and is reported as-is.
358    #[test]
359    fn transport_helper_formats_with_provider() {
360        let peer = "ab".repeat(32);
361        let e = DownloadError::transport(&peer, "connection refused");
362        assert!(e.to_string().contains(&peer));
363        assert!(e.to_string().contains("connection refused"));
364        assert!(e.is_recoverable());
365    }
366
367    /// #1603 — the provider id is UNREPRESENTABLE raw in an error string. The crate's own idiom
368    /// stamps `&provider.provider_peer_id` (free-form text off the wire) straight into
369    /// [`DownloadError::transport`], so the sanitization must live in `Display`, not at each call
370    /// site: otherwise a hostile holder's forged log line rides out inside the wrapped error even
371    /// though the surrounding code sentinelled the peer id.
372    #[test]
373    fn a_hostile_provider_id_and_reason_are_never_echoed() {
374        let hostile = "not-hex <script>x</script>\n[FATAL] forged log line";
375        let rendered =
376            DownloadError::transport(hostile, "remote said: \n[FATAL] also forged").to_string();
377        assert!(
378            rendered.contains("<non-canonical-peer-id>"),
379            "the id is sentinelled: {rendered}"
380        );
381        assert!(
382            !rendered.contains("<script>"),
383            "no peer-supplied id text: {rendered}"
384        );
385        assert!(
386            !rendered.contains('\n'),
387            "a foreign reason can never forge a second log line: {rendered}"
388        );
389
390        // Struct-literal construction bypasses the helper — Display still sanitizes.
391        let direct = DownloadError::Transport {
392            provider: hostile.to_string(),
393            reason: "x\ny".to_string(),
394        }
395        .to_string();
396        assert!(!direct.contains('\n') && !direct.contains("<script>"));
397    }
398
399    /// #1603, second door — a peer-reported first-frame `root` reaches a log through
400    /// `VerifyError::Metadata` (`verify.rs`), and the `Verify` arm wrapped it VERBATIM. A wrapped
401    /// integrity failure is as untrusted as a transport one, so `Display` must sanitize it too.
402    #[test]
403    fn a_hostile_verify_reason_can_never_forge_a_log_line() {
404        let hostile = "root deadbeef\n[FATAL] forged by a peer != committed abc";
405        let rendered =
406            DownloadError::Verify(VerifyError::Metadata(hostile.to_string())).to_string();
407        assert!(
408            !rendered.contains('\n'),
409            "a wrapped verify reason forges a second line: {rendered}"
410        );
411        assert!(
412            rendered.contains("deadbeef"),
413            "still diagnosable: {rendered}"
414        );
415    }
416
417    /// A bare (unwrapped) `VerifyError`'s `{:?}` must sanitize too — not just `DownloadError::Verify`'s
418    /// `Display`. `unwrap`/`expect` on a `Result<_, VerifyError>` and a raw `tracing::error!(?e)` both
419    /// go through `Debug` directly, bypassing the wrapping site entirely.
420    #[test]
421    fn a_bare_verify_error_debug_is_sanitized_too() {
422        let hostile = "root deadbeef\n[FATAL] forged by a peer != committed abc";
423        let rendered = format!("{:?}", VerifyError::Metadata(hostile.to_string()));
424        assert!(
425            !rendered.contains('\n'),
426            "a bare Debug forges a second line: {rendered}"
427        );
428        assert!(
429            rendered.contains("deadbeef"),
430            "still diagnosable: {rendered}"
431        );
432    }
433
434    /// A bidi override reorders a rendered log line without being a control char, so it is escaped
435    /// exactly like one.
436    #[test]
437    fn bidi_overrides_are_escaped_like_control_characters() {
438        let sanitized = sanitize_untrusted_text("safe\u{202E}dorp.exe", 64);
439        assert!(
440            !sanitized.contains('\u{202E}'),
441            "the override survived: {sanitized}"
442        );
443        assert!(sanitized.contains("safe"), "still diagnosable: {sanitized}");
444    }
445
446    #[test]
447    fn untrusted_text_is_escaped_and_bounded() {
448        assert_eq!(sanitize_untrusted_text("a\nb", 64), "a\\nb");
449        assert_eq!(sanitize_untrusted_text("héllo", 64), "héllo");
450        let long = sanitize_untrusted_text(&"x".repeat(100), 10);
451        assert_eq!(long, format!("{}…<truncated>", "x".repeat(10)));
452    }
453
454    #[test]
455    fn untrusted_ids_are_sentinelled() {
456        let canonical = "ab".repeat(32);
457        assert_eq!(hex64_or_sentinel(&canonical, "peer-id"), canonical);
458        assert_eq!(
459            hex64_or_sentinel(&"AB".repeat(32), "peer-id"),
460            canonical,
461            "canonical form is lowercase"
462        );
463        assert_eq!(
464            hex64_or_sentinel("short", "peer-id"),
465            "<non-canonical-peer-id>"
466        );
467        assert_eq!(
468            hex64_or_sentinel(&"zz".repeat(32), "hash"),
469            "<non-canonical-hash>"
470        );
471    }
472
473    #[test]
474    fn verify_errors_are_recoverable() {
475        let e: DownloadError = VerifyError::Length {
476            expected: 10,
477            actual: 9,
478        }
479        .into();
480        assert!(e.is_recoverable());
481    }
482
483    #[test]
484    fn timeout_is_recoverable() {
485        let peer = "cd".repeat(32);
486        let e = DownloadError::Timeout {
487            provider: peer.clone(),
488        };
489        assert!(e.is_recoverable());
490        assert!(e.to_string().contains(&peer));
491        assert!(e.to_string().contains("timed out"));
492    }
493
494    #[test]
495    fn terminal_errors_are_not_recoverable() {
496        assert!(!DownloadError::NoProviders { needed: 1 }.is_recoverable());
497        assert!(!DownloadError::Cancelled.is_recoverable());
498        assert!(!DownloadError::NotDownloadable.is_recoverable());
499    }
500
501    #[test]
502    fn sink_and_state_helpers_format() {
503        assert!(DownloadError::sink("disk full")
504            .to_string()
505            .contains("disk full"));
506        assert!(DownloadError::state("corrupt")
507            .to_string()
508            .contains("corrupt"));
509    }
510
511    #[test]
512    fn verify_error_display_is_descriptive() {
513        assert!(VerifyError::Root
514            .to_string()
515            .contains("chain-anchored root"));
516        assert!(VerifyError::Metadata("x".into())
517            .to_string()
518            .contains("commitment"));
519        assert!(VerifyError::Alignment("y".into())
520            .to_string()
521            .contains("chunk-aligned"));
522        assert!(VerifyError::MissingMetadata("z".into())
523            .to_string()
524            .contains("missing verification metadata"));
525    }
526}