Skip to main content

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;