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