1#![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#[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!("{}.witness", 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(
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#[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#[derive(Debug)]
127pub enum SparseOutcome {
128 CacheHit,
132 FullMetadata,
134 Built { store_error: Option<CacheError> },
140}
141
142pub 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 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 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 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 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 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 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 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}