1#![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#[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 #[error("cached delivery committed to a different filter")]
47 FilterMismatch,
48}
49
50#[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
60pub 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 fs::write(path, bytes)?;
84 Ok(())
85}
86
87pub 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#[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#[derive(Debug)]
142pub enum SparseOutcome {
143 CacheHit,
147 Built { store_error: Option<CacheError> },
153}
154
155pub 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 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 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 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 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 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 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 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}