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;