Skip to main content

heddle_object_model/compact/
tree.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use sley_core::{ObjectFormat as GitObjectFormat, ObjectId as GitObjectId};
4
5use super::{
6    Result, invalid,
7    io::{Reader, Writer, varint_len},
8    limits::{MAX_COMPACT_COUNT, MIN_TREE_ENTRY_BYTES, MIN_TREE_ITEM_BYTES},
9};
10use crate::object::{
11    ContentHash, EntryType, FileMode, SpoolId, StateId, Tree, TreeEntry, TreeEntryTarget,
12};
13
14const TREE_MAGIC: &[u8; 4] = b"HCT1";
15
16/// Whether `bytes` begin with the compact-tree frame discriminator.
17pub fn is_tree_frame(bytes: &[u8]) -> bool {
18    bytes.starts_with(TREE_MAGIC)
19}
20
21/// Exact raw-frame size for `tree`, including its per-tree entry count.
22pub fn encoded_tree_size(tree: &Tree) -> usize {
23    varint_len(tree.len())
24        + tree.len() * 2
25        + tree
26            .entries()
27            .iter()
28            .map(|entry| varint_len(entry.name().len()) + entry.name().len() + target_len(entry))
29            .sum::<usize>()
30}
31
32/// Encode name-sorted trees as columnar mode/kind/name/target payloads.
33pub fn encode_tree_frame(trees: &[Tree]) -> Result<Vec<u8>> {
34    if trees.len() > MAX_COMPACT_COUNT {
35        return Err(invalid(format!(
36            "tree frame count {} exceeds maximum {MAX_COMPACT_COUNT}",
37            trees.len()
38        )));
39    }
40    if let Some(count) = trees
41        .iter()
42        .map(Tree::len)
43        .find(|len| *len > MAX_COMPACT_COUNT)
44    {
45        return Err(invalid(format!(
46            "tree entry count {count} exceeds maximum {MAX_COMPACT_COUNT}"
47        )));
48    }
49    if trees.iter().any(Tree::requires_canonical_body) {
50        // HCT1 columns carry no salt and no Git layout; a V4 salted tree or a
51        // tree with a Git source layout must never be written through this
52        // frame (it would silently re-hash as a different tree).
53        return Err(invalid(
54            "cannot encode a v4 salted tree or a git source layout in an HCT1 compact frame"
55                .to_string(),
56        ));
57    }
58    let mut output = Writer::new(TREE_MAGIC);
59    output.put_u64(trees.len() as u64);
60    for tree in trees {
61        tree.validate()?;
62        output.put_u64(tree.len() as u64);
63        for entry in tree.entries() {
64            output.put_u8(entry.mode().to_byte());
65        }
66        for entry in tree.entries() {
67            output.put_u8(entry.entry_type().to_byte());
68        }
69        for entry in tree.entries() {
70            output.put_bytes(entry.name().as_bytes());
71        }
72        for entry in tree.entries() {
73            encode_target(&mut output, entry);
74        }
75    }
76    Ok(output.finish())
77}
78
79/// Decode and whole-frame-verify every tree in a compact frame.
80pub fn decode_tree_frame(bytes: &[u8]) -> Result<Vec<Tree>> {
81    let mut input = Reader::verified(bytes, TREE_MAGIC)?;
82    let tree_count = input.get_count("tree frame", MIN_TREE_ITEM_BYTES)?;
83    let mut trees = Vec::with_capacity(tree_count);
84    for _ in 0..tree_count {
85        trees.push(decode_tree(&mut input)?);
86    }
87    input.finish()?;
88    Ok(trees)
89}
90
91/// Reconstruct one tree and verify its BLAKE3 typed hash.
92///
93/// The whole-frame checksum is verified first. Each tree is rebuilt from its
94/// SoA columns until `expected` matches the reconstructed typed hash.
95pub fn extract_tree(bytes: &[u8], expected: ContentHash) -> Result<Tree> {
96    let mut input = Reader::verified(bytes, TREE_MAGIC)?;
97    let tree_count = input.get_count("tree frame", MIN_TREE_ITEM_BYTES)?;
98    for _ in 0..tree_count {
99        let tree = decode_tree(&mut input)?;
100        if tree.hash() == expected {
101            return Ok(tree);
102        }
103    }
104    Err(super::CompactError::Missing)
105}
106
107fn decode_tree(input: &mut Reader<'_>) -> Result<Tree> {
108    let count = input.get_count("tree entry", MIN_TREE_ENTRY_BYTES)?;
109    let modes = (0..count)
110        .map(|_| decode_mode(input.get_u8()?))
111        .collect::<Result<Vec<_>>>()?;
112    let kinds = (0..count)
113        .map(|_| decode_kind(input.get_u8()?))
114        .collect::<Result<Vec<_>>>()?;
115    let names = (0..count)
116        .map(|_| {
117            String::from_utf8(input.get_bytes()?).map_err(|_| invalid("tree name is not UTF-8"))
118        })
119        .collect::<Result<Vec<_>>>()?;
120    let mut entries = Vec::with_capacity(count);
121    for index in 0..count {
122        entries.push(decode_entry(
123            input,
124            names[index].clone(),
125            kinds[index],
126            modes[index],
127        )?);
128    }
129    let tree = Tree::from_entries(entries);
130    tree.validate()?;
131    Ok(tree)
132}
133
134fn encode_target(output: &mut Writer, entry: &TreeEntry) {
135    match entry.target() {
136        TreeEntryTarget::Blob { hash, .. }
137        | TreeEntryTarget::Tree { hash }
138        | TreeEntryTarget::Symlink { hash } => {
139            output.put_fixed(hash.as_bytes());
140        }
141        TreeEntryTarget::Gitlink { target } => {
142            output.put_u8(git_format_tag(target.format()));
143            output.put_fixed(target.as_bytes());
144        }
145        TreeEntryTarget::Spoollink { spool_id, state_id } => {
146            output.put_bytes(spool_id.as_str().as_bytes());
147            output.put_fixed(state_id.as_bytes());
148        }
149    }
150}
151
152fn decode_entry(
153    input: &mut Reader<'_>,
154    name: String,
155    kind: EntryType,
156    mode: FileMode,
157) -> Result<TreeEntry> {
158    let entry = match kind {
159        EntryType::Blob => TreeEntry::file(
160            name,
161            ContentHash::from_bytes(input.get_fixed()?),
162            mode == FileMode::Executable,
163        )?,
164        EntryType::Tree => TreeEntry::directory(name, ContentHash::from_bytes(input.get_fixed()?))?,
165        EntryType::Symlink => {
166            TreeEntry::symlink(name, ContentHash::from_bytes(input.get_fixed()?))?
167        }
168        EntryType::Gitlink => {
169            let format = decode_git_format(input.get_u8()?)?;
170            let oid_len = match format {
171                GitObjectFormat::Sha1 => 20,
172                GitObjectFormat::Sha256 => 32,
173            };
174            let target = GitObjectId::from_raw(format, input.take(oid_len)?)
175                .map_err(|error| super::CompactError::GitObjectId(error.to_string()))?;
176            TreeEntry::gitlink(name, target)?
177        }
178        EntryType::Spoollink => {
179            let spool = String::from_utf8(input.get_bytes()?)
180                .map_err(|_| invalid("spool id is not UTF-8"))?;
181            TreeEntry::spoollink(
182                name,
183                SpoolId::parse(spool)
184                    .map_err(|error| super::CompactError::SpoolId(error.to_string()))?,
185                StateId::from_bytes(input.get_fixed()?),
186            )?
187        }
188    };
189    if entry.mode() != mode {
190        return Err(invalid(format!(
191            "tree kind/mode mismatch for {}: {kind:?}/{mode:?}",
192            entry.name()
193        )));
194    }
195    Ok(entry)
196}
197
198fn target_len(entry: &TreeEntry) -> usize {
199    match entry.target() {
200        TreeEntryTarget::Blob { .. }
201        | TreeEntryTarget::Tree { .. }
202        | TreeEntryTarget::Symlink { .. } => 32,
203        TreeEntryTarget::Gitlink { target } => 1 + target.as_bytes().len(),
204        TreeEntryTarget::Spoollink { spool_id, .. } => {
205            varint_len(spool_id.as_str().len()) + spool_id.as_str().len() + 32
206        }
207    }
208}
209
210fn decode_mode(value: u8) -> Result<FileMode> {
211    FileMode::from_byte(value).ok_or_else(|| invalid(format!("invalid tree mode {value}")))
212}
213
214fn decode_kind(value: u8) -> Result<EntryType> {
215    EntryType::from_byte(value).ok_or_else(|| invalid(format!("invalid tree kind {value}")))
216}
217
218fn git_format_tag(format: GitObjectFormat) -> u8 {
219    match format {
220        GitObjectFormat::Sha1 => 1,
221        GitObjectFormat::Sha256 => 2,
222    }
223}
224
225fn decode_git_format(value: u8) -> Result<GitObjectFormat> {
226    match value {
227        1 => Ok(GitObjectFormat::Sha1),
228        2 => Ok(GitObjectFormat::Sha256),
229        _ => Err(invalid(format!("invalid git object format {value}"))),
230    }
231}