decdn_cache/origin/mod.rs
1//! Origin backends — the source of truth a node falls back to on a cache miss.
2//!
3//! [`Origin`] is the seam. Three concrete implementations ship today:
4//! [`HttpOrigin`] (generic HTTP/S), [`FilesystemOrigin`] (local disk), and
5//! [`S3Origin`] (S3-compatible object stores: AWS S3, R2, B2, `MinIO`).
6
7pub(crate) mod decompress;
8pub mod fs;
9pub mod http;
10pub(crate) mod io_runtime;
11pub mod s3;
12
13use std::future::Future;
14use std::pin::Pin;
15
16use bytes::Bytes;
17use futures_util::Stream;
18use iroh_blobs::Hash;
19
20pub use fs::FilesystemOrigin;
21pub use http::HttpOrigin;
22pub use s3::{S3Credentials, S3Origin, S3OriginConfig};
23// `OriginKind`, `OriginUrl`, `DecompressMode`, `parse_origin_url`, and
24// `DEFAULT_USER_AGENT` moved to the `decdn-config-types` leaf crate
25// (#578); re-export from there so `decdn_cache::origin::*` paths and the
26// `Origin` trait's `kind()` return type stay identical to the
27// top-level `decdn_cache::*` re-exports.
28pub use decdn_config_types::{
29 DEFAULT_USER_AGENT, DecompressMode, OriginKind, OriginUrl, parse_origin_url, redact_for_log,
30};
31
32use crate::error::OriginPullError;
33
34/// Marker error packed into the `io::Error` that the cache's
35/// stream wrappers write to the engine's side channel when an
36/// origin delivers more than `max_blob_bytes`. The engine
37/// downcasts via `io::Error::get_ref` to surface
38/// `CacheError::BlobTooLarge` (matching the pre-streaming typed
39/// variant) instead of generic `OriginError`. Adapter-internal
40/// caps (HTTP's encoded chunk cap) and the engine's running cap
41/// on streamed bytes both produce this marker so the operator-
42/// visible error is consistent regardless of which layer first
43/// detected the overrun.
44#[derive(Debug)]
45pub(crate) struct BlobTooLargeMarker {
46 pub max_bytes: u64,
47}
48
49impl std::fmt::Display for BlobTooLargeMarker {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 write!(
52 f,
53 "origin stream exceeded max_blob_bytes={} mid-flight",
54 self.max_bytes
55 )
56 }
57}
58
59impl std::error::Error for BlobTooLargeMarker {}
60
61/// Boxed byte stream returned by [`Origin::fetch`]. Each [`Bytes`] item is
62/// one chunk of the blob's payload; the stream completes when the origin
63/// signals end-of-data.
64///
65/// The bound matches [`iroh_blobs::api::blobs::Blobs::add_stream`]: `Send +
66/// Sync + 'static` so the engine can hand the stream straight through to
67/// the store layer without an intermediate buffer. `'static` means impls
68/// move whatever per-fetch state they hold (file handle, HTTP response,
69/// S3 `ByteStream`) into the stream by `move`-into-async-block; nothing
70/// borrows from `&self` on the origin.
71///
72/// Errors flow as `io::Error`. They are routed through retry
73/// classification by one of two paths (#519):
74///
75/// - **Buffer-then-commit (small blobs):** when the adapter's
76/// `size_hint` is at or below
77/// `cache.origin_retry.buffered_max_bytes` (default 4 MiB), the
78/// engine drains the stream into a bounded `BytesMut` before
79/// handing to iroh-blobs. Drain errors are classified via
80/// `crate::retry::classify_io_error` and re-feed the retry loop.
81/// No on-disk amplification — the partial bytes never reach the
82/// store.
83/// - **Abort + restart (large blobs / unknown `size_hint`):** the
84/// engine streams directly into `iroh_blobs::Blobs::add_stream`
85/// and captures any mid-stream `io::Error` via the side channel.
86/// After `temp_tag().await` completes, the captured error is
87/// classified; on `Transient` the partial `TempTag` is dropped
88/// (iroh-blobs GC reclaims the bytes at `cache.gc_interval_sec`
89/// cadence, #518) and the retry loop restarts from the headers
90/// phase. Worst-case orphaned bytes per fetch are
91/// `(1 + max_retries) * max_blob_bytes` until GC.
92///
93/// Typed `Permanent` markers short-circuit retry regardless of which
94/// path applies: the internal `BlobTooLargeMarker` surfaces as
95/// [`crate::CacheError::BlobTooLarge`] (size-cap breach is
96/// deterministic — retry won't fit a bigger blob into a smaller cap),
97/// and [`crate::OriginError::DecompressionFailed`] surfaces as
98/// `Permanent` (corrupt body — retry sees the same bytes again).
99pub type OriginByteStream =
100 Pin<Box<dyn Stream<Item = std::io::Result<Bytes>> + Send + Sync + 'static>>;
101
102// `OriginKind` (the backend tag surfaced through the admin
103// eviction-preview, #439) moved to the `decdn-config-types` leaf crate
104// (#578) and is re-exported above. Its serde/`as_str`/`Display`
105// wire-form contract and the `origin_kind_*` tests live there.
106
107/// Result of an [`Origin::fetch`] call.
108///
109/// `Found` carries a streaming primitive so the cache engine pipes
110/// origin bytes straight into
111/// [`iroh_blobs::api::blobs::Blobs::add_stream`] without ever holding
112/// the full blob in memory (issue #271). `size_hint` carries the
113/// adapter's best-effort length advertisement (HTTP `Content-Length`,
114/// filesystem `metadata().len()`, S3 `content_length()`); the engine
115/// uses it for a short-circuit `BlobTooLarge` check before reading
116/// the first byte and falls back to a running-total check via
117/// `count_and_cap_stream` for adapters that can't know the length
118/// upfront or origins that lie. The hint is **advisory** — adapters
119/// that don't know set `None`, and even when set the actual stream
120/// length may differ (e.g., HTTP origins lying in `Content-Length`).
121pub enum OriginFetch {
122 /// The origin returned the bytes for this hash as a stream of chunks.
123 Found {
124 /// Chunked byte stream. See [`OriginByteStream`] for the bounds.
125 stream: OriginByteStream,
126 /// Best-effort upfront size estimate from the adapter (HTTP
127 /// `Content-Length`, filesystem `metadata().len()`, S3
128 /// `content_length()`). Treated as advisory by the engine —
129 /// the running cap on streamed bytes is the load-bearing
130 /// defense.
131 size_hint: Option<u64>,
132 },
133 /// The origin reported the object does not exist (e.g. HTTP 404).
134 NotFound,
135 /// The origin streamed this blob into the store itself, verifying each chunk
136 /// group against the rooted hash via [`crate::CacheEngine::admit_bao_stream`], and it
137 /// is present. The engine does not re-ingest: it reads the committed blob back
138 /// for a `ReturnBytes` caller and reports `Committed` for a fill. The
139 /// node-to-node pull uses this so a whole blob never lands in RAM.
140 AlreadyAdmitted,
141}
142
143impl std::fmt::Debug for OriginFetch {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 match self {
146 Self::Found { size_hint, .. } => f
147 .debug_struct("Found")
148 .field("size_hint", size_hint)
149 .finish_non_exhaustive(),
150 Self::NotFound => f.write_str("NotFound"),
151 Self::AlreadyAdmitted => f.write_str("AlreadyAdmitted"),
152 }
153 }
154}
155
156impl OriginFetch {
157 /// Wrap a fully-buffered `Bytes` payload as a single-chunk
158 /// [`OriginFetch::Found`]. Useful for tests and any caller that
159 /// already has the whole payload in hand. Real streaming adapters
160 /// (`FilesystemOrigin`, `HttpOrigin`, `S3Origin`) construct their
161 /// streams directly and don't go through this shim.
162 #[must_use]
163 pub fn found_one_shot(bytes: Bytes) -> Self {
164 let size_hint = u64::try_from(bytes.len()).ok();
165 let stream = futures_util::stream::once(async move { Ok(bytes) });
166 Self::Found {
167 stream: Box::pin(stream),
168 size_hint,
169 }
170 }
171
172 /// Drain the stream into a single [`Bytes`]. Returns `Ok(None)` for
173 /// `NotFound` or `AlreadyAdmitted` (neither carries a stream to drain —
174 /// a direct-admit origin's caller reads the blob back from the store
175 /// instead), `Ok(Some(bytes))` for `Found`. Used by tests and other
176 /// helpers that need the full payload as a contiguous buffer; the cache
177 /// pull-through path goes through `add_stream` directly and never
178 /// collects.
179 ///
180 /// The initial allocation is capped at 1 MiB regardless of
181 /// `size_hint` so a hostile origin advertising a multi-TiB
182 /// `Content-Length` can't trigger a huge alloc up front. The
183 /// buffer still grows incrementally past the cap; the engine's
184 /// running cap on streamed bytes catches the actual overrun.
185 pub async fn collect_to_bytes(self) -> Result<Option<Bytes>, std::io::Error> {
186 // Bound the upfront allocation regardless of `size_hint`. A
187 // hostile origin advertising `Content-Length: 10 TiB` would
188 // otherwise cause `BytesMut::with_capacity` to commit a huge
189 // virtual region before the first byte arrives — the engine's
190 // running cap on the streamed bytes catches the actual
191 // overrun, but the per-fetch initial allocation is the
192 // operator-visible DoS axis we still have to bound here. 1
193 // MiB matches the initial buffer hint the buffered code
194 // path used for the same reason; the buffer grows
195 // incrementally past it as chunks arrive.
196 const INITIAL_CAP: usize = 1 << 20; // 1 MiB
197 use bytes::BytesMut;
198 use futures_util::StreamExt;
199 match self {
200 Self::NotFound | Self::AlreadyAdmitted => Ok(None),
201 Self::Found {
202 mut stream,
203 size_hint,
204 } => {
205 let cap = size_hint
206 .and_then(|n| usize::try_from(n).ok())
207 .unwrap_or(0)
208 .min(INITIAL_CAP);
209 let mut buf = BytesMut::with_capacity(cap);
210 while let Some(chunk) = stream.next().await {
211 buf.extend_from_slice(&chunk?);
212 }
213 Ok(Some(buf.freeze()))
214 }
215 }
216 }
217}
218
219/// Result of an [`Origin::fetch_range_data`] call ([ADR 037 §Origin-tier
220/// pull-through](../../../adr/037-regional-proxy-warming.md), #823).
221///
222/// A range-scoped origin pull fetches the small sibling `{H}.obao4` outboard
223/// once ([`Origin::fetch_outboard`]), then fetches the requested byte span in
224/// windows of at most [`crate::RANGE_PULL_WINDOW_BYTES`], verifying them
225/// against the root `H` before any byte is imported or served — avoiding
226/// whole-blob origin egress to serve a byte range on a cache miss, with memory
227/// bounded by the window rather than the span (#2065). The verifier is
228/// the engine: one [`bao_tree::io::fsm::encode_ranges_validated`] pass on the
229/// [`crate::CacheEngine::origin_range_wire`] path.
230///
231/// The optimization is **best-effort**: when the origin does not honor
232/// `Range`, or the object is absent or shorter than the span, the adapter
233/// returns [`Self::Unsupported`] and the engine degrades to the existing
234/// whole-blob [`Origin::fetch`] pull. That fallback is never a
235/// correctness or availability failure — it only forgoes the cost reduction
236/// (ADR 037 §"Fallback is always correct").
237pub enum OriginRangeFetch {
238 /// The origin served the requested byte span. `data` covers exactly
239 /// `[req.fetch_start, req.fetch_end)` (the chunk-group-aligned span the
240 /// engine asked for) and is untrusted until the engine verifies it against
241 /// `H`.
242 Ranged {
243 /// The aligned data bytes, exactly `req.len()` long.
244 data: Bytes,
245 },
246 /// The origin reported the object (data key) does not exist.
247 NotFound,
248 /// The range optimization is not available for this fetch — no
249 /// `Range`/`206` support, or a span the object does not hold (a stale or
250 /// short copy). The engine degrades to a whole-blob [`Origin::fetch`]
251 /// pull. This is an expected answer, not an error. A missing `{H}.obao4` is
252 /// reported by [`Origin::fetch_outboard`], not here.
253 Unsupported,
254}
255
256impl std::fmt::Debug for OriginRangeFetch {
257 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 match self {
259 Self::Ranged { data } => f
260 .debug_struct("Ranged")
261 .field("data_len", &data.len())
262 .finish(),
263 Self::NotFound => f.write_str("NotFound"),
264 Self::Unsupported => f.write_str("Unsupported"),
265 }
266 }
267}
268
269/// The chunk-group-aligned span a [`Origin::fetch_range_data`] call must
270/// fetch, passed from the engine to the adapter. This is the
271/// produced-by-engine half of the range pull; the verify-against-root half
272/// lives in [`crate::range_pull`] and [`crate::origin_range`].
273#[derive(Debug, Clone, Copy)]
274pub struct OriginRangeRequest {
275 /// First byte of the chunk-group-aligned data span to fetch (inclusive).
276 pub fetch_start: u64,
277 /// One past the last byte of the data span to fetch (exclusive). The
278 /// adapter issues an inclusive-end `Range`/`GetObject` read of
279 /// `[fetch_start, fetch_end)`.
280 pub fetch_end: u64,
281}
282
283impl OriginRangeRequest {
284 /// Number of data bytes to fetch (`fetch_end - fetch_start`). Saturating so
285 /// a mis-constructed request can never underflow; the engine always builds
286 /// these from a validated [`crate::range_pull::AlignedRange`], where
287 /// `fetch_end >= fetch_start` holds by construction.
288 #[must_use]
289 pub const fn len(&self) -> u64 {
290 self.fetch_end.saturating_sub(self.fetch_start)
291 }
292
293 /// Is the requested span empty? Only true for a zero-length blob; the
294 /// engine never issues an empty range against a non-empty blob.
295 #[must_use]
296 pub const fn is_empty(&self) -> bool {
297 self.fetch_end <= self.fetch_start
298 }
299}
300
301/// Result of an [`Origin::fetch_outboard`] call — a standalone fetch of just
302/// the sibling `{H}.obao4` outboard, with no accompanying blob data. Feeds a
303/// later "stream-while-store" serve path (#1130): a node that already holds
304/// the outboard but not the full blob can start verifying/serving a
305/// requester's byte range before the origin pull-through of the data
306/// completes, rather than waiting on a whole-blob fetch first.
307///
308/// Like [`OriginRangeFetch`], the origin is a dumb byte store: the returned
309/// outboard is **untrusted** until verified against the root `H`.
310pub enum OutboardFetch {
311 /// The origin served the sibling `{H}.obao4` outboard, bounded by the
312 /// caller's `outboard_max_bytes`.
313 Found(Bytes),
314 /// The origin reported the outboard object does not exist (e.g. HTTP
315 /// 404, S3 `NoSuchKey`, a missing filesystem sibling).
316 NotFound,
317 /// The outboard fetch is not available from this origin — e.g. a status
318 /// other than success/404 (redirect, permission decline), or an
319 /// oversize outboard rejected before buffering. Never an error: the
320 /// caller degrades to whatever fallback applies (a later whole-blob
321 /// pull will re-surface a genuine, persistent fault at its proper
322 /// severity).
323 Unsupported,
324}
325
326impl std::fmt::Debug for OutboardFetch {
327 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328 match self {
329 Self::Found(bytes) => f.debug_tuple("Found").field(&bytes.len()).finish(),
330 Self::NotFound => f.write_str("NotFound"),
331 Self::Unsupported => f.write_str("Unsupported"),
332 }
333 }
334}
335
336/// An origin backend. Implementors fetch a blob identified by its BLAKE3 hash.
337///
338/// The origin is **not** responsible for verifying the hash — the cache engine
339/// does that after the bytes come back. Returning the wrong bytes is a
340/// detectable protocol violation, not a security breach: no data is trusted
341/// until it matches the content address.
342///
343/// ## Error classification
344///
345/// Adapters return [`OriginPullError::Transient`] for failures that can be
346/// retried (HTTP 5xx/408/429, transient I/O), and
347/// [`OriginPullError::Permanent`] for failures that won't be cured by
348/// retrying (HTTP 4xx other than 404, encoding/cap breaches, permission
349/// denied). The cache engine's retry loop keys off this distinction.
350/// `NotFound` is *not* an error — adapters return [`OriginFetch::NotFound`].
351pub trait Origin: std::fmt::Debug + Send + Sync + 'static {
352 /// Fetch the blob with the given hash. `max_bytes` is an advisory cap —
353 /// implementations should short-circuit if they can cheaply detect the
354 /// payload would exceed it (e.g. from a `Content-Length` header), and the
355 /// cache engine enforces the cap on the returned bytes regardless.
356 fn fetch(
357 &self,
358 hash: Hash,
359 max_bytes: u64,
360 ) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>>;
361
362 /// Fetch the chunk-group-aligned byte span `req` of the blob `hash`, for a
363 /// range-scoped pull
364 /// ([ADR 037 §Origin-tier pull-through](../../../adr/037-regional-proxy-warming.md),
365 /// #823). Each call covers at most [`crate::RANGE_PULL_WINDOW_BYTES`]; a
366 /// longer span takes several calls, in order. The engine fetches the
367 /// sibling `{H}.obao4` outboard separately through [`Self::fetch_outboard`].
368 ///
369 /// The default implementation returns [`OriginRangeFetch::Unsupported`],
370 /// so the engine degrades to a whole-blob [`Self::fetch`] pull. A range
371 /// pull needs BOTH this method and [`Self::fetch_outboard`]: an origin that
372 /// overrides only one never range-pulls. The three shipped adapters
373 /// override both.
374 ///
375 /// `data` must be exactly `req.len()` bytes. A wrong-length window fails
376 /// verification: the engine degrades the range pull, and the own-origin
377 /// serve-miss path reports [`crate::CacheError::VerifyFailed`].
378 ///
379 /// Like [`Self::fetch`], the origin is a dumb byte store: the returned
380 /// `data` is **untrusted** and verified against the root `H` by
381 /// the engine: one [`bao_tree::io::fsm::encode_ranges_validated`] pass on
382 /// the [`crate::CacheEngine::origin_range_wire`] path — before any byte is
383 /// imported or served.
384 ///
385 /// Returning [`OriginRangeFetch::Unsupported`] is the correct, expected
386 /// answer whenever the optimization can't apply (no `Range`/`206`, a short
387 /// object) — it is not an error. Only genuine transport / permission
388 /// failures surface as [`OriginPullError`].
389 fn fetch_range_data(
390 &self,
391 _hash: Hash,
392 _req: OriginRangeRequest,
393 ) -> Pin<Box<dyn Future<Output = Result<OriginRangeFetch, OriginPullError>> + Send + '_>> {
394 Box::pin(async { Ok(OriginRangeFetch::Unsupported) })
395 }
396
397 /// Fetch **just** the sibling `{H}.obao4` outboard for the blob `hash` —
398 /// no accompanying blob data (#1130, feeds a later "stream-while-store"
399 /// serve path: a node that holds the outboard can start verifying
400 /// against `H` before the full blob has finished pulling through).
401 /// `outboard_max_bytes` caps the read — the engine derives it from the
402 /// blob size (an outboard is `O(blob/256)`), and an oversize outboard is
403 /// malformed/foreign. The engine caches a non-empty outboard of at most
404 /// 256 MiB (a blob of up to 64 GiB) per hash across range pulls, so it
405 /// reads such an outboard once while the entry stays cached. It reads an
406 /// empty outboard, or a larger one, again on each draw. Each pull fetches
407 /// its span window by window through [`Self::fetch_range_data`].
408 ///
409 /// The default implementation returns [`OutboardFetch::Unsupported`], which
410 /// also disables range pulls (see [`Self::fetch_range_data`]). The three
411 /// shipped adapters override it.
412 ///
413 /// Like [`Self::fetch_range_data`], returning [`OutboardFetch::Unsupported`]
414 /// or [`OutboardFetch::NotFound`] is never an error — only a genuine
415 /// transport / permission failure surfaces as [`OriginPullError`].
416 fn fetch_outboard(
417 &self,
418 _hash: Hash,
419 _outboard_max_bytes: u64,
420 ) -> Pin<Box<dyn Future<Output = Result<OutboardFetch, OriginPullError>> + Send + '_>> {
421 Box::pin(async { Ok(OutboardFetch::Unsupported) })
422 }
423
424 /// Best-effort total byte size of the blob `hash`, used to scope a
425 /// range-pull ([`Self::fetch_range_data`], #823): the bao tree needs the blob's
426 /// **exact** total length to verify a sub-range against the root `H`, and
427 /// the `{H}.obao4` outboard alone only pins the size to within one chunk
428 /// group (`IROH_BLOCK_SIZE`, 16 KiB — the final group's true length isn't in
429 /// the tree). The
430 /// `cdn/client/v1` serving handler calls this on a cold cache miss — via
431 /// [`crate::CacheEngine::origin_size`] — before it signs a response and
432 /// runs the two-leg serve-miss spine over
433 /// [`crate::CacheEngine::origin_range_wire`].
434 ///
435 /// The probe is cheap (HTTP `HEAD` / S3 `HeadObject` / `fs` metadata), and
436 /// `Ok(None)` is an **authoritative** "this origin does not hold the
437 /// object — or holds it in a shape the caller cannot range against": a
438 /// genuine 404 / not-found, or a backend reporting a `Content-Encoding`
439 /// whose advertised length is the *encoded* size (not the canonical blob
440 /// length, same trap as [`Self::fetch`]'s `size_hint`). Anything that
441 /// answers neither way — a transport failure, a 5xx/408/429 outage, a
442 /// permission decline, a disabled redirect — surfaces as
443 /// [`OriginPullError`] (`Transient` for the retriable statuses and
444 /// transport faults, `Permanent` for the rest) and must NOT degrade to
445 /// `None` inside the adapter. Fault-aware callers depend on the split: the
446 /// origin-only serve gate and the DHT announce set read `Ok(None)` as an
447 /// absence they may memoise, sign as `NotFound` to a paying client, and
448 /// unschedule from republish — an outage folded into `None` does all
449 /// three. `CacheEngine::origin_size` advances the origin chain past a
450 /// per-origin fault, but a probe that ends on faults surfaces the last one
451 /// (#1129): the serve path must report a degraded node, not an empty one.
452 /// The default returns `Ok(None)`, so a custom [`Origin`] needs no change
453 /// and simply never range-pulls.
454 fn size(
455 &self,
456 _hash: Hash,
457 ) -> Pin<Box<dyn Future<Output = Result<Option<u64>, OriginPullError>> + Send + '_>> {
458 Box::pin(async { Ok(None) })
459 }
460
461 /// Enumerate the hashes this origin currently holds, so a node can
462 /// advertise them (probe `has_blob` / DHT announce) *before* any
463 /// pull-through — closing the "cold origin blob is undiscoverable until
464 /// first pulled" gap (#1130).
465 ///
466 /// The default returns an empty list. Only an *enumerable* backend
467 /// overrides it: the local filesystem, whose sharded directory listing
468 /// is the hash set. HTTP has no listing endpoint, and S3's
469 /// `ListObjectsV2` is deliberately not walked (egress + unbounded
470 /// bucket), so remote origins are advertised via the operator's
471 /// `pinned_hashes` instead of this method.
472 ///
473 /// Enumeration is *presence-only* (trust model): it does not read or
474 /// hash file contents. Callers verify served bytes against the content
475 /// address at serve time, and a wrong/corrupt file is the publisher's
476 /// error (it only hurts the publisher — an announced-but-unservable hash
477 /// costs bandwidth + local reputation, never a bond slash, provided the
478 /// serve path never signs an `ok:false` refusal).
479 fn enumerate(
480 &self,
481 ) -> Pin<Box<dyn Future<Output = Result<Vec<Hash>, OriginPullError>> + Send + '_>> {
482 Box::pin(async { Ok(Vec::new()) })
483 }
484
485 /// Tag identifying the backend type. Surfaced through
486 /// [`crate::EvictionPreview::origin_kinds`] so admin dry-run callers
487 /// can estimate origin egress cost (#439) — `Filesystem` is a local
488 /// read; `Http` may bill metered bandwidth.
489 fn kind(&self) -> OriginKind;
490}
491
492#[cfg(test)]
493#[allow(clippy::unwrap_used, clippy::expect_used)] // tests
494mod tests;