Skip to main content

decdn_cache/origin/
fs.rs

1//! Filesystem origin backend.
2//!
3//! Reads blobs from a sharded local directory at
4//! `{base}/{hex[0..2]}/{hex}`. Useful for local dev, pre-seeded caches,
5//! and operators who prefer to hand-curate the origin surface without
6//! standing up an HTTP server.
7//!
8//! The sharded layout mirrors git's object store — it keeps directory
9//! cardinality manageable as the content set grows, and a pre-seed script
10//! is a trivial `cp` loop.
11
12use std::future::Future;
13use std::path::{Path, PathBuf};
14use std::pin::Pin;
15
16use anyhow::Context;
17use bytes::Bytes;
18use futures_util::{Stream, StreamExt};
19use iroh_blobs::Hash;
20use tokio::io::{AsyncReadExt, AsyncSeekExt};
21use tokio_util::io::ReaderStream;
22
23use super::{Origin, OriginFetch, OriginKind, OriginRangeFetch, OriginRangeRequest, OutboardFetch};
24use crate::error::OriginPullError;
25
26/// Sibling-key suffix for the published pre-order bao outboard (`{H}.obao4`),
27/// per [ADR 037 §Origin-tier pull-through](../../../adr/037-regional-proxy-warming.md).
28/// Re-exported from [`decdn_bao_range`] — the shared layout contract every
29/// origin reader (filesystem / S3) and the `decdn origin import` writer derive
30/// the sibling key from, so an operator `aws s3 sync`-ing between backends keeps
31/// the same object names.
32pub(super) use decdn_bao_range::OBAO4_SUFFIX;
33
34/// Origin backed by a local filesystem directory. Blobs live at
35/// `{base}/{hex[0..2]}/{hex}`; the engine is responsible for BLAKE3
36/// verification after the bytes come back (same contract as every other
37/// [`Origin`] impl).
38#[derive(Debug, Clone)]
39pub struct FilesystemOrigin {
40    /// Canonicalized at construction so the per-fetch containment check
41    /// compares two resolved paths — see [`Self::new`] and the
42    /// canonicalize step in [`Origin::fetch`].
43    base: PathBuf,
44}
45
46impl FilesystemOrigin {
47    /// Construct an origin rooted at `base`. Fails fast if `base` doesn't
48    /// exist or isn't a directory — a typo in the config shouldn't surface
49    /// as a per-request miss.
50    ///
51    /// `base` is canonicalized so the per-request containment check in
52    /// [`Origin::fetch`] can compare a resolved blob path against a
53    /// resolved root. Without this, an operator who configured the origin
54    /// via a symlinked path would have every fetch look "outside" itself.
55    pub async fn new(base: impl Into<PathBuf>) -> anyhow::Result<Self> {
56        let base = base.into();
57        let meta = tokio::fs::metadata(&base)
58            .await
59            .with_context(|| format!("cache.origin.path {} is not accessible", base.display()))?;
60        if !meta.is_dir() {
61            anyhow::bail!("cache.origin.path {} is not a directory", base.display());
62        }
63        let base = tokio::fs::canonicalize(&base).await.with_context(|| {
64            format!(
65                "cache.origin.path {} could not be canonicalized",
66                base.display()
67            )
68        })?;
69        Ok(Self { base })
70    }
71
72    /// Expose the configured base for diagnostics / logging.
73    pub fn base(&self) -> &Path {
74        &self.base
75    }
76
77    /// Build the per-hash path: `{base}/{hex[0..2]}/{hex}`. The first
78    /// two hex chars shard the directory so a content set with millions
79    /// of entries doesn't land in a single unix dirent list.
80    fn path_for(&self, hash: Hash) -> PathBuf {
81        let hex = hash.to_hex();
82        // BLAKE3 hex is always 64 lowercase chars; `get(..2)` is defensive
83        // against an unexpected iroh_blobs::Hash format change, and the
84        // workspace's anti-indexing lint forbids `&hex[..2]` here.
85        let shard = hex.get(..2).unwrap_or("");
86        self.base.join(shard).join(hex.as_str())
87    }
88
89    /// Build the sibling outboard path: `{base}/{hex[0..2]}/{hex}.obao4`
90    /// ([ADR 037 §Origin-tier pull-through](../../../adr/037-regional-proxy-warming.md)).
91    /// Sits next to the data object so a pre-seed `cp`/`sync` carries both.
92    fn obao4_path_for(&self, hash: Hash) -> PathBuf {
93        let hex = hash.to_hex();
94        let shard = hex.get(..2).unwrap_or("");
95        self.base
96            .join(shard)
97            .join(format!("{}{OBAO4_SUFFIX}", hex.as_str()))
98    }
99}
100
101/// Classify a filesystem `io::Error` as transient or permanent. Most FS
102/// failures (`PermissionDenied`, "not a directory", read errors on a
103/// closed file handle) are deterministic — retry won't change the
104/// answer. The handful of error kinds the kernel uses for "the syscall
105/// got interrupted, try again" are retriable.
106///
107/// `NotFound` is intentionally not handled here — the call sites
108/// convert it to [`OriginFetch::NotFound`] before reaching this helper.
109/// Symlink-escape is *also* not an `io::Error` and never reaches this
110/// helper — it's detected by path comparison after `canonicalize` and
111/// emits `OriginPullError::Permanent` directly at the call site.
112fn classify_io_error(err: std::io::Error) -> OriginPullError {
113    use std::io::ErrorKind;
114    match err.kind() {
115        ErrorKind::Interrupted
116        | ErrorKind::TimedOut
117        | ErrorKind::ResourceBusy
118        | ErrorKind::WouldBlock => OriginPullError::Transient(err.into()),
119        _ => OriginPullError::Permanent(err.into()),
120    }
121}
122
123/// Parse a sharded leaf file name as a BLAKE3 [`struct@Hash`], panic-free.
124///
125/// `iroh_blobs::Hash`'s own `FromStr` panics (via `data-encoding`) on a
126/// wrong-length input, so it must never see an untrusted directory entry
127/// name (see the same warning in `decdn-common`'s admin hash parser). We
128/// accept only the canonical 64-char lowercase-hex form that `to_hex()`
129/// produces; anything else (stray files, `.obao4` siblings, uppercase) is
130/// `None` and silently skipped by the enumeration.
131fn hash_from_hex_name(name: &str) -> Option<Hash> {
132    if name.len() != 64 {
133        return None;
134    }
135    let bytes = name.as_bytes();
136    let hex_val = |c: u8| -> Option<u8> {
137        match c {
138            b'0'..=b'9' => Some(c - b'0'),
139            b'a'..=b'f' => Some(c - b'a' + 10),
140            _ => None,
141        }
142    };
143    let mut out = [0u8; 32];
144    for (i, slot) in out.iter_mut().enumerate() {
145        let hi = hex_val(*bytes.get(i * 2)?)?;
146        let lo = hex_val(*bytes.get(i * 2 + 1)?)?;
147        *slot = (hi << 4) | lo;
148    }
149    Some(Hash::from_bytes(out))
150}
151
152impl Origin for FilesystemOrigin {
153    fn kind(&self) -> OriginKind {
154        OriginKind::Filesystem
155    }
156
157    fn fetch(
158        &self,
159        hash: Hash,
160        max_bytes: u64,
161    ) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
162        Box::pin(async move {
163            let path = self.path_for(hash);
164
165            // Resolve symlinks before touching the file. A symlink dropped
166            // into the shard tree by an operator mistake or compromised
167            // tooling could otherwise turn a content-addressed read into
168            // an arbitrary-file read of anything the process can see —
169            // and the engine's BLAKE3 check happens *after* the bytes
170            // already left the disk, so it is not a defense for what got
171            // read in the first place. We canonicalize and require the
172            // result to sit under the (already-canonical) base.
173            let canonical = match tokio::fs::canonicalize(&path).await {
174                Ok(p) => p,
175                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
176                    return Ok(OriginFetch::NotFound);
177                }
178                Err(err) => {
179                    let path_msg = format!(
180                        "cache.origin.path canonicalize failed for {}",
181                        path.display()
182                    );
183                    return Err(classify_io_error(err).map_inner(|e| e.context(path_msg)));
184                }
185            };
186            if !canonical.starts_with(&self.base) {
187                // Symlink escape: deterministic permanent failure.
188                return Err(OriginPullError::Permanent(anyhow::anyhow!(
189                    "cache.origin.path entry {} resolves to {} which is outside base {}",
190                    path.display(),
191                    canonical.display(),
192                    self.base.display()
193                )));
194            }
195
196            // Open once and stat via the file handle (fstat), so the
197            // metadata we check and the bytes we read come from the
198            // same inode. A pair of `tokio::fs::metadata` + `tokio::fs::read`
199            // on the same path leaves a TOCTOU window where the path
200            // could be swapped for a much larger file or a different
201            // symlink between the two syscalls — the size cap would
202            // run on stale metadata. Holding the fd avoids that, and
203            // also saves a redundant path traversal.
204            //
205            // The file can also disappear between `canonicalize` above
206            // and this open (eviction, gc, operator cleanup); treat
207            // that the same as a missing leaf and surface `NotFound`
208            // rather than a hard error, matching the canonicalize arm.
209            let file = match tokio::fs::File::open(&canonical).await {
210                Ok(f) => f,
211                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
212                    return Ok(OriginFetch::NotFound);
213                }
214                Err(err) => {
215                    let path_msg =
216                        format!("cache.origin.path open failed for {}", canonical.display());
217                    return Err(classify_io_error(err).map_inner(|e| e.context(path_msg)));
218                }
219            };
220            let meta = file.metadata().await.map_err(|err| {
221                let path_msg = format!("cache.origin.path stat failed for {}", canonical.display());
222                classify_io_error(err).map_inner(|e| e.context(path_msg))
223            })?;
224
225            if !meta.is_file() {
226                return Err(OriginPullError::Permanent(anyhow::anyhow!(
227                    "cache.origin.path entry {} is not a regular file",
228                    canonical.display()
229                )));
230            }
231
232            let len = meta.len();
233            if len > max_bytes {
234                return Err(OriginPullError::Permanent(anyhow::anyhow!(
235                    "cache.origin.path entry {} is {len} bytes, exceeds max {max_bytes}",
236                    canonical.display()
237                )));
238            }
239
240            // Stream the file through `ReaderStream` rather than reading
241            // the entire payload into a `Vec` (issue #271). The owned
242            // `File` moves into `Take`, then into `ReaderStream`, so the
243            // fd outlives the stream rather than being borrowed; iroh
244            // -blobs' `add_stream` can drive this directly without any
245            // intermediate buffer.
246            //
247            // `take(max_bytes + 1)` provides an I/O-layer cap: a
248            // file that grew between `fstat` and the read (append,
249            // pwrite past EOF, truncate-then-extend — all happen on
250            // the same inode our fd is pinning) is bounded by the
251            // kernel's read syscall. The wrapper in
252            // [`cap_at_max_bytes`] catches the one-byte overrun and
253            // converts it to a typed `io::Error` ("grew past max
254            // during read") that the engine surfaces as
255            // `CacheError::OriginError`.
256            //
257            // `saturating_add(1)` preserves correctness for
258            // `max_bytes == u64::MAX`: saturating rather than
259            // wrapping to `0`.
260            let path_for_log = canonical.clone();
261            let reader = file.take(max_bytes.saturating_add(1));
262            let raw = ReaderStream::new(reader);
263            let stream = cap_at_max_bytes(raw, max_bytes, path_for_log);
264            Ok(OriginFetch::Found {
265                stream: Box::pin(stream),
266                size_hint: Some(len),
267            })
268        })
269    }
270
271    fn fetch_range_data(
272        &self,
273        hash: Hash,
274        req: OriginRangeRequest,
275    ) -> Pin<Box<dyn Future<Output = Result<OriginRangeFetch, OriginPullError>> + Send + '_>> {
276        Box::pin(async move {
277            // Resolve + contain the data path exactly as `fetch` does: a
278            // symlink-escape is a permanent failure, a missing data object is
279            // `Unsupported` (caller will whole-blob pull, which then surfaces
280            // the real `NotFound`).
281            let path = self.path_for(hash);
282            let canonical = match tokio::fs::canonicalize(&path).await {
283                Ok(p) => p,
284                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
285                    return Ok(OriginRangeFetch::Unsupported);
286                }
287                Err(err) => {
288                    let msg = format!(
289                        "cache.origin.path canonicalize failed for {}",
290                        path.display()
291                    );
292                    return Err(classify_io_error(err).map_inner(|e| e.context(msg)));
293                }
294            };
295            if !canonical.starts_with(&self.base) {
296                return Err(OriginPullError::Permanent(anyhow::anyhow!(
297                    "cache.origin.path entry {} resolves to {} which is outside base {}",
298                    path.display(),
299                    canonical.display(),
300                    self.base.display()
301                )));
302            }
303
304            let mut file = match tokio::fs::File::open(&canonical).await {
305                Ok(f) => f,
306                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
307                    return Ok(OriginRangeFetch::Unsupported);
308                }
309                Err(err) => {
310                    let msg = format!("cache.origin.path open failed for {}", canonical.display());
311                    return Err(classify_io_error(err).map_inner(|e| e.context(msg)));
312                }
313            };
314
315            // Empty span only arises for a zero-length blob; nothing to read.
316            if req.is_empty() {
317                return Ok(OriginRangeFetch::Ranged { data: Bytes::new() });
318            }
319
320            // Seek to the aligned start and read exactly `req.len()` bytes. A
321            // short read (the on-disk object is smaller than the aligned span
322            // the engine derived from the signed blob size) means the origin
323            // copy is stale/truncated — degrade to whole-blob rather than feed
324            // a partial span to verification (which would reject it anyway).
325            if let Err(err) = file.seek(std::io::SeekFrom::Start(req.fetch_start)).await {
326                let msg = format!("cache.origin.path seek failed for {}", canonical.display());
327                return Err(classify_io_error(err).map_inner(|e| e.context(msg)));
328            }
329            let want = usize::try_from(req.len()).unwrap_or(usize::MAX);
330            let mut data = vec![0u8; want];
331            if let Err(err) = file.read_exact(&mut data).await {
332                if err.kind() == std::io::ErrorKind::UnexpectedEof {
333                    return Ok(OriginRangeFetch::Unsupported);
334                }
335                let msg = format!(
336                    "cache.origin.path range read failed for {}",
337                    canonical.display()
338                );
339                return Err(classify_io_error(err).map_inner(|e| e.context(msg)));
340            }
341
342            Ok(OriginRangeFetch::Ranged {
343                data: Bytes::from(data),
344            })
345        })
346    }
347
348    fn fetch_outboard(
349        &self,
350        hash: Hash,
351        outboard_max_bytes: u64,
352    ) -> Pin<Box<dyn Future<Output = Result<OutboardFetch, OriginPullError>> + Send + '_>> {
353        Box::pin(async move {
354            let obao4_path = self.obao4_path_for(hash);
355            // Check the on-disk length via `metadata()` before reading: a wildly
356            // oversized `.obao4` is malformed/foreign, and reading it first
357            // would buffer the whole thing into memory only to reject it (an
358            // OOM lever for a hostile sibling). Degrade — never a failure; the
359            // engine's outboard length check is the load-bearing reject.
360            match tokio::fs::metadata(&obao4_path).await {
361                Ok(meta) if meta.len() > outboard_max_bytes => {
362                    return Ok(OutboardFetch::Unsupported);
363                }
364                Ok(_) => {}
365                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
366                    return Ok(OutboardFetch::NotFound);
367                }
368                Err(err) => {
369                    let msg = format!(
370                        "cache.origin.path outboard metadata failed for {}",
371                        obao4_path.display()
372                    );
373                    return Err(classify_io_error(err).map_inner(|e| e.context(msg)));
374                }
375            }
376            let outboard = match tokio::fs::read(&obao4_path).await {
377                Ok(b) => b,
378                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
379                    return Ok(OutboardFetch::NotFound);
380                }
381                Err(err) => {
382                    let msg = format!(
383                        "cache.origin.path outboard read failed for {}",
384                        obao4_path.display()
385                    );
386                    return Err(classify_io_error(err).map_inner(|e| e.context(msg)));
387                }
388            };
389            // Defense-in-depth: a file that grew between `metadata` and `read`
390            // (TOCTOU) is still rejected on the buffered length.
391            if u64::try_from(outboard.len()).unwrap_or(u64::MAX) > outboard_max_bytes {
392                return Ok(OutboardFetch::Unsupported);
393            }
394            Ok(OutboardFetch::Found(Bytes::from(outboard)))
395        })
396    }
397
398    fn size(
399        &self,
400        hash: Hash,
401    ) -> Pin<Box<dyn Future<Output = Result<Option<u64>, OriginPullError>> + Send + '_>> {
402        Box::pin(async move {
403            // The local data object's on-disk length IS the canonical blob size
404            // (filesystem origins never compress), so a `metadata()` stat is an
405            // exact, body-free answer. Resolve + contain the path exactly as
406            // `fetch` / `fetch_range_data` do so a symlink escape is a permanent
407            // failure, not a silent read outside `base`.
408            let path = self.path_for(hash);
409            let canonical = match tokio::fs::canonicalize(&path).await {
410                Ok(p) => p,
411                // Missing data object → unknown size, degrade to whole-blob
412                // (which then surfaces the real `NotFound`).
413                Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
414                Err(err) => {
415                    let msg = format!(
416                        "cache.origin.path canonicalize failed for {}",
417                        path.display()
418                    );
419                    return Err(classify_io_error(err).map_inner(|e| e.context(msg)));
420                }
421            };
422            if !canonical.starts_with(&self.base) {
423                return Err(OriginPullError::Permanent(anyhow::anyhow!(
424                    "cache.origin.path entry {} resolves to {} which is outside base {}",
425                    path.display(),
426                    canonical.display(),
427                    self.base.display()
428                )));
429            }
430            match tokio::fs::metadata(&canonical).await {
431                Ok(meta) => Ok(Some(meta.len())),
432                Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
433                Err(err) => {
434                    let msg = format!(
435                        "cache.origin.path metadata failed for {}",
436                        canonical.display()
437                    );
438                    Err(classify_io_error(err).map_inner(|e| e.context(msg)))
439                }
440            }
441        })
442    }
443
444    fn enumerate(
445        &self,
446    ) -> Pin<Box<dyn Future<Output = Result<Vec<Hash>, OriginPullError>> + Send + '_>> {
447        Box::pin(async move {
448            // Presence-only walk of `{base}/{hex[0..2]}/{hex}` — the file
449            // name IS the hash, so this never reads or hashes payloads
450            // (trust model, #1130). Skip sibling `{H}.obao4` outboards,
451            // non-directory shard entries, and any leaf whose name isn't a
452            // valid hash sharded under its own first two hex chars (a stray
453            // file dropped into the tree is silently ignored, not an error).
454            let mut out = Vec::new();
455            let mut shards = match tokio::fs::read_dir(&self.base).await {
456                Ok(rd) => rd,
457                Err(err) => {
458                    let msg = format!(
459                        "cache.origin.path read_dir failed for {}",
460                        self.base.display()
461                    );
462                    return Err(classify_io_error(err).map_inner(|e| e.context(msg)));
463                }
464            };
465            while let Some(shard) = shards.next_entry().await.map_err(|err| {
466                classify_io_error(err)
467                    .map_inner(|e| e.context("cache.origin.path shard read failed".to_string()))
468            })? {
469                let shard_name = shard.file_name();
470                let shard_str = match shard_name.to_str() {
471                    // Shard dirs are exactly the two-char hex prefix.
472                    Some(s) if s.len() == 2 => s,
473                    _ => continue,
474                };
475                match shard.file_type().await {
476                    Ok(ft) if ft.is_dir() => {}
477                    _ => continue,
478                }
479                let shard_path = shard.path();
480                // A shard that vanished mid-walk (gc/operator cleanup) is not
481                // fatal to enumerating the rest of the tree.
482                let Ok(mut entries) = tokio::fs::read_dir(&shard_path).await else {
483                    continue;
484                };
485                while let Some(entry) = entries.next_entry().await.map_err(|err| {
486                    classify_io_error(err)
487                        .map_inner(|e| e.context("cache.origin.path entry read failed".to_string()))
488                })? {
489                    let name = entry.file_name();
490                    let name = match name.to_str() {
491                        Some(n) if !n.ends_with(OBAO4_SUFFIX) => n,
492                        _ => continue,
493                    };
494                    let Some(hash) = hash_from_hex_name(name) else {
495                        continue;
496                    };
497                    // The leaf must live under its own shard, else it's a
498                    // mis-placed file we won't be able to serve by path.
499                    if hash.to_hex().get(..2) == Some(shard_str) {
500                        out.push(hash);
501                    }
502                }
503            }
504            Ok(out)
505        })
506    }
507}
508
509/// Defense-in-depth running-cap wrapper around a chunk stream. The
510/// inner `take(max_bytes + 1)` bounds the I/O-layer read at one byte
511/// past the cap, so a file that grew during read produces a stream
512/// whose chunks sum to at most `max_bytes + 1`. This wrapper trips on
513/// the cumulative byte count and yields the overrun as an
514/// `io::Error`. The engine's outer wrapper (`count_and_cap_stream`)
515/// captures the error into its side channel and yields `None` to
516/// `iroh_blobs::Blobs::add_stream`, so this error never reaches
517/// iroh-blobs directly — it surfaces back to the caller as
518/// `CacheError::OriginError` once `temp_tag().await` completes and
519/// the engine inspects the side channel.
520///
521/// `path_for_log` is captured for the error message so the operator
522/// log surfaces the offending file path. The path is the canonicalized
523/// form (already resolved, so no symlink trickery in logs).
524fn cap_at_max_bytes<S>(
525    stream: S,
526    max_bytes: u64,
527    path_for_log: PathBuf,
528) -> impl Stream<Item = std::io::Result<Bytes>> + Send + Sync + 'static
529where
530    S: Stream<Item = std::io::Result<Bytes>> + Send + Sync + Unpin + 'static,
531{
532    // **Termination after error:** the inner stream is wrapped in
533    // `Option` so that after yielding an `Err`, the next poll returns
534    // `None`. Polling a stream after a terminal error is undefined
535    // (some impls error again, some hang); the sentinel makes the
536    // wrapper deterministic and prevents iroh-blobs' `add_stream` from
537    // hanging when the upstream errors.
538    futures_util::stream::unfold(
539        (Some(stream), 0u64, path_for_log),
540        move |(maybe_s, total, path)| async move {
541            let mut s = maybe_s?;
542            let next = s.next().await?;
543            match next {
544                Err(e) => Some((Err(e), (None, total, path))),
545                Ok(chunk) => {
546                    let new_total = total.saturating_add(chunk.len() as u64);
547                    if new_total > max_bytes {
548                        // Pack a typed `BlobTooLargeMarker` so the engine
549                        // surfaces `CacheError::BlobTooLarge` rather than
550                        // routing through `classify_io_error`, which
551                        // defaults `ErrorKind::Other` to Transient and
552                        // would burn the retry budget on a cap breach.
553                        // Matches `http.rs::response_chunk_stream`'s
554                        // typed-marker symmetry. The file path is
555                        // intentionally dropped from the error — the
556                        // typed marker is the operator-visible signal;
557                        // adding path context would require a wider
558                        // typed variant that `classify_io_error`
559                        // doesn't recognise.
560                        let err = std::io::Error::other(super::BlobTooLargeMarker { max_bytes });
561                        Some((Err(err), (None, total, path)))
562                    } else {
563                        Some((Ok(chunk), (Some(s), new_total, path)))
564                    }
565                }
566            }
567        },
568    )
569}
570
571#[cfg(test)]
572mod tests;