Skip to main content

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;