Skip to main content

mkit_cli/
sparse_cache.rs

1//! On-disk witness 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>.witness
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 using the witness. 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::{self, Read};
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>.witness`. 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!("{}.witness", 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/// Read and authenticate a bounded v2 cache witness against the requested
88/// tree and filter identities. Unsupported versions, malformed and substituted entries fail.
89///
90/// # Errors
91/// Returns filesystem, wire or identity errors; callers may rebuild on a miss.
92pub fn load(
93    layout: &RepoLayout,
94    tree_hash: &Hash,
95    expected_filter_hash: &Hash,
96) -> Result<Option<(SparseManifest, SparseProof)>, CacheError> {
97    let path = cache_path(layout, tree_hash);
98    let file = match fs::File::open(&path) {
99        Ok(file) => file,
100        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
101        Err(e) => return Err(e.into()),
102    };
103    let mut bytes = Vec::new();
104    file.take((mkit_core::sparse::SPARSE_WIRE_MAX_BYTES + 1) as u64)
105        .read_to_end(&mut bytes)?;
106    let (manifest, proof) = decode_sparse_cache(&bytes)?;
107    if manifest.filter_hash != *expected_filter_hash || manifest.tree_hash != *tree_hash {
108        return Err(CacheError::FilterMismatch);
109    }
110    Ok(Some((manifest, proof)))
111}
112
113/// Errors from [`load_or_build`]'s fresh-build path. A cache-read
114/// failure is never one of these — it is always treated as a miss (see
115/// [`load_or_build`]'s doc).
116#[derive(Debug, thiserror::Error)]
117pub enum SparseBuildError {
118    #[error("sparse build: {0}")]
119    Build(#[from] SparseError),
120    #[error("sparse build produced a manifest that fails verify")]
121    VerifyFailed,
122}
123
124/// Outcome of [`load_or_build`]: whether the on-disk cache satisfied
125/// the request or a fresh manifest had to be built.
126#[derive(Debug)]
127pub enum SparseOutcome {
128    /// `(tree_hash, filter_hash)` was already cached — the expensive
129    /// `build_sparse` + `verify_sparse` witness construction
130    /// was skipped entirely.
131    CacheHit,
132    /// Full authenticated metadata is already local; sparse grammar/size is unsupported.
133    FullMetadata,
134    /// No usable cache entry existed (miss, filter mismatch, or a
135    /// corrupt/undecodable entry); a fresh manifest was built and
136    /// self-verified. `store_error` is `Some` if persisting it back to
137    /// the cache failed (best-effort — the caller may want to warn on
138    /// stderr, as `store`'s own doc explains this is never fatal).
139    Built { store_error: Option<CacheError> },
140}
141
142/// Revalidate a cache witness or build it from authenticated local metadata.
143/// Unsupported filters and oversized witnesses retain the existing full-metadata
144/// restoration path. Cache failures are disposable misses.
145///
146/// # Errors
147/// Invalid local trees or a failed self-verification return a build error.
148pub fn load_or_build(
149    layout: &RepoLayout,
150    tree: &Tree,
151    filter: &[PathBuf],
152) -> Result<SparseOutcome, SparseBuildError> {
153    if mkit_core::sparse::validate_filter(filter).is_err() {
154        return Ok(SparseOutcome::FullMetadata);
155    }
156    let th = compute_tree_hash(tree);
157    let fh = hash_filter(filter);
158    if let Ok(Some(_)) = load(layout, &th, &fh) {
159        return Ok(SparseOutcome::CacheHit);
160    }
161
162    let response = match build_sparse(tree, filter) {
163        Ok(value) => value,
164        Err(SparseError::TooLarge | SparseError::UnsupportedFilter) => {
165            return Ok(SparseOutcome::FullMetadata);
166        }
167        Err(error) => return Err(error.into()),
168    };
169    if verify_sparse(&th, filter, &response).is_err() {
170        return Err(SparseBuildError::VerifyFailed);
171    }
172    // Best-effort: a write failure here doesn't invalidate the build
173    // the caller is about to materialise from, only the next run's
174    // ability to skip re-deriving it — surfaced to the caller as
175    // `store_error` rather than swallowed, so it can warn on stderr as
176    // before.
177    let store_error = store(layout, &th, &response.manifest, &response.proof).err();
178    Ok(SparseOutcome::Built { store_error })
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use mkit_core::object::{EntryMode, TreeEntry};
185
186    #[test]
187    fn cache_rejects_valid_witness_under_wrong_tree_filename() {
188        let td = tempfile::tempdir().unwrap();
189        let layout = RepoLayout::single(td.path());
190        let tree = Tree {
191            entries: vec![entry(b"a")],
192        };
193        let filter = [PathBuf::from("a")];
194        let mkit_core::sparse::SparseResponse { manifest, proof } =
195            build_sparse(&tree, &filter).unwrap();
196        let wrong = [7; 32];
197        store(&layout, &wrong, &manifest, &proof).unwrap();
198        assert!(load(&layout, &wrong, &hash_filter(&filter)).is_err());
199        let mut corrupt = encode_sparse_cache(&manifest, &proof).unwrap();
200        *corrupt.last_mut().unwrap() ^= 1;
201        fs::write(cache_path(&layout, &manifest.tree_hash), corrupt).unwrap();
202        assert!(load(&layout, &manifest.tree_hash, &hash_filter(&filter)).is_err());
203    }
204
205    fn entry(name: &[u8]) -> TreeEntry {
206        TreeEntry {
207            name: name.to_vec(),
208            mode: EntryMode::Blob,
209            object_hash: [0u8; 32],
210        }
211    }
212
213    #[test]
214    fn round_trip_load_returns_stored_payload() {
215        let td = tempfile::tempdir().unwrap();
216        let layout = RepoLayout::single(td.path());
217        // Need a .mkit directory shape so cache_path's parent is
218        // creatable from the helper.
219        fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
220        let tree = Tree {
221            entries: vec![entry(b"aa"), entry(b"ab"), entry(b"ac")],
222        };
223        let filter = vec![PathBuf::from("aa")];
224        let mkit_core::sparse::SparseResponse { manifest, proof } =
225            build_sparse(&tree, &filter).unwrap();
226
227        store(&layout, &manifest.tree_hash, &manifest, &proof).unwrap();
228
229        let loaded = load(&layout, &manifest.tree_hash, &manifest.filter_hash)
230            .unwrap()
231            .expect("just stored");
232        assert_eq!(loaded.0.tree_hash, manifest.tree_hash);
233        assert_eq!(loaded.0.filter_hash, manifest.filter_hash);
234        assert_eq!(loaded.1.tree_bytes, proof.tree_bytes);
235    }
236
237    #[test]
238    fn load_returns_none_for_missing_tree() {
239        let td = tempfile::tempdir().unwrap();
240        let layout = RepoLayout::single(td.path());
241        let h = [0u8; 32];
242        let res = load(&layout, &h, &hash_filter(&[])).unwrap();
243        assert!(res.is_none());
244    }
245
246    #[test]
247    fn load_rejects_mismatched_filter_hash() {
248        let td = tempfile::tempdir().unwrap();
249        let layout = RepoLayout::single(td.path());
250        fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
251        let tree = Tree {
252            entries: vec![entry(b"aa"), entry(b"ab")],
253        };
254        let mkit_core::sparse::SparseResponse { manifest, proof } =
255            build_sparse(&tree, &[PathBuf::from("aa")]).unwrap();
256        store(&layout, &manifest.tree_hash, &manifest, &proof).unwrap();
257
258        // Lookup with a *different* filter hash → should fail loudly.
259        let other_filter_hash = hash_filter(&[PathBuf::from("zz")]);
260        let err = load(&layout, &manifest.tree_hash, &other_filter_hash).unwrap_err();
261        assert!(matches!(err, CacheError::FilterMismatch));
262    }
263
264    #[test]
265    fn load_or_build_hits_cache_on_repeat_call() {
266        let td = tempfile::tempdir().unwrap();
267        let layout = RepoLayout::single(td.path());
268        fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
269        let tree = Tree {
270            entries: vec![entry(b"aa"), entry(b"ab"), entry(b"ac")],
271        };
272        let filter = vec![PathBuf::from("aa")];
273
274        let first = load_or_build(&layout, &tree, &filter).unwrap();
275        assert!(
276            matches!(first, SparseOutcome::Built { store_error: None }),
277            "first call for a never-seen (tree, filter) must build fresh, got {first:?}"
278        );
279
280        let second = load_or_build(&layout, &tree, &filter).unwrap();
281        assert!(
282            matches!(second, SparseOutcome::CacheHit),
283            "repeat call with an unchanged filter must hit the cache instead of rebuilding, got {second:?}"
284        );
285    }
286
287    #[test]
288    fn load_or_build_treats_filter_change_as_a_miss_and_rewrites_cache() {
289        let td = tempfile::tempdir().unwrap();
290        let layout = RepoLayout::single(td.path());
291        fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
292        let tree = Tree {
293            entries: vec![entry(b"aa"), entry(b"ab"), entry(b"ac")],
294        };
295        let th = mkit_core::sparse::tree_hash(&tree);
296
297        let first_filter = vec![PathBuf::from("aa")];
298        load_or_build(&layout, &tree, &first_filter).unwrap();
299        let cached_after_first = load(&layout, &th, &hash_filter(&first_filter))
300            .unwrap()
301            .expect("first build cached its own filter");
302
303        // Same tree, different filter: the cache file (keyed only by
304        // tree_hash) exists but commits to the OLD filter — this must
305        // be treated as a miss, never silently returned.
306        let second_filter = vec![PathBuf::from("ab")];
307        let outcome = load_or_build(&layout, &tree, &second_filter).unwrap();
308        assert!(
309            matches!(outcome, SparseOutcome::Built { store_error: None }),
310            "a filter change for the same tree must miss and rebuild, got {outcome:?}"
311        );
312
313        // The miss must have rewritten the cache to the NEW filter, no
314        // error surfaced.
315        let cached_after_second = load(&layout, &th, &hash_filter(&second_filter))
316            .unwrap()
317            .expect("miss must rewrite the cache under the new filter");
318        assert_ne!(
319            cached_after_second.0.filter_hash,
320            cached_after_first.0.filter_hash
321        );
322        assert_eq!(
323            cached_after_second.0.filter_hash,
324            hash_filter(&second_filter)
325        );
326    }
327
328    #[test]
329    fn load_or_build_treats_corrupt_cache_entry_as_a_miss_and_repairs_it() {
330        let td = tempfile::tempdir().unwrap();
331        let layout = RepoLayout::single(td.path());
332        fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
333        let tree = Tree {
334            entries: vec![entry(b"aa"), entry(b"ab"), entry(b"ac")],
335        };
336        let filter = vec![PathBuf::from("aa")];
337        let th = mkit_core::sparse::tree_hash(&tree);
338
339        load_or_build(&layout, &tree, &filter).unwrap();
340
341        // Corrupt the on-disk cache entry directly.
342        let path = cache_path(&layout, &th);
343        fs::write(&path, b"not a valid sparse cache body").unwrap();
344        assert!(matches!(
345            load(&layout, &th, &hash_filter(&filter)),
346            Err(CacheError::Wire(_))
347        ));
348
349        // A corrupt entry must be treated as a miss — fresh build, no
350        // error surfaced — and must repair the cache for next time.
351        let outcome = load_or_build(&layout, &tree, &filter).unwrap();
352        assert!(
353            matches!(outcome, SparseOutcome::Built { store_error: None }),
354            "a corrupt cache entry must miss and rebuild, got {outcome:?}"
355        );
356        assert!(
357            load(&layout, &th, &hash_filter(&filter)).unwrap().is_some(),
358            "the miss must have repaired the cache entry"
359        );
360    }
361}