Skip to main content

decdn_cache/origin/
http.rs

1//! HTTP origin backend.
2//!
3//! Fetches blobs from `{base_url}/{blake3_hex}`. The base URL is opaque
4//! per-node config; it is never put on the wire (see ADR 012 — "no external
5//! origin URLs are ever exposed").
6
7use std::future::Future;
8use std::pin::Pin;
9use std::time::Duration;
10
11use anyhow::Context;
12use bytes::{Bytes, BytesMut};
13use futures_util::Stream;
14use iroh_blobs::Hash;
15use reqwest::StatusCode;
16use reqwest::header::{CONTENT_ENCODING, CONTENT_LENGTH, RANGE};
17
18use super::fs::OBAO4_SUFFIX;
19use super::io_runtime::IoRuntime;
20use super::{
21    DEFAULT_USER_AGENT, DecompressMode, Origin, OriginFetch, OriginKind, OriginRangeFetch,
22    OriginRangeRequest, OriginUrl, OutboardFetch, decompress, parse_origin_url, redact_for_log,
23};
24use crate::error::{OriginError, OriginPullError};
25
26/// How long to wait for the TCP/TLS handshake to complete. Per-request total
27/// duration is intentionally *not* bounded because `max_blob_size_mb` can be
28/// as large as 10 GB and no single total-request timeout fits both small and
29/// large blobs. Instead, we pair this with phase-level timeouts below.
30const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
31
32/// Ceiling on how long the origin may take to respond with headers after
33/// the connection is established. Without this, a compromised or misbehaving
34/// origin could accept the connection and then stall indefinitely, tying up
35/// a fetch task with no bytes in flight to catch it any other way.
36const RESPONSE_HEADERS_TIMEOUT: Duration = Duration::from_secs(30);
37
38/// Ceiling on the idle gap between body chunks. Bounds the time a
39/// slow-trickle origin can tie up a task — total bytes are capped by
40/// `max_bytes`, but without this check a pathological origin could send
41/// a single byte per second within the total cap and keep the task alive
42/// for days.
43const CHUNK_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
44
45/// Origin backed by a plain HTTP(S) endpoint serving content-addressed blobs
46/// at `{base_url}/{blake3_hex}`.
47#[derive(Debug, Clone)]
48pub struct HttpOrigin {
49    client: reqwest::Client,
50    /// The runtime every request send runs on, so the client's pooled
51    /// keep-alive connections live there and not on a caller's pull-leg
52    /// runtime. See [`super::io_runtime`].
53    io: IoRuntime,
54    base_url: OriginUrl,
55    response_headers_timeout: Duration,
56    chunk_idle_timeout: Duration,
57    /// How to handle `Content-Encoding` on the response. Defaults to
58    /// [`DecompressMode::Auto`] — see that type for the full semantics.
59    /// Override with [`Self::with_decompress_mode`].
60    decompress: DecompressMode,
61}
62
63impl HttpOrigin {
64    /// Build an [`HttpOrigin`] around a validated [`OriginUrl`]. The only way
65    /// to obtain one is [`parse_origin_url`], so invariants are enforced at
66    /// the type boundary rather than documented in prose. Timeouts default
67    /// to the `RESPONSE_HEADERS_TIMEOUT` and `CHUNK_IDLE_TIMEOUT` constants; override
68    /// with [`Self::with_timeouts`] if operator policy or tests require
69    /// different values.
70    ///
71    /// Sets [`DEFAULT_USER_AGENT`] on the inner reqwest client so origin
72    /// access logs can attribute CDN pull-through traffic (#435). Override
73    /// via [`Self::new_with_user_agent`].
74    ///
75    /// The origin's connections live on the tokio runtime this is called
76    /// on, so call it on a runtime that outlives every fetch (the node's
77    /// main runtime).
78    ///
79    /// # Errors
80    ///
81    /// Fails outside a tokio runtime, or when the reqwest client cannot be
82    /// built.
83    pub fn new(base_url: OriginUrl) -> anyhow::Result<Self> {
84        Self::new_with_user_agent(base_url, DEFAULT_USER_AGENT)
85    }
86
87    /// Like [`Self::new`] but with a caller-supplied `User-Agent`. Used by
88    /// the runtime to honour an operator-configured `cache.user_agent`
89    /// without rebuilding the client through a separate code path.
90    /// `user_agent` must be a valid header value (visible-ASCII; `reqwest`
91    /// rejects others at build time and the error is surfaced as a
92    /// startup failure).
93    ///
94    /// # Errors
95    ///
96    /// Fails outside a tokio runtime, or when the reqwest client cannot be
97    /// built (an invalid `user_agent`).
98    pub fn new_with_user_agent(base_url: OriginUrl, user_agent: &str) -> anyhow::Result<Self> {
99        // We want to inspect `Content-Encoding` and run the body through
100        // our own decoders, so disable reqwest's built-in transparent
101        // decompression — otherwise reqwest would strip the header and
102        // hand back already-decompressed bytes, masking what the origin
103        // actually sent and bypassing our `UnsupportedEncoding` error path.
104        //
105        // `redirect::Policy::none()` defuses SSRF (#579): the configured
106        // base URL is validated by `parse_origin_url`, but reqwest's
107        // default policy (follow up to 10) would let a compromised or
108        // misconfigured origin 3xx-redirect us to `http://169.254.169.254`
109        // (cloud metadata), `http://127.0.0.1`, or any RFC-1918 host —
110        // the request lands on the internal target before BLAKE3
111        // verification gets a chance to fire on the body. Origins must
112        // serve `{base}/{hex}` directly anyway (query/fragment are
113        // already rejected by `parse_origin_url`), so a redirect was
114        // never a legitimate response shape.
115        //
116        // reqwest's idle keep-alive pool is safe to share across callers:
117        // `send` runs every request on `io`, so each pooled connection lives
118        // on the runtime that built this origin, never on a caller's
119        // pull-leg runtime (`super::io_runtime`).
120        //
121        // The workspace builds reqwest with `rustls-no-provider`, so
122        // `build` panics without a process-wide rustls provider. Install
123        // ring, as `decdn_common::tls::install_crypto_provider` does (this
124        // crate does not depend on `decdn-common`); a no-op once installed.
125        let _ = rustls::crypto::ring::default_provider().install_default();
126        let client = reqwest::Client::builder()
127            .connect_timeout(CONNECT_TIMEOUT)
128            .user_agent(user_agent)
129            .redirect(reqwest::redirect::Policy::none())
130            .no_gzip()
131            .no_deflate()
132            .no_brotli()
133            .no_zstd()
134            .build()
135            .context("failed to build reqwest client")?;
136        Ok(Self {
137            client,
138            io: IoRuntime::current()?,
139            base_url,
140            response_headers_timeout: RESPONSE_HEADERS_TIMEOUT,
141            chunk_idle_timeout: CHUNK_IDLE_TIMEOUT,
142            decompress: DecompressMode::Auto,
143        })
144    }
145
146    /// Convenience constructor that parses `raw` via [`parse_origin_url`]
147    /// before delegating to [`Self::new`]. Prefer [`Self::new`] in
148    /// runtime paths so URL validation happens at config-load time.
149    pub fn parse(raw: &str) -> anyhow::Result<Self> {
150        Self::new(parse_origin_url(raw)?)
151    }
152
153    /// Override the phase timeouts. Primarily exists for tests — runtime
154    /// callers should use the defaults unless operator policy dictates
155    /// otherwise.
156    #[must_use]
157    pub const fn with_timeouts(
158        mut self,
159        response_headers_timeout: Duration,
160        chunk_idle_timeout: Duration,
161    ) -> Self {
162        self.response_headers_timeout = response_headers_timeout;
163        self.chunk_idle_timeout = chunk_idle_timeout;
164        self
165    }
166
167    /// Set the [`DecompressMode`]. See that type for the semantics of
168    /// `Auto` vs `Strict`. Defaults to `Auto`.
169    #[must_use]
170    pub const fn with_decompress_mode(mut self, mode: DecompressMode) -> Self {
171        self.decompress = mode;
172        self
173    }
174}
175
176/// Classify a `reqwest::Error` raised by `.send()` or `.chunk()`. The
177/// transient bucket spans every realistic transport-layer failure on
178/// this code path:
179///
180/// - `is_connect()` — TCP/TLS handshake failed.
181/// - `is_timeout()` — reqwest's own timeout fired (separate from our
182///   `tokio::time::timeout` wrappers, which produce `Elapsed` and never
183///   reach this helper).
184/// - `is_request()` — request-construction problems that *can* fire on
185///   a parsed URL (e.g. invalid header value injected by reqwest mid-
186///   redirect).
187/// - `is_body()` — body stream ended unexpectedly. Could in principle
188///   be a deterministic origin protocol violation (mid-stream framing
189///   error), but in operational practice this fires on connection
190///   resets and is worth retrying. Tradeoff is bounded by `max_retries`.
191/// - `is_decode()` — character-set / chunked-transfer parse failure.
192///   With reqwest auto-decompression disabled (see `HttpOrigin::new`)
193///   this does not fire for gzip/zstd; the remaining triggers are
194///   protocol-level and retrying *may* mask a deterministic bug, but
195///   the `max_retries` ceiling bounds the cost.
196///
197/// The `else` arm covers `is_builder()` (unreachable with a parsed
198/// `OriginUrl`), `is_redirect()` (unreachable with `Policy::none()` —
199/// 3xx responses are returned as `resp.status()` instead of as a
200/// reqwest error; see `HttpOrigin::new_with_user_agent`), `is_status()`
201/// (we handle status codes ourselves before reaching here), and any
202/// future reqwest variant. Permanent because none of those are
203/// operationally retriable.
204fn classify_reqwest_error(e: reqwest::Error) -> OriginPullError {
205    if e.is_connect() || e.is_timeout() || e.is_request() || e.is_body() || e.is_decode() {
206        OriginPullError::Transient(e.into())
207    } else {
208        OriginPullError::Permanent(e.into())
209    }
210}
211
212/// Status-code classification per RFC 9110 + operational practice:
213/// 5xx, 408 (Request Timeout), and 429 (Too Many Requests) are retriable;
214/// every other 4xx is permanent. `404` is intercepted earlier as
215/// `OriginFetch::NotFound`; this helper assumes the caller already
216/// excluded it.
217fn is_transient_status(status: StatusCode) -> bool {
218    status.is_server_error()
219        || status == StatusCode::REQUEST_TIMEOUT
220        || status == StatusCode::TOO_MANY_REQUESTS
221}
222
223impl Origin for HttpOrigin {
224    fn kind(&self) -> OriginKind {
225        OriginKind::Http
226    }
227
228    fn fetch(
229        &self,
230        hash: Hash,
231        max_bytes: u64,
232    ) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
233        Box::pin(async move {
234            // URL build can only fail on a malformed hash or a
235            // pathological base URL — both are caller-side bugs, never
236            // transient.
237            let url = self
238                .base_url
239                .as_url()
240                .join(&hash.to_hex())
241                .with_context(|| format!("failed to build URL for {hash}"))
242                .map_err(OriginPullError::Permanent)?;
243            // Always log the redacted form — `url` may inherit userinfo
244            // from `base_url`, and errors go to logs.
245            let url_log = redact_for_log(&url);
246
247            let resp = self
248                .send(self.client.get(url.clone()), &format!("GET {url_log}"))
249                .await?;
250
251            let status = resp.status();
252            // SSRF defence (#579): with `redirect::Policy::none()` (set
253            // in the client builder), reqwest returns 3xx responses
254            // straight to us instead of following. Refuse them with a
255            // clear operator-visible message rather than letting the
256            // generic "returned 302" path fire — origins must serve
257            // `{base}/{hex}` directly. Permanent because a compliant
258            // origin will not redirect; retrying won't change that.
259            if status.is_redirection() {
260                return Err(OriginPullError::Permanent(anyhow::anyhow!(
261                    "origin GET {url_log} returned {status}; redirects are disabled \
262                     to prevent SSRF — origin must serve {{base}}/{{hex}} directly"
263                )));
264            }
265            if status == StatusCode::NOT_FOUND {
266                return Ok(OriginFetch::NotFound);
267            }
268            if !status.is_success() {
269                let err = anyhow::anyhow!("origin GET {url_log} returned {status}");
270                return Err(if is_transient_status(status) {
271                    OriginPullError::Transient(err)
272                } else {
273                    OriginPullError::Permanent(err)
274                });
275            }
276
277            // Capture the encoding before consuming the body. RFC 9110
278            // § 5.6.7 restricts the header to ASCII tokens; a value
279            // that fails `to_str()` is a malformed origin response,
280            // not "no encoding". Surfacing `MalformedEncoding` keeps
281            // a buggy or hostile origin from smuggling raw compressed
282            // bytes past the decompression layer.
283            let encoding = match resp.headers().get(CONTENT_ENCODING) {
284                None => String::new(),
285                Some(v) => match v.to_str() {
286                    Ok(s) => s.to_string(),
287                    Err(_) => {
288                        // Malformed header is a deterministic origin
289                        // protocol violation — won't be cured by retry.
290                        return Err(OriginPullError::Permanent(
291                            OriginError::MalformedEncoding.into(),
292                        ));
293                    }
294                },
295            };
296            let trimmed = encoding.trim();
297            // Classify the encoding and apply the strict/auto policy in one
298            // place shared with the S3 backend. `Err` covers both unknown
299            // encodings (e.g. `br`) and known encodings rejected under
300            // `Strict` — both are permanent and fire before any body bytes
301            // are read.
302            let supported_encoding = match decompress::resolve_encoding(trimmed, self.decompress) {
303                Ok(encoding) => encoding,
304                Err(err) => return Err(OriginPullError::Permanent(err.into())),
305            };
306
307            // Fast-path rejection using the advertised length before
308            // we read anything. For identity bodies, encoded length is
309            // payload length and must not exceed `max_bytes`. For
310            // compressed bodies, encoded length is decoupled from
311            // decompressed length, but a compressed body advertising
312            // > max_bytes is malicious or misconfigured (compression
313            // ratios < 1 are universal in practice).
314            let advertised_len = resp.content_length();
315            if let Some(len) = advertised_len
316                && len > max_bytes
317            {
318                return Err(OriginPullError::Permanent(anyhow::anyhow!(
319                    "origin GET {url_log} advertises {len} bytes, exceeds max {max_bytes}"
320                )));
321            }
322
323            // Build the body stream:
324            //   1. response_chunk_stream pulls encoded chunks via
325            //      `resp.chunk()` (a `stream::unfold` so we own the
326            //      poll loop and the per-chunk idle timer)
327            //   2. wrap with per-chunk idle timeout + cap on the encoded
328            //      size (defense against unbounded slow-trickle / lying
329            //      origins; the same `max_bytes` cap the advertised-length
330            //      check above uses, applied to
331            //      encoded bytes for compressed responses — compression
332            //      ratios < 1 mean this is also a sound bound on the
333            //      decoded size).
334            //   3. for compressed responses, layer the decoder via
335            //      `decompress::decode_stream` — see that module for the
336            //      decoder sandwich and how the decompressed-side cap is
337            //      enforced by the engine's `count_and_cap_stream` (a 1 KB
338            //      compressed payload that decompresses to 100 GB fails
339            //      fast at the engine seam without ever pinning that
340            //      memory).
341            let idle_timeout = self.chunk_idle_timeout;
342            let raw_stream = response_chunk_stream(resp, idle_timeout, max_bytes, url_log);
343
344            // Layer the decoder (if any) onto the encoded chunk stream.
345            // Decoder errors (truncated body, bad magic, mid-stream
346            // checksum mismatch) are wrapped as a typed
347            // `OriginError::DecompressionFailed` inside the shared helper so
348            // `crate::retry::classify_io_error` routes them into
349            // `OriginPullError::Permanent` (decoder failures aren't
350            // retryable; the body is corrupt).
351            let decoded_stream = decompress::decode_stream(raw_stream, supported_encoding);
352
353            // For a decoded (compressed) response the advertised
354            // `Content-Length` is the *encoded* length, which understates
355            // the canonical length. Reporting it as `size_hint` would let
356            // the engine route a body whose encoded length fits under
357            // `buffered_max_bytes` into the buffer/drain path, where the
358            // drain cap (`buffered_max_bytes`) is applied to the *decoded*
359            // stream and falsely rejects an in-bounds blob as
360            // `BlobTooLarge` (#804). Hand `None` for compressed bodies so
361            // they always take the streaming path, where
362            // `count_and_cap_stream` checks the running decoded total
363            // against the correct `max_blob_bytes`. The pre-stream
364            // `advertised_len > max_bytes` short-circuit above still runs
365            // first (compressed-len > max_bytes already implies
366            // decoded-len > max_bytes under sane ratios). For identity
367            // responses the hint is the canonical length.
368            let size_hint = if supported_encoding.is_some() {
369                None
370            } else {
371                advertised_len
372            };
373            Ok(OriginFetch::Found {
374                stream: decoded_stream,
375                size_hint,
376            })
377        })
378    }
379
380    fn fetch_range_data(
381        &self,
382        hash: Hash,
383        req: OriginRangeRequest,
384    ) -> Pin<Box<dyn Future<Output = Result<OriginRangeFetch, OriginPullError>> + Send + '_>> {
385        Box::pin(async move {
386            // Ranged data read. `Range: bytes=a-(b-1)` is inclusive-end. A
387            // compliant origin answers `206 Partial Content` with exactly the
388            // requested span. A `200` means the origin ignored `Range` and
389            // would stream the whole blob — refuse and let the engine
390            // whole-blob pull instead of buffering the entire object here.
391            let data_url = self
392                .base_url
393                .as_url()
394                .join(&hash.to_hex())
395                .with_context(|| format!("failed to build URL for {hash}"))
396                .map_err(OriginPullError::Permanent)?;
397            // Empty span only for a zero-length blob — nothing to range.
398            if req.is_empty() {
399                return Ok(OriginRangeFetch::Ranged { data: Bytes::new() });
400            }
401            // Inclusive end: HTTP byte ranges are `[a, b]`, our span is
402            // `[fetch_start, fetch_end)`. `fetch_end > fetch_start` here (the
403            // empty-span case returned above), so the subtraction is sound.
404            let range_val = format!("bytes={}-{}", req.fetch_start, req.fetch_end - 1);
405            let want = req.len();
406            let Some(data) = self.get_range_bytes(&data_url, &range_val, want).await? else {
407                return Ok(OriginRangeFetch::Unsupported);
408            };
409            Ok(OriginRangeFetch::Ranged { data })
410        })
411    }
412
413    fn fetch_outboard(
414        &self,
415        hash: Hash,
416        outboard_max_bytes: u64,
417    ) -> Pin<Box<dyn Future<Output = Result<OutboardFetch, OriginPullError>> + Send + '_>> {
418        Box::pin(async move {
419            let hex = hash.to_hex();
420            let obao4_url = self
421                .base_url
422                .as_url()
423                .join(&format!("{hex}{OBAO4_SUFFIX}"))
424                .with_context(|| format!("failed to build outboard URL for {hash}"))
425                .map_err(OriginPullError::Permanent)?;
426            self.get_outboard_bounded(&obao4_url, outboard_max_bytes)
427                .await
428        })
429    }
430
431    fn size(
432        &self,
433        hash: Hash,
434    ) -> Pin<Box<dyn Future<Output = Result<Option<u64>, OriginPullError>> + Send + '_>> {
435        Box::pin(async move {
436            let hex = hash.to_hex();
437            let url = self
438                .base_url
439                .as_url()
440                .join(&hex)
441                .with_context(|| format!("failed to build URL for {hash}"))
442                .map_err(OriginPullError::Permanent)?;
443            let url_log = redact_for_log(&url);
444            // A `HEAD` is the cheapest way to learn the canonical length — no
445            // body crosses the wire.
446            let resp = self
447                .send(self.client.head(url.clone()), &format!("HEAD {url_log}"))
448                .await?;
449            let status = resp.status();
450            // Status classification mirrors `fetch` and the S3 adapter's
451            // `classify_head_object_error`: only a 404 asserts absence and
452            // degrades to `Ok(None)`; every other non-success is a fault the
453            // caller must not read as "the origin does not hold the object".
454            // The distinction is load-bearing well beyond the range-pull
455            // degrade: `probe_origin_chain` feeds the origin-only serve gate
456            // and the DHT announce set, and an `Ok(None)` there is an
457            // authoritative absence — memoised under the negative TTL, signed
458            // as `NotFound` to a paying client, and grounds for the
459            // republisher to unschedule the hash. A 503 must never do that.
460            // Redirects stay disabled (SSRF, #579); a 3xx is a deterministic
461            // origin misconfiguration, so it classifies as permanent like the
462            // other non-transient statuses. Callers that only wanted the
463            // best-effort range scope swallow the error and degrade to a
464            // whole-blob `fetch`, which re-surfaces a persistent fault at
465            // full severity.
466            if status == StatusCode::NOT_FOUND {
467                return Ok(None);
468            }
469            if !status.is_success() {
470                let err = anyhow::anyhow!("origin HEAD {url_log} returned {status}");
471                return Err(if is_transient_status(status) {
472                    OriginPullError::Transient(err)
473                } else {
474                    OriginPullError::Permanent(err)
475                });
476            }
477            // A `Content-Encoding` response advertises the *encoded* length in
478            // `Content-Length`, not the canonical blob size the bao tree needs
479            // — the same trap `fetch` sidesteps for `size_hint`. Treat as
480            // unknown so a compressed origin degrades to a whole-blob pull
481            // rather than feeding a wrong size into bao alignment. (A compressed
482            // *ranged* fetch is separately caught downstream: the S3 adapter
483            // refuses it explicitly, and the HTTP adapter's exact-length gate +
484            // bao verification reject it — either way it degrades, never serves.)
485            if resp
486                .headers()
487                .get(CONTENT_ENCODING)
488                .is_some_and(|v| !v.is_empty())
489            {
490                return Ok(None);
491            }
492            // Read the `Content-Length` header directly rather than
493            // `resp.content_length()`: the latter reflects the (empty) HEAD
494            // response body, not the header, so it cannot surface the object's
495            // true length; the header carries it. A malformed/absent header →
496            // unknown size, degrade.
497            Ok(resp
498                .headers()
499                .get(CONTENT_LENGTH)
500                .and_then(|v| v.to_str().ok())
501                .and_then(|s| s.trim().parse::<u64>().ok()))
502        })
503    }
504}
505
506impl HttpOrigin {
507    /// Send `req` on [`Self::io`] and wait for the response headers.
508    /// `what` names the request in errors (`GET {url}`, already redacted).
509    ///
510    /// The client's `connect_timeout` covers the TCP/TLS handshake. The
511    /// headers timeout covers the whole send — request write through
512    /// response-header receipt, plus a connect when no idle connection is
513    /// pooled — and bounds a server that accepts the connection but never
514    /// writes back. The deadline runs on the caller's runtime, so it also
515    /// fires when `io` stops polling the send; dropping the send then aborts
516    /// the spawned task.
517    ///
518    /// # Errors
519    ///
520    /// A headers timeout is [`OriginPullError::Transient`]. A transport fault
521    /// is classified by `classify_reqwest_error`. A fault of the spawned task
522    /// itself comes from [`IoRuntime::run`].
523    async fn send(
524        &self,
525        req: reqwest::RequestBuilder,
526        what: &str,
527    ) -> Result<reqwest::Response, OriginPullError> {
528        let headers_timeout = self.response_headers_timeout;
529        let sent = tokio::time::timeout(headers_timeout, self.io.run(req.send()))
530            .await
531            .map_err(|_elapsed| {
532                OriginPullError::Transient(anyhow::anyhow!(
533                    "origin {what} headers timed out after {headers_timeout:?}"
534                ))
535            })?
536            .map_err(|e| e.map_inner(|e| e.context(format!("origin {what} failed"))))?;
537        sent.map_err(|reqwest_err| {
538            classify_reqwest_error(reqwest_err)
539                .map_inner(|e| e.context(format!("origin {what} failed")))
540        })
541    }
542
543    /// GET `url` and buffer the whole body, capped at `max_bytes`, as an
544    /// [`OutboardFetch`]. A genuine 404 is [`OutboardFetch::NotFound`]; every
545    /// other non-success status — redirect (disabled per #579), permission
546    /// decline, 5xx — is [`OutboardFetch::Unsupported`]. Neither is an error; only a
547    /// transport-level fault on the `.send()` surfaces as [`OriginPullError`].
548    async fn get_outboard_bounded(
549        &self,
550        url: &reqwest::Url,
551        max_bytes: u64,
552    ) -> Result<OutboardFetch, OriginPullError> {
553        let url_log = redact_for_log(url);
554        let resp = self
555            .send(self.client.get(url.clone()), &format!("GET {url_log}"))
556            .await?;
557        if resp.status() == StatusCode::NOT_FOUND {
558            return Ok(OutboardFetch::NotFound);
559        }
560        // Any other non-success (3xx — redirects are disabled, #579 — 401/403,
561        // 5xx) degrades rather than errors: the optimization is best-effort,
562        // and a persistent fault re-surfaces at full severity on whatever
563        // fallback path the caller takes next.
564        if !resp.status().is_success() {
565            return Ok(OutboardFetch::Unsupported);
566        }
567        if let Some(len) = resp.content_length()
568            && len > max_bytes
569        {
570            return Ok(OutboardFetch::Unsupported);
571        }
572        match self.collect_capped(resp, max_bytes, url_log).await? {
573            Some(bytes) => Ok(OutboardFetch::Found(bytes)),
574            None => Ok(OutboardFetch::Unsupported),
575        }
576    }
577
578    /// GET `url` with a `Range` header and buffer the partial body. Returns
579    /// `Ok(None)` when the origin does not honor the range (any status other
580    /// than `206`, e.g. a `200` whole-blob response) or the returned span is
581    /// not exactly `want` bytes — both degrade the engine to a whole-blob
582    /// pull. `want` is capped both as the buffer ceiling and as an exact-length
583    /// check, so an origin that streams the whole object on a `200` is rejected
584    /// at the header (status) before any large buffer is committed.
585    async fn get_range_bytes(
586        &self,
587        url: &reqwest::Url,
588        range_val: &str,
589        want: u64,
590    ) -> Result<Option<Bytes>, OriginPullError> {
591        let url_log = redact_for_log(url);
592        let resp = self
593            .send(
594                self.client.get(url.clone()).header(RANGE, range_val),
595                &format!("GET {url_log} (range)"),
596            )
597            .await?;
598        // Only `206 Partial Content` is an honored range. A `200` means the
599        // origin ignored `Range` and is sending the whole blob — degrade
600        // before reading the (potentially huge) body.
601        if resp.status() != StatusCode::PARTIAL_CONTENT {
602            return Ok(None);
603        }
604        // A `206` whose advertised `Content-Length` already differs from the
605        // requested span is a misbehaving origin — degrade BEFORE collecting
606        // the body rather than buffering up to `want` bytes only to reject
607        // them. `collect_capped` caps at `want`, and the exact-length gate
608        // below is the load-bearing check, but a wrong `Content-Length` lets
609        // us skip the read entirely.
610        if let Some(len) = resp.content_length()
611            && len != want
612        {
613            return Ok(None);
614        }
615        let Some(bytes) = self.collect_capped(resp, want, url_log).await? else {
616            return Ok(None);
617        };
618        // A `206` whose body length differs from the requested span is a
619        // misbehaving origin (multipart/byteranges, off-by-one, truncation).
620        // Degrade rather than feed a wrong-length span to verification.
621        if u64::try_from(bytes.len()).unwrap_or(u64::MAX) != want {
622            return Ok(None);
623        }
624        Ok(Some(bytes))
625    }
626
627    /// Drain `resp`'s body into `Bytes`, aborting (returning `Ok(None)`) the
628    /// moment the cumulative size exceeds `max_bytes`. Per-chunk idle timeout
629    /// bounds a slow-trickle origin. Transport errors mid-body surface as
630    /// `Transient`. The range path buffers (rather than streams) because the
631    /// payloads are bounded: the outboard is `O(blob/256)` and the data span
632    /// is at most one [`crate::RANGE_PULL_WINDOW_BYTES`] window, both far
633    /// below the whole-blob streaming threshold that motivated #271.
634    async fn collect_capped(
635        &self,
636        mut resp: reqwest::Response,
637        max_bytes: u64,
638        url_log: String,
639    ) -> Result<Option<Bytes>, OriginPullError> {
640        let cap = usize::try_from(max_bytes).unwrap_or(usize::MAX);
641        let mut buf = BytesMut::new();
642        loop {
643            match tokio::time::timeout(self.chunk_idle_timeout, resp.chunk()).await {
644                Err(_elapsed) => {
645                    return Err(OriginPullError::Transient(anyhow::anyhow!(
646                        "origin GET {url_log} body read stalled"
647                    )));
648                }
649                Ok(Err(reqwest_err)) => {
650                    return Err(classify_reqwest_error(reqwest_err)
651                        .map_inner(|e| e.context(format!("origin GET {url_log} body failed"))));
652                }
653                Ok(Ok(None)) => break,
654                Ok(Ok(Some(chunk))) => {
655                    if buf.len().saturating_add(chunk.len()) > cap {
656                        // Over the bound → degrade (the optimization is
657                        // best-effort; a too-big sibling/span is treated as
658                        // "not range-pullable", never a hard error).
659                        return Ok(None);
660                    }
661                    buf.extend_from_slice(&chunk);
662                }
663            }
664        }
665        Ok(Some(buf.freeze()))
666    }
667}
668
669/// Build the per-chunk stream over `resp.chunk()`. Wraps each chunk
670/// fetch in a `tokio::time::timeout` to bound slow-trickle origins
671/// (idle gap), and runs a per-chunk cap that aborts the moment
672/// cumulative encoded bytes exceed `max_bytes`. reqwest transport
673/// errors are converted to `io::Error::other(...)` since
674/// `iroh_blobs::Blobs::add_stream` expects `io::Result<Bytes>`; the
675/// transient/permanent classification we'd normally apply is not
676/// recoverable mid-stream anyway (the engine surfaces stream errors
677/// as a generic origin failure).
678fn response_chunk_stream(
679    resp: reqwest::Response,
680    idle_timeout: Duration,
681    max_bytes: u64,
682    url_log: String,
683) -> impl Stream<Item = std::io::Result<Bytes>> + Send + Sync + 'static {
684    // **Termination after error:** wrap `resp` in `Option` so polling
685    // stops once we yield `Err`. After an error or cap breach,
686    // calling `resp.chunk()` again is undefined behaviour for reqwest
687    // (the body stream may be in a torn-down state); the sentinel
688    // makes the wrapper deterministic and prevents iroh-blobs'
689    // `add_stream` from hanging on a re-poll.
690    futures_util::stream::unfold(
691        (Some(resp), 0u64, idle_timeout, url_log),
692        move |(maybe_resp, total, idle, url_log)| async move {
693            let mut resp = maybe_resp?;
694            let chunk = match tokio::time::timeout(idle, resp.chunk()).await {
695                Err(_elapsed) => {
696                    let err = std::io::Error::other(format!(
697                        "origin GET {url_log} body read stalled for {idle:?} after {total} bytes buffered"
698                    ));
699                    return Some((Err(err), (None, total, idle, url_log)));
700                }
701                Ok(Err(reqwest_err)) => {
702                    let err = std::io::Error::other(format!(
703                        "origin GET {url_log} body read failed: {reqwest_err}"
704                    ));
705                    return Some((Err(err), (None, total, idle, url_log)));
706                }
707                Ok(Ok(None)) => return None,
708                Ok(Ok(Some(chunk))) => chunk,
709            };
710            let next_total = total.saturating_add(chunk.len() as u64);
711            if next_total > max_bytes {
712                // Pack a typed `BlobTooLargeMarker` so the engine
713                // surfaces `CacheError::BlobTooLarge` rather than
714                // generic `OriginError`. This is the *encoded*-side
715                // cap; for compressed bodies an encoded overrun
716                // implies a decoded overrun (compression ratios <1
717                // in practice), so the same typed shape is the right
718                // operator-visible error.
719                let err = std::io::Error::other(super::BlobTooLargeMarker { max_bytes });
720                return Some((Err(err), (None, total, idle, url_log)));
721            }
722            Some((Ok(chunk), (Some(resp), next_total, idle, url_log)))
723        },
724    )
725}
726
727#[cfg(test)]
728mod tests;