Skip to main content

mkit_core/
sparse.rs

1//! Canonical-tree witnesses for sparse checkout (SPEC-SPARSE-CHECKOUT v2).
2//!
3//! A witness carries complete metadata for one tree. The verifier binds it to
4//! an independently trusted object ID and derives the selected entries locally.
5//! Directory entries authenticate child IDs; recursive consumers must verify a
6//! witness for every selected child before declaring the traversal complete.
7use crate::hash::{Hash, Hasher};
8use crate::object::{EntryMode, Object, Tree, TreeEntry};
9use crate::serialize::{deserialize, serialize};
10use std::path::PathBuf;
11
12pub const MAX_LEAVES: u64 = 1_000_000;
13pub const MAX_FILTER_PATHS: usize = 100_000;
14pub const MAX_FILTER_BYTES: usize = 1024 * 1024;
15pub const SPARSE_WIRE_MAGIC: [u8; 4] = *b"MSP1";
16pub const SPARSE_WIRE_VERSION: u8 = 2;
17pub const SPARSE_WIRE_MAX_BYTES: usize = 16 * 1024 * 1024;
18pub const SPARSE_CACHE_MAGIC: [u8; 4] = *b"MSPC";
19pub const SPARSE_CACHE_VERSION: u8 = 2;
20pub const SPARSE_CACHE_DIR: &str = "sparse";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub struct SparseManifest {
24    pub tree_hash: Hash,
25    pub filter_hash: Hash,
26}
27#[derive(Debug, Clone)]
28pub struct SparseProof {
29    /// Exact canonical serialized tree object, bounded by the wire limit.
30    pub tree_bytes: Vec<u8>,
31}
32#[derive(Debug, Clone)]
33pub struct SparseResponse {
34    pub manifest: SparseManifest,
35    pub proof: SparseProof,
36}
37/// Locally derived selection after witness and requested-context verification.
38#[derive(Debug, Clone)]
39pub struct VerifiedSparseTree {
40    pub manifest: SparseManifest,
41    pub entries: Vec<TreeEntry>,
42}
43#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
44pub enum SparseError {
45    #[error("tree has {actual} entries, exceeds MAX_LEAVES")]
46    TooManyLeaves { actual: u64 },
47    #[error("filter has {actual} paths, exceeds MAX_FILTER_PATHS")]
48    TooManyFilterPaths { actual: usize },
49    #[error("source tree is not strictly sorted")]
50    UnsortedTree,
51    #[error("invalid canonical tree")]
52    InvalidTree,
53    #[error("unsupported filter; requires full authenticated metadata")]
54    UnsupportedFilter,
55    #[error("witness exceeds size limit; requires full authenticated metadata")]
56    TooLarge,
57}
58#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
59pub enum SparseWireError {
60    #[error("sparse wire: truncated")]
61    Truncated,
62    #[error("sparse wire: bad magic")]
63    BadMagic,
64    #[error("sparse wire: unsupported version {0}")]
65    UnsupportedVersion(u8),
66    #[error("sparse wire: length out of bounds")]
67    LengthOutOfBounds,
68    #[error("sparse wire: response exceeds maximum size")]
69    TooLarge,
70    #[error("sparse wire: invalid tree witness")]
71    InvalidTree,
72}
73
74/// Filters are UTF-8 repository-relative literal path prefixes. `.` selects
75/// everything; an empty list selects nothing. Negation and globbing require the
76/// authenticated full-metadata fallback, never an approximate sparse filter.
77pub fn validate_filter(filter: &[PathBuf]) -> Result<(), SparseError> {
78    if filter.len() > MAX_FILTER_PATHS {
79        return Err(SparseError::TooManyFilterPaths {
80            actual: filter.len(),
81        });
82    }
83    let mut total = 0usize;
84    for path in filter {
85        let value = path.to_str().ok_or(SparseError::UnsupportedFilter)?;
86        total = total
87            .checked_add(value.len())
88            .ok_or(SparseError::UnsupportedFilter)?;
89        if total > MAX_FILTER_BYTES
90            || value.is_empty()
91            || value.contains(['!', '*', '?', '[', ']', '\\'])
92            || (value != "."
93                && value
94                    .split('/')
95                    .any(|p| p.is_empty() || p == "." || p == ".."))
96        {
97            return Err(SparseError::UnsupportedFilter);
98        }
99    }
100    Ok(())
101}
102#[must_use]
103pub fn hash_filter(filter: &[PathBuf]) -> Hash {
104    let mut canonical: Vec<_> = filter
105        .iter()
106        .map(|p| p.as_os_str().as_encoded_bytes())
107        .collect();
108    canonical.sort_unstable();
109    canonical.dedup();
110    let mut h = Hasher::new();
111    h.update(b"mkit-sparse-filter-v2\0");
112    for bytes in canonical {
113        h.update(&(bytes.len() as u64).to_le_bytes());
114        h.update(bytes);
115    }
116    h.finalize()
117}
118fn matches(entry: &TreeEntry, filter: &[PathBuf]) -> bool {
119    filter.iter().any(|p| {
120        let prefix = p.as_os_str().as_encoded_bytes();
121        prefix == b"."
122            || prefix == entry.name
123            || (entry.mode == EntryMode::Tree
124                && prefix.starts_with(&entry.name)
125                && prefix.get(entry.name.len()) == Some(&b'/'))
126    })
127}
128fn validate_tree(tree: &Tree) -> Result<(), SparseError> {
129    if tree.entries.len() as u64 > MAX_LEAVES {
130        return Err(SparseError::TooManyLeaves {
131            actual: tree.entries.len() as u64,
132        });
133    }
134    if !tree.is_sorted() {
135        return Err(SparseError::UnsortedTree);
136    }
137    if tree
138        .entries
139        .iter()
140        .any(|e| !TreeEntry::validate_name(&e.name))
141    {
142        return Err(SparseError::InvalidTree);
143    }
144    Ok(())
145}
146#[must_use]
147pub fn tree_hash(tree: &Tree) -> Hash {
148    crate::merkle::compute_tree_id(tree)
149}
150
151pub fn build_sparse(tree: &Tree, filter: &[PathBuf]) -> Result<SparseResponse, SparseError> {
152    validate_filter(filter)?;
153    validate_tree(tree)?;
154    // Preflight before cloning/serializing attacker-controlled names.
155    let size = tree
156        .entries
157        .iter()
158        .try_fold(10usize, |n, e| n.checked_add(37 + e.name.len()))
159        .ok_or(SparseError::TooLarge)?;
160    if size > SPARSE_WIRE_MAX_BYTES - 73 {
161        return Err(SparseError::TooLarge);
162    }
163    let tree_bytes =
164        serialize(&Object::Tree(tree.clone())).map_err(|_| SparseError::InvalidTree)?;
165    Ok(SparseResponse {
166        manifest: SparseManifest {
167            tree_hash: tree_hash(tree),
168            filter_hash: hash_filter(filter),
169        },
170        proof: SparseProof { tree_bytes },
171    })
172}
173/// Authenticate the witness against the requested root and filter, then derive
174/// the complete selection locally. No server-selected list is accepted.
175pub fn verify_sparse(
176    expected_tree: &Hash,
177    filter: &[PathBuf],
178    response: &SparseResponse,
179) -> Result<VerifiedSparseTree, SparseWireError> {
180    validate_filter(filter).map_err(|_| SparseWireError::InvalidTree)?;
181    if response.manifest.tree_hash != *expected_tree
182        || response.manifest.filter_hash != hash_filter(filter)
183    {
184        return Err(SparseWireError::InvalidTree);
185    }
186    let tree = witness_tree(&response.proof)?;
187    if tree_hash(&tree) != *expected_tree {
188        return Err(SparseWireError::InvalidTree);
189    }
190    Ok(VerifiedSparseTree {
191        manifest: response.manifest,
192        entries: tree
193            .entries
194            .into_iter()
195            .filter(|entry| matches(entry, filter))
196            .collect(),
197    })
198}
199
200fn witness_tree(proof: &SparseProof) -> Result<Tree, SparseWireError> {
201    if proof.tree_bytes.len() > SPARSE_WIRE_MAX_BYTES - 73 {
202        return Err(SparseWireError::TooLarge);
203    }
204    let Object::Tree(tree) =
205        deserialize(&proof.tree_bytes).map_err(|_| SparseWireError::InvalidTree)?
206    else {
207        return Err(SparseWireError::InvalidTree);
208    };
209    validate_tree(&tree).map_err(|_| SparseWireError::InvalidTree)?;
210    if serialize(&Object::Tree(tree.clone())).map_err(|_| SparseWireError::InvalidTree)?
211        != proof.tree_bytes
212    {
213        return Err(SparseWireError::InvalidTree);
214    }
215    Ok(tree)
216}
217/// The wire contains only the root, filter commitment and full tree witness.
218/// Delivered entries are derived from that witness by `verify_sparse` consumers.
219pub fn encode_sparse_response(resp: &SparseResponse) -> Result<Vec<u8>, SparseWireError> {
220    let mut out = encode_sparse_cache(&resp.manifest, &resp.proof)?;
221    out[..4].copy_from_slice(&SPARSE_WIRE_MAGIC);
222    Ok(out)
223}
224pub fn decode_sparse_response(buf: &[u8]) -> Result<SparseResponse, SparseWireError> {
225    let (manifest, proof) = decode_envelope(buf, SPARSE_WIRE_MAGIC)?;
226    Ok(SparseResponse { manifest, proof })
227}
228pub fn encode_sparse_cache(
229    manifest: &SparseManifest,
230    proof: &SparseProof,
231) -> Result<Vec<u8>, SparseWireError> {
232    witness_tree(proof)?;
233    let mut out = Vec::with_capacity(73 + proof.tree_bytes.len());
234    out.extend_from_slice(&SPARSE_CACHE_MAGIC);
235    out.push(SPARSE_CACHE_VERSION);
236    out.extend_from_slice(&manifest.tree_hash);
237    out.extend_from_slice(&manifest.filter_hash);
238    out.extend_from_slice(
239        &u32::try_from(proof.tree_bytes.len())
240            .map_err(|_| SparseWireError::TooLarge)?
241            .to_le_bytes(),
242    );
243    out.extend_from_slice(&proof.tree_bytes);
244    Ok(out)
245}
246pub fn decode_sparse_cache(buf: &[u8]) -> Result<(SparseManifest, SparseProof), SparseWireError> {
247    decode_envelope(buf, SPARSE_CACHE_MAGIC)
248}
249fn decode_envelope(
250    buf: &[u8],
251    magic: [u8; 4],
252) -> Result<(SparseManifest, SparseProof), SparseWireError> {
253    if buf.len() > SPARSE_WIRE_MAX_BYTES {
254        return Err(SparseWireError::TooLarge);
255    }
256    if buf.len() < 5 {
257        return Err(SparseWireError::Truncated);
258    }
259    if buf[..4] != magic {
260        return Err(SparseWireError::BadMagic);
261    }
262    if buf[4] != SPARSE_WIRE_VERSION {
263        return Err(SparseWireError::UnsupportedVersion(buf[4]));
264    }
265    if buf.len() < 73 {
266        return Err(SparseWireError::Truncated);
267    }
268    let len = u32::from_le_bytes(
269        buf[69..73]
270            .try_into()
271            .map_err(|_| SparseWireError::Truncated)?,
272    ) as usize;
273    if len != buf.len() - 73 {
274        return Err(SparseWireError::LengthOutOfBounds);
275    }
276    let manifest = SparseManifest {
277        tree_hash: buf[5..37]
278            .try_into()
279            .map_err(|_| SparseWireError::Truncated)?,
280        filter_hash: buf[37..69]
281            .try_into()
282            .map_err(|_| SparseWireError::Truncated)?,
283    };
284    let proof = SparseProof {
285        tree_bytes: buf[73..].to_vec(),
286    };
287    let tree = witness_tree(&proof)?;
288    if tree_hash(&tree) != manifest.tree_hash {
289        return Err(SparseWireError::InvalidTree);
290    }
291    Ok((manifest, proof))
292}
293
294/// Verify an entire selected hierarchy. Missing selected child witnesses fail;
295/// ancestors are followed using only IDs authenticated by their parent tree.
296/// Limits bound aggregate witness bytes, traversal count and path depth.
297pub fn verify_sparse_hierarchy(
298    root: Hash,
299    filter: &[PathBuf],
300    mut fetch: impl FnMut(&Hash, &[PathBuf]) -> Result<SparseResponse, SparseWireError>,
301) -> Result<Vec<(Vec<u8>, TreeEntry)>, SparseWireError> {
302    validate_filter(filter).map_err(|_| SparseWireError::InvalidTree)?;
303    let mut canonical_filter = filter.to_vec();
304    canonical_filter.sort();
305    canonical_filter.dedup();
306    let mut pending_bytes: usize = canonical_filter.iter().map(|p| p.as_os_str().len()).sum();
307    let mut pending = vec![(root, Vec::<u8>::new(), canonical_filter, 0usize)];
308    let mut result = Vec::new();
309    let mut total = 0usize;
310    let mut visited = 0usize;
311    while let Some((id, path, filter, depth)) = pending.pop() {
312        pending_bytes -= path.len() + filter.iter().map(|p| p.as_os_str().len()).sum::<usize>();
313        visited += 1;
314        if visited > 100_000 || depth > 256 {
315            return Err(SparseWireError::TooLarge);
316        }
317        let response = fetch(&id, &filter)?;
318        total = total
319            .checked_add(response.proof.tree_bytes.len())
320            .ok_or(SparseWireError::TooLarge)?;
321        if total > 64 * 1024 * 1024 {
322            return Err(SparseWireError::TooLarge);
323        }
324        let verified = verify_sparse(&id, &filter, &response)?;
325        for entry in verified.entries {
326            let mut full = path.clone();
327            if !full.is_empty() {
328                full.push(b'/');
329            }
330            full.extend_from_slice(&entry.name);
331            if full.len() > 4096 || result.len() as u64 >= MAX_LEAVES {
332                return Err(SparseWireError::TooLarge);
333            }
334            if entry.mode == EntryMode::Tree {
335                let mut child_filter = Vec::new();
336                for prefix in &filter {
337                    let bytes = prefix.as_os_str().as_encoded_bytes();
338                    if bytes == b"." || bytes == entry.name {
339                        child_filter.push(PathBuf::from("."));
340                    } else if bytes.starts_with(&entry.name)
341                        && bytes.get(entry.name.len()) == Some(&b'/')
342                    {
343                        let suffix = std::str::from_utf8(&bytes[entry.name.len() + 1..])
344                            .map_err(|_| SparseWireError::InvalidTree)?;
345                        child_filter.push(PathBuf::from(suffix));
346                    }
347                }
348                child_filter.sort();
349                child_filter.dedup();
350                pending_bytes = pending_bytes
351                    .checked_add(
352                        full.len()
353                            + child_filter
354                                .iter()
355                                .map(|p| p.as_os_str().len())
356                                .sum::<usize>(),
357                    )
358                    .ok_or(SparseWireError::TooLarge)?;
359                if pending.len() >= 100_000 || pending_bytes > 64 * 1024 * 1024 {
360                    return Err(SparseWireError::TooLarge);
361                }
362                pending.push((entry.object_hash, full.clone(), child_filter, depth + 1));
363            }
364            result.push((full, entry));
365        }
366    }
367    result.sort_by(|a, b| a.0.cmp(&b.0));
368    Ok(result)
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    fn tree() -> Tree {
375        Tree {
376            entries: vec![
377                entry(b"a", EntryMode::Blob, [1; 32]),
378                entry(b"b", EntryMode::Blob, [2; 32]),
379            ],
380        }
381    }
382    fn entry(name: &[u8], mode: EntryMode, object_hash: Hash) -> TreeEntry {
383        TreeEntry {
384            name: name.to_vec(),
385            mode,
386            object_hash,
387        }
388    }
389    fn response(tree: &Tree, filter: &[PathBuf]) -> SparseResponse {
390        build_sparse(tree, filter).unwrap()
391    }
392    #[test]
393    fn canonical_identity_and_content_substitution() {
394        let tree = tree();
395        let root = Object::Tree(tree.clone()).id().unwrap();
396        let filter = [PathBuf::from("a")];
397        let r = response(&tree, &filter);
398        assert_eq!(r.manifest.tree_hash, root);
399        let verified = verify_sparse(&root, &filter, &r).unwrap();
400        assert_eq!(verified.entries, vec![tree.entries[0].clone()]);
401        for altered in [
402            Tree {
403                entries: vec![
404                    entry(b"a", EntryMode::Blob, [9; 32]),
405                    tree.entries[1].clone(),
406                ],
407            },
408            Tree {
409                entries: vec![
410                    entry(b"a", EntryMode::Tree, [1; 32]),
411                    tree.entries[1].clone(),
412                ],
413            },
414            Tree {
415                entries: vec![tree.entries[1].clone()],
416            },
417        ] {
418            let forged = SparseResponse {
419                manifest: r.manifest,
420                proof: response(&altered, &filter).proof,
421            };
422            assert!(verify_sparse(&root, &filter, &forged).is_err());
423        }
424        assert!(verify_sparse(&[9; 32], &filter, &r).is_err());
425    }
426    #[test]
427    fn wire_cache_reject_wrong_version_trailing_and_substitution() {
428        let r = response(&tree(), &[PathBuf::from(".")]);
429        let bytes = encode_sparse_response(&r).unwrap();
430        let decoded = decode_sparse_response(&bytes).unwrap();
431        assert_eq!(
432            verify_sparse(&r.manifest.tree_hash, &[PathBuf::from(".")], &decoded)
433                .unwrap()
434                .entries,
435            tree().entries
436        );
437        let mut bad = bytes.clone();
438        bad[4] = 1;
439        assert!(matches!(
440            decode_sparse_response(&bad),
441            Err(SparseWireError::UnsupportedVersion(1))
442        ));
443        let mut bad = bytes.clone();
444        bad.push(0);
445        assert!(decode_sparse_response(&bad).is_err());
446        let mut bad = bytes;
447        bad[5] ^= 1;
448        assert!(decode_sparse_response(&bad).is_err());
449        let cache = encode_sparse_cache(&r.manifest, &r.proof).unwrap();
450        assert_eq!(decode_sparse_cache(&cache).unwrap().0, r.manifest);
451    }
452    #[test]
453    fn hierarchy_authenticates_children_and_requires_completeness() {
454        let child = tree();
455        let root = Tree {
456            entries: vec![entry(b"src", EntryMode::Tree, tree_hash(&child))],
457        };
458        let filter = [PathBuf::from("src/a")];
459        let result = verify_sparse_hierarchy(tree_hash(&root), &filter, |id, f| {
460            Ok(response(
461                if *id == tree_hash(&root) {
462                    &root
463                } else {
464                    &child
465                },
466                f,
467            ))
468        })
469        .unwrap();
470        assert_eq!(
471            result.iter().map(|x| x.0.as_slice()).collect::<Vec<_>>(),
472            vec![b"src".as_slice(), b"src/a".as_slice()]
473        );
474        assert!(
475            verify_sparse_hierarchy(tree_hash(&root), &filter, |id, f| {
476                if *id == tree_hash(&root) {
477                    Ok(response(&root, f))
478                } else {
479                    Err(SparseWireError::Truncated)
480                }
481            })
482            .is_err()
483        );
484        assert!(
485            verify_sparse_hierarchy(tree_hash(&root), &filter, |_, f| Ok(response(&root, f)))
486                .is_err()
487        );
488    }
489    #[test]
490    fn sparse_v2_golden_bytes() {
491        let r = response(&tree(), &[PathBuf::from("a")]);
492        let bytes = encode_sparse_response(&r).unwrap();
493        if std::env::var_os("MKIT_UPDATE_SPARSE_GOLDEN").is_some() {
494            std::fs::write(
495                concat!(
496                    env!("CARGO_MANIFEST_DIR"),
497                    "/../../tests/golden/sparse/response_v2.bin"
498                ),
499                &bytes,
500            )
501            .unwrap();
502            return;
503        }
504        assert_eq!(
505            bytes,
506            include_bytes!("../../../tests/golden/sparse/response_v2.bin")
507        );
508        assert_eq!(r.manifest.tree_hash, Object::Tree(tree()).id().unwrap());
509    }
510    #[test]
511    fn strict_filters_and_invalid_flattened_names() {
512        for filter in ["", "/a", "a/", "../a", "a/*", "!a", "a//b"] {
513            assert!(build_sparse(&tree(), &[PathBuf::from(filter)]).is_err());
514        }
515        let bad = Tree {
516            entries: vec![entry(b"src/a", EntryMode::Blob, [0; 32])],
517        };
518        assert!(build_sparse(&bad, &[]).is_err());
519        assert!(
520            verify_sparse(&tree_hash(&tree()), &[], &response(&tree(), &[]))
521                .unwrap()
522                .entries
523                .is_empty()
524        );
525        assert_eq!(
526            verify_sparse(
527                &tree_hash(&tree()),
528                &[PathBuf::from(".")],
529                &response(&tree(), &[PathBuf::from(".")])
530            )
531            .unwrap()
532            .entries
533            .len(),
534            2
535        );
536    }
537}