Skip to main content

heddle_object_model/object/
tree_canonical.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Streamable canonical Tree encodings (HTR4).
3//!
4//! Each entry is a length-prefixed frame, so a reader can yield one entry or
5//! a caller-sized page and resume at a byte offset without decoding the whole
6//! tree. Version 4 stores frames raw. Version 5 groups frames into independently
7//! compressed blocks with raw restart anchors and a fixed-width range index.
8
9use sley_core::{ObjectFormat as GitObjectFormat, ObjectId as GitObjectId};
10
11use super::{
12    ContentHash, EntryType, FileMode, PartialTree, PartialTreeLeaf, SpoolId, StateId, Tree,
13    TreeEntry, TreeError, TreeScheme,
14    tree::{git_format_from_tag, git_format_to_tag},
15    tree_git_layout::{apply_layout_trailer, layout_trailer_len, split_layout_flags},
16    tree_stream::TreeStreamError,
17};
18
19/// Durable encoding version stored in every HTR4 header and resume cursor.
20pub const TREE_ENCODING_VERSION: u8 = 4;
21/// Block-compressed HTR4 variant. Readers accept both v4 and v5.
22pub const TREE_BLOCK_ENCODING_VERSION: u8 = 5;
23/// Body version byte for a salted V4 canonical body (HSR1).
24pub const TREE_SALTED_ENCODING_VERSION: u8 = 1;
25/// Body version byte for a redacted V4 projection (HRT1).
26pub const TREE_REDACTED_ENCODING_VERSION: u8 = 1;
27/// Frame discriminator for a single canonical tree.
28pub const TREE_CANONICAL_MAGIC: &[u8; 4] = b"HTR4";
29/// Lean hot-path tree anchor. The object key supplies the omitted tree hash.
30pub const TREE_LEAN_MAGIC: &[u8; 4] = b"HLR1";
31/// One-hop cumulative delta against a materialized tree anchor.
32pub const TREE_DELTA_MAGIC: &[u8; 4] = b"HDC1";
33/// Salted redactable V4 tree — the canonical stored form for V4. Full custody:
34/// carries every entry's 32-byte salt inline. Rides loose objects and packs.
35pub const TREE_SALTED_MAGIC: &[u8; 4] = b"HSR1";
36/// Redacted V4 projection — serve-only. Visible entries carry salt + frame;
37/// redacted entries carry only their opaque 32-byte leaf hash. NEVER packed.
38pub const TREE_REDACTED_MAGIC: &[u8; 4] = b"HRT1";
39/// Cursor version used by the HLR1 streaming reader.
40pub const TREE_LEAN_ENCODING_VERSION: u8 = 6;
41/// Current HDC1 body version.
42pub const TREE_DELTA_ENCODING_VERSION: u8 = 1;
43/// Fixed HDC1 header from the HTR4 radical spike.
44pub const TREE_DELTA_HEADER_LEN: usize = 59;
45/// A lineage is refreshed after 127 delta descendants.
46pub const TREE_DELTA_ANCHOR_INTERVAL: u8 = 128;
47/// Cumulative deltas above this operation count become anchors.
48pub const TREE_DELTA_MAX_OPS: usize = 512;
49/// Fixed header size: magic + version + tree id + counts.
50pub const TREE_HEADER_LEN: usize = 4 + 1 + 32 + 8 + 8 + 8;
51/// Small real trees do not repay block/index overhead.
52pub const TREE_BLOCK_MIN_ENTRIES: usize = 18;
53
54pub(crate) const TREE_BLOCK_ENTRIES: usize = 256;
55pub(crate) const TREE_BLOCK_PREAMBLE_LEN: usize = 16;
56pub(crate) const TREE_BLOCK_INDEX_LEN: usize = 24;
57const TREE_BLOCK_CODEC_ZSTD: u8 = 1;
58// The largest encoder-produced frame is a spoollink with u16-max name and
59// spool-id fields: u32 frame length, mode, kind, u16 name length, name, u16
60// spool length, spool, and the 32-byte state id.
61const TREE_BLOCK_MAX_ENTRY_FRAME_LEN: usize =
62    4 + 1 + 1 + 2 + u16::MAX as usize + 2 + u16::MAX as usize + 32;
63pub(crate) const TREE_BLOCK_MAX_RAW_LEN: usize =
64    TREE_BLOCK_ENTRIES * TREE_BLOCK_MAX_ENTRY_FRAME_LEN;
65// A zstd block expands to at most 128 KiB and consumes encoded block bytes.
66// This deliberately loose format bound admits all encoder output while
67// preventing a tiny stored block from claiming the structural maximum.
68const TREE_BLOCK_MAX_EXPANSION_RATIO: usize = 128 * 1024;
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub(crate) struct TreeBlockHeader {
72    pub block_entries: usize,
73    pub block_count: usize,
74    pub entry_count: u64,
75    pub raw_payload_len: u64,
76    pub index_end: u64,
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub(crate) struct TreeBlockIndex {
81    pub raw_offset: u64,
82    pub stored_offset: u64,
83    pub stored_len: usize,
84    pub raw_len: usize,
85}
86
87/// Parsed HTR4 header. Counts and payload length are known before any entry.
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct TreeHeader {
90    pub version: u8,
91    pub tree_id: ContentHash,
92    pub entry_count: u64,
93    pub payload_len: u64,
94    pub logical_len: u64,
95}
96
97/// True when `bytes` begin with the canonical tree discriminator.
98pub fn is_canonical_tree(bytes: &[u8]) -> bool {
99    bytes.starts_with(TREE_CANONICAL_MAGIC)
100}
101
102/// True when `bytes` contain a lean materialized anchor.
103pub fn is_lean_tree(bytes: &[u8]) -> bool {
104    bytes.starts_with(TREE_LEAN_MAGIC)
105}
106
107/// True when `bytes` contain an anchor-relative delta.
108pub fn is_delta_tree(bytes: &[u8]) -> bool {
109    bytes.starts_with(TREE_DELTA_MAGIC)
110}
111
112/// True when `bytes` contain a salted V4 canonical body (HSR1).
113pub fn is_salted_tree(bytes: &[u8]) -> bool {
114    bytes.starts_with(TREE_SALTED_MAGIC)
115}
116
117/// True when `bytes` contain a redacted V4 projection (HRT1). These are
118/// serve-only and must be rejected by every pack/stream reader.
119pub fn is_redacted_tree(bytes: &[u8]) -> bool {
120    bytes.starts_with(TREE_REDACTED_MAGIC)
121}
122
123/// True when the body can be paged directly without reconstruction.
124pub fn is_streamable_tree(bytes: &[u8]) -> bool {
125    is_canonical_tree(bytes) || is_lean_tree(bytes)
126}
127
128impl Tree {
129    /// Encode this tree as an uncompressed canonical body: HTR4 for a V3 flat
130    /// tree, HSR1 for a V4 salted tree. Scheme-total: a V4 tree is NEVER
131    /// emitted through the salt-less HTR4 body.
132    pub fn encode_canonical(&self) -> Result<Vec<u8>, TreeStreamError> {
133        if self.scheme() == TreeScheme::V4Salted {
134            return self.encode_salted_v4();
135        }
136        self.validate()?;
137        let tree_id = self.hash();
138        let mut payload = Vec::new();
139        let mut logical_len = 0u64;
140        for (index, entry) in self.entries().iter().enumerate() {
141            let source_position = self.source_position_at(index);
142            logical_len = logical_len
143                .checked_add(entry.encoded_len(source_position) as u64)
144                .ok_or_else(|| TreeStreamError::Malformed("logical length overflow".into()))?;
145            let frame = encode_entry_frame(entry, source_position)?;
146            let frame_len = u32::try_from(frame.len()).map_err(|_| {
147                TreeStreamError::Malformed(format!("entry '{}' frame exceeds u32", entry.name()))
148            })?;
149            payload.extend_from_slice(&frame_len.to_le_bytes());
150            payload.extend_from_slice(&frame);
151        }
152        let mut out = Vec::with_capacity(TREE_HEADER_LEN + payload.len());
153        out.extend_from_slice(TREE_CANONICAL_MAGIC);
154        out.push(TREE_ENCODING_VERSION);
155        out.extend_from_slice(tree_id.as_bytes());
156        out.extend_from_slice(&(self.len() as u64).to_le_bytes());
157        out.extend_from_slice(&(payload.len() as u64).to_le_bytes());
158        out.extend_from_slice(&logical_len.to_le_bytes());
159        out.extend_from_slice(&payload);
160        Ok(out)
161    }
162
163    /// Decode a complete canonical body. Dispatches on the body magic: HTR4 →
164    /// flat V3 decode; HSR1 → salted V4 decode. A redacted projection (HRT1) is
165    /// rejected here — it is serve-only and must never be read as a full tree.
166    pub fn decode_canonical(data: &[u8]) -> Result<Self, TreeStreamError> {
167        if is_salted_tree(data) {
168            return decode_salted_v4(data);
169        }
170        if is_redacted_tree(data) {
171            return Err(TreeStreamError::Malformed(
172                "HRT1 redacted projection cannot be decoded as a full tree".into(),
173            ));
174        }
175        let header = decode_header(data)?;
176        if header.version == TREE_BLOCK_ENCODING_VERSION {
177            return Self::decode_canonical_streamed(data);
178        }
179        let expected_len = TREE_HEADER_LEN as u64 + header.payload_len;
180        if (data.len() as u64) < expected_len {
181            return Err(TreeStreamError::TruncatedFrame {
182                offset: data.len() as u64,
183            });
184        }
185        if (data.len() as u64) > expected_len {
186            return Err(TreeStreamError::TrailingBytes {
187                extra: data.len() as u64 - expected_len,
188            });
189        }
190        let mut entries = Vec::new();
191        let mut positions = SourcePositions::default();
192        let mut offset = TREE_HEADER_LEN;
193        let payload_end = data.len();
194        for _ in 0..header.entry_count {
195            let (entry, position, consumed) = decode_entry_at(data, offset, payload_end)?;
196            positions.push(entries.len(), position)?;
197            entries.push(entry);
198            offset += consumed;
199        }
200        if offset != payload_end {
201            return Err(TreeStreamError::TrailingBytes {
202                extra: (payload_end - offset) as u64,
203            });
204        }
205        let tree = Tree::try_from_decoded_layout(entries, positions.finish())?;
206        let found = tree.hash();
207        if found != header.tree_id {
208            return Err(TreeStreamError::HashMismatch {
209                expected: header.tree_id,
210                found,
211            });
212        }
213        if tree
214            .entries()
215            .iter()
216            .enumerate()
217            .map(|(index, entry)| entry.encoded_len(tree.source_position_at(index)) as u64)
218            .sum::<u64>()
219            != header.logical_len
220        {
221            return Err(TreeStreamError::Malformed(
222                "declared logical length does not match entries".into(),
223            ));
224        }
225        Ok(tree)
226    }
227
228    /// Encode block-compressed HTR4, falling back to raw v4 when the complete
229    /// object would not be smaller. Callers apply the small-tree policy.
230    pub fn encode_canonical_blocked(
231        &self,
232        level: i32,
233        min_size: usize,
234    ) -> Result<Vec<u8>, TreeStreamError> {
235        let raw = self.encode_canonical()?;
236        // Block compression is an HTR4-only layout (its block index parses an
237        // HTR4 header). A V4 salted body (HSR1) is stored raw.
238        if self.scheme() == TreeScheme::V4Salted || raw.len() < min_size {
239            return Ok(raw);
240        }
241        let blocked = encode_blocked_htr4(&raw, level)?;
242        if blocked.len() < raw.len() {
243            Ok(blocked)
244        } else {
245            Ok(raw)
246        }
247    }
248}
249
250/// Parse the fixed HTR4 header. Does not read entry frames.
251pub fn decode_header(data: &[u8]) -> Result<TreeHeader, TreeStreamError> {
252    if data.len() < TREE_HEADER_LEN {
253        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
254    }
255    if !is_canonical_tree(data) {
256        return Err(TreeStreamError::Malformed(
257            "bytes are not a canonical HTR4 tree".into(),
258        ));
259    }
260    let version = data[4];
261    if version != TREE_ENCODING_VERSION && version != TREE_BLOCK_ENCODING_VERSION {
262        return Err(TreeStreamError::UnsupportedVersion { found: version });
263    }
264    let tree_id = ContentHash::from_bytes(
265        data[5..37]
266            .try_into()
267            .map_err(|_| TreeStreamError::Malformed("tree id slice is not 32 bytes".into()))?,
268    );
269    let entry_count = u64::from_le_bytes(
270        data[37..45]
271            .try_into()
272            .map_err(|_| TreeStreamError::Malformed("entry count slice is not 8 bytes".into()))?,
273    );
274    let payload_len =
275        u64::from_le_bytes(data[45..53].try_into().map_err(|_| {
276            TreeStreamError::Malformed("payload length slice is not 8 bytes".into())
277        })?);
278    let logical_len =
279        u64::from_le_bytes(data[53..61].try_into().map_err(|_| {
280            TreeStreamError::Malformed("logical length slice is not 8 bytes".into())
281        })?);
282    Ok(TreeHeader {
283        version,
284        tree_id,
285        entry_count,
286        payload_len,
287        logical_len,
288    })
289}
290
291pub(crate) fn decode_block_header(
292    header: &TreeHeader,
293    preamble: &[u8],
294    object_len: u64,
295) -> Result<TreeBlockHeader, TreeStreamError> {
296    if header.version != TREE_BLOCK_ENCODING_VERSION {
297        return Err(TreeStreamError::Malformed(
298            "tree does not use block compression".into(),
299        ));
300    }
301    if preamble.len() != TREE_BLOCK_PREAMBLE_LEN {
302        return Err(TreeStreamError::TruncatedFrame {
303            offset: TREE_HEADER_LEN as u64,
304        });
305    }
306    if preamble[0] != TREE_BLOCK_CODEC_ZSTD {
307        return Err(TreeStreamError::Malformed(format!(
308            "unsupported tree block codec {}",
309            preamble[0]
310        )));
311    }
312    if preamble[1] != 0 {
313        return Err(TreeStreamError::Malformed(
314            "unsupported tree block flags".into(),
315        ));
316    }
317    let block_entries = u16::from_le_bytes([preamble[2], preamble[3]]) as usize;
318    if block_entries == 0 {
319        return Err(TreeStreamError::Malformed(
320            "tree block size must be nonzero".into(),
321        ));
322    }
323    if block_entries > TREE_BLOCK_ENTRIES {
324        return Err(TreeStreamError::Malformed(format!(
325            "tree block entry count {block_entries} exceeds maximum {TREE_BLOCK_ENTRIES}"
326        )));
327    }
328    let block_count = u32::from_le_bytes(
329        preamble[4..8]
330            .try_into()
331            .map_err(|_| TreeStreamError::Malformed("invalid tree block count".into()))?,
332    ) as usize;
333    let raw_payload_len = u64::from_le_bytes(
334        preamble[8..16]
335            .try_into()
336            .map_err(|_| TreeStreamError::Malformed("invalid raw tree payload length".into()))?,
337    );
338    let expected_blocks = header.entry_count.div_ceil(block_entries as u64);
339    if block_count as u64 != expected_blocks {
340        return Err(TreeStreamError::Malformed(
341            "tree block count does not match entry count".into(),
342        ));
343    }
344    let index_bytes = block_count
345        .checked_mul(TREE_BLOCK_INDEX_LEN)
346        .ok_or_else(|| TreeStreamError::Malformed("tree block index length overflow".into()))?;
347    let index_end = TREE_HEADER_LEN
348        .checked_add(TREE_BLOCK_PREAMBLE_LEN)
349        .and_then(|len| len.checked_add(index_bytes))
350        .ok_or_else(|| TreeStreamError::Malformed("tree block index end overflow".into()))?
351        as u64;
352    let expected_object_len = (TREE_HEADER_LEN as u64)
353        .checked_add(header.payload_len)
354        .ok_or_else(|| TreeStreamError::Malformed("tree object length overflow".into()))?;
355    if index_end > expected_object_len || expected_object_len != object_len {
356        return Err(TreeStreamError::TruncatedFrame { offset: object_len });
357    }
358    Ok(TreeBlockHeader {
359        block_entries,
360        block_count,
361        entry_count: header.entry_count,
362        raw_payload_len,
363        index_end,
364    })
365}
366
367pub(crate) fn decode_block_index(
368    bytes: &[u8],
369    block: usize,
370    block_header: &TreeBlockHeader,
371    object_len: u64,
372) -> Result<TreeBlockIndex, TreeStreamError> {
373    if bytes.len() != TREE_BLOCK_INDEX_LEN || block >= block_header.block_count {
374        return Err(TreeStreamError::Malformed(
375            "invalid tree block index entry".into(),
376        ));
377    }
378    let raw_offset = u64::from_le_bytes(
379        bytes[0..8]
380            .try_into()
381            .map_err(|_| TreeStreamError::Malformed("invalid tree block raw offset".into()))?,
382    );
383    let stored_offset = u64::from_le_bytes(
384        bytes[8..16]
385            .try_into()
386            .map_err(|_| TreeStreamError::Malformed("invalid tree block stored offset".into()))?,
387    );
388    let stored_len = u32::from_le_bytes(
389        bytes[16..20]
390            .try_into()
391            .map_err(|_| TreeStreamError::Malformed("invalid tree block stored length".into()))?,
392    ) as usize;
393    let raw_len = u32::from_le_bytes(
394        bytes[20..24]
395            .try_into()
396            .map_err(|_| TreeStreamError::Malformed("invalid tree block raw length".into()))?,
397    ) as usize;
398    let first_entry = (block as u64)
399        .checked_mul(block_header.block_entries as u64)
400        .ok_or_else(|| TreeStreamError::Malformed("tree block ordinal overflow".into()))?;
401    let entries = block_header
402        .entry_count
403        .checked_sub(first_entry)
404        .ok_or_else(|| TreeStreamError::Malformed("tree block starts past entries".into()))?
405        .min(block_header.block_entries as u64) as usize;
406    let max_raw_len = entries
407        .checked_mul(TREE_BLOCK_MAX_ENTRY_FRAME_LEN)
408        .ok_or_else(|| TreeStreamError::Malformed("tree block raw length overflow".into()))?;
409    validate_block_lengths(stored_len, raw_len, max_raw_len)?;
410    if stored_offset < block_header.index_end {
411        return Err(TreeStreamError::Malformed(
412            "invalid empty or overlapping tree block".into(),
413        ));
414    }
415    if stored_offset
416        .checked_add(stored_len as u64)
417        .is_none_or(|end| end > object_len)
418    {
419        return Err(TreeStreamError::TruncatedFrame {
420            offset: stored_offset,
421        });
422    }
423    Ok(TreeBlockIndex {
424        raw_offset,
425        stored_offset,
426        stored_len,
427        raw_len,
428    })
429}
430
431fn validate_block_lengths(
432    stored_len: usize,
433    raw_len: usize,
434    max_raw_len: usize,
435) -> Result<(), TreeStreamError> {
436    if raw_len > max_raw_len {
437        return Err(TreeStreamError::Malformed(format!(
438            "tree block raw length {raw_len} exceeds maximum {max_raw_len}"
439        )));
440    }
441    if stored_len == 0 || raw_len == 0 {
442        return Err(TreeStreamError::Malformed(
443            "invalid empty or overlapping tree block".into(),
444        ));
445    }
446    let expansion_limit = (stored_len as u64) * (TREE_BLOCK_MAX_EXPANSION_RATIO as u64);
447    if raw_len as u64 > expansion_limit {
448        return Err(TreeStreamError::Malformed(format!(
449            "tree block raw length {raw_len} exceeds {TREE_BLOCK_MAX_EXPANSION_RATIO}:1 expansion limit for {stored_len} stored bytes"
450        )));
451    }
452    Ok(())
453}
454
455pub(crate) fn decode_block_payload(
456    stored: &[u8],
457    raw_len: usize,
458) -> Result<Vec<u8>, TreeStreamError> {
459    validate_block_lengths(stored.len(), raw_len, TREE_BLOCK_MAX_RAW_LEN)?;
460    if stored.len() == raw_len {
461        return Ok(stored.to_vec());
462    }
463    let anchor_end = block_anchor_end(stored)?;
464    if anchor_end >= raw_len {
465        return Err(TreeStreamError::Malformed(
466            "compressed tree block has no tail".into(),
467        ));
468    }
469    let tail = decompress_block_tail(&stored[anchor_end..], raw_len - anchor_end)?;
470    let mut raw = Vec::with_capacity(raw_len);
471    raw.extend_from_slice(&stored[..anchor_end]);
472    raw.extend_from_slice(&tail);
473    if raw.len() != raw_len {
474        return Err(TreeStreamError::Malformed(
475            "decoded tree block length mismatch".into(),
476        ));
477    }
478    Ok(raw)
479}
480
481pub(crate) fn block_anchor_end(stored: &[u8]) -> Result<usize, TreeStreamError> {
482    let len_bytes = stored
483        .get(..4)
484        .ok_or(TreeStreamError::TruncatedFrame { offset: 0 })?;
485    let frame_len = u32::from_le_bytes(
486        len_bytes
487            .try_into()
488            .map_err(|_| TreeStreamError::Malformed("invalid anchor frame length".into()))?,
489    ) as usize;
490    let end = 4usize
491        .checked_add(frame_len)
492        .ok_or(TreeStreamError::TruncatedFrame { offset: 0 })?;
493    if end > stored.len() {
494        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
495    }
496    Ok(end)
497}
498
499fn encode_blocked_htr4(raw: &[u8], level: i32) -> Result<Vec<u8>, TreeStreamError> {
500    let header = decode_header(raw)?;
501    if header.version != TREE_ENCODING_VERSION {
502        return Err(TreeStreamError::Malformed(
503            "block encoder requires raw HTR4".into(),
504        ));
505    }
506    let ranges = entry_frame_ranges(raw, header.entry_count)?;
507    let block_count = ranges.len().div_ceil(TREE_BLOCK_ENTRIES);
508    let block_count_u32 = u32::try_from(block_count)
509        .map_err(|_| TreeStreamError::Malformed("tree has too many blocks".into()))?;
510    let mut blocks = Vec::with_capacity(block_count);
511    for chunk in ranges.chunks(TREE_BLOCK_ENTRIES) {
512        let (start, anchor_end) = *chunk
513            .first()
514            .ok_or_else(|| TreeStreamError::Malformed("empty tree block".into()))?;
515        let end = chunk
516            .last()
517            .map(|range| range.1)
518            .ok_or_else(|| TreeStreamError::Malformed("empty tree block".into()))?;
519        let block = &raw[start..end];
520        let compressed_tail = compress_block_tail(&raw[anchor_end..end], level)?;
521        if anchor_end - start + compressed_tail.len() < block.len() {
522            let mut stored = Vec::with_capacity(anchor_end - start + compressed_tail.len());
523            stored.extend_from_slice(&raw[start..anchor_end]);
524            stored.extend_from_slice(&compressed_tail);
525            blocks.push(stored);
526        } else {
527            blocks.push(block.to_vec());
528        }
529    }
530
531    let index_bytes = block_count
532        .checked_mul(TREE_BLOCK_INDEX_LEN)
533        .ok_or_else(|| TreeStreamError::Malformed("tree block index length overflow".into()))?;
534    let stored_payload_len = TREE_BLOCK_PREAMBLE_LEN
535        .checked_add(index_bytes)
536        .and_then(|len| {
537            blocks
538                .iter()
539                .try_fold(len, |total, block| total.checked_add(block.len()))
540        })
541        .ok_or_else(|| TreeStreamError::Malformed("blocked tree length overflow".into()))?;
542    let mut out = Vec::with_capacity(TREE_HEADER_LEN + stored_payload_len);
543    out.extend_from_slice(TREE_CANONICAL_MAGIC);
544    out.push(TREE_BLOCK_ENCODING_VERSION);
545    out.extend_from_slice(header.tree_id.as_bytes());
546    out.extend_from_slice(&header.entry_count.to_le_bytes());
547    out.extend_from_slice(&(stored_payload_len as u64).to_le_bytes());
548    out.extend_from_slice(&header.logical_len.to_le_bytes());
549    out.push(TREE_BLOCK_CODEC_ZSTD);
550    out.push(0);
551    out.extend_from_slice(&(TREE_BLOCK_ENTRIES as u16).to_le_bytes());
552    out.extend_from_slice(&block_count_u32.to_le_bytes());
553    out.extend_from_slice(&header.payload_len.to_le_bytes());
554
555    let mut stored_offset = TREE_HEADER_LEN
556        .checked_add(TREE_BLOCK_PREAMBLE_LEN)
557        .and_then(|len| len.checked_add(index_bytes))
558        .ok_or_else(|| TreeStreamError::Malformed("tree block offset overflow".into()))?;
559    for (block, stored) in blocks.iter().enumerate() {
560        let first_entry = block * TREE_BLOCK_ENTRIES;
561        let chunk = &ranges[first_entry..(first_entry + TREE_BLOCK_ENTRIES).min(ranges.len())];
562        let raw_offset = chunk[0].0 - TREE_HEADER_LEN;
563        let raw_len = chunk[chunk.len() - 1].1 - chunk[0].0;
564        let stored_len = u32::try_from(stored.len())
565            .map_err(|_| TreeStreamError::Malformed("stored tree block exceeds u32".into()))?;
566        let raw_len = u32::try_from(raw_len)
567            .map_err(|_| TreeStreamError::Malformed("raw tree block exceeds u32".into()))?;
568        out.extend_from_slice(&(raw_offset as u64).to_le_bytes());
569        out.extend_from_slice(&(stored_offset as u64).to_le_bytes());
570        out.extend_from_slice(&stored_len.to_le_bytes());
571        out.extend_from_slice(&raw_len.to_le_bytes());
572        stored_offset = stored_offset
573            .checked_add(stored.len())
574            .ok_or_else(|| TreeStreamError::Malformed("tree block offset overflow".into()))?;
575    }
576    for block in blocks {
577        out.extend_from_slice(&block);
578    }
579    Ok(out)
580}
581
582fn entry_frame_ranges(
583    raw: &[u8],
584    entry_count: u64,
585) -> Result<Vec<(usize, usize)>, TreeStreamError> {
586    let count = usize::try_from(entry_count)
587        .map_err(|_| TreeStreamError::Malformed("tree entry count exceeds usize".into()))?;
588    let mut ranges = Vec::with_capacity(count);
589    let mut offset = TREE_HEADER_LEN;
590    for _ in 0..count {
591        let len_bytes = raw
592            .get(offset..offset + 4)
593            .ok_or(TreeStreamError::TruncatedFrame {
594                offset: offset as u64,
595            })?;
596        let frame_len = u32::from_le_bytes(
597            len_bytes
598                .try_into()
599                .map_err(|_| TreeStreamError::Malformed("invalid frame length".into()))?,
600        ) as usize;
601        let end = offset
602            .checked_add(4)
603            .and_then(|start| start.checked_add(frame_len))
604            .ok_or(TreeStreamError::TruncatedFrame {
605                offset: offset as u64,
606            })?;
607        if end > raw.len() {
608            return Err(TreeStreamError::TruncatedFrame {
609                offset: offset as u64,
610            });
611        }
612        ranges.push((offset, end));
613        offset = end;
614    }
615    if offset != raw.len() {
616        return Err(TreeStreamError::TrailingBytes {
617            extra: raw.len().abs_diff(offset) as u64,
618        });
619    }
620    Ok(ranges)
621}
622
623#[cfg(feature = "zstd")]
624fn compress_block_tail(data: &[u8], level: i32) -> Result<Vec<u8>, TreeStreamError> {
625    zstd::bulk::compress(data, level)
626        .map_err(|error| TreeStreamError::Compression(error.to_string()))
627}
628
629#[cfg(not(feature = "zstd"))]
630fn compress_block_tail(_data: &[u8], _level: i32) -> Result<Vec<u8>, TreeStreamError> {
631    Err(TreeStreamError::Compression(
632        "zstd support is not compiled into this build".into(),
633    ))
634}
635
636#[cfg(feature = "zstd")]
637fn decompress_block_tail(data: &[u8], capacity: usize) -> Result<Vec<u8>, TreeStreamError> {
638    if capacity > TREE_BLOCK_MAX_RAW_LEN {
639        return Err(TreeStreamError::Malformed(format!(
640            "tree block tail length {capacity} exceeds maximum {TREE_BLOCK_MAX_RAW_LEN}"
641        )));
642    }
643    let decoded = zstd::bulk::decompress(data, capacity)
644        .map_err(|error| TreeStreamError::Compression(error.to_string()))?;
645    if decoded.len() != capacity {
646        return Err(TreeStreamError::Malformed(
647            "decoded tree block tail length mismatch".into(),
648        ));
649    }
650    Ok(decoded)
651}
652
653#[cfg(not(feature = "zstd"))]
654fn decompress_block_tail(_data: &[u8], _capacity: usize) -> Result<Vec<u8>, TreeStreamError> {
655    Err(TreeStreamError::Compression(
656        "zstd support is not compiled into this build".into(),
657    ))
658}
659
660/// A cumulative HDC1 operation against a materialized anchor.
661#[derive(Clone, Debug, PartialEq, Eq)]
662pub enum TreeDeltaOp {
663    Remove(String),
664    Upsert(TreeEntry),
665}
666
667impl TreeDeltaOp {
668    pub fn name(&self) -> &str {
669        match self {
670            Self::Remove(name) => name,
671            Self::Upsert(entry) => entry.name(),
672        }
673    }
674}
675
676/// Parsed HDC1 header, including its bounded first-entry and first-100 porch.
677#[derive(Clone, Copy, Debug, PartialEq, Eq)]
678pub struct TreeDeltaHeader {
679    pub anchor: ContentHash,
680    pub result_count: usize,
681    pub op_count: usize,
682    pub first_op_count: usize,
683    pub first_base_count: usize,
684    pub first_end: usize,
685    pub hundred_op_count: usize,
686    pub hundred_base_count: usize,
687    pub hundred_end: usize,
688}
689
690impl Tree {
691    /// Encode a cheap HLR1 materialized anchor. The content hash deliberately
692    /// stays outside the body and must be supplied by the object store while
693    /// decoding.
694    pub fn encode_lean(&self) -> Result<Vec<u8>, TreeStreamError> {
695        if self.scheme() == TreeScheme::V4Salted {
696            // HLR1 drops the payload bytes a salt commitment needs; a V4 tree
697            // must never be written through a salt-less lean body.
698            return Err(TreeStreamError::Malformed(
699                "cannot encode a v4 salted tree as HLR1 lean; use HSR1".into(),
700            ));
701        }
702        if self.has_git_layout() {
703            // HLR1 carries no Git layout; such a tree is stored as HTR4.
704            return Err(TreeStreamError::Malformed(
705                "cannot encode a tree with a git source layout as HLR1 lean; use HTR4".into(),
706            ));
707        }
708        self.validate()?;
709        encode_lean_entries(self.entries())
710    }
711
712    /// Decode a complete HLR1 anchor and validate it against its object key.
713    pub fn decode_lean(data: &[u8], expected: ContentHash) -> Result<Self, TreeStreamError> {
714        let (entries, consumed) = decode_lean_entries(data, usize::MAX)?;
715        if consumed != data.len() {
716            return Err(TreeStreamError::TrailingBytes {
717                extra: data.len().abs_diff(consumed) as u64,
718            });
719        }
720        let tree = Tree::try_from_decoded_entries(entries)?;
721        let found = tree.hash();
722        if found != expected {
723            return Err(TreeStreamError::HashMismatch { expected, found });
724        }
725        Ok(tree)
726    }
727}
728
729/// Decode at most `wanted` entries from the compact HLR1 porch. The returned
730/// byte count is the exact prefix a file-backed reader had to consume.
731pub fn decode_lean_prefix(
732    data: &[u8],
733    wanted: usize,
734) -> Result<(Vec<TreeEntry>, usize), TreeStreamError> {
735    decode_lean_entries(data, wanted)
736}
737
738fn encode_lean_entries(entries: &[TreeEntry]) -> Result<Vec<u8>, TreeStreamError> {
739    let mut out = Vec::new();
740    out.extend_from_slice(TREE_LEAN_MAGIC);
741    put_varint(entries.len(), &mut out);
742    let mut previous = "";
743    for entry in entries {
744        encode_lean_entry(entry, previous, &mut out)?;
745        previous = entry.name();
746    }
747    Ok(out)
748}
749
750fn decode_lean_entries(
751    data: &[u8],
752    wanted: usize,
753) -> Result<(Vec<TreeEntry>, usize), TreeStreamError> {
754    if !is_lean_tree(data) {
755        return Err(TreeStreamError::Malformed(
756            "bytes are not an HLR1 tree anchor".into(),
757        ));
758    }
759    let mut offset = TREE_LEAN_MAGIC.len();
760    let count = take_varint(data, &mut offset)?;
761    let wanted = wanted.min(count);
762    let mut entries = Vec::with_capacity(wanted);
763    let mut previous = String::new();
764    for _ in 0..wanted {
765        let entry = decode_compact_entry(data, &mut offset, &previous)?;
766        if !previous.is_empty() && previous.as_str() >= entry.name() {
767            return Err(TreeError::InvalidStructure(
768                "entries must be strictly sorted by name".into(),
769            )
770            .into());
771        }
772        previous = entry.name().to_string();
773        entries.push(entry);
774    }
775    if wanted == count && offset != data.len() {
776        return Err(TreeStreamError::TrailingBytes {
777            extra: data.len().abs_diff(offset) as u64,
778        });
779    }
780    Ok((entries, offset))
781}
782
783fn put_varint(mut value: usize, out: &mut Vec<u8>) {
784    while value >= 0x80 {
785        out.push((value as u8) | 0x80);
786        value >>= 7;
787    }
788    out.push(value as u8);
789}
790
791pub(crate) fn take_varint(bytes: &[u8], offset: &mut usize) -> Result<usize, TreeStreamError> {
792    let mut value = 0usize;
793    for shift in (0..usize::BITS).step_by(7) {
794        let byte = *bytes.get(*offset).ok_or(TreeStreamError::TruncatedFrame {
795            offset: *offset as u64,
796        })?;
797        *offset += 1;
798        value |= ((byte & 0x7f) as usize)
799            .checked_shl(shift)
800            .ok_or_else(|| TreeStreamError::Malformed("varint overflow".into()))?;
801        if byte & 0x80 == 0 {
802            return Ok(value);
803        }
804    }
805    Err(TreeStreamError::Malformed("varint overflow".into()))
806}
807
808fn shared_prefix(left: &str, right: &str) -> usize {
809    left.as_bytes()
810        .iter()
811        .zip(right.as_bytes())
812        .take_while(|(left, right)| left == right)
813        .count()
814}
815
816/// Append one HLR1 entry using `previous_name` as its prefix-compression base.
817/// Store readers use this to expose a lazily merged HDC1 body as HLR1 bytes.
818pub fn encode_lean_entry(
819    entry: &TreeEntry,
820    previous_name: &str,
821    out: &mut Vec<u8>,
822) -> Result<(), TreeStreamError> {
823    if entry.raw_git_mode().is_some() {
824        return Err(TreeStreamError::Malformed(format!(
825            "compact entry '{}' cannot carry a raw git mode",
826            entry.name()
827        )));
828    }
829    out.push((entry.mode().to_byte() << 3) | entry.entry_type().to_byte());
830    let prefix = shared_prefix(previous_name, entry.name());
831    put_varint(prefix, out);
832    put_varint(entry.name().len() - prefix, out);
833    out.extend_from_slice(&entry.name().as_bytes()[prefix..]);
834    match entry.entry_type() {
835        EntryType::Blob | EntryType::Tree | EntryType::Symlink => {
836            let hash = entry.content_hash().ok_or_else(|| {
837                TreeStreamError::Malformed("compact entry is missing its content hash".into())
838            })?;
839            out.extend_from_slice(hash.as_bytes());
840        }
841        EntryType::Gitlink => {
842            let target = entry.gitlink_target().ok_or_else(|| {
843                TreeStreamError::Malformed("compact gitlink is missing its target".into())
844            })?;
845            out.push(git_format_to_tag(target.format()));
846            out.extend_from_slice(target.as_bytes());
847        }
848        EntryType::Spoollink => {
849            let (spool, state) = entry.spoollink_target().ok_or_else(|| {
850                TreeStreamError::Malformed("compact spoollink is missing its target".into())
851            })?;
852            put_varint(spool.as_str().len(), out);
853            out.extend_from_slice(spool.as_str().as_bytes());
854            out.extend_from_slice(state.as_bytes());
855        }
856    }
857    Ok(())
858}
859
860pub(crate) fn decode_compact_entry(
861    bytes: &[u8],
862    offset: &mut usize,
863    previous_name: &str,
864) -> Result<TreeEntry, TreeStreamError> {
865    let tag = *bytes.get(*offset).ok_or(TreeStreamError::TruncatedFrame {
866        offset: *offset as u64,
867    })?;
868    *offset += 1;
869    let mode = FileMode::from_byte(tag >> 3).ok_or_else(|| {
870        TreeStreamError::Malformed(format!("invalid compact entry mode {}", tag >> 3))
871    })?;
872    let kind = EntryType::from_byte(tag & 0x07).ok_or_else(|| {
873        TreeStreamError::Malformed(format!("invalid compact entry kind {}", tag & 0x07))
874    })?;
875    let prefix = take_varint(bytes, offset)?;
876    let suffix_len = take_varint(bytes, offset)?;
877    if prefix > previous_name.len() {
878        return Err(TreeStreamError::Malformed(
879            "compact name prefix exceeds predecessor".into(),
880        ));
881    }
882    let suffix_end = offset
883        .checked_add(suffix_len)
884        .ok_or_else(|| TreeStreamError::Malformed("compact name length overflow".into()))?;
885    let suffix = bytes
886        .get(*offset..suffix_end)
887        .ok_or(TreeStreamError::TruncatedFrame {
888            offset: *offset as u64,
889        })?;
890    let mut name = previous_name.as_bytes()[..prefix].to_vec();
891    name.extend_from_slice(suffix);
892    let name = String::from_utf8(name)
893        .map_err(|_| TreeStreamError::Malformed("compact entry name is not UTF-8".into()))?;
894    *offset = suffix_end;
895    let entry = match kind {
896        EntryType::Blob | EntryType::Tree | EntryType::Symlink => {
897            let end = offset
898                .checked_add(32)
899                .ok_or_else(|| TreeStreamError::Malformed("compact hash overflow".into()))?;
900            let hash = ContentHash::from_bytes(
901                bytes
902                    .get(*offset..end)
903                    .ok_or(TreeStreamError::TruncatedFrame {
904                        offset: *offset as u64,
905                    })?
906                    .try_into()
907                    .map_err(|_| {
908                        TreeStreamError::Malformed("compact hash is not 32 bytes".into())
909                    })?,
910            );
911            *offset = end;
912            match kind {
913                EntryType::Blob => TreeEntry::file(name, hash, mode == FileMode::Executable)?,
914                EntryType::Tree => TreeEntry::directory(name, hash)?,
915                EntryType::Symlink => TreeEntry::symlink(name, hash)?,
916                EntryType::Gitlink | EntryType::Spoollink => {
917                    return Err(TreeStreamError::Malformed(
918                        "invalid compact content-addressed kind".into(),
919                    ));
920                }
921            }
922        }
923        EntryType::Gitlink => {
924            let format_tag = *bytes.get(*offset).ok_or(TreeStreamError::TruncatedFrame {
925                offset: *offset as u64,
926            })?;
927            *offset += 1;
928            let format = git_format_from_tag(format_tag)?;
929            let oid_len = match format {
930                GitObjectFormat::Sha1 => 20,
931                GitObjectFormat::Sha256 => 32,
932            };
933            let end = offset.checked_add(oid_len).ok_or_else(|| {
934                TreeStreamError::Malformed("compact gitlink length overflow".into())
935            })?;
936            let target = GitObjectId::from_raw(
937                format,
938                bytes
939                    .get(*offset..end)
940                    .ok_or(TreeStreamError::TruncatedFrame {
941                        offset: *offset as u64,
942                    })?,
943            )
944            .map_err(|error| {
945                TreeStreamError::Malformed(format!("invalid compact gitlink: {error}"))
946            })?;
947            *offset = end;
948            TreeEntry::gitlink(name, target)?
949        }
950        EntryType::Spoollink => {
951            let spool_len = take_varint(bytes, offset)?;
952            let spool_end = offset.checked_add(spool_len).ok_or_else(|| {
953                TreeStreamError::Malformed("compact spool length overflow".into())
954            })?;
955            let spool = std::str::from_utf8(bytes.get(*offset..spool_end).ok_or(
956                TreeStreamError::TruncatedFrame {
957                    offset: *offset as u64,
958                },
959            )?)
960            .map_err(|_| TreeStreamError::Malformed("compact spool id is not UTF-8".into()))?;
961            *offset = spool_end;
962            let state_end = offset
963                .checked_add(32)
964                .ok_or_else(|| TreeStreamError::Malformed("compact state overflow".into()))?;
965            let state = StateId::from_bytes(
966                bytes
967                    .get(*offset..state_end)
968                    .ok_or(TreeStreamError::TruncatedFrame {
969                        offset: *offset as u64,
970                    })?
971                    .try_into()
972                    .map_err(|_| {
973                        TreeStreamError::Malformed("compact state is not 32 bytes".into())
974                    })?,
975            );
976            *offset = state_end;
977            let spool_id = SpoolId::parse(spool).map_err(|error| {
978                TreeStreamError::Malformed(format!("invalid compact spool id: {error}"))
979            })?;
980            TreeEntry::spoollink(name, spool_id, state)?
981        }
982    };
983    if entry.mode() != mode {
984        return Err(TreeStreamError::Malformed(format!(
985            "compact entry kind/mode mismatch for {}: {kind:?}/{mode:?}",
986            entry.name()
987        )));
988    }
989    Ok(entry)
990}
991
992/// Compute the sorted cumulative edit from `anchor` to `current`.
993pub fn tree_delta(anchor: &Tree, current: &Tree) -> Vec<TreeDeltaOp> {
994    let mut ops = Vec::new();
995    let mut anchor_index = 0usize;
996    let mut current_index = 0usize;
997    while anchor_index < anchor.len() || current_index < current.len() {
998        match (
999            anchor.entries().get(anchor_index),
1000            current.entries().get(current_index),
1001        ) {
1002            (Some(anchor_entry), Some(current_entry)) => {
1003                match anchor_entry.name().cmp(current_entry.name()) {
1004                    std::cmp::Ordering::Less => {
1005                        ops.push(TreeDeltaOp::Remove(anchor_entry.name().to_string()));
1006                        anchor_index += 1;
1007                    }
1008                    std::cmp::Ordering::Greater => {
1009                        ops.push(TreeDeltaOp::Upsert(current_entry.clone()));
1010                        current_index += 1;
1011                    }
1012                    std::cmp::Ordering::Equal => {
1013                        if anchor_entry != current_entry {
1014                            ops.push(TreeDeltaOp::Upsert(current_entry.clone()));
1015                        }
1016                        anchor_index += 1;
1017                        current_index += 1;
1018                    }
1019                }
1020            }
1021            (Some(anchor_entry), None) => {
1022                ops.push(TreeDeltaOp::Remove(anchor_entry.name().to_string()));
1023                anchor_index += 1;
1024            }
1025            (None, Some(current_entry)) => {
1026                ops.push(TreeDeltaOp::Upsert(current_entry.clone()));
1027                current_index += 1;
1028            }
1029            (None, None) => break,
1030        }
1031    }
1032    ops
1033}
1034
1035/// Apply a sorted cumulative delta to its materialized anchor.
1036pub fn apply_tree_delta(anchor: &Tree, ops: &[TreeDeltaOp]) -> Result<Tree, TreeStreamError> {
1037    if anchor.scheme() == TreeScheme::V4Salted {
1038        // Deltas are a V3-only form; applying ops to a V4 anchor would silently
1039        // produce a V3 (salt-less) result with a different id. Unreachable via
1040        // the store paths today, but this is public API — fail loud.
1041        return Err(TreeStreamError::Malformed(
1042            "cannot apply an HDC1 delta to a v4 salted anchor".into(),
1043        ));
1044    }
1045    let mut entries = Vec::with_capacity(anchor.len().saturating_add(ops.len()));
1046    let mut anchor_index = 0usize;
1047    let mut op_index = 0usize;
1048    while anchor_index < anchor.len() || op_index < ops.len() {
1049        match (anchor.entries().get(anchor_index), ops.get(op_index)) {
1050            (Some(anchor_entry), Some(op)) => match anchor_entry.name().cmp(op.name()) {
1051                std::cmp::Ordering::Less => {
1052                    entries.push(anchor_entry.clone());
1053                    anchor_index += 1;
1054                }
1055                std::cmp::Ordering::Greater => {
1056                    if let TreeDeltaOp::Upsert(entry) = op {
1057                        entries.push(entry.clone());
1058                    }
1059                    op_index += 1;
1060                }
1061                std::cmp::Ordering::Equal => {
1062                    if let TreeDeltaOp::Upsert(entry) = op {
1063                        entries.push(entry.clone());
1064                    }
1065                    anchor_index += 1;
1066                    op_index += 1;
1067                }
1068            },
1069            (Some(anchor_entry), None) => {
1070                entries.push(anchor_entry.clone());
1071                anchor_index += 1;
1072            }
1073            (None, Some(op)) => {
1074                if let TreeDeltaOp::Upsert(entry) = op {
1075                    entries.push(entry.clone());
1076                }
1077                op_index += 1;
1078            }
1079            (None, None) => break,
1080        }
1081    }
1082    Tree::try_from_decoded_entries(entries).map_err(TreeStreamError::from)
1083}
1084
1085fn delta_prefix_counts(
1086    anchor: &Tree,
1087    current: &Tree,
1088    ops: &[TreeDeltaOp],
1089    count: usize,
1090) -> Result<(u16, u16), TreeStreamError> {
1091    if current.is_empty() {
1092        return Ok((0, 0));
1093    }
1094    let boundary = current.entries()[count.min(current.len()) - 1].name();
1095    let op_count = ops.partition_point(|op| op.name() <= boundary);
1096    let base_count = anchor
1097        .entries()
1098        .partition_point(|entry| entry.name() <= boundary);
1099    Ok((
1100        u16::try_from(op_count)
1101            .map_err(|_| TreeStreamError::Malformed("delta porch op count exceeds u16".into()))?,
1102        u16::try_from(base_count).map_err(|_| {
1103            TreeStreamError::Malformed("delta porch anchor count exceeds u16".into())
1104        })?,
1105    ))
1106}
1107
1108/// Encode a one-hop HDC1 body against `anchor`.
1109pub fn encode_tree_delta(
1110    anchor_id: ContentHash,
1111    anchor: &Tree,
1112    current: &Tree,
1113    ops: &[TreeDeltaOp],
1114) -> Result<Vec<u8>, TreeStreamError> {
1115    if anchor.scheme() == TreeScheme::V4Salted || current.scheme() == TreeScheme::V4Salted {
1116        // HDC1 deltas are a V3-only hot path; a V4 salted tree is stored as a
1117        // full HSR1 body, never a salt-less delta.
1118        return Err(TreeStreamError::Malformed(
1119            "cannot encode a v4 salted tree as an HDC1 delta; use HSR1".into(),
1120        ));
1121    }
1122    if anchor.has_git_layout() || current.has_git_layout() {
1123        // A tree with a Git source layout is stored as a full HTR4 body.
1124        return Err(TreeStreamError::Malformed(
1125            "cannot encode a tree with a git source layout as an HDC1 delta; use HTR4".into(),
1126        ));
1127    }
1128    anchor.validate()?;
1129    current.validate()?;
1130    if anchor.hash() != anchor_id {
1131        return Err(TreeStreamError::HashMismatch {
1132            expected: anchor_id,
1133            found: anchor.hash(),
1134        });
1135    }
1136    if ops.len() > TREE_DELTA_MAX_OPS {
1137        return Err(TreeStreamError::Malformed(format!(
1138            "tree delta has {} operations; maximum is {TREE_DELTA_MAX_OPS}",
1139            ops.len()
1140        )));
1141    }
1142    if apply_tree_delta(anchor, ops)? != *current {
1143        return Err(TreeStreamError::Malformed(
1144            "tree delta operations do not reconstruct the result".into(),
1145        ));
1146    }
1147    let result_count = u32::try_from(current.len())
1148        .map_err(|_| TreeStreamError::Malformed("delta result count exceeds u32".into()))?;
1149    let op_count = u16::try_from(ops.len())
1150        .map_err(|_| TreeStreamError::Malformed("delta operation count exceeds u16".into()))?;
1151    let (first_ops, first_base) = delta_prefix_counts(anchor, current, ops, 1)?;
1152    let (hundred_ops, hundred_base) = delta_prefix_counts(anchor, current, ops, 100)?;
1153    let mut body = Vec::new();
1154    let mut ends = Vec::with_capacity(ops.len());
1155    let mut previous = "";
1156    for op in ops {
1157        match op {
1158            TreeDeltaOp::Remove(name) => {
1159                body.push(0);
1160                let prefix = shared_prefix(previous, name);
1161                put_varint(prefix, &mut body);
1162                put_varint(name.len() - prefix, &mut body);
1163                body.extend_from_slice(&name.as_bytes()[prefix..]);
1164            }
1165            TreeDeltaOp::Upsert(entry) => {
1166                body.push(1);
1167                encode_lean_entry(entry, previous, &mut body)?;
1168            }
1169        }
1170        if !previous.is_empty() && previous >= op.name() {
1171            return Err(TreeStreamError::Malformed(
1172                "tree delta operations must be strictly sorted".into(),
1173            ));
1174        }
1175        previous = op.name();
1176        ends.push(body.len());
1177    }
1178    let end_for = |count: u16| -> Result<u32, TreeStreamError> {
1179        let end = if count == 0 {
1180            TREE_DELTA_HEADER_LEN
1181        } else {
1182            TREE_DELTA_HEADER_LEN
1183                .checked_add(*ends.get(count as usize - 1).ok_or_else(|| {
1184                    TreeStreamError::Malformed("delta porch exceeds operation count".into())
1185                })?)
1186                .ok_or_else(|| TreeStreamError::Malformed("delta porch offset overflow".into()))?
1187        };
1188        u32::try_from(end)
1189            .map_err(|_| TreeStreamError::Malformed("delta porch offset exceeds u32".into()))
1190    };
1191    let mut out = Vec::with_capacity(TREE_DELTA_HEADER_LEN + body.len());
1192    out.extend_from_slice(TREE_DELTA_MAGIC);
1193    out.push(TREE_DELTA_ENCODING_VERSION);
1194    out.extend_from_slice(anchor_id.as_bytes());
1195    out.extend_from_slice(&result_count.to_le_bytes());
1196    out.extend_from_slice(&op_count.to_le_bytes());
1197    out.extend_from_slice(&first_ops.to_le_bytes());
1198    out.extend_from_slice(&first_base.to_le_bytes());
1199    out.extend_from_slice(&end_for(first_ops)?.to_le_bytes());
1200    out.extend_from_slice(&hundred_ops.to_le_bytes());
1201    out.extend_from_slice(&hundred_base.to_le_bytes());
1202    out.extend_from_slice(&end_for(hundred_ops)?.to_le_bytes());
1203    if out.len() != TREE_DELTA_HEADER_LEN {
1204        return Err(TreeStreamError::Malformed(
1205            "internal HDC1 header length mismatch".into(),
1206        ));
1207    }
1208    out.extend_from_slice(&body);
1209    Ok(out)
1210}
1211
1212/// Parse and validate the fixed HDC1 header without reading its operations.
1213pub fn decode_tree_delta_header(data: &[u8]) -> Result<TreeDeltaHeader, TreeStreamError> {
1214    decode_tree_delta_header_prefix(data, data.len())
1215}
1216
1217/// Parse an HDC1 header from a bounded prefix while validating its offsets
1218/// against the complete file length.
1219pub fn decode_tree_delta_header_prefix(
1220    data: &[u8],
1221    object_len: usize,
1222) -> Result<TreeDeltaHeader, TreeStreamError> {
1223    if data.len() < TREE_DELTA_HEADER_LEN {
1224        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
1225    }
1226    if !is_delta_tree(data) {
1227        return Err(TreeStreamError::Malformed(
1228            "bytes are not an HDC1 tree delta".into(),
1229        ));
1230    }
1231    if data[4] != TREE_DELTA_ENCODING_VERSION {
1232        return Err(TreeStreamError::UnsupportedVersion { found: data[4] });
1233    }
1234    let anchor = ContentHash::from_bytes(
1235        data[5..37]
1236            .try_into()
1237            .map_err(|_| TreeStreamError::Malformed("delta anchor hash is not 32 bytes".into()))?,
1238    );
1239    let header = TreeDeltaHeader {
1240        anchor,
1241        result_count: read_u32_at(data, 37)? as usize,
1242        op_count: read_u16_at(data, 41)? as usize,
1243        first_op_count: read_u16_at(data, 43)? as usize,
1244        first_base_count: read_u16_at(data, 45)? as usize,
1245        first_end: read_u32_at(data, 47)? as usize,
1246        hundred_op_count: read_u16_at(data, 51)? as usize,
1247        hundred_base_count: read_u16_at(data, 53)? as usize,
1248        hundred_end: read_u32_at(data, 55)? as usize,
1249    };
1250    if header.op_count > TREE_DELTA_MAX_OPS
1251        || header.first_op_count > header.op_count
1252        || header.hundred_op_count > header.op_count
1253        || header.first_end < TREE_DELTA_HEADER_LEN
1254        || header.hundred_end < header.first_end
1255        || header.hundred_end > object_len
1256    {
1257        return Err(TreeStreamError::Malformed(
1258            "invalid HDC1 porch or operation bounds".into(),
1259        ));
1260    }
1261    Ok(header)
1262}
1263
1264/// Decode `wanted` HDC1 operations. Used by bounded porch reads as well as
1265/// full reconstruction.
1266pub fn decode_tree_delta_ops(
1267    data: &[u8],
1268    wanted: usize,
1269) -> Result<(TreeDeltaHeader, Vec<TreeDeltaOp>, usize), TreeStreamError> {
1270    decode_tree_delta_ops_prefix(data, data.len(), wanted)
1271}
1272
1273/// Decode an HDC1 operation porch without transferring the rest of the body.
1274pub fn decode_tree_delta_ops_prefix(
1275    data: &[u8],
1276    object_len: usize,
1277    wanted: usize,
1278) -> Result<(TreeDeltaHeader, Vec<TreeDeltaOp>, usize), TreeStreamError> {
1279    let header = decode_tree_delta_header_prefix(data, object_len)?;
1280    if wanted > header.op_count {
1281        return Err(TreeStreamError::Malformed(
1282            "partial delta operation count exceeds object".into(),
1283        ));
1284    }
1285    let mut offset = TREE_DELTA_HEADER_LEN;
1286    let mut previous = String::new();
1287    let mut ops = Vec::with_capacity(wanted);
1288    for _ in 0..wanted {
1289        let opcode = *data.get(offset).ok_or(TreeStreamError::TruncatedFrame {
1290            offset: offset as u64,
1291        })?;
1292        offset += 1;
1293        let op =
1294            match opcode {
1295                0 => {
1296                    let prefix = take_varint(data, &mut offset)?;
1297                    let suffix_len = take_varint(data, &mut offset)?;
1298                    if prefix > previous.len() {
1299                        return Err(TreeStreamError::Malformed(
1300                            "delta name prefix exceeds predecessor".into(),
1301                        ));
1302                    }
1303                    let end = offset.checked_add(suffix_len).ok_or_else(|| {
1304                        TreeStreamError::Malformed("delta name length overflow".into())
1305                    })?;
1306                    let mut name = previous.as_bytes()[..prefix].to_vec();
1307                    name.extend_from_slice(data.get(offset..end).ok_or(
1308                        TreeStreamError::TruncatedFrame {
1309                            offset: offset as u64,
1310                        },
1311                    )?);
1312                    offset = end;
1313                    TreeDeltaOp::Remove(String::from_utf8(name).map_err(|_| {
1314                        TreeStreamError::Malformed("delta name is not UTF-8".into())
1315                    })?)
1316                }
1317                1 => TreeDeltaOp::Upsert(decode_compact_entry(data, &mut offset, &previous)?),
1318                _ => {
1319                    return Err(TreeStreamError::Malformed(format!(
1320                        "invalid tree delta opcode {opcode}"
1321                    )));
1322                }
1323            };
1324        if !previous.is_empty() && previous.as_str() >= op.name() {
1325            return Err(TreeStreamError::Malformed(
1326                "tree delta operations must be strictly sorted".into(),
1327            ));
1328        }
1329        previous = op.name().to_string();
1330        ops.push(op);
1331    }
1332    if (wanted == header.first_op_count && offset != header.first_end)
1333        || (wanted == header.hundred_op_count && offset != header.hundred_end)
1334    {
1335        return Err(TreeStreamError::Malformed(
1336            "HDC1 porch offset does not match decoded operations".into(),
1337        ));
1338    }
1339    Ok((header, ops, offset))
1340}
1341
1342/// Reconstruct an HDC1 tree from exactly one materialized anchor and validate
1343/// the result against its external object key.
1344pub fn decode_tree_delta(
1345    data: &[u8],
1346    anchor: &Tree,
1347    expected: ContentHash,
1348) -> Result<Tree, TreeStreamError> {
1349    let header = decode_tree_delta_header(data)?;
1350    let (decoded_header, ops, consumed) = decode_tree_delta_ops(data, header.op_count)?;
1351    if consumed != data.len() {
1352        return Err(TreeStreamError::TrailingBytes {
1353            extra: data.len().abs_diff(consumed) as u64,
1354        });
1355    }
1356    let anchor_found = anchor.hash();
1357    if anchor_found != decoded_header.anchor {
1358        return Err(TreeStreamError::HashMismatch {
1359            expected: decoded_header.anchor,
1360            found: anchor_found,
1361        });
1362    }
1363    let tree = apply_tree_delta(anchor, &ops)?;
1364    if tree.len() != decoded_header.result_count {
1365        return Err(TreeStreamError::Malformed(
1366            "delta result count does not match reconstructed tree".into(),
1367        ));
1368    }
1369    let found = tree.hash();
1370    if found != expected {
1371        return Err(TreeStreamError::HashMismatch { expected, found });
1372    }
1373    Ok(tree)
1374}
1375
1376fn read_u16_at(data: &[u8], offset: usize) -> Result<u16, TreeStreamError> {
1377    Ok(u16::from_le_bytes(
1378        data.get(offset..offset + 2)
1379            .ok_or(TreeStreamError::TruncatedFrame {
1380                offset: offset as u64,
1381            })?
1382            .try_into()
1383            .map_err(|_| TreeStreamError::Malformed("invalid u16 field".into()))?,
1384    ))
1385}
1386
1387fn read_u32_at(data: &[u8], offset: usize) -> Result<u32, TreeStreamError> {
1388    Ok(u32::from_le_bytes(
1389        data.get(offset..offset + 4)
1390            .ok_or(TreeStreamError::TruncatedFrame {
1391                offset: offset as u64,
1392            })?
1393            .try_into()
1394            .map_err(|_| TreeStreamError::Malformed("invalid u32 field".into()))?,
1395    ))
1396}
1397
1398/// Encode one entry frame: `mode|flags ‖ kind ‖ name_len ‖ name ‖ target ‖
1399/// layout trailer`. Without a Git layout the flags are zero and the trailer is
1400/// empty, which is the historical frame.
1401pub(crate) fn encode_entry_frame(
1402    entry: &TreeEntry,
1403    source_position: Option<u32>,
1404) -> Result<Vec<u8>, TreeStreamError> {
1405    let name = entry.name().as_bytes();
1406    let name_len = u16::try_from(name.len()).map_err(|_| {
1407        TreeStreamError::Malformed(format!("entry name '{}' exceeds u16", entry.name()))
1408    })?;
1409    let mut frame = Vec::new();
1410    frame.push(entry.mode().to_byte() | entry.layout_flags(source_position));
1411    frame.push(entry.entry_type().to_byte());
1412    frame.extend_from_slice(&name_len.to_le_bytes());
1413    frame.extend_from_slice(name);
1414    encode_target(&mut frame, entry)?;
1415    entry.write_layout_trailer(source_position, |bytes| frame.extend_from_slice(bytes));
1416    Ok(frame)
1417}
1418
1419/// Collects the per-entry source positions of a decoded body: every entry
1420/// carries one, or none does.
1421#[derive(Default)]
1422pub(crate) struct SourcePositions {
1423    positions: Vec<u32>,
1424}
1425
1426impl SourcePositions {
1427    pub(crate) fn push(
1428        &mut self,
1429        index: usize,
1430        position: Option<u32>,
1431    ) -> Result<(), TreeStreamError> {
1432        match position {
1433            Some(position) if self.positions.len() == index => {
1434                self.positions.push(position);
1435                Ok(())
1436            }
1437            None if self.positions.is_empty() => Ok(()),
1438            _ => Err(TreeStreamError::Malformed(
1439                "source positions must be recorded on every entry or none".into(),
1440            )),
1441        }
1442    }
1443
1444    pub(crate) fn finish(self) -> Vec<u32> {
1445        self.positions
1446    }
1447}
1448
1449pub(crate) fn decode_entry_at(
1450    data: &[u8],
1451    offset: usize,
1452    payload_end: usize,
1453) -> Result<(TreeEntry, Option<u32>, usize), TreeStreamError> {
1454    if offset + 4 > payload_end {
1455        return Err(TreeStreamError::TruncatedFrame {
1456            offset: offset as u64,
1457        });
1458    }
1459    let frame_len = u32::from_le_bytes(
1460        data[offset..offset + 4]
1461            .try_into()
1462            .map_err(|_| TreeStreamError::Malformed("frame length slice is not 4 bytes".into()))?,
1463    ) as usize;
1464    let frame_start = offset + 4;
1465    let frame_end = frame_start
1466        .checked_add(frame_len)
1467        .ok_or(TreeStreamError::TruncatedFrame {
1468            offset: offset as u64,
1469        })?;
1470    if frame_end > payload_end {
1471        return Err(TreeStreamError::TruncatedFrame {
1472            offset: offset as u64,
1473        });
1474    }
1475    let (entry, position) = decode_entry_frame(&data[frame_start..frame_end])?;
1476    Ok((entry, position, 4 + frame_len))
1477}
1478
1479/// Decode one entry frame and its source position, if it records one.
1480pub(crate) fn decode_entry_frame(
1481    frame: &[u8],
1482) -> Result<(TreeEntry, Option<u32>), TreeStreamError> {
1483    if frame.len() < 4 {
1484        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
1485    }
1486    let (flags, mode_byte) = split_layout_flags(frame[0]);
1487    let mode = FileMode::from_byte(mode_byte).ok_or_else(|| {
1488        TreeStreamError::Malformed(format!("malformed tree entry mode {}", frame[0]))
1489    })?;
1490    let kind = EntryType::from_byte(frame[1]).ok_or_else(|| {
1491        TreeStreamError::Malformed(format!("malformed tree entry kind {}", frame[1]))
1492    })?;
1493    let name_len = u16::from_le_bytes([frame[2], frame[3]]) as usize;
1494    let name_end = 4 + name_len;
1495    if frame.len() < name_end {
1496        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
1497    }
1498    let name = std::str::from_utf8(&frame[4..name_end])
1499        .map_err(|_| TreeStreamError::Malformed("tree entry name is not UTF-8".into()))?
1500        .to_string();
1501    let target_end = frame
1502        .len()
1503        .checked_sub(layout_trailer_len(flags))
1504        .filter(|end| *end >= name_end)
1505        .ok_or(TreeStreamError::TruncatedFrame { offset: 0 })?;
1506    let entry = decode_target(name, kind, mode, &frame[name_end..target_end])?;
1507    if entry.mode() != mode {
1508        return Err(TreeStreamError::Malformed(format!(
1509            "tree kind/mode mismatch for {}: {kind:?}/{mode:?}",
1510            entry.name()
1511        )));
1512    }
1513    Ok(apply_layout_trailer(entry, flags, &frame[target_end..])?)
1514}
1515
1516fn encode_target(frame: &mut Vec<u8>, entry: &TreeEntry) -> Result<(), TreeStreamError> {
1517    match entry.entry_type() {
1518        EntryType::Blob | EntryType::Tree | EntryType::Symlink => {
1519            frame.extend_from_slice(entry.require_content_hash().as_bytes());
1520        }
1521        EntryType::Gitlink => {
1522            let target = entry.gitlink_target().ok_or_else(|| {
1523                TreeStreamError::Malformed("gitlink entry is missing target".into())
1524            })?;
1525            frame.push(git_format_to_tag(target.format()));
1526            frame.extend_from_slice(target.as_bytes());
1527        }
1528        EntryType::Spoollink => {
1529            let (spool, state) = entry.spoollink_target().ok_or_else(|| {
1530                TreeStreamError::Malformed("spoollink entry is missing target".into())
1531            })?;
1532            let spool_bytes = spool.as_str().as_bytes();
1533            let spool_len = u16::try_from(spool_bytes.len())
1534                .map_err(|_| TreeStreamError::Malformed("spool id exceeds u16".into()))?;
1535            frame.extend_from_slice(&spool_len.to_le_bytes());
1536            frame.extend_from_slice(spool_bytes);
1537            frame.extend_from_slice(state.as_bytes());
1538        }
1539    }
1540    Ok(())
1541}
1542
1543fn decode_target(
1544    name: String,
1545    kind: EntryType,
1546    mode: FileMode,
1547    payload: &[u8],
1548) -> Result<TreeEntry, TreeStreamError> {
1549    match kind {
1550        EntryType::Blob => TreeEntry::file(name, take_hash(payload)?, mode == FileMode::Executable)
1551            .map_err(TreeStreamError::from),
1552        EntryType::Tree => {
1553            TreeEntry::directory(name, take_hash(payload)?).map_err(TreeStreamError::from)
1554        }
1555        EntryType::Symlink => {
1556            TreeEntry::symlink(name, take_hash(payload)?).map_err(TreeStreamError::from)
1557        }
1558        EntryType::Gitlink => decode_gitlink(name, payload),
1559        EntryType::Spoollink => decode_spoollink(name, payload),
1560    }
1561}
1562
1563fn take_hash(payload: &[u8]) -> Result<ContentHash, TreeStreamError> {
1564    let bytes: [u8; 32] = payload
1565        .try_into()
1566        .map_err(|_| TreeStreamError::Malformed("malformed tree entry object id".into()))?;
1567    Ok(ContentHash::from_bytes(bytes))
1568}
1569
1570fn decode_gitlink(name: String, payload: &[u8]) -> Result<TreeEntry, TreeStreamError> {
1571    if payload.is_empty() {
1572        return Err(TreeStreamError::Malformed(
1573            "malformed tree entry object id".into(),
1574        ));
1575    }
1576    let format = git_format_from_tag(payload[0])?;
1577    let oid = &payload[1..];
1578    let expected = match format {
1579        GitObjectFormat::Sha1 => 20,
1580        GitObjectFormat::Sha256 => 32,
1581    };
1582    if oid.len() != expected {
1583        return Err(TreeStreamError::Malformed(
1584            "malformed tree entry object id".into(),
1585        ));
1586    }
1587    let target = GitObjectId::from_raw(format, oid)
1588        .map_err(|err| TreeError::InvalidStructure(format!("invalid gitlink target: {err}")))?;
1589    TreeEntry::gitlink(name, target).map_err(TreeStreamError::from)
1590}
1591
1592fn decode_spoollink(name: String, payload: &[u8]) -> Result<TreeEntry, TreeStreamError> {
1593    if payload.len() < 2 {
1594        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
1595    }
1596    let spool_len = u16::from_le_bytes([payload[0], payload[1]]) as usize;
1597    let spool_end = 2 + spool_len;
1598    let state_end = spool_end + 32;
1599    if payload.len() != state_end {
1600        return Err(TreeStreamError::Malformed(
1601            "malformed tree entry object id".into(),
1602        ));
1603    }
1604    let spool = std::str::from_utf8(&payload[2..spool_end])
1605        .map_err(|_| TreeStreamError::Malformed("spool id is not UTF-8".into()))?;
1606    let spool_id = SpoolId::parse(spool)
1607        .map_err(|err| TreeStreamError::Malformed(format!("invalid spool id: {err}")))?;
1608    let state =
1609        StateId::from_bytes(payload[spool_end..state_end].try_into().map_err(|_| {
1610            TreeStreamError::Malformed("spoollink state id is not 32 bytes".into())
1611        })?);
1612    TreeEntry::spoollink(name, spool_id, state).map_err(TreeStreamError::from)
1613}
1614
1615// ── HSR1 salted-canonical + HRT1 redacted projection ────────────────
1616
1617/// Fixed HRT1 header: magic + version + declared root + entry count.
1618pub const TREE_REDACTED_HEADER_LEN: usize = 4 + 1 + 32 + 8;
1619
1620/// A salted entry frame: `salt(32) ‖ <HTR4 entry frame>`. The salt rides inside
1621/// the length-prefixed frame so the frame machinery (and resume cursors) stay
1622/// uniform with HTR4.
1623fn encode_salted_entry_frame(
1624    entry: &TreeEntry,
1625    salt: &[u8; 32],
1626) -> Result<Vec<u8>, TreeStreamError> {
1627    let inner = encode_entry_frame(entry, None)?;
1628    let mut frame = Vec::with_capacity(32 + inner.len());
1629    frame.extend_from_slice(salt);
1630    frame.extend_from_slice(&inner);
1631    Ok(frame)
1632}
1633
1634pub(crate) fn decode_salted_entry_frame(
1635    frame: &[u8],
1636) -> Result<([u8; 32], TreeEntry), TreeStreamError> {
1637    if frame.len() < 32 {
1638        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
1639    }
1640    let salt: [u8; 32] = frame[..32]
1641        .try_into()
1642        .map_err(|_| TreeStreamError::Malformed("salted frame salt is not 32 bytes".into()))?;
1643    let (entry, position) = decode_entry_frame(&frame[32..])?;
1644    if position.is_some() {
1645        return Err(TreeStreamError::Malformed(
1646            "salted v4 entries do not record a git source position".into(),
1647        ));
1648    }
1649    Ok((salt, entry))
1650}
1651
1652impl Tree {
1653    /// Encode a V4 salted tree as HSR1 (full custody: every salt inline).
1654    pub(crate) fn encode_salted_v4(&self) -> Result<Vec<u8>, TreeStreamError> {
1655        if self.scheme() != TreeScheme::V4Salted {
1656            return Err(TreeStreamError::Malformed(
1657                "HSR1 encoding requires a v4 salted tree".into(),
1658            ));
1659        }
1660        self.validate()?;
1661        let declared_root = self.hash();
1662        let mut payload = Vec::new();
1663        let mut logical_len = 0u64;
1664        for (entry, salt) in self.entries().iter().zip(self.salts().iter()) {
1665            logical_len = logical_len
1666                .checked_add(entry.encoded_len(None) as u64)
1667                .ok_or_else(|| TreeStreamError::Malformed("logical length overflow".into()))?;
1668            let frame = encode_salted_entry_frame(entry, salt)?;
1669            let frame_len = u32::try_from(frame.len()).map_err(|_| {
1670                TreeStreamError::Malformed(format!("entry '{}' frame exceeds u32", entry.name()))
1671            })?;
1672            payload.extend_from_slice(&frame_len.to_le_bytes());
1673            payload.extend_from_slice(&frame);
1674        }
1675        let mut out = Vec::with_capacity(TREE_HEADER_LEN + payload.len());
1676        out.extend_from_slice(TREE_SALTED_MAGIC);
1677        out.push(TREE_SALTED_ENCODING_VERSION);
1678        out.extend_from_slice(declared_root.as_bytes());
1679        out.extend_from_slice(&(self.len() as u64).to_le_bytes());
1680        out.extend_from_slice(&(payload.len() as u64).to_le_bytes());
1681        out.extend_from_slice(&logical_len.to_le_bytes());
1682        out.extend_from_slice(&payload);
1683        Ok(out)
1684    }
1685}
1686
1687/// Decode a complete HSR1 salted-canonical body and verify its declared root.
1688pub fn decode_salted_v4(data: &[u8]) -> Result<Tree, TreeStreamError> {
1689    if !is_salted_tree(data) {
1690        return Err(TreeStreamError::Malformed(
1691            "bytes are not an HSR1 salted tree".into(),
1692        ));
1693    }
1694    if data.len() < TREE_HEADER_LEN {
1695        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
1696    }
1697    let version = data[4];
1698    if version != TREE_SALTED_ENCODING_VERSION {
1699        return Err(TreeStreamError::UnsupportedVersion { found: version });
1700    }
1701    let declared_root = ContentHash::from_bytes(
1702        data[5..37]
1703            .try_into()
1704            .map_err(|_| TreeStreamError::Malformed("tree id slice is not 32 bytes".into()))?,
1705    );
1706    let entry_count = u64::from_le_bytes(
1707        data[37..45]
1708            .try_into()
1709            .map_err(|_| TreeStreamError::Malformed("entry count slice is not 8 bytes".into()))?,
1710    );
1711    let payload_len =
1712        u64::from_le_bytes(data[45..53].try_into().map_err(|_| {
1713            TreeStreamError::Malformed("payload length slice is not 8 bytes".into())
1714        })?);
1715    let logical_len =
1716        u64::from_le_bytes(data[53..61].try_into().map_err(|_| {
1717            TreeStreamError::Malformed("logical length slice is not 8 bytes".into())
1718        })?);
1719    let expected_len = TREE_HEADER_LEN as u64 + payload_len;
1720    if (data.len() as u64) < expected_len {
1721        return Err(TreeStreamError::TruncatedFrame {
1722            offset: data.len() as u64,
1723        });
1724    }
1725    if (data.len() as u64) > expected_len {
1726        return Err(TreeStreamError::TrailingBytes {
1727            extra: data.len() as u64 - expected_len,
1728        });
1729    }
1730    let mut entries = Vec::new();
1731    let mut salts = Vec::new();
1732    let mut offset = TREE_HEADER_LEN;
1733    let payload_end = data.len();
1734    for _ in 0..entry_count {
1735        if offset + 4 > payload_end {
1736            return Err(TreeStreamError::TruncatedFrame {
1737                offset: offset as u64,
1738            });
1739        }
1740        let frame_len =
1741            u32::from_le_bytes(data[offset..offset + 4].try_into().map_err(|_| {
1742                TreeStreamError::Malformed("frame length slice is not 4 bytes".into())
1743            })?) as usize;
1744        let frame_start = offset + 4;
1745        let frame_end =
1746            frame_start
1747                .checked_add(frame_len)
1748                .ok_or(TreeStreamError::TruncatedFrame {
1749                    offset: offset as u64,
1750                })?;
1751        if frame_end > payload_end {
1752            return Err(TreeStreamError::TruncatedFrame {
1753                offset: offset as u64,
1754            });
1755        }
1756        let (salt, entry) = decode_salted_entry_frame(&data[frame_start..frame_end])?;
1757        entries.push(entry);
1758        salts.push(salt);
1759        offset = frame_end;
1760    }
1761    if offset != payload_end {
1762        return Err(TreeStreamError::TrailingBytes {
1763            extra: (payload_end - offset) as u64,
1764        });
1765    }
1766    let tree = Tree::try_from_decoded_entries_salted_v4(entries, salts)?;
1767    let found = tree.hash();
1768    if found != declared_root {
1769        return Err(TreeStreamError::HashMismatch {
1770            expected: declared_root,
1771            found,
1772        });
1773    }
1774    if tree
1775        .entries()
1776        .iter()
1777        .map(|entry| entry.encoded_len(None) as u64)
1778        .sum::<u64>()
1779        != logical_len
1780    {
1781        return Err(TreeStreamError::Malformed(
1782            "declared logical length does not match entries".into(),
1783        ));
1784    }
1785    Ok(tree)
1786}
1787
1788/// Encode a redacted V4 projection as HRT1 (serve-only). Visible frames are
1789/// written first in strict ascending name order; redacted leaf hashes follow in
1790/// strict ascending order.
1791pub fn encode_redacted_projection(partial: &PartialTree) -> Result<Vec<u8>, TreeStreamError> {
1792    let mut visibles: Vec<(&TreeEntry, &[u8; 32])> = Vec::new();
1793    let mut redacted: Vec<ContentHash> = Vec::new();
1794    for leaf in partial.leaves() {
1795        match leaf {
1796            PartialTreeLeaf::Visible { entry, salt } => visibles.push((entry, salt)),
1797            PartialTreeLeaf::Redacted { leaf_hash } => redacted.push(*leaf_hash),
1798        }
1799    }
1800    visibles.sort_by(|a, b| a.0.name().cmp(b.0.name()));
1801    redacted.sort_unstable();
1802    let entry_count = visibles.len() + redacted.len();
1803    let mut out = Vec::new();
1804    out.extend_from_slice(TREE_REDACTED_MAGIC);
1805    out.push(TREE_REDACTED_ENCODING_VERSION);
1806    out.extend_from_slice(partial.declared_root().as_bytes());
1807    out.extend_from_slice(&(entry_count as u64).to_le_bytes());
1808    for (entry, salt) in visibles {
1809        out.push(0);
1810        let frame = encode_salted_entry_frame(entry, salt)?;
1811        let frame_len = u32::try_from(frame.len()).map_err(|_| {
1812            TreeStreamError::Malformed(format!("entry '{}' frame exceeds u32", entry.name()))
1813        })?;
1814        out.extend_from_slice(&frame_len.to_le_bytes());
1815        out.extend_from_slice(&frame);
1816    }
1817    for leaf_hash in redacted {
1818        out.push(1);
1819        out.extend_from_slice(leaf_hash.as_bytes());
1820    }
1821    Ok(out)
1822}
1823
1824/// Decode an HRT1 redacted projection and verify it reconstructs its declared
1825/// root.
1826pub fn decode_redacted_projection(data: &[u8]) -> Result<PartialTree, TreeStreamError> {
1827    if !is_redacted_tree(data) {
1828        return Err(TreeStreamError::Malformed(
1829            "bytes are not an HRT1 redacted projection".into(),
1830        ));
1831    }
1832    if data.len() < TREE_REDACTED_HEADER_LEN {
1833        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
1834    }
1835    let version = data[4];
1836    if version != TREE_REDACTED_ENCODING_VERSION {
1837        return Err(TreeStreamError::UnsupportedVersion { found: version });
1838    }
1839    let declared_root = ContentHash::from_bytes(
1840        data[5..37]
1841            .try_into()
1842            .map_err(|_| TreeStreamError::Malformed("tree id slice is not 32 bytes".into()))?,
1843    );
1844    let entry_count = u64::from_le_bytes(
1845        data[37..45]
1846            .try_into()
1847            .map_err(|_| TreeStreamError::Malformed("entry count slice is not 8 bytes".into()))?,
1848    );
1849    let mut offset = TREE_REDACTED_HEADER_LEN;
1850    let mut leaves = Vec::new();
1851    let mut prev_visible_name: Option<String> = None;
1852    let mut prev_redacted: Option<ContentHash> = None;
1853    let mut seen_redacted = false;
1854    for _ in 0..entry_count {
1855        let flag = *data.get(offset).ok_or(TreeStreamError::TruncatedFrame {
1856            offset: offset as u64,
1857        })?;
1858        offset += 1;
1859        match flag {
1860            0 => {
1861                if seen_redacted {
1862                    return Err(TreeStreamError::Malformed(
1863                        "HRT1 visible frame follows a redacted leaf".into(),
1864                    ));
1865                }
1866                if offset + 4 > data.len() {
1867                    return Err(TreeStreamError::TruncatedFrame {
1868                        offset: offset as u64,
1869                    });
1870                }
1871                let frame_len =
1872                    u32::from_le_bytes(data[offset..offset + 4].try_into().map_err(|_| {
1873                        TreeStreamError::Malformed("frame length slice is not 4 bytes".into())
1874                    })?) as usize;
1875                let frame_start = offset + 4;
1876                let frame_end =
1877                    frame_start
1878                        .checked_add(frame_len)
1879                        .ok_or(TreeStreamError::TruncatedFrame {
1880                            offset: offset as u64,
1881                        })?;
1882                if frame_end > data.len() {
1883                    return Err(TreeStreamError::TruncatedFrame {
1884                        offset: offset as u64,
1885                    });
1886                }
1887                let (salt, entry) = decode_salted_entry_frame(&data[frame_start..frame_end])?;
1888                if let Some(previous) = &prev_visible_name
1889                    && previous.as_str() >= entry.name()
1890                {
1891                    return Err(TreeStreamError::Malformed(
1892                        "HRT1 visible frames must be strictly sorted by name".into(),
1893                    ));
1894                }
1895                prev_visible_name = Some(entry.name().to_string());
1896                leaves.push(PartialTreeLeaf::Visible { entry, salt });
1897                offset = frame_end;
1898            }
1899            1 => {
1900                seen_redacted = true;
1901                let end = offset
1902                    .checked_add(32)
1903                    .ok_or(TreeStreamError::TruncatedFrame {
1904                        offset: offset as u64,
1905                    })?;
1906                let leaf_hash = ContentHash::from_bytes(
1907                    data.get(offset..end)
1908                        .ok_or(TreeStreamError::TruncatedFrame {
1909                            offset: offset as u64,
1910                        })?
1911                        .try_into()
1912                        .map_err(|_| {
1913                            TreeStreamError::Malformed("redacted leaf hash is not 32 bytes".into())
1914                        })?,
1915                );
1916                if let Some(previous) = prev_redacted
1917                    && previous >= leaf_hash
1918                {
1919                    return Err(TreeStreamError::Malformed(
1920                        "HRT1 redacted leaves must be strictly sorted by leaf hash".into(),
1921                    ));
1922                }
1923                prev_redacted = Some(leaf_hash);
1924                leaves.push(PartialTreeLeaf::Redacted { leaf_hash });
1925                offset = end;
1926            }
1927            other => {
1928                return Err(TreeStreamError::Malformed(format!(
1929                    "invalid HRT1 leaf flag {other}"
1930                )));
1931            }
1932        }
1933    }
1934    if offset != data.len() {
1935        return Err(TreeStreamError::TrailingBytes {
1936            extra: (data.len() - offset) as u64,
1937        });
1938    }
1939    PartialTree::from_leaves_verified(declared_root, leaves).map_err(TreeStreamError::from)
1940}
1941
1942#[cfg(all(test, feature = "zstd"))]
1943mod block_tests {
1944    use super::*;
1945
1946    fn fixture(entries: usize) -> Tree {
1947        Tree::from_entries(
1948            (0..entries)
1949                .map(|index| {
1950                    TreeEntry::file(
1951                        format!("module_{index:04}.rs"),
1952                        ContentHash::compute(format!("blob-{index}").as_bytes()),
1953                        false,
1954                    )
1955                    .expect("fixture entry")
1956                })
1957                .collect(),
1958        )
1959    }
1960
1961    fn maximum_size_block_fixture() -> Tree {
1962        let name_prefix = "n".repeat(u16::MAX as usize - 5);
1963        let spool_id = SpoolId::parse(format!("s/{}", "s".repeat(u16::MAX as usize - 2)))
1964            .expect("maximum-size spool id");
1965        assert_eq!(spool_id.as_str().len(), u16::MAX as usize);
1966        Tree::from_entries(
1967            (0..TREE_BLOCK_ENTRIES)
1968                .map(|index| {
1969                    let name = format!("{name_prefix}{index:05}");
1970                    assert_eq!(name.len(), u16::MAX as usize);
1971                    TreeEntry::spoollink(name, spool_id.clone(), StateId::from_bytes([7; 32]))
1972                        .expect("maximum-size fixture entry")
1973                })
1974                .collect(),
1975        )
1976    }
1977
1978    #[test]
1979    fn blocked_encoder_falls_back_for_the_complete_object() {
1980        let tree = fixture(1);
1981        let encoded = tree.encode_canonical_blocked(3, 0).expect("encode");
1982        assert_eq!(encoded[4], TREE_ENCODING_VERSION);
1983    }
1984
1985    #[test]
1986    fn final_single_entry_block_is_stored_raw() {
1987        let tree = fixture(TREE_BLOCK_ENTRIES + 1);
1988        let encoded = tree.encode_canonical_blocked(3, 0).expect("encode");
1989        assert_eq!(encoded[4], TREE_BLOCK_ENCODING_VERSION);
1990        let header = decode_header(&encoded).expect("header");
1991        let preamble = &encoded[TREE_HEADER_LEN..TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN];
1992        let block_header =
1993            decode_block_header(&header, preamble, encoded.len() as u64).expect("block header");
1994        let second_index = TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN + TREE_BLOCK_INDEX_LEN;
1995        let index = decode_block_index(
1996            &encoded[second_index..second_index + TREE_BLOCK_INDEX_LEN],
1997            1,
1998            &block_header,
1999            encoded.len() as u64,
2000        )
2001        .expect("second block");
2002        assert_eq!(index.stored_len, index.raw_len);
2003    }
2004
2005    #[test]
2006    fn trailing_bytes_in_a_raw_block_are_rejected() {
2007        let tree = fixture(TREE_BLOCK_ENTRIES + 1);
2008        let mut encoded = tree.encode_canonical_blocked(3, 0).expect("encode");
2009        let second_index = TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN + TREE_BLOCK_INDEX_LEN;
2010        let stored_len_offset = second_index + 16;
2011        let stored_len = u32::from_le_bytes(
2012            encoded[stored_len_offset..stored_len_offset + 4]
2013                .try_into()
2014                .expect("stored length"),
2015        );
2016        encoded[stored_len_offset..stored_len_offset + 4]
2017            .copy_from_slice(&(stored_len + 1).to_le_bytes());
2018        let payload_len =
2019            u64::from_le_bytes(encoded[45..53].try_into().expect("stored payload length"));
2020        encoded[45..53].copy_from_slice(&(payload_len + 1).to_le_bytes());
2021        encoded.push(0);
2022
2023        assert!(Tree::decode_canonical(&encoded).is_err());
2024    }
2025
2026    #[test]
2027    fn block_raw_length_above_encoder_ceiling_is_rejected_in_the_index() {
2028        let tree = fixture(TREE_BLOCK_ENTRIES);
2029        let encoded = tree.encode_canonical_blocked(3, 0).expect("encode");
2030        let header = decode_header(&encoded).expect("header");
2031        let preamble = &encoded[TREE_HEADER_LEN..TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN];
2032        let block_header =
2033            decode_block_header(&header, preamble, encoded.len() as u64).expect("block header");
2034        let index_offset = TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN;
2035        let mut index: [u8; TREE_BLOCK_INDEX_LEN] = encoded
2036            [index_offset..index_offset + TREE_BLOCK_INDEX_LEN]
2037            .try_into()
2038            .expect("index");
2039        index[20..24].copy_from_slice(&u32::MAX.to_le_bytes());
2040
2041        let error = decode_block_index(&index, 0, &block_header, encoded.len() as u64)
2042            .expect_err("attacker-controlled raw length must fail at the index boundary");
2043        assert!(
2044            matches!(error, TreeStreamError::Malformed(ref message) if message.contains("raw length") && message.contains("exceeds maximum")),
2045            "unexpected error: {error}"
2046        );
2047    }
2048
2049    #[test]
2050    fn block_entry_count_above_encoder_limit_is_rejected() {
2051        let tree = fixture(TREE_BLOCK_ENTRIES);
2052        let mut encoded = tree.encode_canonical_blocked(3, 0).expect("encode");
2053        let block_entries_offset = TREE_HEADER_LEN + 2;
2054        encoded[block_entries_offset..block_entries_offset + 2]
2055            .copy_from_slice(&u16::MAX.to_le_bytes());
2056
2057        let error = Tree::decode_canonical(&encoded)
2058            .expect_err("block entry count above the encoder limit must fail");
2059        assert!(
2060            matches!(error, TreeStreamError::Malformed(ref message) if message.contains("entry count") && message.contains("exceeds maximum")),
2061            "unexpected error: {error}"
2062        );
2063    }
2064
2065    #[test]
2066    fn short_final_block_uses_its_actual_entry_count_for_the_ceiling() {
2067        let tree = fixture(TREE_BLOCK_ENTRIES + 1);
2068        let encoded = tree.encode_canonical_blocked(3, 0).expect("encode");
2069        let header = decode_header(&encoded).expect("header");
2070        let preamble = &encoded[TREE_HEADER_LEN..TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN];
2071        let block_header =
2072            decode_block_header(&header, preamble, encoded.len() as u64).expect("block header");
2073        let index_offset = TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN + TREE_BLOCK_INDEX_LEN;
2074        let mut index: [u8; TREE_BLOCK_INDEX_LEN] = encoded
2075            [index_offset..index_offset + TREE_BLOCK_INDEX_LEN]
2076            .try_into()
2077            .expect("final index");
2078        let one_entry_max_plus_one =
2079            u32::try_from(TREE_BLOCK_MAX_ENTRY_FRAME_LEN + 1).expect("one over final block");
2080        index[20..24].copy_from_slice(&one_entry_max_plus_one.to_le_bytes());
2081
2082        let error = decode_block_index(&index, 1, &block_header, encoded.len() as u64)
2083            .expect_err("short final block must use its actual structural ceiling");
2084        let expected_max = format!("maximum {TREE_BLOCK_MAX_ENTRY_FRAME_LEN}");
2085        assert!(
2086            matches!(error, TreeStreamError::Malformed(ref message) if message.contains("raw length") && message.contains(&expected_max)),
2087            "unexpected error: {error}"
2088        );
2089    }
2090
2091    #[test]
2092    fn block_raw_length_above_zstd_expansion_bound_is_rejected() {
2093        let stored_offset = 128u64;
2094        let stored_len = 1u32;
2095        let raw_len = u32::try_from(TREE_BLOCK_MAX_EXPANSION_RATIO + 1).expect("raw length");
2096        let mut index = [0u8; TREE_BLOCK_INDEX_LEN];
2097        index[8..16].copy_from_slice(&stored_offset.to_le_bytes());
2098        index[16..20].copy_from_slice(&stored_len.to_le_bytes());
2099        index[20..24].copy_from_slice(&raw_len.to_le_bytes());
2100        let block_header = TreeBlockHeader {
2101            block_entries: TREE_BLOCK_ENTRIES,
2102            block_count: 1,
2103            entry_count: 1,
2104            raw_payload_len: raw_len as u64,
2105            index_end: stored_offset,
2106        };
2107
2108        let error = decode_block_index(&index, 0, &block_header, stored_offset + stored_len as u64)
2109            .expect_err("excessive zstd expansion must fail at the index boundary");
2110        assert!(
2111            matches!(error, TreeStreamError::Malformed(ref message) if message.contains("expansion limit")),
2112            "unexpected error: {error}"
2113        );
2114    }
2115
2116    #[test]
2117    fn payload_and_zstd_helpers_recheck_the_structural_ceiling() {
2118        let over_limit = TREE_BLOCK_MAX_RAW_LEN + 1;
2119        let payload_error = decode_block_payload(&[0], over_limit)
2120            .expect_err("payload helper must reject an unchecked raw length");
2121        assert!(
2122            matches!(payload_error, TreeStreamError::Malformed(ref message) if message.contains("raw length")),
2123            "unexpected error: {payload_error}"
2124        );
2125        let zstd_error = decompress_block_tail(&[], over_limit)
2126            .expect_err("zstd helper must cap its output independently");
2127        assert!(
2128            matches!(zstd_error, TreeStreamError::Malformed(ref message) if message.contains("tail length")),
2129            "unexpected error: {zstd_error}"
2130        );
2131    }
2132
2133    #[test]
2134    fn maximum_size_encoder_block_round_trips_and_one_over_is_rejected() {
2135        let tree = maximum_size_block_fixture();
2136        let raw = tree.encode_canonical().expect("encode raw");
2137        assert_eq!(raw.len() - TREE_HEADER_LEN, TREE_BLOCK_MAX_RAW_LEN);
2138
2139        let blocked = tree.encode_canonical_blocked(3, 0).expect("encode blocked");
2140        assert_eq!(blocked[4], TREE_BLOCK_ENCODING_VERSION);
2141        let header = decode_header(&blocked).expect("header");
2142        let preamble = &blocked[TREE_HEADER_LEN..TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN];
2143        let block_header =
2144            decode_block_header(&header, preamble, blocked.len() as u64).expect("block header");
2145        let index_offset = TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN;
2146        let index = decode_block_index(
2147            &blocked[index_offset..index_offset + TREE_BLOCK_INDEX_LEN],
2148            0,
2149            &block_header,
2150            blocked.len() as u64,
2151        )
2152        .expect("maximum-size block index");
2153        assert_eq!(index.raw_len, TREE_BLOCK_MAX_RAW_LEN);
2154
2155        let decoded = Tree::decode_canonical(&blocked).expect("decode maximum-size block");
2156        assert_eq!(decoded, tree);
2157        assert_eq!(decoded.encode_canonical().expect("re-encode decoded"), raw);
2158        assert_eq!(
2159            decoded
2160                .encode_canonical_blocked(3, 0)
2161                .expect("re-encode blocked"),
2162            blocked
2163        );
2164
2165        let mut over_limit = blocked;
2166        let raw_len_offset = index_offset + 20;
2167        let one_over = u32::try_from(TREE_BLOCK_MAX_RAW_LEN + 1).expect("one over");
2168        over_limit[raw_len_offset..raw_len_offset + 4].copy_from_slice(&one_over.to_le_bytes());
2169        let error = Tree::decode_canonical(&over_limit)
2170            .expect_err("one byte above the block ceiling must fail");
2171        assert!(
2172            matches!(error, TreeStreamError::Malformed(ref message) if message.contains("raw length")),
2173            "unexpected error: {error}"
2174        );
2175    }
2176}