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;