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