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::{ObjectFormat as GitObjectFormat, ObjectId as GitObjectId};
10
11use super::{
12    ContentHash, EntryType, FileMode, SpoolId, StateId, Tree, TreeEntry, TreeError,
13    tree::{git_format_from_tag, git_format_to_tag},
14    tree_stream::TreeStreamError,
15};
16
17/// Durable encoding version stored in every HTR4 header and resume cursor.
18pub const TREE_ENCODING_VERSION: u8 = 4;
19/// Block-compressed HTR4 variant. Readers accept both v4 and v5.
20pub const TREE_BLOCK_ENCODING_VERSION: u8 = 5;
21/// Frame discriminator for a single canonical tree.
22pub const TREE_CANONICAL_MAGIC: &[u8; 4] = b"HTR4";
23/// Lean hot-path tree anchor. The object key supplies the omitted tree hash.
24pub const TREE_LEAN_MAGIC: &[u8; 4] = b"HLR1";
25/// One-hop cumulative delta against a materialized tree anchor.
26pub const TREE_DELTA_MAGIC: &[u8; 4] = b"HDC1";
27/// Cursor version used by the HLR1 streaming reader.
28pub const TREE_LEAN_ENCODING_VERSION: u8 = 6;
29/// Current HDC1 body version.
30pub const TREE_DELTA_ENCODING_VERSION: u8 = 1;
31/// Fixed HDC1 header from the HTR4 radical spike.
32pub const TREE_DELTA_HEADER_LEN: usize = 59;
33/// A lineage is refreshed after 127 delta descendants.
34pub const TREE_DELTA_ANCHOR_INTERVAL: u8 = 128;
35/// Cumulative deltas above this operation count become anchors.
36pub const TREE_DELTA_MAX_OPS: usize = 512;
37/// Fixed header size: magic + version + tree id + counts.
38pub const TREE_HEADER_LEN: usize = 4 + 1 + 32 + 8 + 8 + 8;
39/// Small real trees do not repay block/index overhead.
40pub const TREE_BLOCK_MIN_ENTRIES: usize = 18;
41
42pub(crate) const TREE_BLOCK_ENTRIES: usize = 256;
43pub(crate) const TREE_BLOCK_PREAMBLE_LEN: usize = 16;
44pub(crate) const TREE_BLOCK_INDEX_LEN: usize = 24;
45const TREE_BLOCK_CODEC_ZSTD: u8 = 1;
46// The largest encoder-produced frame is a spoollink with u16-max name and
47// spool-id fields: u32 frame length, mode, kind, u16 name length, name, u16
48// spool length, spool, and the 32-byte state id.
49const TREE_BLOCK_MAX_ENTRY_FRAME_LEN: usize =
50    4 + 1 + 1 + 2 + u16::MAX as usize + 2 + u16::MAX as usize + 32;
51pub(crate) const TREE_BLOCK_MAX_RAW_LEN: usize =
52    TREE_BLOCK_ENTRIES * TREE_BLOCK_MAX_ENTRY_FRAME_LEN;
53// A zstd block expands to at most 128 KiB and consumes encoded block bytes.
54// This deliberately loose format bound admits all encoder output while
55// preventing a tiny stored block from claiming the structural maximum.
56const TREE_BLOCK_MAX_EXPANSION_RATIO: usize = 128 * 1024;
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub(crate) struct TreeBlockHeader {
60    pub block_entries: usize,
61    pub block_count: usize,
62    pub entry_count: u64,
63    pub raw_payload_len: u64,
64    pub index_end: u64,
65}
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub(crate) struct TreeBlockIndex {
69    pub raw_offset: u64,
70    pub stored_offset: u64,
71    pub stored_len: usize,
72    pub raw_len: usize,
73}
74
75/// Parsed HTR4 header. Counts and payload length are known before any entry.
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct TreeHeader {
78    pub version: u8,
79    pub tree_id: ContentHash,
80    pub entry_count: u64,
81    pub payload_len: u64,
82    pub logical_len: u64,
83}
84
85/// True when `bytes` begin with the canonical tree discriminator.
86pub fn is_canonical_tree(bytes: &[u8]) -> bool {
87    bytes.starts_with(TREE_CANONICAL_MAGIC)
88}
89
90/// True when `bytes` contain a lean materialized anchor.
91pub fn is_lean_tree(bytes: &[u8]) -> bool {
92    bytes.starts_with(TREE_LEAN_MAGIC)
93}
94
95/// True when `bytes` contain an anchor-relative delta.
96pub fn is_delta_tree(bytes: &[u8]) -> bool {
97    bytes.starts_with(TREE_DELTA_MAGIC)
98}
99
100/// True when the body can be paged directly without reconstruction.
101pub fn is_streamable_tree(bytes: &[u8]) -> bool {
102    is_canonical_tree(bytes) || is_lean_tree(bytes)
103}
104
105impl Tree {
106    /// Encode this tree as uncompressed HTR4.
107    pub fn encode_canonical(&self) -> Result<Vec<u8>, TreeStreamError> {
108        self.validate()?;
109        let tree_id = self.hash();
110        let mut payload = Vec::new();
111        let mut logical_len = 0u64;
112        for entry in self.entries() {
113            logical_len = logical_len
114                .checked_add(entry.encoded_len() as u64)
115                .ok_or_else(|| TreeStreamError::Malformed("logical length overflow".into()))?;
116            let frame = encode_entry_frame(entry)?;
117            let frame_len = u32::try_from(frame.len()).map_err(|_| {
118                TreeStreamError::Malformed(format!("entry '{}' frame exceeds u32", entry.name()))
119            })?;
120            payload.extend_from_slice(&frame_len.to_le_bytes());
121            payload.extend_from_slice(&frame);
122        }
123        let mut out = Vec::with_capacity(TREE_HEADER_LEN + payload.len());
124        out.extend_from_slice(TREE_CANONICAL_MAGIC);
125        out.push(TREE_ENCODING_VERSION);
126        out.extend_from_slice(tree_id.as_bytes());
127        out.extend_from_slice(&(self.len() as u64).to_le_bytes());
128        out.extend_from_slice(&(payload.len() as u64).to_le_bytes());
129        out.extend_from_slice(&logical_len.to_le_bytes());
130        out.extend_from_slice(&payload);
131        Ok(out)
132    }
133
134    /// Decode a complete HTR4 body, validating order incrementally.
135    pub fn decode_canonical(data: &[u8]) -> Result<Self, TreeStreamError> {
136        let header = decode_header(data)?;
137        if header.version == TREE_BLOCK_ENCODING_VERSION {
138            return Self::decode_canonical_streamed(data);
139        }
140        let expected_len = TREE_HEADER_LEN as u64 + header.payload_len;
141        if (data.len() as u64) < expected_len {
142            return Err(TreeStreamError::TruncatedFrame {
143                offset: data.len() as u64,
144            });
145        }
146        if (data.len() as u64) > expected_len {
147            return Err(TreeStreamError::TrailingBytes {
148                extra: data.len() as u64 - expected_len,
149            });
150        }
151        let mut entries = Vec::new();
152        let mut offset = TREE_HEADER_LEN;
153        let payload_end = data.len();
154        for _ in 0..header.entry_count {
155            let (entry, consumed) = decode_entry_at(data, offset, payload_end)?;
156            entries.push(entry);
157            offset += consumed;
158        }
159        if offset != payload_end {
160            return Err(TreeStreamError::TrailingBytes {
161                extra: (payload_end - offset) as u64,
162            });
163        }
164        let tree = Tree::try_from_decoded_entries(entries)?;
165        let found = tree.hash();
166        if found != header.tree_id {
167            return Err(TreeStreamError::HashMismatch {
168                expected: header.tree_id,
169                found,
170            });
171        }
172        if tree
173            .entries()
174            .iter()
175            .map(|entry| entry.encoded_len() as u64)
176            .sum::<u64>()
177            != header.logical_len
178        {
179            return Err(TreeStreamError::Malformed(
180                "declared logical length does not match entries".into(),
181            ));
182        }
183        Ok(tree)
184    }
185
186    /// Encode block-compressed HTR4, falling back to raw v4 when the complete
187    /// object would not be smaller. Callers apply the small-tree policy.
188    pub fn encode_canonical_blocked(
189        &self,
190        level: i32,
191        min_size: usize,
192    ) -> Result<Vec<u8>, TreeStreamError> {
193        let raw = self.encode_canonical()?;
194        if raw.len() < min_size {
195            return Ok(raw);
196        }
197        let blocked = encode_blocked_htr4(&raw, level)?;
198        if blocked.len() < raw.len() {
199            Ok(blocked)
200        } else {
201            Ok(raw)
202        }
203    }
204}
205
206/// Parse the fixed HTR4 header. Does not read entry frames.
207pub fn decode_header(data: &[u8]) -> Result<TreeHeader, TreeStreamError> {
208    if data.len() < TREE_HEADER_LEN {
209        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
210    }
211    if !is_canonical_tree(data) {
212        return Err(TreeStreamError::Malformed(
213            "bytes are not a canonical HTR4 tree".into(),
214        ));
215    }
216    let version = data[4];
217    if version != TREE_ENCODING_VERSION && version != TREE_BLOCK_ENCODING_VERSION {
218        return Err(TreeStreamError::UnsupportedVersion { found: version });
219    }
220    let tree_id = ContentHash::from_bytes(
221        data[5..37]
222            .try_into()
223            .map_err(|_| TreeStreamError::Malformed("tree id slice is not 32 bytes".into()))?,
224    );
225    let entry_count = u64::from_le_bytes(
226        data[37..45]
227            .try_into()
228            .map_err(|_| TreeStreamError::Malformed("entry count slice is not 8 bytes".into()))?,
229    );
230    let payload_len =
231        u64::from_le_bytes(data[45..53].try_into().map_err(|_| {
232            TreeStreamError::Malformed("payload length slice is not 8 bytes".into())
233        })?);
234    let logical_len =
235        u64::from_le_bytes(data[53..61].try_into().map_err(|_| {
236            TreeStreamError::Malformed("logical length slice is not 8 bytes".into())
237        })?);
238    Ok(TreeHeader {
239        version,
240        tree_id,
241        entry_count,
242        payload_len,
243        logical_len,
244    })
245}
246
247pub(crate) fn decode_block_header(
248    header: &TreeHeader,
249    preamble: &[u8],
250    object_len: u64,
251) -> Result<TreeBlockHeader, TreeStreamError> {
252    if header.version != TREE_BLOCK_ENCODING_VERSION {
253        return Err(TreeStreamError::Malformed(
254            "tree does not use block compression".into(),
255        ));
256    }
257    if preamble.len() != TREE_BLOCK_PREAMBLE_LEN {
258        return Err(TreeStreamError::TruncatedFrame {
259            offset: TREE_HEADER_LEN as u64,
260        });
261    }
262    if preamble[0] != TREE_BLOCK_CODEC_ZSTD {
263        return Err(TreeStreamError::Malformed(format!(
264            "unsupported tree block codec {}",
265            preamble[0]
266        )));
267    }
268    if preamble[1] != 0 {
269        return Err(TreeStreamError::Malformed(
270            "unsupported tree block flags".into(),
271        ));
272    }
273    let block_entries = u16::from_le_bytes([preamble[2], preamble[3]]) as usize;
274    if block_entries == 0 {
275        return Err(TreeStreamError::Malformed(
276            "tree block size must be nonzero".into(),
277        ));
278    }
279    if block_entries > TREE_BLOCK_ENTRIES {
280        return Err(TreeStreamError::Malformed(format!(
281            "tree block entry count {block_entries} exceeds maximum {TREE_BLOCK_ENTRIES}"
282        )));
283    }
284    let block_count = u32::from_le_bytes(
285        preamble[4..8]
286            .try_into()
287            .map_err(|_| TreeStreamError::Malformed("invalid tree block count".into()))?,
288    ) as usize;
289    let raw_payload_len = u64::from_le_bytes(
290        preamble[8..16]
291            .try_into()
292            .map_err(|_| TreeStreamError::Malformed("invalid raw tree payload length".into()))?,
293    );
294    let expected_blocks = header.entry_count.div_ceil(block_entries as u64);
295    if block_count as u64 != expected_blocks {
296        return Err(TreeStreamError::Malformed(
297            "tree block count does not match entry count".into(),
298        ));
299    }
300    let index_bytes = block_count
301        .checked_mul(TREE_BLOCK_INDEX_LEN)
302        .ok_or_else(|| TreeStreamError::Malformed("tree block index length overflow".into()))?;
303    let index_end = TREE_HEADER_LEN
304        .checked_add(TREE_BLOCK_PREAMBLE_LEN)
305        .and_then(|len| len.checked_add(index_bytes))
306        .ok_or_else(|| TreeStreamError::Malformed("tree block index end overflow".into()))?
307        as u64;
308    let expected_object_len = (TREE_HEADER_LEN as u64)
309        .checked_add(header.payload_len)
310        .ok_or_else(|| TreeStreamError::Malformed("tree object length overflow".into()))?;
311    if index_end > expected_object_len || expected_object_len != object_len {
312        return Err(TreeStreamError::TruncatedFrame { offset: object_len });
313    }
314    Ok(TreeBlockHeader {
315        block_entries,
316        block_count,
317        entry_count: header.entry_count,
318        raw_payload_len,
319        index_end,
320    })
321}
322
323pub(crate) fn decode_block_index(
324    bytes: &[u8],
325    block: usize,
326    block_header: &TreeBlockHeader,
327    object_len: u64,
328) -> Result<TreeBlockIndex, TreeStreamError> {
329    if bytes.len() != TREE_BLOCK_INDEX_LEN || block >= block_header.block_count {
330        return Err(TreeStreamError::Malformed(
331            "invalid tree block index entry".into(),
332        ));
333    }
334    let raw_offset = u64::from_le_bytes(
335        bytes[0..8]
336            .try_into()
337            .map_err(|_| TreeStreamError::Malformed("invalid tree block raw offset".into()))?,
338    );
339    let stored_offset = u64::from_le_bytes(
340        bytes[8..16]
341            .try_into()
342            .map_err(|_| TreeStreamError::Malformed("invalid tree block stored offset".into()))?,
343    );
344    let stored_len = u32::from_le_bytes(
345        bytes[16..20]
346            .try_into()
347            .map_err(|_| TreeStreamError::Malformed("invalid tree block stored length".into()))?,
348    ) as usize;
349    let raw_len = u32::from_le_bytes(
350        bytes[20..24]
351            .try_into()
352            .map_err(|_| TreeStreamError::Malformed("invalid tree block raw length".into()))?,
353    ) as usize;
354    let first_entry = (block as u64)
355        .checked_mul(block_header.block_entries as u64)
356        .ok_or_else(|| TreeStreamError::Malformed("tree block ordinal overflow".into()))?;
357    let entries = block_header
358        .entry_count
359        .checked_sub(first_entry)
360        .ok_or_else(|| TreeStreamError::Malformed("tree block starts past entries".into()))?
361        .min(block_header.block_entries as u64) as usize;
362    let max_raw_len = entries
363        .checked_mul(TREE_BLOCK_MAX_ENTRY_FRAME_LEN)
364        .ok_or_else(|| TreeStreamError::Malformed("tree block raw length overflow".into()))?;
365    validate_block_lengths(stored_len, raw_len, max_raw_len)?;
366    if stored_offset < block_header.index_end {
367        return Err(TreeStreamError::Malformed(
368            "invalid empty or overlapping tree block".into(),
369        ));
370    }
371    if stored_offset
372        .checked_add(stored_len as u64)
373        .is_none_or(|end| end > object_len)
374    {
375        return Err(TreeStreamError::TruncatedFrame {
376            offset: stored_offset,
377        });
378    }
379    Ok(TreeBlockIndex {
380        raw_offset,
381        stored_offset,
382        stored_len,
383        raw_len,
384    })
385}
386
387fn validate_block_lengths(
388    stored_len: usize,
389    raw_len: usize,
390    max_raw_len: usize,
391) -> Result<(), TreeStreamError> {
392    if raw_len > max_raw_len {
393        return Err(TreeStreamError::Malformed(format!(
394            "tree block raw length {raw_len} exceeds maximum {max_raw_len}"
395        )));
396    }
397    if stored_len == 0 || raw_len == 0 {
398        return Err(TreeStreamError::Malformed(
399            "invalid empty or overlapping tree block".into(),
400        ));
401    }
402    let expansion_limit = (stored_len as u64) * (TREE_BLOCK_MAX_EXPANSION_RATIO as u64);
403    if raw_len as u64 > expansion_limit {
404        return Err(TreeStreamError::Malformed(format!(
405            "tree block raw length {raw_len} exceeds {TREE_BLOCK_MAX_EXPANSION_RATIO}:1 expansion limit for {stored_len} stored bytes"
406        )));
407    }
408    Ok(())
409}
410
411pub(crate) fn decode_block_payload(
412    stored: &[u8],
413    raw_len: usize,
414) -> Result<Vec<u8>, TreeStreamError> {
415    validate_block_lengths(stored.len(), raw_len, TREE_BLOCK_MAX_RAW_LEN)?;
416    if stored.len() == raw_len {
417        return Ok(stored.to_vec());
418    }
419    let anchor_end = block_anchor_end(stored)?;
420    if anchor_end >= raw_len {
421        return Err(TreeStreamError::Malformed(
422            "compressed tree block has no tail".into(),
423        ));
424    }
425    let tail = decompress_block_tail(&stored[anchor_end..], raw_len - anchor_end)?;
426    let mut raw = Vec::with_capacity(raw_len);
427    raw.extend_from_slice(&stored[..anchor_end]);
428    raw.extend_from_slice(&tail);
429    if raw.len() != raw_len {
430        return Err(TreeStreamError::Malformed(
431            "decoded tree block length mismatch".into(),
432        ));
433    }
434    Ok(raw)
435}
436
437pub(crate) fn block_anchor_end(stored: &[u8]) -> Result<usize, TreeStreamError> {
438    let len_bytes = stored
439        .get(..4)
440        .ok_or(TreeStreamError::TruncatedFrame { offset: 0 })?;
441    let frame_len = u32::from_le_bytes(
442        len_bytes
443            .try_into()
444            .map_err(|_| TreeStreamError::Malformed("invalid anchor frame length".into()))?,
445    ) as usize;
446    let end = 4usize
447        .checked_add(frame_len)
448        .ok_or(TreeStreamError::TruncatedFrame { offset: 0 })?;
449    if end > stored.len() {
450        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
451    }
452    Ok(end)
453}
454
455fn encode_blocked_htr4(raw: &[u8], level: i32) -> Result<Vec<u8>, TreeStreamError> {
456    let header = decode_header(raw)?;
457    if header.version != TREE_ENCODING_VERSION {
458        return Err(TreeStreamError::Malformed(
459            "block encoder requires raw HTR4".into(),
460        ));
461    }
462    let ranges = entry_frame_ranges(raw, header.entry_count)?;
463    let block_count = ranges.len().div_ceil(TREE_BLOCK_ENTRIES);
464    let block_count_u32 = u32::try_from(block_count)
465        .map_err(|_| TreeStreamError::Malformed("tree has too many blocks".into()))?;
466    let mut blocks = Vec::with_capacity(block_count);
467    for chunk in ranges.chunks(TREE_BLOCK_ENTRIES) {
468        let (start, anchor_end) = *chunk
469            .first()
470            .ok_or_else(|| TreeStreamError::Malformed("empty tree block".into()))?;
471        let end = chunk
472            .last()
473            .map(|range| range.1)
474            .ok_or_else(|| TreeStreamError::Malformed("empty tree block".into()))?;
475        let block = &raw[start..end];
476        let compressed_tail = compress_block_tail(&raw[anchor_end..end], level)?;
477        if anchor_end - start + compressed_tail.len() < block.len() {
478            let mut stored = Vec::with_capacity(anchor_end - start + compressed_tail.len());
479            stored.extend_from_slice(&raw[start..anchor_end]);
480            stored.extend_from_slice(&compressed_tail);
481            blocks.push(stored);
482        } else {
483            blocks.push(block.to_vec());
484        }
485    }
486
487    let index_bytes = block_count
488        .checked_mul(TREE_BLOCK_INDEX_LEN)
489        .ok_or_else(|| TreeStreamError::Malformed("tree block index length overflow".into()))?;
490    let stored_payload_len = TREE_BLOCK_PREAMBLE_LEN
491        .checked_add(index_bytes)
492        .and_then(|len| {
493            blocks
494                .iter()
495                .try_fold(len, |total, block| total.checked_add(block.len()))
496        })
497        .ok_or_else(|| TreeStreamError::Malformed("blocked tree length overflow".into()))?;
498    let mut out = Vec::with_capacity(TREE_HEADER_LEN + stored_payload_len);
499    out.extend_from_slice(TREE_CANONICAL_MAGIC);
500    out.push(TREE_BLOCK_ENCODING_VERSION);
501    out.extend_from_slice(header.tree_id.as_bytes());
502    out.extend_from_slice(&header.entry_count.to_le_bytes());
503    out.extend_from_slice(&(stored_payload_len as u64).to_le_bytes());
504    out.extend_from_slice(&header.logical_len.to_le_bytes());
505    out.push(TREE_BLOCK_CODEC_ZSTD);
506    out.push(0);
507    out.extend_from_slice(&(TREE_BLOCK_ENTRIES as u16).to_le_bytes());
508    out.extend_from_slice(&block_count_u32.to_le_bytes());
509    out.extend_from_slice(&header.payload_len.to_le_bytes());
510
511    let mut stored_offset = TREE_HEADER_LEN
512        .checked_add(TREE_BLOCK_PREAMBLE_LEN)
513        .and_then(|len| len.checked_add(index_bytes))
514        .ok_or_else(|| TreeStreamError::Malformed("tree block offset overflow".into()))?;
515    for (block, stored) in blocks.iter().enumerate() {
516        let first_entry = block * TREE_BLOCK_ENTRIES;
517        let chunk = &ranges[first_entry..(first_entry + TREE_BLOCK_ENTRIES).min(ranges.len())];
518        let raw_offset = chunk[0].0 - TREE_HEADER_LEN;
519        let raw_len = chunk[chunk.len() - 1].1 - chunk[0].0;
520        let stored_len = u32::try_from(stored.len())
521            .map_err(|_| TreeStreamError::Malformed("stored tree block exceeds u32".into()))?;
522        let raw_len = u32::try_from(raw_len)
523            .map_err(|_| TreeStreamError::Malformed("raw tree block exceeds u32".into()))?;
524        out.extend_from_slice(&(raw_offset as u64).to_le_bytes());
525        out.extend_from_slice(&(stored_offset as u64).to_le_bytes());
526        out.extend_from_slice(&stored_len.to_le_bytes());
527        out.extend_from_slice(&raw_len.to_le_bytes());
528        stored_offset = stored_offset
529            .checked_add(stored.len())
530            .ok_or_else(|| TreeStreamError::Malformed("tree block offset overflow".into()))?;
531    }
532    for block in blocks {
533        out.extend_from_slice(&block);
534    }
535    Ok(out)
536}
537
538fn entry_frame_ranges(
539    raw: &[u8],
540    entry_count: u64,
541) -> Result<Vec<(usize, usize)>, TreeStreamError> {
542    let count = usize::try_from(entry_count)
543        .map_err(|_| TreeStreamError::Malformed("tree entry count exceeds usize".into()))?;
544    let mut ranges = Vec::with_capacity(count);
545    let mut offset = TREE_HEADER_LEN;
546    for _ in 0..count {
547        let len_bytes = raw
548            .get(offset..offset + 4)
549            .ok_or(TreeStreamError::TruncatedFrame {
550                offset: offset as u64,
551            })?;
552        let frame_len = u32::from_le_bytes(
553            len_bytes
554                .try_into()
555                .map_err(|_| TreeStreamError::Malformed("invalid frame length".into()))?,
556        ) as usize;
557        let end = offset
558            .checked_add(4)
559            .and_then(|start| start.checked_add(frame_len))
560            .ok_or(TreeStreamError::TruncatedFrame {
561                offset: offset as u64,
562            })?;
563        if end > raw.len() {
564            return Err(TreeStreamError::TruncatedFrame {
565                offset: offset as u64,
566            });
567        }
568        ranges.push((offset, end));
569        offset = end;
570    }
571    if offset != raw.len() {
572        return Err(TreeStreamError::TrailingBytes {
573            extra: raw.len().abs_diff(offset) as u64,
574        });
575    }
576    Ok(ranges)
577}
578
579#[cfg(feature = "zstd")]
580fn compress_block_tail(data: &[u8], level: i32) -> Result<Vec<u8>, TreeStreamError> {
581    zstd::bulk::compress(data, level)
582        .map_err(|error| TreeStreamError::Compression(error.to_string()))
583}
584
585#[cfg(not(feature = "zstd"))]
586fn compress_block_tail(_data: &[u8], _level: i32) -> Result<Vec<u8>, TreeStreamError> {
587    Err(TreeStreamError::Compression(
588        "zstd support is not compiled into this build".into(),
589    ))
590}
591
592#[cfg(feature = "zstd")]
593fn decompress_block_tail(data: &[u8], capacity: usize) -> Result<Vec<u8>, TreeStreamError> {
594    if capacity > TREE_BLOCK_MAX_RAW_LEN {
595        return Err(TreeStreamError::Malformed(format!(
596            "tree block tail length {capacity} exceeds maximum {TREE_BLOCK_MAX_RAW_LEN}"
597        )));
598    }
599    let decoded = zstd::bulk::decompress(data, capacity)
600        .map_err(|error| TreeStreamError::Compression(error.to_string()))?;
601    if decoded.len() != capacity {
602        return Err(TreeStreamError::Malformed(
603            "decoded tree block tail length mismatch".into(),
604        ));
605    }
606    Ok(decoded)
607}
608
609#[cfg(not(feature = "zstd"))]
610fn decompress_block_tail(_data: &[u8], _capacity: usize) -> Result<Vec<u8>, TreeStreamError> {
611    Err(TreeStreamError::Compression(
612        "zstd support is not compiled into this build".into(),
613    ))
614}
615
616/// A cumulative HDC1 operation against a materialized anchor.
617#[derive(Clone, Debug, PartialEq, Eq)]
618pub enum TreeDeltaOp {
619    Remove(String),
620    Upsert(TreeEntry),
621}
622
623impl TreeDeltaOp {
624    pub fn name(&self) -> &str {
625        match self {
626            Self::Remove(name) => name,
627            Self::Upsert(entry) => entry.name(),
628        }
629    }
630}
631
632/// Parsed HDC1 header, including its bounded first-entry and first-100 porch.
633#[derive(Clone, Copy, Debug, PartialEq, Eq)]
634pub struct TreeDeltaHeader {
635    pub anchor: ContentHash,
636    pub result_count: usize,
637    pub op_count: usize,
638    pub first_op_count: usize,
639    pub first_base_count: usize,
640    pub first_end: usize,
641    pub hundred_op_count: usize,
642    pub hundred_base_count: usize,
643    pub hundred_end: usize,
644}
645
646impl Tree {
647    /// Encode a cheap HLR1 materialized anchor. The content hash deliberately
648    /// stays outside the body and must be supplied by the object store while
649    /// decoding.
650    pub fn encode_lean(&self) -> Result<Vec<u8>, TreeStreamError> {
651        self.validate()?;
652        encode_lean_entries(self.entries())
653    }
654
655    /// Decode a complete HLR1 anchor and validate it against its object key.
656    pub fn decode_lean(data: &[u8], expected: ContentHash) -> Result<Self, TreeStreamError> {
657        let (entries, consumed) = decode_lean_entries(data, usize::MAX)?;
658        if consumed != data.len() {
659            return Err(TreeStreamError::TrailingBytes {
660                extra: data.len().abs_diff(consumed) as u64,
661            });
662        }
663        let tree = Tree::try_from_decoded_entries(entries)?;
664        let found = tree.hash();
665        if found != expected {
666            return Err(TreeStreamError::HashMismatch { expected, found });
667        }
668        Ok(tree)
669    }
670}
671
672/// Decode at most `wanted` entries from the compact HLR1 porch. The returned
673/// byte count is the exact prefix a file-backed reader had to consume.
674pub fn decode_lean_prefix(
675    data: &[u8],
676    wanted: usize,
677) -> Result<(Vec<TreeEntry>, usize), TreeStreamError> {
678    decode_lean_entries(data, wanted)
679}
680
681fn encode_lean_entries(entries: &[TreeEntry]) -> Result<Vec<u8>, TreeStreamError> {
682    let mut out = Vec::new();
683    out.extend_from_slice(TREE_LEAN_MAGIC);
684    put_varint(entries.len(), &mut out);
685    let mut previous = "";
686    for entry in entries {
687        encode_lean_entry(entry, previous, &mut out)?;
688        previous = entry.name();
689    }
690    Ok(out)
691}
692
693fn decode_lean_entries(
694    data: &[u8],
695    wanted: usize,
696) -> Result<(Vec<TreeEntry>, usize), TreeStreamError> {
697    if !is_lean_tree(data) {
698        return Err(TreeStreamError::Malformed(
699            "bytes are not an HLR1 tree anchor".into(),
700        ));
701    }
702    let mut offset = TREE_LEAN_MAGIC.len();
703    let count = take_varint(data, &mut offset)?;
704    let wanted = wanted.min(count);
705    let mut entries = Vec::with_capacity(wanted);
706    let mut previous = String::new();
707    for _ in 0..wanted {
708        let entry = decode_compact_entry(data, &mut offset, &previous)?;
709        if !previous.is_empty() && previous.as_str() >= entry.name() {
710            return Err(TreeError::InvalidStructure(
711                "entries must be strictly sorted by name".into(),
712            )
713            .into());
714        }
715        previous = entry.name().to_string();
716        entries.push(entry);
717    }
718    if wanted == count && offset != data.len() {
719        return Err(TreeStreamError::TrailingBytes {
720            extra: data.len().abs_diff(offset) as u64,
721        });
722    }
723    Ok((entries, offset))
724}
725
726fn put_varint(mut value: usize, out: &mut Vec<u8>) {
727    while value >= 0x80 {
728        out.push((value as u8) | 0x80);
729        value >>= 7;
730    }
731    out.push(value as u8);
732}
733
734pub(crate) fn take_varint(bytes: &[u8], offset: &mut usize) -> Result<usize, TreeStreamError> {
735    let mut value = 0usize;
736    for shift in (0..usize::BITS).step_by(7) {
737        let byte = *bytes.get(*offset).ok_or(TreeStreamError::TruncatedFrame {
738            offset: *offset as u64,
739        })?;
740        *offset += 1;
741        value |= ((byte & 0x7f) as usize)
742            .checked_shl(shift)
743            .ok_or_else(|| TreeStreamError::Malformed("varint overflow".into()))?;
744        if byte & 0x80 == 0 {
745            return Ok(value);
746        }
747    }
748    Err(TreeStreamError::Malformed("varint overflow".into()))
749}
750
751fn shared_prefix(left: &str, right: &str) -> usize {
752    left.as_bytes()
753        .iter()
754        .zip(right.as_bytes())
755        .take_while(|(left, right)| left == right)
756        .count()
757}
758
759/// Append one HLR1 entry using `previous_name` as its prefix-compression base.
760/// Store readers use this to expose a lazily merged HDC1 body as HLR1 bytes.
761pub fn encode_lean_entry(
762    entry: &TreeEntry,
763    previous_name: &str,
764    out: &mut Vec<u8>,
765) -> Result<(), TreeStreamError> {
766    out.push((entry.mode().to_byte() << 3) | entry.entry_type().to_byte());
767    let prefix = shared_prefix(previous_name, entry.name());
768    put_varint(prefix, out);
769    put_varint(entry.name().len() - prefix, out);
770    out.extend_from_slice(&entry.name().as_bytes()[prefix..]);
771    match entry.entry_type() {
772        EntryType::Blob | EntryType::Tree | EntryType::Symlink => {
773            let hash = entry.content_hash().ok_or_else(|| {
774                TreeStreamError::Malformed("compact entry is missing its content hash".into())
775            })?;
776            out.extend_from_slice(hash.as_bytes());
777        }
778        EntryType::Gitlink => {
779            let target = entry.gitlink_target().ok_or_else(|| {
780                TreeStreamError::Malformed("compact gitlink is missing its target".into())
781            })?;
782            out.push(git_format_to_tag(target.format()));
783            out.extend_from_slice(target.as_bytes());
784        }
785        EntryType::Spoollink => {
786            let (spool, state) = entry.spoollink_target().ok_or_else(|| {
787                TreeStreamError::Malformed("compact spoollink is missing its target".into())
788            })?;
789            put_varint(spool.as_str().len(), out);
790            out.extend_from_slice(spool.as_str().as_bytes());
791            out.extend_from_slice(state.as_bytes());
792        }
793    }
794    Ok(())
795}
796
797pub(crate) fn decode_compact_entry(
798    bytes: &[u8],
799    offset: &mut usize,
800    previous_name: &str,
801) -> Result<TreeEntry, TreeStreamError> {
802    let tag = *bytes.get(*offset).ok_or(TreeStreamError::TruncatedFrame {
803        offset: *offset as u64,
804    })?;
805    *offset += 1;
806    let mode = FileMode::from_byte(tag >> 3).ok_or_else(|| {
807        TreeStreamError::Malformed(format!("invalid compact entry mode {}", tag >> 3))
808    })?;
809    let kind = EntryType::from_byte(tag & 0x07).ok_or_else(|| {
810        TreeStreamError::Malformed(format!("invalid compact entry kind {}", tag & 0x07))
811    })?;
812    let prefix = take_varint(bytes, offset)?;
813    let suffix_len = take_varint(bytes, offset)?;
814    if prefix > previous_name.len() {
815        return Err(TreeStreamError::Malformed(
816            "compact name prefix exceeds predecessor".into(),
817        ));
818    }
819    let suffix_end = offset
820        .checked_add(suffix_len)
821        .ok_or_else(|| TreeStreamError::Malformed("compact name length overflow".into()))?;
822    let suffix = bytes
823        .get(*offset..suffix_end)
824        .ok_or(TreeStreamError::TruncatedFrame {
825            offset: *offset as u64,
826        })?;
827    let mut name = previous_name.as_bytes()[..prefix].to_vec();
828    name.extend_from_slice(suffix);
829    let name = String::from_utf8(name)
830        .map_err(|_| TreeStreamError::Malformed("compact entry name is not UTF-8".into()))?;
831    *offset = suffix_end;
832    let entry = match kind {
833        EntryType::Blob | EntryType::Tree | EntryType::Symlink => {
834            let end = offset
835                .checked_add(32)
836                .ok_or_else(|| TreeStreamError::Malformed("compact hash overflow".into()))?;
837            let hash = ContentHash::from_bytes(
838                bytes
839                    .get(*offset..end)
840                    .ok_or(TreeStreamError::TruncatedFrame {
841                        offset: *offset as u64,
842                    })?
843                    .try_into()
844                    .map_err(|_| {
845                        TreeStreamError::Malformed("compact hash is not 32 bytes".into())
846                    })?,
847            );
848            *offset = end;
849            match kind {
850                EntryType::Blob => TreeEntry::file(name, hash, mode == FileMode::Executable)?,
851                EntryType::Tree => TreeEntry::directory(name, hash)?,
852                EntryType::Symlink => TreeEntry::symlink(name, hash)?,
853                EntryType::Gitlink | EntryType::Spoollink => {
854                    return Err(TreeStreamError::Malformed(
855                        "invalid compact content-addressed kind".into(),
856                    ));
857                }
858            }
859        }
860        EntryType::Gitlink => {
861            let format_tag = *bytes.get(*offset).ok_or(TreeStreamError::TruncatedFrame {
862                offset: *offset as u64,
863            })?;
864            *offset += 1;
865            let format = git_format_from_tag(format_tag)?;
866            let oid_len = match format {
867                GitObjectFormat::Sha1 => 20,
868                GitObjectFormat::Sha256 => 32,
869            };
870            let end = offset.checked_add(oid_len).ok_or_else(|| {
871                TreeStreamError::Malformed("compact gitlink length overflow".into())
872            })?;
873            let target = GitObjectId::from_raw(
874                format,
875                bytes
876                    .get(*offset..end)
877                    .ok_or(TreeStreamError::TruncatedFrame {
878                        offset: *offset as u64,
879                    })?,
880            )
881            .map_err(|error| {
882                TreeStreamError::Malformed(format!("invalid compact gitlink: {error}"))
883            })?;
884            *offset = end;
885            TreeEntry::gitlink(name, target)?
886        }
887        EntryType::Spoollink => {
888            let spool_len = take_varint(bytes, offset)?;
889            let spool_end = offset.checked_add(spool_len).ok_or_else(|| {
890                TreeStreamError::Malformed("compact spool length overflow".into())
891            })?;
892            let spool = std::str::from_utf8(bytes.get(*offset..spool_end).ok_or(
893                TreeStreamError::TruncatedFrame {
894                    offset: *offset as u64,
895                },
896            )?)
897            .map_err(|_| TreeStreamError::Malformed("compact spool id is not UTF-8".into()))?;
898            *offset = spool_end;
899            let state_end = offset
900                .checked_add(32)
901                .ok_or_else(|| TreeStreamError::Malformed("compact state overflow".into()))?;
902            let state = StateId::from_bytes(
903                bytes
904                    .get(*offset..state_end)
905                    .ok_or(TreeStreamError::TruncatedFrame {
906                        offset: *offset as u64,
907                    })?
908                    .try_into()
909                    .map_err(|_| {
910                        TreeStreamError::Malformed("compact state is not 32 bytes".into())
911                    })?,
912            );
913            *offset = state_end;
914            let spool_id = SpoolId::parse(spool).map_err(|error| {
915                TreeStreamError::Malformed(format!("invalid compact spool id: {error}"))
916            })?;
917            TreeEntry::spoollink(name, spool_id, state)?
918        }
919    };
920    if entry.mode() != mode {
921        return Err(TreeStreamError::Malformed(format!(
922            "compact entry kind/mode mismatch for {}: {kind:?}/{mode:?}",
923            entry.name()
924        )));
925    }
926    Ok(entry)
927}
928
929/// Compute the sorted cumulative edit from `anchor` to `current`.
930pub fn tree_delta(anchor: &Tree, current: &Tree) -> Vec<TreeDeltaOp> {
931    let mut ops = Vec::new();
932    let mut anchor_index = 0usize;
933    let mut current_index = 0usize;
934    while anchor_index < anchor.len() || current_index < current.len() {
935        match (
936            anchor.entries().get(anchor_index),
937            current.entries().get(current_index),
938        ) {
939            (Some(anchor_entry), Some(current_entry)) => {
940                match anchor_entry.name().cmp(current_entry.name()) {
941                    std::cmp::Ordering::Less => {
942                        ops.push(TreeDeltaOp::Remove(anchor_entry.name().to_string()));
943                        anchor_index += 1;
944                    }
945                    std::cmp::Ordering::Greater => {
946                        ops.push(TreeDeltaOp::Upsert(current_entry.clone()));
947                        current_index += 1;
948                    }
949                    std::cmp::Ordering::Equal => {
950                        if anchor_entry != current_entry {
951                            ops.push(TreeDeltaOp::Upsert(current_entry.clone()));
952                        }
953                        anchor_index += 1;
954                        current_index += 1;
955                    }
956                }
957            }
958            (Some(anchor_entry), None) => {
959                ops.push(TreeDeltaOp::Remove(anchor_entry.name().to_string()));
960                anchor_index += 1;
961            }
962            (None, Some(current_entry)) => {
963                ops.push(TreeDeltaOp::Upsert(current_entry.clone()));
964                current_index += 1;
965            }
966            (None, None) => break,
967        }
968    }
969    ops
970}
971
972/// Apply a sorted cumulative delta to its materialized anchor.
973pub fn apply_tree_delta(anchor: &Tree, ops: &[TreeDeltaOp]) -> Result<Tree, TreeStreamError> {
974    let mut entries = Vec::with_capacity(anchor.len().saturating_add(ops.len()));
975    let mut anchor_index = 0usize;
976    let mut op_index = 0usize;
977    while anchor_index < anchor.len() || op_index < ops.len() {
978        match (anchor.entries().get(anchor_index), ops.get(op_index)) {
979            (Some(anchor_entry), Some(op)) => match anchor_entry.name().cmp(op.name()) {
980                std::cmp::Ordering::Less => {
981                    entries.push(anchor_entry.clone());
982                    anchor_index += 1;
983                }
984                std::cmp::Ordering::Greater => {
985                    if let TreeDeltaOp::Upsert(entry) = op {
986                        entries.push(entry.clone());
987                    }
988                    op_index += 1;
989                }
990                std::cmp::Ordering::Equal => {
991                    if let TreeDeltaOp::Upsert(entry) = op {
992                        entries.push(entry.clone());
993                    }
994                    anchor_index += 1;
995                    op_index += 1;
996                }
997            },
998            (Some(anchor_entry), None) => {
999                entries.push(anchor_entry.clone());
1000                anchor_index += 1;
1001            }
1002            (None, Some(op)) => {
1003                if let TreeDeltaOp::Upsert(entry) = op {
1004                    entries.push(entry.clone());
1005                }
1006                op_index += 1;
1007            }
1008            (None, None) => break,
1009        }
1010    }
1011    Tree::try_from_decoded_entries(entries).map_err(TreeStreamError::from)
1012}
1013
1014fn delta_prefix_counts(
1015    anchor: &Tree,
1016    current: &Tree,
1017    ops: &[TreeDeltaOp],
1018    count: usize,
1019) -> Result<(u16, u16), TreeStreamError> {
1020    if current.is_empty() {
1021        return Ok((0, 0));
1022    }
1023    let boundary = current.entries()[count.min(current.len()) - 1].name();
1024    let op_count = ops.partition_point(|op| op.name() <= boundary);
1025    let base_count = anchor
1026        .entries()
1027        .partition_point(|entry| entry.name() <= boundary);
1028    Ok((
1029        u16::try_from(op_count)
1030            .map_err(|_| TreeStreamError::Malformed("delta porch op count exceeds u16".into()))?,
1031        u16::try_from(base_count).map_err(|_| {
1032            TreeStreamError::Malformed("delta porch anchor count exceeds u16".into())
1033        })?,
1034    ))
1035}
1036
1037/// Encode a one-hop HDC1 body against `anchor`.
1038pub fn encode_tree_delta(
1039    anchor_id: ContentHash,
1040    anchor: &Tree,
1041    current: &Tree,
1042    ops: &[TreeDeltaOp],
1043) -> Result<Vec<u8>, TreeStreamError> {
1044    anchor.validate()?;
1045    current.validate()?;
1046    if anchor.hash() != anchor_id {
1047        return Err(TreeStreamError::HashMismatch {
1048            expected: anchor_id,
1049            found: anchor.hash(),
1050        });
1051    }
1052    if ops.len() > TREE_DELTA_MAX_OPS {
1053        return Err(TreeStreamError::Malformed(format!(
1054            "tree delta has {} operations; maximum is {TREE_DELTA_MAX_OPS}",
1055            ops.len()
1056        )));
1057    }
1058    if apply_tree_delta(anchor, ops)? != *current {
1059        return Err(TreeStreamError::Malformed(
1060            "tree delta operations do not reconstruct the result".into(),
1061        ));
1062    }
1063    let result_count = u32::try_from(current.len())
1064        .map_err(|_| TreeStreamError::Malformed("delta result count exceeds u32".into()))?;
1065    let op_count = u16::try_from(ops.len())
1066        .map_err(|_| TreeStreamError::Malformed("delta operation count exceeds u16".into()))?;
1067    let (first_ops, first_base) = delta_prefix_counts(anchor, current, ops, 1)?;
1068    let (hundred_ops, hundred_base) = delta_prefix_counts(anchor, current, ops, 100)?;
1069    let mut body = Vec::new();
1070    let mut ends = Vec::with_capacity(ops.len());
1071    let mut previous = "";
1072    for op in ops {
1073        match op {
1074            TreeDeltaOp::Remove(name) => {
1075                body.push(0);
1076                let prefix = shared_prefix(previous, name);
1077                put_varint(prefix, &mut body);
1078                put_varint(name.len() - prefix, &mut body);
1079                body.extend_from_slice(&name.as_bytes()[prefix..]);
1080            }
1081            TreeDeltaOp::Upsert(entry) => {
1082                body.push(1);
1083                encode_lean_entry(entry, previous, &mut body)?;
1084            }
1085        }
1086        if !previous.is_empty() && previous >= op.name() {
1087            return Err(TreeStreamError::Malformed(
1088                "tree delta operations must be strictly sorted".into(),
1089            ));
1090        }
1091        previous = op.name();
1092        ends.push(body.len());
1093    }
1094    let end_for = |count: u16| -> Result<u32, TreeStreamError> {
1095        let end = if count == 0 {
1096            TREE_DELTA_HEADER_LEN
1097        } else {
1098            TREE_DELTA_HEADER_LEN
1099                .checked_add(*ends.get(count as usize - 1).ok_or_else(|| {
1100                    TreeStreamError::Malformed("delta porch exceeds operation count".into())
1101                })?)
1102                .ok_or_else(|| TreeStreamError::Malformed("delta porch offset overflow".into()))?
1103        };
1104        u32::try_from(end)
1105            .map_err(|_| TreeStreamError::Malformed("delta porch offset exceeds u32".into()))
1106    };
1107    let mut out = Vec::with_capacity(TREE_DELTA_HEADER_LEN + body.len());
1108    out.extend_from_slice(TREE_DELTA_MAGIC);
1109    out.push(TREE_DELTA_ENCODING_VERSION);
1110    out.extend_from_slice(anchor_id.as_bytes());
1111    out.extend_from_slice(&result_count.to_le_bytes());
1112    out.extend_from_slice(&op_count.to_le_bytes());
1113    out.extend_from_slice(&first_ops.to_le_bytes());
1114    out.extend_from_slice(&first_base.to_le_bytes());
1115    out.extend_from_slice(&end_for(first_ops)?.to_le_bytes());
1116    out.extend_from_slice(&hundred_ops.to_le_bytes());
1117    out.extend_from_slice(&hundred_base.to_le_bytes());
1118    out.extend_from_slice(&end_for(hundred_ops)?.to_le_bytes());
1119    if out.len() != TREE_DELTA_HEADER_LEN {
1120        return Err(TreeStreamError::Malformed(
1121            "internal HDC1 header length mismatch".into(),
1122        ));
1123    }
1124    out.extend_from_slice(&body);
1125    Ok(out)
1126}
1127
1128/// Parse and validate the fixed HDC1 header without reading its operations.
1129pub fn decode_tree_delta_header(data: &[u8]) -> Result<TreeDeltaHeader, TreeStreamError> {
1130    decode_tree_delta_header_prefix(data, data.len())
1131}
1132
1133/// Parse an HDC1 header from a bounded prefix while validating its offsets
1134/// against the complete file length.
1135pub fn decode_tree_delta_header_prefix(
1136    data: &[u8],
1137    object_len: usize,
1138) -> Result<TreeDeltaHeader, TreeStreamError> {
1139    if data.len() < TREE_DELTA_HEADER_LEN {
1140        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
1141    }
1142    if !is_delta_tree(data) {
1143        return Err(TreeStreamError::Malformed(
1144            "bytes are not an HDC1 tree delta".into(),
1145        ));
1146    }
1147    if data[4] != TREE_DELTA_ENCODING_VERSION {
1148        return Err(TreeStreamError::UnsupportedVersion { found: data[4] });
1149    }
1150    let anchor = ContentHash::from_bytes(
1151        data[5..37]
1152            .try_into()
1153            .map_err(|_| TreeStreamError::Malformed("delta anchor hash is not 32 bytes".into()))?,
1154    );
1155    let header = TreeDeltaHeader {
1156        anchor,
1157        result_count: read_u32_at(data, 37)? as usize,
1158        op_count: read_u16_at(data, 41)? as usize,
1159        first_op_count: read_u16_at(data, 43)? as usize,
1160        first_base_count: read_u16_at(data, 45)? as usize,
1161        first_end: read_u32_at(data, 47)? as usize,
1162        hundred_op_count: read_u16_at(data, 51)? as usize,
1163        hundred_base_count: read_u16_at(data, 53)? as usize,
1164        hundred_end: read_u32_at(data, 55)? as usize,
1165    };
1166    if header.op_count > TREE_DELTA_MAX_OPS
1167        || header.first_op_count > header.op_count
1168        || header.hundred_op_count > header.op_count
1169        || header.first_end < TREE_DELTA_HEADER_LEN
1170        || header.hundred_end < header.first_end
1171        || header.hundred_end > object_len
1172    {
1173        return Err(TreeStreamError::Malformed(
1174            "invalid HDC1 porch or operation bounds".into(),
1175        ));
1176    }
1177    Ok(header)
1178}
1179
1180/// Decode `wanted` HDC1 operations. Used by bounded porch reads as well as
1181/// full reconstruction.
1182pub fn decode_tree_delta_ops(
1183    data: &[u8],
1184    wanted: usize,
1185) -> Result<(TreeDeltaHeader, Vec<TreeDeltaOp>, usize), TreeStreamError> {
1186    decode_tree_delta_ops_prefix(data, data.len(), wanted)
1187}
1188
1189/// Decode an HDC1 operation porch without transferring the rest of the body.
1190pub fn decode_tree_delta_ops_prefix(
1191    data: &[u8],
1192    object_len: usize,
1193    wanted: usize,
1194) -> Result<(TreeDeltaHeader, Vec<TreeDeltaOp>, usize), TreeStreamError> {
1195    let header = decode_tree_delta_header_prefix(data, object_len)?;
1196    if wanted > header.op_count {
1197        return Err(TreeStreamError::Malformed(
1198            "partial delta operation count exceeds object".into(),
1199        ));
1200    }
1201    let mut offset = TREE_DELTA_HEADER_LEN;
1202    let mut previous = String::new();
1203    let mut ops = Vec::with_capacity(wanted);
1204    for _ in 0..wanted {
1205        let opcode = *data.get(offset).ok_or(TreeStreamError::TruncatedFrame {
1206            offset: offset as u64,
1207        })?;
1208        offset += 1;
1209        let op =
1210            match opcode {
1211                0 => {
1212                    let prefix = take_varint(data, &mut offset)?;
1213                    let suffix_len = take_varint(data, &mut offset)?;
1214                    if prefix > previous.len() {
1215                        return Err(TreeStreamError::Malformed(
1216                            "delta name prefix exceeds predecessor".into(),
1217                        ));
1218                    }
1219                    let end = offset.checked_add(suffix_len).ok_or_else(|| {
1220                        TreeStreamError::Malformed("delta name length overflow".into())
1221                    })?;
1222                    let mut name = previous.as_bytes()[..prefix].to_vec();
1223                    name.extend_from_slice(data.get(offset..end).ok_or(
1224                        TreeStreamError::TruncatedFrame {
1225                            offset: offset as u64,
1226                        },
1227                    )?);
1228                    offset = end;
1229                    TreeDeltaOp::Remove(String::from_utf8(name).map_err(|_| {
1230                        TreeStreamError::Malformed("delta name is not UTF-8".into())
1231                    })?)
1232                }
1233                1 => TreeDeltaOp::Upsert(decode_compact_entry(data, &mut offset, &previous)?),
1234                _ => {
1235                    return Err(TreeStreamError::Malformed(format!(
1236                        "invalid tree delta opcode {opcode}"
1237                    )));
1238                }
1239            };
1240        if !previous.is_empty() && previous.as_str() >= op.name() {
1241            return Err(TreeStreamError::Malformed(
1242                "tree delta operations must be strictly sorted".into(),
1243            ));
1244        }
1245        previous = op.name().to_string();
1246        ops.push(op);
1247    }
1248    if (wanted == header.first_op_count && offset != header.first_end)
1249        || (wanted == header.hundred_op_count && offset != header.hundred_end)
1250    {
1251        return Err(TreeStreamError::Malformed(
1252            "HDC1 porch offset does not match decoded operations".into(),
1253        ));
1254    }
1255    Ok((header, ops, offset))
1256}
1257
1258/// Reconstruct an HDC1 tree from exactly one materialized anchor and validate
1259/// the result against its external object key.
1260pub fn decode_tree_delta(
1261    data: &[u8],
1262    anchor: &Tree,
1263    expected: ContentHash,
1264) -> Result<Tree, TreeStreamError> {
1265    let header = decode_tree_delta_header(data)?;
1266    let (decoded_header, ops, consumed) = decode_tree_delta_ops(data, header.op_count)?;
1267    if consumed != data.len() {
1268        return Err(TreeStreamError::TrailingBytes {
1269            extra: data.len().abs_diff(consumed) as u64,
1270        });
1271    }
1272    let anchor_found = anchor.hash();
1273    if anchor_found != decoded_header.anchor {
1274        return Err(TreeStreamError::HashMismatch {
1275            expected: decoded_header.anchor,
1276            found: anchor_found,
1277        });
1278    }
1279    let tree = apply_tree_delta(anchor, &ops)?;
1280    if tree.len() != decoded_header.result_count {
1281        return Err(TreeStreamError::Malformed(
1282            "delta result count does not match reconstructed tree".into(),
1283        ));
1284    }
1285    let found = tree.hash();
1286    if found != expected {
1287        return Err(TreeStreamError::HashMismatch { expected, found });
1288    }
1289    Ok(tree)
1290}
1291
1292fn read_u16_at(data: &[u8], offset: usize) -> Result<u16, TreeStreamError> {
1293    Ok(u16::from_le_bytes(
1294        data.get(offset..offset + 2)
1295            .ok_or(TreeStreamError::TruncatedFrame {
1296                offset: offset as u64,
1297            })?
1298            .try_into()
1299            .map_err(|_| TreeStreamError::Malformed("invalid u16 field".into()))?,
1300    ))
1301}
1302
1303fn read_u32_at(data: &[u8], offset: usize) -> Result<u32, TreeStreamError> {
1304    Ok(u32::from_le_bytes(
1305        data.get(offset..offset + 4)
1306            .ok_or(TreeStreamError::TruncatedFrame {
1307                offset: offset as u64,
1308            })?
1309            .try_into()
1310            .map_err(|_| TreeStreamError::Malformed("invalid u32 field".into()))?,
1311    ))
1312}
1313
1314pub(crate) fn encode_entry_frame(entry: &TreeEntry) -> Result<Vec<u8>, TreeStreamError> {
1315    let name = entry.name().as_bytes();
1316    let name_len = u16::try_from(name.len()).map_err(|_| {
1317        TreeStreamError::Malformed(format!("entry name '{}' exceeds u16", entry.name()))
1318    })?;
1319    let mut frame = Vec::new();
1320    frame.push(entry.mode().to_byte());
1321    frame.push(entry.entry_type().to_byte());
1322    frame.extend_from_slice(&name_len.to_le_bytes());
1323    frame.extend_from_slice(name);
1324    encode_target(&mut frame, entry)?;
1325    Ok(frame)
1326}
1327
1328pub(crate) fn decode_entry_at(
1329    data: &[u8],
1330    offset: usize,
1331    payload_end: usize,
1332) -> Result<(TreeEntry, usize), TreeStreamError> {
1333    if offset + 4 > payload_end {
1334        return Err(TreeStreamError::TruncatedFrame {
1335            offset: offset as u64,
1336        });
1337    }
1338    let frame_len = u32::from_le_bytes(
1339        data[offset..offset + 4]
1340            .try_into()
1341            .map_err(|_| TreeStreamError::Malformed("frame length slice is not 4 bytes".into()))?,
1342    ) as usize;
1343    let frame_start = offset + 4;
1344    let frame_end = frame_start
1345        .checked_add(frame_len)
1346        .ok_or(TreeStreamError::TruncatedFrame {
1347            offset: offset as u64,
1348        })?;
1349    if frame_end > payload_end {
1350        return Err(TreeStreamError::TruncatedFrame {
1351            offset: offset as u64,
1352        });
1353    }
1354    let entry = decode_entry_frame(&data[frame_start..frame_end])?;
1355    Ok((entry, 4 + frame_len))
1356}
1357
1358pub(crate) fn decode_entry_frame(frame: &[u8]) -> Result<TreeEntry, TreeStreamError> {
1359    if frame.len() < 4 {
1360        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
1361    }
1362    let mode = FileMode::from_byte(frame[0]).ok_or_else(|| {
1363        TreeStreamError::Malformed(format!("malformed tree entry mode {}", frame[0]))
1364    })?;
1365    let kind = EntryType::from_byte(frame[1]).ok_or_else(|| {
1366        TreeStreamError::Malformed(format!("malformed tree entry kind {}", frame[1]))
1367    })?;
1368    let name_len = u16::from_le_bytes([frame[2], frame[3]]) as usize;
1369    let name_end = 4 + name_len;
1370    if frame.len() < name_end {
1371        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
1372    }
1373    let name = std::str::from_utf8(&frame[4..name_end])
1374        .map_err(|_| TreeStreamError::Malformed("tree entry name is not UTF-8".into()))?
1375        .to_string();
1376    let entry = decode_target(name, kind, mode, &frame[name_end..])?;
1377    if entry.mode() != mode {
1378        return Err(TreeStreamError::Malformed(format!(
1379            "tree kind/mode mismatch for {}: {kind:?}/{mode:?}",
1380            entry.name()
1381        )));
1382    }
1383    Ok(entry)
1384}
1385
1386fn encode_target(frame: &mut Vec<u8>, entry: &TreeEntry) -> Result<(), TreeStreamError> {
1387    match entry.entry_type() {
1388        EntryType::Blob | EntryType::Tree | EntryType::Symlink => {
1389            frame.extend_from_slice(entry.require_content_hash().as_bytes());
1390        }
1391        EntryType::Gitlink => {
1392            let target = entry.gitlink_target().ok_or_else(|| {
1393                TreeStreamError::Malformed("gitlink entry is missing target".into())
1394            })?;
1395            frame.push(git_format_to_tag(target.format()));
1396            frame.extend_from_slice(target.as_bytes());
1397        }
1398        EntryType::Spoollink => {
1399            let (spool, state) = entry.spoollink_target().ok_or_else(|| {
1400                TreeStreamError::Malformed("spoollink entry is missing target".into())
1401            })?;
1402            let spool_bytes = spool.as_str().as_bytes();
1403            let spool_len = u16::try_from(spool_bytes.len())
1404                .map_err(|_| TreeStreamError::Malformed("spool id exceeds u16".into()))?;
1405            frame.extend_from_slice(&spool_len.to_le_bytes());
1406            frame.extend_from_slice(spool_bytes);
1407            frame.extend_from_slice(state.as_bytes());
1408        }
1409    }
1410    Ok(())
1411}
1412
1413fn decode_target(
1414    name: String,
1415    kind: EntryType,
1416    mode: FileMode,
1417    payload: &[u8],
1418) -> Result<TreeEntry, TreeStreamError> {
1419    match kind {
1420        EntryType::Blob => TreeEntry::file(name, take_hash(payload)?, mode == FileMode::Executable)
1421            .map_err(TreeStreamError::from),
1422        EntryType::Tree => {
1423            TreeEntry::directory(name, take_hash(payload)?).map_err(TreeStreamError::from)
1424        }
1425        EntryType::Symlink => {
1426            TreeEntry::symlink(name, take_hash(payload)?).map_err(TreeStreamError::from)
1427        }
1428        EntryType::Gitlink => decode_gitlink(name, payload),
1429        EntryType::Spoollink => decode_spoollink(name, payload),
1430    }
1431}
1432
1433fn take_hash(payload: &[u8]) -> Result<ContentHash, TreeStreamError> {
1434    let bytes: [u8; 32] = payload
1435        .try_into()
1436        .map_err(|_| TreeStreamError::Malformed("malformed tree entry object id".into()))?;
1437    Ok(ContentHash::from_bytes(bytes))
1438}
1439
1440fn decode_gitlink(name: String, payload: &[u8]) -> Result<TreeEntry, TreeStreamError> {
1441    if payload.is_empty() {
1442        return Err(TreeStreamError::Malformed(
1443            "malformed tree entry object id".into(),
1444        ));
1445    }
1446    let format = git_format_from_tag(payload[0])?;
1447    let oid = &payload[1..];
1448    let expected = match format {
1449        GitObjectFormat::Sha1 => 20,
1450        GitObjectFormat::Sha256 => 32,
1451    };
1452    if oid.len() != expected {
1453        return Err(TreeStreamError::Malformed(
1454            "malformed tree entry object id".into(),
1455        ));
1456    }
1457    let target = GitObjectId::from_raw(format, oid)
1458        .map_err(|err| TreeError::InvalidStructure(format!("invalid gitlink target: {err}")))?;
1459    TreeEntry::gitlink(name, target).map_err(TreeStreamError::from)
1460}
1461
1462fn decode_spoollink(name: String, payload: &[u8]) -> Result<TreeEntry, TreeStreamError> {
1463    if payload.len() < 2 {
1464        return Err(TreeStreamError::TruncatedFrame { offset: 0 });
1465    }
1466    let spool_len = u16::from_le_bytes([payload[0], payload[1]]) as usize;
1467    let spool_end = 2 + spool_len;
1468    let state_end = spool_end + 32;
1469    if payload.len() != state_end {
1470        return Err(TreeStreamError::Malformed(
1471            "malformed tree entry object id".into(),
1472        ));
1473    }
1474    let spool = std::str::from_utf8(&payload[2..spool_end])
1475        .map_err(|_| TreeStreamError::Malformed("spool id is not UTF-8".into()))?;
1476    let spool_id = SpoolId::parse(spool)
1477        .map_err(|err| TreeStreamError::Malformed(format!("invalid spool id: {err}")))?;
1478    let state =
1479        StateId::from_bytes(payload[spool_end..state_end].try_into().map_err(|_| {
1480            TreeStreamError::Malformed("spoollink state id is not 32 bytes".into())
1481        })?);
1482    TreeEntry::spoollink(name, spool_id, state).map_err(TreeStreamError::from)
1483}
1484
1485#[cfg(all(test, feature = "zstd"))]
1486mod block_tests {
1487    use super::*;
1488
1489    fn fixture(entries: usize) -> Tree {
1490        Tree::from_entries(
1491            (0..entries)
1492                .map(|index| {
1493                    TreeEntry::file(
1494                        format!("module_{index:04}.rs"),
1495                        ContentHash::compute(format!("blob-{index}").as_bytes()),
1496                        false,
1497                    )
1498                    .expect("fixture entry")
1499                })
1500                .collect(),
1501        )
1502    }
1503
1504    fn maximum_size_block_fixture() -> Tree {
1505        let name_prefix = "n".repeat(u16::MAX as usize - 5);
1506        let spool_id = SpoolId::parse(format!("s/{}", "s".repeat(u16::MAX as usize - 2)))
1507            .expect("maximum-size spool id");
1508        assert_eq!(spool_id.as_str().len(), u16::MAX as usize);
1509        Tree::from_entries(
1510            (0..TREE_BLOCK_ENTRIES)
1511                .map(|index| {
1512                    let name = format!("{name_prefix}{index:05}");
1513                    assert_eq!(name.len(), u16::MAX as usize);
1514                    TreeEntry::spoollink(name, spool_id.clone(), StateId::from_bytes([7; 32]))
1515                        .expect("maximum-size fixture entry")
1516                })
1517                .collect(),
1518        )
1519    }
1520
1521    #[test]
1522    fn blocked_encoder_falls_back_for_the_complete_object() {
1523        let tree = fixture(1);
1524        let encoded = tree.encode_canonical_blocked(3, 0).expect("encode");
1525        assert_eq!(encoded[4], TREE_ENCODING_VERSION);
1526    }
1527
1528    #[test]
1529    fn final_single_entry_block_is_stored_raw() {
1530        let tree = fixture(TREE_BLOCK_ENTRIES + 1);
1531        let encoded = tree.encode_canonical_blocked(3, 0).expect("encode");
1532        assert_eq!(encoded[4], TREE_BLOCK_ENCODING_VERSION);
1533        let header = decode_header(&encoded).expect("header");
1534        let preamble = &encoded[TREE_HEADER_LEN..TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN];
1535        let block_header =
1536            decode_block_header(&header, preamble, encoded.len() as u64).expect("block header");
1537        let second_index = TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN + TREE_BLOCK_INDEX_LEN;
1538        let index = decode_block_index(
1539            &encoded[second_index..second_index + TREE_BLOCK_INDEX_LEN],
1540            1,
1541            &block_header,
1542            encoded.len() as u64,
1543        )
1544        .expect("second block");
1545        assert_eq!(index.stored_len, index.raw_len);
1546    }
1547
1548    #[test]
1549    fn trailing_bytes_in_a_raw_block_are_rejected() {
1550        let tree = fixture(TREE_BLOCK_ENTRIES + 1);
1551        let mut encoded = tree.encode_canonical_blocked(3, 0).expect("encode");
1552        let second_index = TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN + TREE_BLOCK_INDEX_LEN;
1553        let stored_len_offset = second_index + 16;
1554        let stored_len = u32::from_le_bytes(
1555            encoded[stored_len_offset..stored_len_offset + 4]
1556                .try_into()
1557                .expect("stored length"),
1558        );
1559        encoded[stored_len_offset..stored_len_offset + 4]
1560            .copy_from_slice(&(stored_len + 1).to_le_bytes());
1561        let payload_len =
1562            u64::from_le_bytes(encoded[45..53].try_into().expect("stored payload length"));
1563        encoded[45..53].copy_from_slice(&(payload_len + 1).to_le_bytes());
1564        encoded.push(0);
1565
1566        assert!(Tree::decode_canonical(&encoded).is_err());
1567    }
1568
1569    #[test]
1570    fn block_raw_length_above_encoder_ceiling_is_rejected_in_the_index() {
1571        let tree = fixture(TREE_BLOCK_ENTRIES);
1572        let encoded = tree.encode_canonical_blocked(3, 0).expect("encode");
1573        let header = decode_header(&encoded).expect("header");
1574        let preamble = &encoded[TREE_HEADER_LEN..TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN];
1575        let block_header =
1576            decode_block_header(&header, preamble, encoded.len() as u64).expect("block header");
1577        let index_offset = TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN;
1578        let mut index: [u8; TREE_BLOCK_INDEX_LEN] = encoded
1579            [index_offset..index_offset + TREE_BLOCK_INDEX_LEN]
1580            .try_into()
1581            .expect("index");
1582        index[20..24].copy_from_slice(&u32::MAX.to_le_bytes());
1583
1584        let error = decode_block_index(&index, 0, &block_header, encoded.len() as u64)
1585            .expect_err("attacker-controlled raw length must fail at the index boundary");
1586        assert!(
1587            matches!(error, TreeStreamError::Malformed(ref message) if message.contains("raw length") && message.contains("exceeds maximum")),
1588            "unexpected error: {error}"
1589        );
1590    }
1591
1592    #[test]
1593    fn block_entry_count_above_encoder_limit_is_rejected() {
1594        let tree = fixture(TREE_BLOCK_ENTRIES);
1595        let mut encoded = tree.encode_canonical_blocked(3, 0).expect("encode");
1596        let block_entries_offset = TREE_HEADER_LEN + 2;
1597        encoded[block_entries_offset..block_entries_offset + 2]
1598            .copy_from_slice(&u16::MAX.to_le_bytes());
1599
1600        let error = Tree::decode_canonical(&encoded)
1601            .expect_err("block entry count above the encoder limit must fail");
1602        assert!(
1603            matches!(error, TreeStreamError::Malformed(ref message) if message.contains("entry count") && message.contains("exceeds maximum")),
1604            "unexpected error: {error}"
1605        );
1606    }
1607
1608    #[test]
1609    fn short_final_block_uses_its_actual_entry_count_for_the_ceiling() {
1610        let tree = fixture(TREE_BLOCK_ENTRIES + 1);
1611        let encoded = tree.encode_canonical_blocked(3, 0).expect("encode");
1612        let header = decode_header(&encoded).expect("header");
1613        let preamble = &encoded[TREE_HEADER_LEN..TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN];
1614        let block_header =
1615            decode_block_header(&header, preamble, encoded.len() as u64).expect("block header");
1616        let index_offset = TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN + TREE_BLOCK_INDEX_LEN;
1617        let mut index: [u8; TREE_BLOCK_INDEX_LEN] = encoded
1618            [index_offset..index_offset + TREE_BLOCK_INDEX_LEN]
1619            .try_into()
1620            .expect("final index");
1621        let one_entry_max_plus_one =
1622            u32::try_from(TREE_BLOCK_MAX_ENTRY_FRAME_LEN + 1).expect("one over final block");
1623        index[20..24].copy_from_slice(&one_entry_max_plus_one.to_le_bytes());
1624
1625        let error = decode_block_index(&index, 1, &block_header, encoded.len() as u64)
1626            .expect_err("short final block must use its actual structural ceiling");
1627        let expected_max = format!("maximum {TREE_BLOCK_MAX_ENTRY_FRAME_LEN}");
1628        assert!(
1629            matches!(error, TreeStreamError::Malformed(ref message) if message.contains("raw length") && message.contains(&expected_max)),
1630            "unexpected error: {error}"
1631        );
1632    }
1633
1634    #[test]
1635    fn block_raw_length_above_zstd_expansion_bound_is_rejected() {
1636        let stored_offset = 128u64;
1637        let stored_len = 1u32;
1638        let raw_len = u32::try_from(TREE_BLOCK_MAX_EXPANSION_RATIO + 1).expect("raw length");
1639        let mut index = [0u8; TREE_BLOCK_INDEX_LEN];
1640        index[8..16].copy_from_slice(&stored_offset.to_le_bytes());
1641        index[16..20].copy_from_slice(&stored_len.to_le_bytes());
1642        index[20..24].copy_from_slice(&raw_len.to_le_bytes());
1643        let block_header = TreeBlockHeader {
1644            block_entries: TREE_BLOCK_ENTRIES,
1645            block_count: 1,
1646            entry_count: 1,
1647            raw_payload_len: raw_len as u64,
1648            index_end: stored_offset,
1649        };
1650
1651        let error = decode_block_index(&index, 0, &block_header, stored_offset + stored_len as u64)
1652            .expect_err("excessive zstd expansion must fail at the index boundary");
1653        assert!(
1654            matches!(error, TreeStreamError::Malformed(ref message) if message.contains("expansion limit")),
1655            "unexpected error: {error}"
1656        );
1657    }
1658
1659    #[test]
1660    fn payload_and_zstd_helpers_recheck_the_structural_ceiling() {
1661        let over_limit = TREE_BLOCK_MAX_RAW_LEN + 1;
1662        let payload_error = decode_block_payload(&[0], over_limit)
1663            .expect_err("payload helper must reject an unchecked raw length");
1664        assert!(
1665            matches!(payload_error, TreeStreamError::Malformed(ref message) if message.contains("raw length")),
1666            "unexpected error: {payload_error}"
1667        );
1668        let zstd_error = decompress_block_tail(&[], over_limit)
1669            .expect_err("zstd helper must cap its output independently");
1670        assert!(
1671            matches!(zstd_error, TreeStreamError::Malformed(ref message) if message.contains("tail length")),
1672            "unexpected error: {zstd_error}"
1673        );
1674    }
1675
1676    #[test]
1677    fn maximum_size_encoder_block_round_trips_and_one_over_is_rejected() {
1678        let tree = maximum_size_block_fixture();
1679        let raw = tree.encode_canonical().expect("encode raw");
1680        assert_eq!(raw.len() - TREE_HEADER_LEN, TREE_BLOCK_MAX_RAW_LEN);
1681
1682        let blocked = tree.encode_canonical_blocked(3, 0).expect("encode blocked");
1683        assert_eq!(blocked[4], TREE_BLOCK_ENCODING_VERSION);
1684        let header = decode_header(&blocked).expect("header");
1685        let preamble = &blocked[TREE_HEADER_LEN..TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN];
1686        let block_header =
1687            decode_block_header(&header, preamble, blocked.len() as u64).expect("block header");
1688        let index_offset = TREE_HEADER_LEN + TREE_BLOCK_PREAMBLE_LEN;
1689        let index = decode_block_index(
1690            &blocked[index_offset..index_offset + TREE_BLOCK_INDEX_LEN],
1691            0,
1692            &block_header,
1693            blocked.len() as u64,
1694        )
1695        .expect("maximum-size block index");
1696        assert_eq!(index.raw_len, TREE_BLOCK_MAX_RAW_LEN);
1697
1698        let decoded = Tree::decode_canonical(&blocked).expect("decode maximum-size block");
1699        assert_eq!(decoded, tree);
1700        assert_eq!(decoded.encode_canonical().expect("re-encode decoded"), raw);
1701        assert_eq!(
1702            decoded
1703                .encode_canonical_blocked(3, 0)
1704                .expect("re-encode blocked"),
1705            blocked
1706        );
1707
1708        let mut over_limit = blocked;
1709        let raw_len_offset = index_offset + 20;
1710        let one_over = u32::try_from(TREE_BLOCK_MAX_RAW_LEN + 1).expect("one over");
1711        over_limit[raw_len_offset..raw_len_offset + 4].copy_from_slice(&one_over.to_le_bytes());
1712        let error = Tree::decode_canonical(&over_limit)
1713            .expect_err("one byte above the block ceiling must fail");
1714        assert!(
1715            matches!(error, TreeStreamError::Malformed(ref message) if message.contains("raw length")),
1716            "unexpected error: {error}"
1717        );
1718    }
1719}