#![cfg(feature = "sparse-checkout")]
use mkit_core::layout::RepoLayout;
use std::fs;
use std::io;
use std::path::PathBuf;
use mkit_core::hash::{Hash, to_hex};
use mkit_core::object::Tree;
use mkit_core::sparse::{
SparseError, SparseManifest, SparseProof, SparseWireError, build_sparse, decode_sparse_cache,
encode_sparse_cache, hash_filter, tree_hash as compute_tree_hash, verify_sparse,
};
#[derive(Debug, thiserror::Error)]
pub enum CacheError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("wire: {0}")]
Wire(#[from] SparseWireError),
#[error("cached delivery committed to a different filter")]
FilterMismatch,
}
#[must_use]
pub fn cache_path(layout: &RepoLayout, tree_hash: &Hash) -> PathBuf {
layout
.sparse_cache_dir()
.join(format!("{}.bitmap", to_hex(tree_hash)))
}
pub fn store(
layout: &RepoLayout,
tree_hash: &Hash,
manifest: &SparseManifest,
proof: &SparseProof,
) -> Result<(), CacheError> {
let path = cache_path(layout, tree_hash);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let bytes = encode_sparse_cache(manifest, proof);
fs::write(path, bytes)?;
Ok(())
}
pub fn load(
layout: &RepoLayout,
tree_hash: &Hash,
expected_filter_hash: &Hash,
) -> Result<Option<(SparseManifest, SparseProof)>, CacheError> {
let path = cache_path(layout, tree_hash);
let bytes = match fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let (bitmap_root, filter_hash, leaf_count, bitmap_bytes) = decode_sparse_cache(&bytes)?;
if filter_hash != *expected_filter_hash {
return Err(CacheError::FilterMismatch);
}
Ok(Some((
SparseManifest {
tree_hash: *tree_hash,
bitmap_root,
filter_hash,
leaf_count,
},
SparseProof { bitmap_bytes },
)))
}
#[derive(Debug, thiserror::Error)]
pub enum SparseBuildError {
#[error("sparse build: {0}")]
Build(#[from] SparseError),
#[error("sparse build produced a manifest that fails verify")]
VerifyFailed,
}
#[derive(Debug)]
pub enum SparseOutcome {
CacheHit,
Built { store_error: Option<CacheError> },
}
pub fn load_or_build(
layout: &RepoLayout,
tree: &Tree,
filter: &[PathBuf],
) -> Result<SparseOutcome, SparseBuildError> {
let th = compute_tree_hash(tree);
let fh = hash_filter(filter);
if let Ok(Some(_)) = load(layout, &th, &fh) {
return Ok(SparseOutcome::CacheHit);
}
let (delivered, manifest, proof) = build_sparse(tree, filter)?;
if !verify_sparse(&manifest, &delivered, filter, &proof) {
return Err(SparseBuildError::VerifyFailed);
}
let store_error = store(layout, &manifest.tree_hash, &manifest, &proof).err();
Ok(SparseOutcome::Built { store_error })
}
#[cfg(test)]
mod tests {
use super::*;
use mkit_core::object::{EntryMode, TreeEntry};
fn entry(name: &[u8]) -> TreeEntry {
TreeEntry {
name: name.to_vec(),
mode: EntryMode::Blob,
object_hash: [0u8; 32],
}
}
#[test]
fn round_trip_load_returns_stored_payload() {
let td = tempfile::tempdir().unwrap();
let layout = RepoLayout::single(td.path());
fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
let tree = Tree {
entries: vec![entry(b"aa"), entry(b"ab"), entry(b"ac")],
};
let filter = vec![PathBuf::from("aa")];
let (_, manifest, proof) = build_sparse(&tree, &filter).unwrap();
store(&layout, &manifest.tree_hash, &manifest, &proof).unwrap();
let loaded = load(&layout, &manifest.tree_hash, &manifest.filter_hash)
.unwrap()
.expect("just stored");
assert_eq!(loaded.0.bitmap_root, manifest.bitmap_root);
assert_eq!(loaded.0.filter_hash, manifest.filter_hash);
assert_eq!(loaded.0.leaf_count, manifest.leaf_count);
assert_eq!(loaded.1.bitmap_bytes, proof.bitmap_bytes);
}
#[test]
fn load_returns_none_for_missing_tree() {
let td = tempfile::tempdir().unwrap();
let layout = RepoLayout::single(td.path());
let h = [0u8; 32];
let res = load(&layout, &h, &hash_filter(&[])).unwrap();
assert!(res.is_none());
}
#[test]
fn load_rejects_mismatched_filter_hash() {
let td = tempfile::tempdir().unwrap();
let layout = RepoLayout::single(td.path());
fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
let tree = Tree {
entries: vec![entry(b"aa"), entry(b"ab")],
};
let (_, manifest, proof) = build_sparse(&tree, &[PathBuf::from("aa")]).unwrap();
store(&layout, &manifest.tree_hash, &manifest, &proof).unwrap();
let other_filter_hash = hash_filter(&[PathBuf::from("zz")]);
let err = load(&layout, &manifest.tree_hash, &other_filter_hash).unwrap_err();
assert!(matches!(err, CacheError::FilterMismatch));
}
#[test]
fn load_or_build_hits_cache_on_repeat_call() {
let td = tempfile::tempdir().unwrap();
let layout = RepoLayout::single(td.path());
fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
let tree = Tree {
entries: vec![entry(b"aa"), entry(b"ab"), entry(b"ac")],
};
let filter = vec![PathBuf::from("aa")];
let first = load_or_build(&layout, &tree, &filter).unwrap();
assert!(
matches!(first, SparseOutcome::Built { store_error: None }),
"first call for a never-seen (tree, filter) must build fresh, got {first:?}"
);
let second = load_or_build(&layout, &tree, &filter).unwrap();
assert!(
matches!(second, SparseOutcome::CacheHit),
"repeat call with an unchanged filter must hit the cache instead of rebuilding, got {second:?}"
);
}
#[test]
fn load_or_build_treats_filter_change_as_a_miss_and_rewrites_cache() {
let td = tempfile::tempdir().unwrap();
let layout = RepoLayout::single(td.path());
fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
let tree = Tree {
entries: vec![entry(b"aa"), entry(b"ab"), entry(b"ac")],
};
let th = mkit_core::sparse::tree_hash(&tree);
let first_filter = vec![PathBuf::from("aa")];
load_or_build(&layout, &tree, &first_filter).unwrap();
let cached_after_first = load(&layout, &th, &hash_filter(&first_filter))
.unwrap()
.expect("first build cached its own filter");
let second_filter = vec![PathBuf::from("ab")];
let outcome = load_or_build(&layout, &tree, &second_filter).unwrap();
assert!(
matches!(outcome, SparseOutcome::Built { store_error: None }),
"a filter change for the same tree must miss and rebuild, got {outcome:?}"
);
let cached_after_second = load(&layout, &th, &hash_filter(&second_filter))
.unwrap()
.expect("miss must rewrite the cache under the new filter");
assert_ne!(
cached_after_second.0.filter_hash,
cached_after_first.0.filter_hash
);
assert_eq!(
cached_after_second.0.filter_hash,
hash_filter(&second_filter)
);
}
#[test]
fn load_or_build_treats_corrupt_cache_entry_as_a_miss_and_repairs_it() {
let td = tempfile::tempdir().unwrap();
let layout = RepoLayout::single(td.path());
fs::create_dir_all(td.path().join(mkit_core::MKIT_DIR)).unwrap();
let tree = Tree {
entries: vec![entry(b"aa"), entry(b"ab"), entry(b"ac")],
};
let filter = vec![PathBuf::from("aa")];
let th = mkit_core::sparse::tree_hash(&tree);
load_or_build(&layout, &tree, &filter).unwrap();
let path = cache_path(&layout, &th);
fs::write(&path, b"not a valid sparse cache body").unwrap();
assert!(matches!(
load(&layout, &th, &hash_filter(&filter)),
Err(CacheError::Wire(_))
));
let outcome = load_or_build(&layout, &tree, &filter).unwrap();
assert!(
matches!(outcome, SparseOutcome::Built { store_error: None }),
"a corrupt cache entry must miss and rebuild, got {outcome:?}"
);
assert!(
load(&layout, &th, &hash_filter(&filter)).unwrap().is_some(),
"the miss must have repaired the cache entry"
);
}
}