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;