decdn_cli/commands/bundle_cache.rs
1//! The local bundle-manifest cache `decdn bundle pull --hash` keeps under an
2//! output directory. It stores each fetched bundle manifest blob verbatim, keyed
3//! by its BLAKE3 content address, so a repeat pull of the same bundle into the
4//! same directory reads the manifest from disk instead of paying to fetch the
5//! blob again over `cdn/client/v1`.
6//!
7//! The cache is content-addressed and self-verifying: a load re-hashes the bytes
8//! and returns them only when the digest still equals the requested hash, so a
9//! corrupt or tampered file is never trusted. A load tells two cases apart: a
10//! missing file is a plain miss, and any other failure (a read error, or bytes
11//! that fail the hash check) is an error that names the path, for the caller to
12//! act on. A real pull is fail-open: it prints that error as a warning and
13//! fetches the manifest fresh, so a bad cache file only costs one manifest
14//! fetch. A write failure prints a warning that names the path and never fails
15//! the pull. A real pull under `--overwrite` skips the read to force a fresh
16//! fetch. The cache also lets `bundle pull --hash --dry-run` list a bundle's
17//! entries with no network; the dry run reads it even under `--overwrite`,
18//! because verified bytes equal what a fresh fetch returns, and it fails on a
19//! cache file it cannot use, because the cache is its whole answer.
20//!
21//! Only the `--hash` path uses it: an `-i` local manifest is already on disk for
22//! free, with nothing to cache or re-pay.
23
24use std::path::{Path, PathBuf};
25
26/// Directory under the output root that holds cached bundle manifests, one file
27/// per bundle keyed by hash. Sits beside the `.decdn-manifest.json` skip-cache.
28pub(crate) const CACHE_DIR: &str = ".decdn-bundles";
29
30/// The on-disk path a bundle manifest with content address `hash` caches to:
31/// `<out_root>/.decdn-bundles/<hex>.json`. Keyed by the hash so two different
32/// bundles pulled into one directory never clobber each other.
33pub(crate) fn cache_path(out_root: &Path, hash: [u8; 32]) -> PathBuf {
34 let name = blake3::Hash::from_bytes(hash).to_hex();
35 out_root.join(CACHE_DIR).join(format!("{name}.json"))
36}
37
38/// Load the cached manifest bytes for `hash`. Returns `Ok(None)` when no cache
39/// file exists. Returns an error that names the path for any other read failure
40/// (for example permission denied, or a directory in the way) and when the
41/// file's BLAKE3 digest does not equal `hash`. The returned bytes always hash
42/// to `hash`.
43pub(crate) fn load(out_root: &Path, hash: [u8; 32]) -> anyhow::Result<Option<Vec<u8>>> {
44 use anyhow::Context as _;
45
46 let path = cache_path(out_root, hash);
47 let bytes = match std::fs::read(&path) {
48 Ok(bytes) => bytes,
49 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
50 Err(e) => {
51 return Err(e)
52 .with_context(|| format!("read cached bundle manifest {}", path.display()));
53 }
54 };
55 if *blake3::hash(&bytes).as_bytes() != hash {
56 anyhow::bail!(
57 "cached bundle manifest {} does not hash to the bundle",
58 path.display()
59 );
60 }
61 Ok(Some(bytes))
62}
63
64/// Cache `bytes` (a verified bundle manifest with content address `hash`) under
65/// `out_root`. Best-effort and advisory: a filesystem error prints a warning and
66/// is swallowed, because a pull must not fail over a cache write.
67pub(crate) fn store(out_root: &Path, hash: [u8; 32], bytes: &[u8]) {
68 if let Err(e) = try_store(out_root, hash, bytes) {
69 eprintln!("warning: bundle manifest cache not written: {e:#}");
70 }
71}
72
73/// The fallible core of [`store`]: create the cache directory and write the
74/// manifest bytes atomically (temp file in the same directory, `sync_all`, then
75/// rename), so a reader never sees a partial file. Every error names the cache
76/// directory or the destination file.
77fn try_store(out_root: &Path, hash: [u8; 32], bytes: &[u8]) -> anyhow::Result<()> {
78 use anyhow::Context as _;
79
80 let dest = cache_path(out_root, hash);
81 if let Some(parent) = dest.parent() {
82 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
83 }
84 let shown = dest.display();
85 let mut tmp = super::fetch::temp_in_parent(&dest).with_context(|| format!("stage {shown}"))?;
86 std::io::Write::write_all(tmp.as_file_mut(), bytes)
87 .with_context(|| format!("write {shown}"))?;
88 tmp.as_file()
89 .sync_all()
90 .with_context(|| format!("sync {shown}"))?;
91 tmp.persist(&dest)
92 .map_err(|e| e.error)
93 .with_context(|| format!("persist {shown}"))?;
94 Ok(())
95}
96
97#[cfg(test)]
98#[allow(
99 clippy::unwrap_used,
100 clippy::expect_used,
101 clippy::indexing_slicing,
102 clippy::panic
103)]
104mod tests;