Skip to main content

mkit_cli/
sparse_cache.rs

1//! On-disk bitmap cache for verified sparse-checkout deliveries.
2//!
3//! Spec: `docs/specs/SPEC-SPARSE-CHECKOUT.md` §6. Cache layout:
4//!
5//! ```text
6//! <repo-root>/.mkit/sparse/<tree-hex>.bitmap
7//! ```
8//!
9//! One file per (`tree_hash`) — the per-filter binding lives inside the
10//! file body. A cache hit means "we have *some* verified sparse
11//! delivery for this tree"; the caller still has to cross-check the
12//! filter hash before trusting the bitmap. The file format is defined
13//! by [`mkit_core::sparse::encode_sparse_cache`] /
14//! [`mkit_core::sparse::decode_sparse_cache`].
15//!
16//! This module is feature-gated by `sparse-checkout` because it depends
17//! on the `mkit_core::sparse` module which is itself feature-gated.
18
19#![cfg(feature = "sparse-checkout")]
20
21use mkit_core::layout::RepoLayout;
22use std::fs;
23use std::io;
24use std::path::PathBuf;
25
26use mkit_core::hash::{Hash, to_hex};
27use mkit_core::object::Tree;
28use mkit_core::sparse::{
29    SparseError, SparseManifest, SparseProof, SparseWireError, build_sparse, decode_sparse_cache,
30    encode_sparse_cache, hash_filter, tree_hash as compute_tree_hash, verify_sparse,
31};
32
33/// Errors raised by the cache I/O helpers. Wrapping the `io::Error`
34/// directly keeps the call sites concise — the cache is best-effort,
35/// so callers usually log-and-continue rather than blow up.
36#[derive(Debug, thiserror::Error)]
37pub enum CacheError {
38    #[error("io: {0}")]
39    Io(#[from] io::Error),
40    #[error("wire: {0}")]
41    Wire(#[from] SparseWireError),
42    /// Cache file existed but recorded a different filter hash. Not
43    /// strictly an "error" — callers usually treat this as a cache
44    /// miss and re-fetch — but distinct from the I/O / wire variants
45    /// so a misfit cache doesn't get silently overwritten.
46    #[error("cached delivery committed to a different filter")]
47    FilterMismatch,
48}
49
50/// Compute `<common dir>/sparse/<tree-hex>.bitmap`. The directory may
51/// not exist yet; [`store`] creates it on demand. Common-dir state:
52/// the cache is keyed by tree hash, so it is shared across worktrees.
53#[must_use]
54pub fn cache_path(layout: &RepoLayout, tree_hash: &Hash) -> PathBuf {
55    layout
56        .sparse_cache_dir()
57        .join(format!("{}.bitmap", to_hex(tree_hash)))
58}
59
60/// Persist a verified manifest + proof to the cache. Idempotent:
61/// re-storing the same `(tree_hash, manifest, proof)` triple
62/// over-writes the existing bytes byte-for-byte.
63///
64/// # Errors
65///
66/// Returns [`CacheError::Io`] for any underlying filesystem error. The
67/// missing parent directory is created first; if that or the write
68/// fails (typically permissions / disk full) the error is propagated.
69pub fn store(
70    layout: &RepoLayout,
71    tree_hash: &Hash,
72    manifest: &SparseManifest,
73    proof: &SparseProof,
74) -> Result<(), CacheError> {
75    let path = cache_path(layout, tree_hash);
76    if let Some(parent) = path.parent() {
77        fs::create_dir_all(parent)?;
78    }
79    let bytes = encode_sparse_cache(manifest, proof);
80    // Write-rename is overkill for a best-effort cache — a torn write
81    // surfaces as a wire-decode failure on the next read, which the
82    // caller treats as a cache miss. Plain `fs::write` is fine.
83    fs::write(path, bytes)?;
84    Ok(())
85}
86
87/// Load a cached delivery for `(tree_hash, filter_hash)`. Returns
88/// `Ok(None)` for a fresh repo / cache miss; `Err(_)` only for I/O or
89/// wire failures.
90///
91/// The function does *not* re-verify the bitmap-root against the
92/// bytes — that's the verifier's job. It only enforces that the
93/// cached filter hash matches the caller's expected hash, so a stale
94/// cache for a different filter doesn't get silently returned.
95///
96/// # Errors
97///
98/// - [`CacheError::Io`] — I/O failure other than "not found".
99/// - [`CacheError::Wire`] — the cache file exists but is malformed.
100/// - [`CacheError::FilterMismatch`] — cache exists for the tree but
101///   for a different filter.
102pub fn load(
103    layout: &RepoLayout,
104    tree_hash: &Hash,
105    expected_filter_hash: &Hash,
106) -> Result<Option<(SparseManifest, SparseProof)>, CacheError> {
107    let path = cache_path(layout, tree_hash);
108    let bytes = match fs::read(&path) {
109        Ok(b) => b,
110        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
111        Err(e) => return Err(e.into()),
112    };
113    let (bitmap_root, filter_hash, leaf_count, bitmap_bytes) = decode_sparse_cache(&bytes)?;
114    if filter_hash != *expected_filter_hash {
115        return Err(CacheError::FilterMismatch);
116    }
117    Ok(Some((
118        SparseManifest {
119            tree_hash: *tree_hash,
120            bitmap_root,
121            filter_hash,
122            leaf_count,
123        },
124        SparseProof { bitmap_bytes },
125    )))
126}
127
128/// Errors from [`load_or_build`]'s fresh-build path. A cache-read
129/// failure is never one of these — it is always treated as a miss (see
130/// [`load_or_build`]'s doc).
131#[derive(Debug, thiserror::Error)]
132pub enum SparseBuildError {
133    #[error("sparse build: {0}")]
134    Build(#[from] SparseError),
135    #[error("sparse build produced a manifest that fails verify")]
136    VerifyFailed,
137}
138
139/// Outcome of [`load_or_build`]: whether the on-disk cache satisfied
140/// the request or a fresh manifest had to be built.
141#[derive(Debug)]
142pub enum SparseOutcome {
143    /// `(tree_hash, filter_hash)` was already cached — the expensive
144    /// `build_sparse` + `verify_sparse` Merkle-bitmap reconstruction
145    /// was skipped entirely.
146    CacheHit,
147    /// No usable cache entry existed (miss, filter mismatch, or a
148    /// corrupt/undecodable entry); a fresh manifest was built and
149    /// self-verified. `store_error` is `Some` if persisting it back to
150    /// the cache failed (best-effort — the caller may want to warn on
151    /// stderr, as `store`'s own doc explains this is never fatal).
152    Built { store_error: Option<CacheError> },
153}
154
155/// Cache-aware front end for the `build_sparse` → `verify_sparse` →
156/// `store` pipeline shared by `mkit checkout --sparse` and `mkit clone
157/// --sparse` (SPEC-SPARSE-CHECKOUT §8).
158///
159/// Looks up `(tree_hash(tree), hash_filter(filter))` in the on-disk
160/// cache first. A hit means this exact tree/filter pair was already
161/// built and self-verified by a previous invocation — the cache file
162/// is keyed by `tree_hash`, which is content-addressed, so a hit is
163/// trustworthy without redoing the Merkle-bitmap reconstruction that
164/// `build_sparse`/`verify_sparse` perform.
165///
166/// A cache-read failure is treated exactly like a plain miss and never
167/// propagated: [`CacheError::FilterMismatch`] (stale entry for a
168/// different filter), a wire-decode failure (corrupt entry), and any
169/// I/O error all fall through to a fresh build, which then overwrites
170/// the bad entry. The cache is a best-effort optimisation — see
171/// [`CacheError`] — so a failure to read it must never block sparse
172/// checkout.
173///
174/// # Errors
175///
176/// [`SparseBuildError`] only from the fresh-build path: a malformed
177/// tree/filter (`SparseBuildError::Build`) or a self-verification
178/// failure (`SparseBuildError::VerifyFailed`). Never returned on a
179/// cache hit.
180pub fn load_or_build(
181    layout: &RepoLayout,
182    tree: &Tree,
183    filter: &[PathBuf],
184) -> Result<SparseOutcome, SparseBuildError> {
185    let th = compute_tree_hash(tree);
186    let fh = hash_filter(filter);
187    if let Ok(Some(_)) = load(layout, &th, &fh) {
188        return Ok(SparseOutcome::CacheHit);
189    }
190
191    let (delivered, manifest, proof) = build_sparse(tree, filter)?;
192    if !verify_sparse(&manifest, &delivered, filter, &proof) {
193        return Err(SparseBuildError::VerifyFailed);
194    }
195    // Best-effort: a write failure here doesn't invalidate the build
196    // the caller is about to materialise from, only the next run's
197    // ability to skip re-deriving it — surfaced to the caller as
198    // `store_error` rather than swallowed, so it can warn on stderr as
199    // before.
200    let store_error = store(layout, &manifest.tree_hash, &manifest, &proof).err();
201    Ok(SparseOutcome::Built { store_error })
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use mkit_core::object::{EntryMode, TreeEntry};
208
209    fn entry(name: &[u8]) -> TreeEntry {
210        TreeEntry {
211            name: name.to_vec(),
212            mode: EntryMode::Blob,
213            object_hash: [0u8; 32],
214        }
215    }
216
217    #[test]
218    fn round_trip_load_returns_stored_payload() {
219        let td = tempfile::tempdir().unwrap();
220        let layout = RepoLayout::single(td.path());
221        // Need a .mkit directory shape so cache_path's parent is
222        // creatable from the helper.
223        fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
224        let tree = Tree {
225            entries: vec![entry(b"aa"), entry(b"ab"), entry(b"ac")],
226        };
227        let filter = vec![PathBuf::from("aa")];
228        let (_, manifest, proof) = build_sparse(&tree, &filter).unwrap();
229
230        store(&layout, &manifest.tree_hash, &manifest, &proof).unwrap();
231
232        let loaded = load(&layout, &manifest.tree_hash, &manifest.filter_hash)
233            .unwrap()
234            .expect("just stored");
235        assert_eq!(loaded.0.bitmap_root, manifest.bitmap_root);
236        assert_eq!(loaded.0.filter_hash, manifest.filter_hash);
237        assert_eq!(loaded.0.leaf_count, manifest.leaf_count);
238        assert_eq!(loaded.1.bitmap_bytes, proof.bitmap_bytes);
239    }
240
241    #[test]
242    fn load_returns_none_for_missing_tree() {
243        let td = tempfile::tempdir().unwrap();
244        let layout = RepoLayout::single(td.path());
245        let h = [0u8; 32];
246        let res = load(&layout, &h, &hash_filter(&[])).unwrap();
247        assert!(res.is_none());
248    }
249
250    #[test]
251    fn load_rejects_mismatched_filter_hash() {
252        let td = tempfile::tempdir().unwrap();
253        let layout = RepoLayout::single(td.path());
254        fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
255        let tree = Tree {
256            entries: vec![entry(b"aa"), entry(b"ab")],
257        };
258        let (_, manifest, proof) = build_sparse(&tree, &[PathBuf::from("aa")]).unwrap();
259        store(&layout, &manifest.tree_hash, &manifest, &proof).unwrap();
260
261        // Lookup with a *different* filter hash → should fail loudly.
262        let other_filter_hash = hash_filter(&[PathBuf::from("zz")]);
263        let err = load(&layout, &manifest.tree_hash, &other_filter_hash).unwrap_err();
264        assert!(matches!(err, CacheError::FilterMismatch));
265    }
266
267    #[test]
268    fn load_or_build_hits_cache_on_repeat_call() {
269        let td = tempfile::tempdir().unwrap();
270        let layout = RepoLayout::single(td.path());
271        fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
272        let tree = Tree {
273            entries: vec![entry(b"aa"), entry(b"ab"), entry(b"ac")],
274        };
275        let filter = vec![PathBuf::from("aa")];
276
277        let first = load_or_build(&layout, &tree, &filter).unwrap();
278        assert!(
279            matches!(first, SparseOutcome::Built { store_error: None }),
280            "first call for a never-seen (tree, filter) must build fresh, got {first:?}"
281        );
282
283        let second = load_or_build(&layout, &tree, &filter).unwrap();
284        assert!(
285            matches!(second, SparseOutcome::CacheHit),
286            "repeat call with an unchanged filter must hit the cache instead of rebuilding, got {second:?}"
287        );
288    }
289
290    #[test]
291    fn load_or_build_treats_filter_change_as_a_miss_and_rewrites_cache() {
292        let td = tempfile::tempdir().unwrap();
293        let layout = RepoLayout::single(td.path());
294        fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
295        let tree = Tree {
296            entries: vec![entry(b"aa"), entry(b"ab"), entry(b"ac")],
297        };
298        let th = mkit_core::sparse::tree_hash(&tree);
299
300        let first_filter = vec![PathBuf::from("aa")];
301        load_or_build(&layout, &tree, &first_filter).unwrap();
302        let cached_after_first = load(&layout, &th, &hash_filter(&first_filter))
303            .unwrap()
304            .expect("first build cached its own filter");
305
306        // Same tree, different filter: the cache file (keyed only by
307        // tree_hash) exists but commits to the OLD filter — this must
308        // be treated as a miss, never silently returned.
309        let second_filter = vec![PathBuf::from("ab")];
310        let outcome = load_or_build(&layout, &tree, &second_filter).unwrap();
311        assert!(
312            matches!(outcome, SparseOutcome::Built { store_error: None }),
313            "a filter change for the same tree must miss and rebuild, got {outcome:?}"
314        );
315
316        // The miss must have rewritten the cache to the NEW filter, no
317        // error surfaced.
318        let cached_after_second = load(&layout, &th, &hash_filter(&second_filter))
319            .unwrap()
320            .expect("miss must rewrite the cache under the new filter");
321        assert_ne!(
322            cached_after_second.0.filter_hash,
323            cached_after_first.0.filter_hash
324        );
325        assert_eq!(
326            cached_after_second.0.filter_hash,
327            hash_filter(&second_filter)
328        );
329    }
330
331    #[test]
332    fn load_or_build_treats_corrupt_cache_entry_as_a_miss_and_repairs_it() {
333        let td = tempfile::tempdir().unwrap();
334        let layout = RepoLayout::single(td.path());
335        fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
336        let tree = Tree {
337            entries: vec![entry(b"aa"), entry(b"ab"), entry(b"ac")],
338        };
339        let filter = vec![PathBuf::from("aa")];
340        let th = mkit_core::sparse::tree_hash(&tree);
341
342        load_or_build(&layout, &tree, &filter).unwrap();
343
344        // Corrupt the on-disk cache entry directly.
345        let path = cache_path(&layout, &th);
346        fs::write(&path, b"not a valid sparse cache body").unwrap();
347        assert!(matches!(
348            load(&layout, &th, &hash_filter(&filter)),
349            Err(CacheError::Wire(_))
350        ));
351
352        // A corrupt entry must be treated as a miss — fresh build, no
353        // error surfaced — and must repair the cache for next time.
354        let outcome = load_or_build(&layout, &tree, &filter).unwrap();
355        assert!(
356            matches!(outcome, SparseOutcome::Built { store_error: None }),
357            "a corrupt cache entry must miss and rebuild, got {outcome:?}"
358        );
359        assert!(
360            load(&layout, &th, &hash_filter(&filter)).unwrap().is_some(),
361            "the miss must have repaired the cache entry"
362        );
363    }
364}