Skip to main content

heddle_object_model/object/
tree.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Tree types: entries, structure, and supporting enums.
3
4use std::{fmt, path::Path, sync::Arc};
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7use sley_core::{ObjectFormat as GitObjectFormat, ObjectId as GitObjectId};
8
9use super::{
10    ContentHash, SpoolId, StateId,
11    tree_git_layout::{RawGitMode, git_canonical_order},
12};
13
14/// Durable msgpack encoding version for the flat V3 tree body. This is the
15/// serde-representation version, NOT the hash-scheme selector: the scheme is
16/// carried separately by [`TreeScheme`] / the body magic. Leave this at 3.
17const TREE_FORMAT_VERSION: u8 = 3;
18/// Durable msgpack encoding version for a salted V4 tree body. A `version == 4`
19/// msgpack body carries a parallel per-entry `salts` column and decodes to
20/// [`TreeScheme::V4Salted`].
21const TREE_FORMAT_VERSION_V4: u8 = 4;
22/// Domain prefix for a V4 per-entry leaf commitment (routed through
23/// [`ContentHash::typed_hasher`]).
24const TREE_V4_LEAF_PREFIX: &str = "tree-v4-leaf";
25/// Domain prefix for a V4 interior Merkle node.
26const TREE_V4_NODE_PREFIX: &str = "tree-v4-node";
27/// The v3 empty-tree domain prefix. The V4 empty root is defined to equal the
28/// V3 empty-tree hash (`ContentHash::compute_typed("tree", b"")`) so the
29/// import/nothing-adopted anchor sentinels do not diverge (MF-5).
30const TREE_EMPTY_PREFIX: &str = "tree";
31const ENTRY_KIND_BLOB: u8 = 0;
32const ENTRY_KIND_TREE: u8 = 1;
33const ENTRY_KIND_SYMLINK: u8 = 2;
34const ENTRY_KIND_GITLINK: u8 = 3;
35/// Native child-spool edge: the entry's payload is a spool-id + anchored
36/// state-id, not a git commit OID. This link is
37/// deliberately NOT a git submodule — see [`FileMode::Spoollink`].
38const ENTRY_KIND_SPOOLLINK: u8 = 4;
39const GIT_OBJECT_FORMAT_SHA1: u8 = 1;
40const GIT_OBJECT_FORMAT_SHA256: u8 = 2;
41
42// ── TreeScheme ──────────────────────────────────────────────────────
43
44/// How a [`Tree`]'s content id is computed. The scheme is part of the
45/// in-memory value, so `Tree::hash()` is a pure function of `(scheme, salts,
46/// entries)` and the value determines the id at every call site (MF-4).
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum TreeScheme {
49    /// Flat BLAKE3 over the concatenated entry preimages (the historical hash).
50    V3Flat,
51    /// Salted binary Merkle tree over per-entry leaf commitments, redactable at
52    /// entry granularity. Carries a parallel 32-byte salt per entry.
53    V4Salted,
54}
55
56// ── TreeError ───────────────────────────────────────────────────────
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum TreeError {
60    InvalidName(String),
61    InvalidStructure(String),
62}
63
64impl std::error::Error for TreeError {}
65
66impl fmt::Display for TreeError {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            TreeError::InvalidName(msg) => write!(f, "invalid tree entry name: {}", msg),
70            TreeError::InvalidStructure(msg) => write!(f, "invalid tree structure: {}", msg),
71        }
72    }
73}
74
75// ── FileMode ────────────────────────────────────────────────────────
76
77#[repr(u8)]
78#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
79pub enum FileMode {
80    Normal,
81    Executable,
82    Symlink,
83    Gitlink,
84    /// Native child-spool edge. This is NOT a git file mode: a spoollink
85    /// points at a spool-id + state-id, not a git object, so it has no valid
86    /// git submodule (`160000`) representation and [`Self::to_unix_mode`]
87    /// returns `0`. Git-boundary code MUST handle it explicitly rather than
88    /// emit a bogus mode.
89    Spoollink,
90}
91
92impl FileMode {
93    pub fn to_byte(&self) -> u8 {
94        match self {
95            FileMode::Normal => 0,
96            FileMode::Executable => 1,
97            FileMode::Symlink => 2,
98            FileMode::Gitlink => 3,
99            FileMode::Spoollink => 4,
100        }
101    }
102
103    pub fn from_byte(b: u8) -> Option<Self> {
104        match b {
105            0 => Some(FileMode::Normal),
106            1 => Some(FileMode::Executable),
107            2 => Some(FileMode::Symlink),
108            3 => Some(FileMode::Gitlink),
109            4 => Some(FileMode::Spoollink),
110            _ => None,
111        }
112    }
113
114    /// The git tree/index mode for this entry. A spoollink has no git mode
115    /// (it is not a git object) and returns `0` — callers on a git boundary
116    /// must skip spoollinks rather than treat this as a real mode.
117    pub fn to_unix_mode(&self) -> u32 {
118        match self {
119            FileMode::Normal => 0o100644,
120            FileMode::Executable => 0o100755,
121            FileMode::Symlink => 0o120000,
122            FileMode::Gitlink => 0o160000,
123            FileMode::Spoollink => 0,
124        }
125    }
126}
127
128// ── EntryType ───────────────────────────────────────────────────────
129
130#[repr(u8)]
131#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
132pub enum EntryType {
133    Blob,
134    Tree,
135    Symlink,
136    Gitlink,
137    /// Native child-spool edge (see [`TreeEntryTarget::Spoollink`]).
138    Spoollink,
139}
140
141impl EntryType {
142    pub fn to_byte(&self) -> u8 {
143        match self {
144            EntryType::Blob => 0,
145            EntryType::Tree => 1,
146            EntryType::Symlink => 2,
147            EntryType::Gitlink => 3,
148            EntryType::Spoollink => 4,
149        }
150    }
151
152    pub fn from_byte(b: u8) -> Option<Self> {
153        match b {
154            0 => Some(EntryType::Blob),
155            1 => Some(EntryType::Tree),
156            2 => Some(EntryType::Symlink),
157            3 => Some(EntryType::Gitlink),
158            4 => Some(EntryType::Spoollink),
159            _ => None,
160        }
161    }
162}
163
164// ── TreeEntryTarget ────────────────────────────────────────────────
165
166#[derive(Clone, Debug, PartialEq, Eq)]
167pub enum TreeEntryTarget {
168    Blob {
169        hash: ContentHash,
170        executable: bool,
171    },
172    Tree {
173        hash: ContentHash,
174    },
175    Symlink {
176        hash: ContentHash,
177    },
178    Gitlink {
179        target: GitObjectId,
180    },
181    /// Native pointer to a child spool: a spool-id plus an anchored state-id.
182    /// Unlike [`Self::Gitlink`], this is NOT a git object OID and cannot
183    /// round-trip to a git submodule; git-boundary code must handle it
184    /// explicitly (skip on export). The Spool children facet consumes this in
185    /// a later phase.
186    Spoollink {
187        spool_id: SpoolId,
188        state_id: StateId,
189    },
190}
191
192impl TreeEntryTarget {
193    pub fn entry_type(&self) -> EntryType {
194        match self {
195            TreeEntryTarget::Blob { .. } => EntryType::Blob,
196            TreeEntryTarget::Tree { .. } => EntryType::Tree,
197            TreeEntryTarget::Symlink { .. } => EntryType::Symlink,
198            TreeEntryTarget::Gitlink { .. } => EntryType::Gitlink,
199            TreeEntryTarget::Spoollink { .. } => EntryType::Spoollink,
200        }
201    }
202
203    pub fn mode(&self) -> FileMode {
204        match self {
205            TreeEntryTarget::Blob {
206                executable: true, ..
207            } => FileMode::Executable,
208            TreeEntryTarget::Blob { .. } => FileMode::Normal,
209            TreeEntryTarget::Tree { .. } => FileMode::Normal,
210            TreeEntryTarget::Symlink { .. } => FileMode::Symlink,
211            TreeEntryTarget::Gitlink { .. } => FileMode::Gitlink,
212            TreeEntryTarget::Spoollink { .. } => FileMode::Spoollink,
213        }
214    }
215
216    pub fn content_hash(&self) -> Option<ContentHash> {
217        match self {
218            TreeEntryTarget::Blob { hash, .. }
219            | TreeEntryTarget::Tree { hash }
220            | TreeEntryTarget::Symlink { hash } => Some(*hash),
221            TreeEntryTarget::Gitlink { .. } | TreeEntryTarget::Spoollink { .. } => None,
222        }
223    }
224
225    pub fn gitlink_target(&self) -> Option<GitObjectId> {
226        match self {
227            TreeEntryTarget::Gitlink { target } => Some(*target),
228            _ => None,
229        }
230    }
231
232    /// The child-spool pointer `(spool_id, state_id)` for a spoollink entry,
233    /// or `None` for any other kind.
234    pub fn spoollink_target(&self) -> Option<(&SpoolId, StateId)> {
235        match self {
236            TreeEntryTarget::Spoollink { spool_id, state_id } => Some((spool_id, *state_id)),
237            _ => None,
238        }
239    }
240
241    fn encoded_payload_len(&self) -> usize {
242        match self {
243            TreeEntryTarget::Blob { hash, .. }
244            | TreeEntryTarget::Tree { hash }
245            | TreeEntryTarget::Symlink { hash } => hash.as_bytes().len(),
246            TreeEntryTarget::Gitlink { target } => target.as_bytes().len(),
247            TreeEntryTarget::Spoollink { spool_id, state_id } => {
248                4 + spool_id.as_str().len() + state_id.as_bytes().len()
249            }
250        }
251    }
252
253    /// Emit the canonical `mode ‖ entry_type ‖ target_payload` byte sequence.
254    /// `layout_flags` are the Git-layout trailer flags of the owning entry
255    /// (zero for every entry without one, which keeps its bytes unchanged).
256    ///
257    /// This is the single source of truth for both the V3 flat hash
258    /// ([`TreeEntry::update_hasher`]) and the V4 leaf preimage
259    /// ([`Tree::v4_leaf_preimage`]), so the two encodings can never drift.
260    fn write_payload(&self, layout_flags: u8, mut emit: impl FnMut(&[u8])) {
261        emit(&[self.mode().to_byte() | layout_flags]);
262        emit(&[self.entry_type().to_byte()]);
263        match self {
264            TreeEntryTarget::Blob { hash, .. }
265            | TreeEntryTarget::Tree { hash }
266            | TreeEntryTarget::Symlink { hash } => emit(hash.as_bytes()),
267            TreeEntryTarget::Gitlink { target } => {
268                emit(&[git_format_to_tag(target.format())]);
269                emit(target.as_bytes());
270            }
271            TreeEntryTarget::Spoollink { spool_id, state_id } => {
272                emit(&(spool_id.as_str().len() as u32).to_le_bytes());
273                emit(spool_id.as_str().as_bytes());
274                emit(state_id.as_bytes());
275            }
276        };
277    }
278}
279
280// ── TreeEntry ───────────────────────────────────────────────────────
281
282pub fn validate_name(name: &str) -> Result<(), TreeError> {
283    if name.is_empty() {
284        return Err(TreeError::InvalidName("entry name cannot be empty".into()));
285    }
286    if name == "." || name == ".." {
287        return Err(TreeError::InvalidName(format!(
288            "'{}' is not a valid entry name",
289            name
290        )));
291    }
292    if name.contains('/') || name.contains('\\') {
293        return Err(TreeError::InvalidName(
294            "entry name cannot contain path separators".into(),
295        ));
296    }
297    if name.bytes().any(|b| b < 0x20 || b == 0x7f) {
298        return Err(TreeError::InvalidName(
299            "entry name contains control characters".into(),
300        ));
301    }
302    if name.len() > u16::MAX as usize {
303        return Err(TreeError::InvalidName(
304            "entry name exceeds the HTR4 u16 length bound".into(),
305        ));
306    }
307    Ok(())
308}
309
310#[derive(Clone, Debug, PartialEq, Eq)]
311pub struct TreeEntry {
312    name: String,
313    target: TreeEntryTarget,
314    // The source Git mode, recorded only when it differs from the mode Git
315    // writes for `target` (heddle#2018). Checkout, diff and merge ignore it
316    // (see `same_meaning`). Only V3 trees imported from Git carry it.
317    git_mode: Option<RawGitMode>,
318}
319
320impl TreeEntry {
321    pub(crate) fn validate(&self) -> Result<(), TreeError> {
322        validate_name(&self.name)?;
323        if let Some(mode) = self.git_mode {
324            self.check_raw_git_mode(mode)?;
325        }
326        Ok(())
327    }
328
329    fn new(name: String, target: TreeEntryTarget) -> Self {
330        Self {
331            name,
332            target,
333            git_mode: None,
334        }
335    }
336
337    /// Record the mode this entry had in its source Git tree. A mode Git would
338    /// write anyway is not recorded, so a canonical entry stays byte-identical
339    /// to one built without a source mode. Errors when `mode` does not read as
340    /// this entry's kind and executable bit.
341    pub fn with_raw_git_mode(self, mode: RawGitMode) -> Result<Self, TreeError> {
342        let canonical = RawGitMode::canonical(self.entry_type(), self.is_executable());
343        if canonical == Some(mode) {
344            return Ok(Self {
345                git_mode: None,
346                ..self
347            });
348        }
349        self.check_raw_git_mode(mode)?;
350        Ok(self.with_checked_raw_git_mode(mode))
351    }
352
353    pub(crate) fn with_checked_raw_git_mode(self, mode: RawGitMode) -> Self {
354        Self {
355            git_mode: Some(mode),
356            ..self
357        }
358    }
359
360    /// The recorded source Git mode, present only when it is not canonical.
361    pub fn raw_git_mode(&self) -> Option<RawGitMode> {
362        self.git_mode
363    }
364
365    /// Whether two entries mean the same thing: same name, kind, executable
366    /// bit and target. Ignores the recorded source Git mode, which only
367    /// affects export. Merge and other semantic comparisons use this; `==`
368    /// compares the encoded entry.
369    pub fn same_meaning(&self, other: &Self) -> bool {
370        self.name == other.name && self.target == other.target
371    }
372
373    fn drop_raw_git_mode(&mut self) {
374        self.git_mode = None;
375    }
376
377    /// The mode to write for this entry in a Git tree: the recorded source
378    /// mode, else the canonical one. `None` for a spoollink.
379    pub fn git_mode(&self) -> Option<RawGitMode> {
380        self.git_mode
381            .or_else(|| RawGitMode::canonical(self.entry_type(), self.is_executable()))
382    }
383
384    pub fn file(
385        name: impl Into<String>,
386        hash: ContentHash,
387        executable: bool,
388    ) -> Result<Self, TreeError> {
389        let name = name.into();
390        validate_name(&name)?;
391        Ok(Self::new(name, TreeEntryTarget::Blob { hash, executable }))
392    }
393
394    pub fn directory(name: impl Into<String>, hash: ContentHash) -> Result<Self, TreeError> {
395        let name = name.into();
396        validate_name(&name)?;
397        Ok(Self::new(name, TreeEntryTarget::Tree { hash }))
398    }
399
400    pub fn symlink(name: impl Into<String>, hash: ContentHash) -> Result<Self, TreeError> {
401        let name = name.into();
402        validate_name(&name)?;
403        Ok(Self::new(name, TreeEntryTarget::Symlink { hash }))
404    }
405
406    pub fn gitlink(name: impl Into<String>, target: GitObjectId) -> Result<Self, TreeError> {
407        let name = name.into();
408        validate_name(&name)?;
409        Ok(Self::new(name, TreeEntryTarget::Gitlink { target }))
410    }
411
412    /// Build a native child-spool edge: a pointer to `spool_id` anchored at
413    /// `state_id`. Not a git submodule (see [`TreeEntryTarget::Spoollink`]).
414    pub fn spoollink(
415        name: impl Into<String>,
416        spool_id: SpoolId,
417        state_id: StateId,
418    ) -> Result<Self, TreeError> {
419        let name = name.into();
420        validate_name(&name)?;
421        Ok(Self::new(
422            name,
423            TreeEntryTarget::Spoollink { spool_id, state_id },
424        ))
425    }
426
427    pub fn name(&self) -> &str {
428        &self.name
429    }
430
431    pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), TreeError> {
432        let name = name.into();
433        validate_name(&name)?;
434        self.name = name;
435        Ok(())
436    }
437
438    pub fn with_mode(&self, mode: FileMode) -> Result<Self, TreeError> {
439        match (&self.target, mode) {
440            (TreeEntryTarget::Blob { hash, .. }, FileMode::Normal | FileMode::Executable) => {
441                Self::file(self.name.clone(), *hash, mode == FileMode::Executable)
442            }
443            (TreeEntryTarget::Symlink { .. }, FileMode::Symlink)
444            | (TreeEntryTarget::Tree { .. }, _)
445            | (TreeEntryTarget::Gitlink { .. }, FileMode::Gitlink)
446            | (TreeEntryTarget::Spoollink { .. }, FileMode::Spoollink)
447                if mode == self.mode() =>
448            {
449                Ok(self.clone())
450            }
451            _ => Err(TreeError::InvalidStructure(format!(
452                "cannot apply mode {:?} to {:?} entry '{}'",
453                mode,
454                self.entry_type(),
455                self.name
456            ))),
457        }
458    }
459
460    pub fn target(&self) -> &TreeEntryTarget {
461        &self.target
462    }
463
464    pub fn entry_type(&self) -> EntryType {
465        self.target.entry_type()
466    }
467
468    pub fn mode(&self) -> FileMode {
469        self.target.mode()
470    }
471
472    pub fn content_hash(&self) -> Option<ContentHash> {
473        self.target.content_hash()
474    }
475
476    pub fn leaf_content_hash(&self) -> Option<ContentHash> {
477        match self.target {
478            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => Some(hash),
479            TreeEntryTarget::Tree { .. }
480            | TreeEntryTarget::Gitlink { .. }
481            | TreeEntryTarget::Spoollink { .. } => None,
482        }
483    }
484
485    pub fn require_content_hash(&self) -> ContentHash {
486        self.content_hash()
487            .expect("tree entry target does not carry a Heddle content hash")
488    }
489
490    pub fn blob_hash(&self) -> Option<ContentHash> {
491        match self.target {
492            TreeEntryTarget::Blob { hash, .. } => Some(hash),
493            _ => None,
494        }
495    }
496
497    pub fn tree_hash(&self) -> Option<ContentHash> {
498        match self.target {
499            TreeEntryTarget::Tree { hash } => Some(hash),
500            _ => None,
501        }
502    }
503
504    pub fn symlink_hash(&self) -> Option<ContentHash> {
505        match self.target {
506            TreeEntryTarget::Symlink { hash } => Some(hash),
507            _ => None,
508        }
509    }
510
511    pub fn gitlink_target(&self) -> Option<GitObjectId> {
512        self.target.gitlink_target()
513    }
514
515    /// The `(spool_id, state_id)` pointer for a spoollink entry, else `None`.
516    pub fn spoollink_target(&self) -> Option<(&SpoolId, StateId)> {
517        self.target.spoollink_target()
518    }
519
520    pub fn is_tree(&self) -> bool {
521        self.entry_type() == EntryType::Tree
522    }
523
524    pub fn is_blob(&self) -> bool {
525        self.entry_type() == EntryType::Blob
526    }
527
528    pub fn is_symlink(&self) -> bool {
529        self.entry_type() == EntryType::Symlink
530    }
531
532    pub fn is_gitlink(&self) -> bool {
533        self.entry_type() == EntryType::Gitlink
534    }
535
536    pub fn is_spoollink(&self) -> bool {
537        self.entry_type() == EntryType::Spoollink
538    }
539
540    pub fn is_executable(&self) -> bool {
541        self.mode() == FileMode::Executable
542    }
543
544    /// Length of this entry's hash preimage. `source_position` is the entry's
545    /// position in its tree's recorded Git source order, if any.
546    pub(crate) fn encoded_len(&self, source_position: Option<u32>) -> usize {
547        let flags = self.layout_flags(source_position);
548        1 + 1
549            + self.target.encoded_payload_len()
550            + self.name.len()
551            + 1
552            + super::tree_git_layout::layout_trailer_len(flags)
553    }
554
555    /// Owned name-plus-target bytes used by streaming page budgets.
556    pub fn decoded_size(&self) -> usize {
557        self.name.len() + self.target.encoded_payload_len()
558    }
559
560    /// Feed this entry's V3 preimage, `mode|flags ‖ type ‖ payload ‖ name ‖
561    /// NUL ‖ layout trailer`. Without a layout the flags are zero and the
562    /// trailer is empty, which is the historical preimage.
563    pub(crate) fn update_hasher(&self, hasher: &mut blake3::Hasher, source_position: Option<u32>) {
564        let flags = self.layout_flags(source_position);
565        self.target.write_payload(flags, |bytes| {
566            hasher.update(bytes);
567        });
568        hasher.update(self.name.as_bytes());
569        hasher.update(&[0]);
570        self.write_layout_trailer(source_position, |bytes| {
571            hasher.update(bytes);
572        });
573    }
574}
575
576// ── Tree ────────────────────────────────────────────────────────────
577
578/// A complete tree with its encoding scheme and per-entry salts kept together.
579/// Use [`Self::from_entries_salted_v4`] to supply explicit salts for a new tree;
580/// mutations through [`Self::insert`] maintain the selected scheme.
581///
582/// Explicit salt mutation is internal, so it cannot corrupt a flat tree:
583///
584/// ```compile_fail,E0624
585/// use heddle_object_model::object::{ContentHash, Tree, TreeEntry};
586/// let mut tree = Tree::new();
587/// if let Ok(entry) = TreeEntry::file("readme", ContentHash::compute(b"text"), false) {
588///     tree.insert_salted(entry, [7; 32]);
589/// }
590/// ```
591#[derive(Clone, Debug, PartialEq, Eq)]
592pub struct Tree {
593    // Trees are immutable on every read path and only change while a caller is
594    // constructing a replacement tree. Sharing the entry vector makes those
595    // read-path clones O(1); insert/remove detach with copy-on-write.
596    entries: Arc<Vec<TreeEntry>>,
597    // How this tree's id is computed. V3 trees carry `salts.is_empty()`.
598    scheme: TreeScheme,
599    // Per-entry 32-byte salts, parallel to `entries` (same index / name order).
600    // Non-empty iff `scheme == TreeScheme::V4Salted`, in which case
601    // `salts.len() == entries.len()` is a maintained invariant.
602    salts: Arc<Vec<[u8; 32]>>,
603    // Each entry's position in its source Git tree, parallel to `entries`.
604    // Empty unless the tree was imported from a Git tree whose entries were
605    // not in Git's canonical order (heddle#2018); then it is a permutation of
606    // `0..entries.len()` that differs from Git's order. V3 only. Any mutation
607    // drops it and every raw mode: an edited tree has no source to reproduce.
608    source_positions: Arc<Vec<u32>>,
609}
610
611impl Tree {
612    pub fn new() -> Self {
613        Self {
614            entries: Arc::new(Vec::new()),
615            scheme: TreeScheme::V3Flat,
616            salts: Arc::new(Vec::new()),
617            source_positions: Arc::new(Vec::new()),
618        }
619    }
620
621    /// Build a native tree. A native tree has Git's canonical layout, so any
622    /// recorded source Git mode on the entries is dropped, the way Git's index
623    /// normalises modes: only a tree imported from Git
624    /// ([`Self::from_git_entries`]) or reused verbatim by hash keeps a layout.
625    pub fn from_entries(mut entries: Vec<TreeEntry>) -> Self {
626        entries.iter_mut().for_each(TreeEntry::drop_raw_git_mode);
627        entries.sort_by(|a, b| a.name.cmp(&b.name));
628        Self {
629            entries: Arc::new(entries),
630            scheme: TreeScheme::V3Flat,
631            salts: Arc::new(Vec::new()),
632            source_positions: Arc::new(Vec::new()),
633        }
634    }
635
636    /// Build a tree from the entries of a Git tree, in the Git tree's own
637    /// order. The source order is recorded only when it is not Git's
638    /// canonical order. Entries carry their own raw modes
639    /// ([`TreeEntry::with_raw_git_mode`]).
640    ///
641    /// A Git tree with two entries of the same name is not representable and
642    /// is rejected, naming the entry.
643    pub fn from_git_entries(entries: Vec<TreeEntry>) -> Result<Self, TreeError> {
644        let canonical = entries
645            .windows(2)
646            .all(|pair| git_canonical_order(&pair[0], &pair[1]) == std::cmp::Ordering::Less);
647        let mut paired: Vec<(TreeEntry, u32)> = Vec::with_capacity(entries.len());
648        for (position, entry) in entries.into_iter().enumerate() {
649            let position = u32::try_from(position).map_err(|_| {
650                TreeError::InvalidStructure("git tree has more than u32::MAX entries".into())
651            })?;
652            paired.push((entry, position));
653        }
654        paired.sort_by(|a, b| a.0.name.cmp(&b.0.name));
655        if let Some(pair) = paired
656            .windows(2)
657            .find(|pair| pair[0].0.name == pair[1].0.name)
658        {
659            return Err(TreeError::InvalidStructure(format!(
660                "duplicate entry name '{}'",
661                pair[0].0.name
662            )));
663        }
664        let (entries, positions): (Vec<TreeEntry>, Vec<u32>) = paired.into_iter().unzip();
665        let tree = Self {
666            entries: Arc::new(entries),
667            scheme: TreeScheme::V3Flat,
668            salts: Arc::new(Vec::new()),
669            source_positions: Arc::new(if canonical { Vec::new() } else { positions }),
670        };
671        tree.validate()?;
672        Ok(tree)
673    }
674
675    /// Build a salted V4 tree from entries and their parallel salts.
676    ///
677    /// `salts[i]` is the salt for `entries[i]` (before sorting); the pair is
678    /// sorted together by entry name so the parallel-vector invariant holds.
679    /// The sticky-salt *inheritance* policy is a later capture-leg concern —
680    /// this constructor carries whatever salts it is given.
681    /// Like [`Self::from_entries`], a salted tree is native and drops any
682    /// recorded source Git mode; V4 trees never carry a Git layout.
683    pub fn from_entries_salted_v4(
684        mut entries: Vec<TreeEntry>,
685        salts: Vec<[u8; 32]>,
686    ) -> Result<Self, TreeError> {
687        entries.iter_mut().for_each(TreeEntry::drop_raw_git_mode);
688        if entries.len() != salts.len() {
689            return Err(TreeError::InvalidStructure(format!(
690                "v4 tree has {} entries but {} salts",
691                entries.len(),
692                salts.len()
693            )));
694        }
695        let mut paired: Vec<(TreeEntry, [u8; 32])> = entries.into_iter().zip(salts).collect();
696        paired.sort_by(|a, b| a.0.name.cmp(&b.0.name));
697        let (entries, salts): (Vec<TreeEntry>, Vec<[u8; 32]>) = paired.into_iter().unzip();
698        Self::try_from_decoded_entries_salted_v4(entries, salts)
699    }
700
701    /// Build a tree from entries that are already in canonical name order.
702    ///
703    /// Unlike [`Self::from_entries`], this does not sort. Decoders use it so
704    /// eager and streaming paths reject the same out-of-order or duplicate
705    /// encodings instead of silently canonicalizing them.
706    pub fn try_from_decoded_entries(entries: Vec<TreeEntry>) -> Result<Self, TreeError> {
707        Self::try_from_decoded_layout(entries, Vec::new())
708    }
709
710    /// [`Self::try_from_decoded_entries`] for a body that carries per-entry
711    /// source positions (empty when it carries none).
712    pub(crate) fn try_from_decoded_layout(
713        entries: Vec<TreeEntry>,
714        source_positions: Vec<u32>,
715    ) -> Result<Self, TreeError> {
716        let tree = Self {
717            entries: Arc::new(entries),
718            scheme: TreeScheme::V3Flat,
719            salts: Arc::new(Vec::new()),
720            source_positions: Arc::new(source_positions),
721        };
722        tree.validate()?;
723        Ok(tree)
724    }
725
726    /// Build a salted V4 tree from already-name-ordered entries and their
727    /// parallel salts. Decoders (HSR1, msgpack v4) use this: it does not sort,
728    /// so it rejects the same out-of-order/duplicate encodings V3 does.
729    pub fn try_from_decoded_entries_salted_v4(
730        entries: Vec<TreeEntry>,
731        salts: Vec<[u8; 32]>,
732    ) -> Result<Self, TreeError> {
733        if entries.len() != salts.len() {
734            return Err(TreeError::InvalidStructure(format!(
735                "v4 tree has {} entries but {} salts",
736                entries.len(),
737                salts.len()
738            )));
739        }
740        let tree = Self {
741            entries: Arc::new(entries),
742            scheme: TreeScheme::V4Salted,
743            salts: Arc::new(salts),
744            source_positions: Arc::new(Vec::new()),
745        };
746        tree.validate()?;
747        Ok(tree)
748    }
749
750    /// The hashing scheme this tree's id is computed under.
751    pub fn scheme(&self) -> TreeScheme {
752        self.scheme
753    }
754
755    /// The parallel per-entry salt vector (empty for V3 trees).
756    pub fn salts(&self) -> &[[u8; 32]] {
757        &self.salts
758    }
759
760    /// The salt for the entry at `index` (V4 only), or `None` for V3 / out of
761    /// range.
762    pub fn salt_at(&self, index: usize) -> Option<[u8; 32]> {
763        self.salts.get(index).copied()
764    }
765
766    /// The source Git position of each entry, parallel to [`Self::entries`].
767    /// Empty unless the source tree was not in Git's canonical order.
768    pub fn source_positions(&self) -> &[u32] {
769        &self.source_positions
770    }
771
772    /// The source position of the entry at `index`, if the tree records one.
773    pub fn source_position_at(&self, index: usize) -> Option<u32> {
774        self.source_positions.get(index).copied()
775    }
776
777    /// Whether this tree records any part of a non-canonical Git source
778    /// layout: a raw mode on an entry, or the source entry order.
779    pub fn has_git_layout(&self) -> bool {
780        !self.source_positions.is_empty()
781            || self.entries.iter().any(|entry| entry.git_mode.is_some())
782    }
783
784    /// Whether this tree can only be stored as a full, self-keyed canonical
785    /// body (HTR4 / HSR1). The lean, delta, and packed columnar forms carry
786    /// neither salts nor the Git layout.
787    pub fn requires_canonical_body(&self) -> bool {
788        self.scheme == TreeScheme::V4Salted || self.has_git_layout()
789    }
790
791    /// The entries in the order a Git tree lists them: the recorded source
792    /// order when there is one, else Git's canonical order.
793    pub fn git_ordered_entries(&self) -> Vec<&TreeEntry> {
794        let mut ordered: Vec<(usize, &TreeEntry)> = self.entries.iter().enumerate().collect();
795        if self.source_positions.is_empty() {
796            ordered.sort_by(|a, b| git_canonical_order(a.1, b.1));
797        } else {
798            ordered.sort_by_key(|(index, _)| self.source_positions.get(*index).copied());
799        }
800        ordered.into_iter().map(|(_, entry)| entry).collect()
801    }
802
803    pub fn validate(&self) -> Result<(), TreeError> {
804        match self.scheme {
805            TreeScheme::V3Flat => {
806                if !self.salts.is_empty() {
807                    return Err(TreeError::InvalidStructure(
808                        "v3 tree must not carry per-entry salts".into(),
809                    ));
810                }
811            }
812            TreeScheme::V4Salted => {
813                if self.salts.len() != self.entries.len() {
814                    return Err(TreeError::InvalidStructure(format!(
815                        "v4 tree has {} entries but {} salts",
816                        self.entries.len(),
817                        self.salts.len()
818                    )));
819                }
820                if self.entries.iter().any(|entry| entry.git_mode.is_some()) {
821                    return Err(TreeError::InvalidStructure(
822                        "v4 trees do not record a git source layout".into(),
823                    ));
824                }
825            }
826        }
827        let mut previous_name: Option<&str> = None;
828        for entry in self.entries.iter() {
829            entry.validate()?;
830            if let Some(previous) = previous_name
831                && previous >= entry.name.as_str()
832            {
833                return Err(TreeError::InvalidStructure(
834                    "entries must be strictly sorted by name".to_string(),
835                ));
836            }
837            previous_name = Some(&entry.name);
838        }
839        self.validate_source_positions()
840    }
841
842    /// Source positions are absent, or a permutation of the entries that
843    /// differs from Git's canonical order. Recording the canonical order would
844    /// give one Git tree two native ids.
845    fn validate_source_positions(&self) -> Result<(), TreeError> {
846        if self.source_positions.is_empty() {
847            return Ok(());
848        }
849        if self.scheme != TreeScheme::V3Flat {
850            return Err(TreeError::InvalidStructure(
851                "only v3 trees record a git source order".into(),
852            ));
853        }
854        if self.source_positions.len() != self.entries.len() {
855            return Err(TreeError::InvalidStructure(format!(
856                "tree has {} entries but {} source positions",
857                self.entries.len(),
858                self.source_positions.len()
859            )));
860        }
861        let mut seen = vec![false; self.entries.len()];
862        for position in self.source_positions.iter() {
863            let slot = usize::try_from(*position)
864                .ok()
865                .and_then(|index| seen.get_mut(index))
866                .ok_or_else(|| {
867                    TreeError::InvalidStructure(format!(
868                        "source position {position} is out of range"
869                    ))
870                })?;
871            if *slot {
872                return Err(TreeError::InvalidStructure(format!(
873                    "source position {position} is repeated"
874                )));
875            }
876            *slot = true;
877        }
878        let ordered = self.git_ordered_entries();
879        if ordered
880            .windows(2)
881            .all(|pair| git_canonical_order(pair[0], pair[1]) == std::cmp::Ordering::Less)
882        {
883            return Err(TreeError::InvalidStructure(
884                "recorded git source order is the canonical order".into(),
885            ));
886        }
887        Ok(())
888    }
889
890    /// An edited tree has no source tree to reproduce, so it takes Git's
891    /// canonical layout, as a native tree does.
892    fn drop_git_layout(&mut self) {
893        if !self.source_positions.is_empty() {
894            self.source_positions = Arc::new(Vec::new());
895        }
896        if self.entries.iter().any(|entry| entry.git_mode.is_some()) {
897            Arc::make_mut(&mut self.entries)
898                .iter_mut()
899                .for_each(TreeEntry::drop_raw_git_mode);
900        }
901    }
902
903    pub fn entries(&self) -> &[TreeEntry] {
904        &self.entries
905    }
906
907    pub fn get(&self, name: &str) -> Option<&TreeEntry> {
908        let index = self
909            .entries
910            .binary_search_by(|entry| entry.name.as_str().cmp(name))
911            .ok()?;
912        self.entries.get(index)
913    }
914
915    pub fn insert(&mut self, mut entry: TreeEntry) {
916        self.drop_git_layout();
917        entry.drop_raw_git_mode();
918        match self.scheme {
919            TreeScheme::V3Flat => {
920                let entries = Arc::make_mut(&mut self.entries);
921                entries.retain(|e| e.name != entry.name);
922                let pos = entries
923                    .iter()
924                    .position(|e| e.name > entry.name)
925                    .unwrap_or(entries.len());
926                entries.insert(pos, entry);
927            }
928            TreeScheme::V4Salted => {
929                // A fresh insert or a changed entry mints a fresh 256-bit salt.
930                // (Sticky-salt *inheritance* on unchanged entries is applied by
931                // the capture leg before it constructs the tree, not here.)
932                self.insert_salted(entry, rand::random());
933            }
934        }
935    }
936
937    /// V4 insert with an explicit salt, maintaining the parallel salt vector.
938    /// Replacing an existing entry of the same name drops its old salt.
939    fn insert_salted(&mut self, entry: TreeEntry, salt: [u8; 32]) {
940        debug_assert_eq!(self.scheme, TreeScheme::V4Salted);
941        let entries = Arc::make_mut(&mut self.entries);
942        let salts = Arc::make_mut(&mut self.salts);
943        if let Some(existing) = entries.iter().position(|e| e.name == entry.name) {
944            entries.remove(existing);
945            salts.remove(existing);
946        }
947        let pos = entries
948            .iter()
949            .position(|e| e.name > entry.name)
950            .unwrap_or(entries.len());
951        entries.insert(pos, entry);
952        salts.insert(pos, salt);
953    }
954
955    pub fn remove(&mut self, name: &str) -> Option<TreeEntry> {
956        let pos = self.entries.iter().position(|e| e.name == name)?;
957        self.drop_git_layout();
958        if matches!(self.scheme, TreeScheme::V4Salted) {
959            Arc::make_mut(&mut self.salts).remove(pos);
960        }
961        Some(Arc::make_mut(&mut self.entries).remove(pos))
962    }
963
964    pub fn is_empty(&self) -> bool {
965        self.entries.is_empty()
966    }
967
968    pub fn len(&self) -> usize {
969        self.entries.len()
970    }
971
972    pub fn hash(&self) -> ContentHash {
973        match self.scheme {
974            TreeScheme::V3Flat => self.flat_hash_v3(),
975            TreeScheme::V4Salted => self.merkle_root_v4(),
976        }
977    }
978
979    /// The historical flat hash: typed BLAKE3 over every entry preimage.
980    fn flat_hash_v3(&self) -> ContentHash {
981        let total_len: usize = self
982            .entries
983            .iter()
984            .enumerate()
985            .map(|(index, entry)| entry.encoded_len(self.source_position_at(index)))
986            .sum();
987        ContentHash::compute_typed_with_len(TREE_EMPTY_PREFIX, total_len as u64, |hasher| {
988            for (index, entry) in self.entries.iter().enumerate() {
989                entry.update_hasher(hasher, self.source_position_at(index));
990            }
991        })
992    }
993
994    /// The V4 salted per-entry leaf commitment for `entries[index]`.
995    ///
996    /// `leaf = typed_hasher("tree-v4-leaf", len)(salt ‖ mode ‖ entry_type ‖
997    /// target_payload ‖ name_len(u16 LE) ‖ name ‖ layout trailer)`, where the
998    /// `mode ‖ entry_type ‖ target_payload` bytes are exactly those
999    /// [`TreeEntryTarget::write_payload`] emits. V4 trees record no source
1000    /// order, so the trailer is only ever an entry's raw Git mode.
1001    ///
1002    /// Panics only via `debug_assert` if called on a V3 tree or out of range;
1003    /// production callers go through [`Self::merkle_root_v4`].
1004    fn v4_leaf_hash(entry: &TreeEntry, salt: &[u8; 32]) -> ContentHash {
1005        let preimage = Self::v4_leaf_preimage(entry, salt);
1006        ContentHash::compute_typed(TREE_V4_LEAF_PREFIX, &preimage)
1007    }
1008
1009    /// The exact byte preimage hashed by [`Self::v4_leaf_hash`].
1010    fn v4_leaf_preimage(entry: &TreeEntry, salt: &[u8; 32]) -> Vec<u8> {
1011        let name = entry.name.as_bytes();
1012        // salt(32) + mode(1) + type(1) + target_payload + name_len(2) + name
1013        let mut buf =
1014            Vec::with_capacity(32 + 2 + entry.target.encoded_payload_len() + 2 + name.len());
1015        buf.extend_from_slice(salt);
1016        entry
1017            .target
1018            .write_payload(entry.layout_flags(None), |bytes| {
1019                buf.extend_from_slice(bytes)
1020            });
1021        // Names are bounded to u16::MAX by `validate_name`.
1022        buf.extend_from_slice(&(name.len() as u16).to_le_bytes());
1023        buf.extend_from_slice(name);
1024        entry.write_layout_trailer(None, |bytes| buf.extend_from_slice(bytes));
1025        buf
1026    }
1027
1028    /// The salted per-entry leaf commitment for the entry at `index`, or `None`
1029    /// for a V3 tree / out-of-range index. This is the name-free handle a
1030    /// redacted serve projection is keyed by; capture-time entry-visibility
1031    /// authoring resolves a path to its enclosing tree + this leaf hash.
1032    pub fn v4_leaf_hash_at(&self, index: usize) -> Option<ContentHash> {
1033        if self.scheme != TreeScheme::V4Salted {
1034            return None;
1035        }
1036        let entry = self.entries.get(index)?;
1037        let salt = self.salts.get(index)?;
1038        Some(Self::v4_leaf_hash(entry, salt))
1039    }
1040
1041    /// The salted leaf commitment for the entry named `name`, or `None` if the
1042    /// name is absent or this is a V3 tree.
1043    pub fn v4_leaf_hash_for(&self, name: &str) -> Option<ContentHash> {
1044        let index = self
1045            .entries
1046            .binary_search_by(|entry| entry.name.as_str().cmp(name))
1047            .ok()?;
1048        self.v4_leaf_hash_at(index)
1049    }
1050
1051    /// The V4 Merkle root over the salted per-entry leaves, ordered by leaf
1052    /// hash (§2). The empty tree reproduces the V3 empty-tree id (MF-5).
1053    fn merkle_root_v4(&self) -> ContentHash {
1054        let mut leaves: Vec<ContentHash> = self
1055            .entries
1056            .iter()
1057            .zip(self.salts.iter())
1058            .map(|(entry, salt)| Self::v4_leaf_hash(entry, salt))
1059            .collect();
1060        merkle_root_from_leaves(&mut leaves)
1061    }
1062
1063    pub fn iter(&self) -> impl Iterator<Item = &TreeEntry> {
1064        self.entries.iter()
1065    }
1066
1067    pub fn get_path(&self, path: &Path) -> Option<&TreeEntry> {
1068        let name = path.file_name()?.to_str()?;
1069        if path.parent().is_none_or(|p| p.as_os_str().is_empty()) {
1070            self.get(name)
1071        } else {
1072            None
1073        }
1074    }
1075}
1076
1077// ── V4 Merkle root + PartialTree ────────────────────────────────────
1078
1079/// Compute the RFC 6962 Merkle Tree Hash over V4 leaf hashes.
1080///
1081/// Leaves are sorted ascending by their 32-byte leaf hash first (§2.2): every
1082/// party — a full holder recomputing leaves, or a redacted-tip holder handed
1083/// opaque leaf hashes — sorts the identical list, so [`Tree`] and
1084/// [`PartialTree`] reconstruct byte-identical roots. Ordering is by leaf hash
1085/// alone (no preimage tie-break): a 256-bit leaf collision is cryptographically
1086/// negligible, and hash-only ordering is what lets a redacted leaf (which
1087/// carries no preimage) participate in the same total order.
1088fn merkle_root_from_leaves(leaves: &mut [ContentHash]) -> ContentHash {
1089    leaves.sort_unstable();
1090    merkle_tree_hash(leaves)
1091}
1092
1093/// RFC 6962 Merkle Tree Hash over already-ordered leaves.
1094fn merkle_tree_hash(leaves: &[ContentHash]) -> ContentHash {
1095    match leaves.len() {
1096        // Empty parity (MF-5): the V4 empty root equals the V3 empty-tree id.
1097        0 => ContentHash::compute_typed(TREE_EMPTY_PREFIX, b""),
1098        1 => leaves[0],
1099        n => {
1100            // k = largest power of two strictly less than n (RFC 6962:
1101            // k < n <= 2k). `leading_zeros` is taken on `usize` (not a widened
1102            // u64) so the shift is arch-independent — a crypto path must not
1103            // depend on the pointer width (wasm32 has usize::BITS == 32).
1104            let k = 1usize << ((usize::BITS - 1) - (n - 1).leading_zeros());
1105            let left = merkle_tree_hash(&leaves[..k]);
1106            let right = merkle_tree_hash(&leaves[k..]);
1107            let mut hasher = ContentHash::typed_hasher(TREE_V4_NODE_PREFIX, 64);
1108            hasher.update(left.as_bytes());
1109            hasher.update(right.as_bytes());
1110            ContentHash::from_bytes(hasher.finalize().into())
1111        }
1112    }
1113}
1114
1115/// One leaf of a [`PartialTree`]: either fully visible (carrying its entry and
1116/// salt, so its leaf hash is recomputable) or redacted to an opaque 32-byte
1117/// leaf hash (salt, name, and target all withheld).
1118#[derive(Clone, Debug, PartialEq, Eq)]
1119pub enum PartialTreeLeaf {
1120    Visible { entry: TreeEntry, salt: [u8; 32] },
1121    Redacted { leaf_hash: ContentHash },
1122}
1123
1124impl PartialTreeLeaf {
1125    /// The leaf hash this leaf contributes to the Merkle root.
1126    pub fn leaf_hash(&self) -> ContentHash {
1127        match self {
1128            PartialTreeLeaf::Visible { entry, salt } => Tree::v4_leaf_hash(entry, salt),
1129            PartialTreeLeaf::Redacted { leaf_hash } => *leaf_hash,
1130        }
1131    }
1132
1133    pub fn is_redacted(&self) -> bool {
1134        matches!(self, PartialTreeLeaf::Redacted { .. })
1135    }
1136}
1137
1138/// A redaction projection of a V4 [`Tree`]: visible entries keep their
1139/// preimage + salt; redacted entries are reduced to their opaque 32-byte leaf
1140/// hash. A `PartialTree` reconstructs the SAME Merkle root as the full tree, so
1141/// a state that commits to the full tree still verifies against the projection.
1142///
1143/// This is deliberately NOT a `Tree` (a `Tree` requires a resolved name+target
1144/// for every entry); a viewer holding redacted leaves cannot author over them.
1145#[derive(Clone, Debug, PartialEq, Eq)]
1146pub struct PartialTree {
1147    declared_root: ContentHash,
1148    leaves: Vec<PartialTreeLeaf>,
1149}
1150
1151impl PartialTree {
1152    /// Assemble a partial tree from its declared root and leaves. Callers that
1153    /// need the root checked should use [`Self::verify`] or
1154    /// [`Self::from_leaves_verified`].
1155    pub fn new(declared_root: ContentHash, leaves: Vec<PartialTreeLeaf>) -> Self {
1156        Self {
1157            declared_root,
1158            leaves,
1159        }
1160    }
1161
1162    /// Assemble and verify that the leaves reconstruct `declared_root`.
1163    pub fn from_leaves_verified(
1164        declared_root: ContentHash,
1165        leaves: Vec<PartialTreeLeaf>,
1166    ) -> Result<Self, TreeError> {
1167        let partial = Self::new(declared_root, leaves);
1168        partial.verify()?;
1169        Ok(partial)
1170    }
1171
1172    /// Project a full V4 tree, redacting every entry whose leaf hash is in
1173    /// `redacted`. Entries not in `redacted` stay visible. Errors on a V3 tree
1174    /// (nothing to salt) or a broken salt invariant.
1175    pub fn project(
1176        tree: &Tree,
1177        redacted: &std::collections::HashSet<ContentHash>,
1178    ) -> Result<Self, TreeError> {
1179        if tree.scheme != TreeScheme::V4Salted {
1180            return Err(TreeError::InvalidStructure(
1181                "cannot project a redacted tree from a non-v4 tree".into(),
1182            ));
1183        }
1184        tree.validate()?;
1185        let declared_root = tree.hash();
1186        let leaves = tree
1187            .entries
1188            .iter()
1189            .zip(tree.salts.iter())
1190            .map(|(entry, salt)| {
1191                let leaf_hash = Tree::v4_leaf_hash(entry, salt);
1192                if redacted.contains(&leaf_hash) {
1193                    PartialTreeLeaf::Redacted { leaf_hash }
1194                } else {
1195                    PartialTreeLeaf::Visible {
1196                        entry: entry.clone(),
1197                        salt: *salt,
1198                    }
1199                }
1200            })
1201            .collect();
1202        Ok(Self {
1203            declared_root,
1204            leaves,
1205        })
1206    }
1207
1208    pub fn declared_root(&self) -> ContentHash {
1209        self.declared_root
1210    }
1211
1212    pub fn leaves(&self) -> &[PartialTreeLeaf] {
1213        &self.leaves
1214    }
1215
1216    pub fn redacted_count(&self) -> usize {
1217        self.leaves.iter().filter(|leaf| leaf.is_redacted()).count()
1218    }
1219
1220    pub fn has_redactions(&self) -> bool {
1221        self.leaves.iter().any(PartialTreeLeaf::is_redacted)
1222    }
1223
1224    /// Reconstruct the Merkle root from the (visible + redacted) leaves.
1225    pub fn reconstruct_root(&self) -> ContentHash {
1226        let mut leaves: Vec<ContentHash> =
1227            self.leaves.iter().map(PartialTreeLeaf::leaf_hash).collect();
1228        merkle_root_from_leaves(&mut leaves)
1229    }
1230
1231    /// Verify the reconstructed root equals the declared root.
1232    pub fn verify(&self) -> Result<(), TreeError> {
1233        let found = self.reconstruct_root();
1234        if found != self.declared_root {
1235            return Err(TreeError::InvalidStructure(format!(
1236                "partial tree reconstructs {found} but declares {}",
1237                self.declared_root
1238            )));
1239        }
1240        Ok(())
1241    }
1242
1243    /// Build a V4 [`Tree`] from ONLY the visible entries of this projection,
1244    /// dropping the withheld (redacted) leaves entirely. Unlike
1245    /// [`PartialTree::into_tree`], this never errors on redacted leaves — it
1246    /// omits them. The result's hash therefore does NOT equal the declared
1247    /// root (it has fewer entries); it is the "visible set" view for status
1248    /// comparison, where the withheld entries are unknown to this client by
1249    /// construction and so must not be reported as local deletions.
1250    pub fn visible_tree(&self) -> Result<Tree, TreeError> {
1251        let mut entries = Vec::new();
1252        let mut salts = Vec::new();
1253        for leaf in &self.leaves {
1254            if let PartialTreeLeaf::Visible { entry, salt } = leaf {
1255                entries.push(entry.clone());
1256                salts.push(*salt);
1257            }
1258        }
1259        Tree::from_entries_salted_v4(entries, salts)
1260    }
1261
1262    /// Losslessly convert a fully-visible partial tree back to a V4 [`Tree`].
1263    /// Errors if any leaf is redacted (the name/target are unknown) or the
1264    /// reconstructed root does not match the declared root.
1265    pub fn into_tree(self) -> Result<Tree, TreeError> {
1266        self.verify()?;
1267        let mut entries = Vec::with_capacity(self.leaves.len());
1268        let mut salts = Vec::with_capacity(self.leaves.len());
1269        for leaf in self.leaves {
1270            match leaf {
1271                PartialTreeLeaf::Visible { entry, salt } => {
1272                    entries.push(entry);
1273                    salts.push(salt);
1274                }
1275                PartialTreeLeaf::Redacted { .. } => {
1276                    return Err(TreeError::InvalidStructure(
1277                        "cannot materialize a redacted leaf into a full tree".into(),
1278                    ));
1279                }
1280            }
1281        }
1282        Tree::from_entries_salted_v4(entries, salts)
1283    }
1284}
1285
1286// ── Durable V2 tree encoding ───────────────────────────────────────
1287
1288// Both durable msgpack structs serialize by hand as maps (below), so a
1289// positional (`rmp_serde::to_vec`) encoder
1290// can never shift a later optional field into a skipped one's slot. Unknown
1291// fields are refused: a binary that does not understand a newer field (such
1292// as the Git layout) must not decode the tree without it.
1293#[derive(Deserialize)]
1294#[serde(deny_unknown_fields)]
1295struct EncodedTreeV2 {
1296    version: u8,
1297    entries: Vec<EncodedTreeEntryV2>,
1298    // Parallel per-entry salts for a V4 salted tree. `default` keeps V3 bodies
1299    // byte-identical (the field is omitted entirely for V3), so existing
1300    // on-disk caches (`worktree-current-tree.bin`, hot sidecars) are unchanged.
1301    #[serde(default)]
1302    salts: Option<Vec<[u8; 32]>>,
1303    // Parallel per-entry Git source positions (heddle#2018). Omitted unless the
1304    // tree records a non-canonical source order.
1305    #[serde(default)]
1306    source_positions: Option<Vec<u32>>,
1307}
1308
1309#[derive(Deserialize)]
1310#[serde(deny_unknown_fields)]
1311struct EncodedTreeEntryV2 {
1312    name: String,
1313    kind: u8,
1314    hash: Option<ContentHash>,
1315    executable: Option<bool>,
1316    git_format: Option<u8>,
1317    git_oid: Option<Vec<u8>>,
1318    // Child-spool pointer for SPOOLLINK entries. `default`
1319    // keeps the encoding backward-compatible: pre-SPOOLLINK payloads simply
1320    // omit these fields.
1321    #[serde(default)]
1322    spool_id: Option<SpoolId>,
1323    #[serde(default)]
1324    spool_state_id: Option<StateId>,
1325    // Source Git mode digits (heddle#2018), only when not canonical.
1326    #[serde(default)]
1327    git_mode: Option<String>,
1328}
1329
1330// The hand-written maps below match the derived named form byte for byte
1331// (pinned by the golden corpus): same keys, same order, absent optionals
1332// omitted.
1333impl Serialize for EncodedTreeV2 {
1334    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1335        use serde::ser::SerializeMap;
1336        let len =
1337            2 + usize::from(self.salts.is_some()) + usize::from(self.source_positions.is_some());
1338        let mut map = serializer.serialize_map(Some(len))?;
1339        map.serialize_entry("version", &self.version)?;
1340        map.serialize_entry("entries", &self.entries)?;
1341        if let Some(salts) = &self.salts {
1342            map.serialize_entry("salts", salts)?;
1343        }
1344        if let Some(positions) = &self.source_positions {
1345            map.serialize_entry("source_positions", positions)?;
1346        }
1347        map.end()
1348    }
1349}
1350
1351impl Serialize for EncodedTreeEntryV2 {
1352    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1353        use serde::ser::SerializeMap;
1354        let len = 6
1355            + usize::from(self.spool_id.is_some())
1356            + usize::from(self.spool_state_id.is_some())
1357            + usize::from(self.git_mode.is_some());
1358        let mut map = serializer.serialize_map(Some(len))?;
1359        map.serialize_entry("name", &self.name)?;
1360        map.serialize_entry("kind", &self.kind)?;
1361        map.serialize_entry("hash", &self.hash)?;
1362        map.serialize_entry("executable", &self.executable)?;
1363        map.serialize_entry("git_format", &self.git_format)?;
1364        map.serialize_entry("git_oid", &self.git_oid)?;
1365        if let Some(spool_id) = &self.spool_id {
1366            map.serialize_entry("spool_id", spool_id)?;
1367        }
1368        if let Some(state_id) = &self.spool_state_id {
1369            map.serialize_entry("spool_state_id", state_id)?;
1370        }
1371        if let Some(git_mode) = &self.git_mode {
1372            map.serialize_entry("git_mode", git_mode)?;
1373        }
1374        map.end()
1375    }
1376}
1377
1378impl Serialize for Tree {
1379    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1380    where
1381        S: Serializer,
1382    {
1383        EncodedTreeV2::from(self).serialize(serializer)
1384    }
1385}
1386
1387impl<'de> Deserialize<'de> for Tree {
1388    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1389    where
1390        D: Deserializer<'de>,
1391    {
1392        let encoded = EncodedTreeV2::deserialize(deserializer)?;
1393        Tree::try_from(encoded).map_err(de::Error::custom)
1394    }
1395}
1396
1397#[derive(Debug)]
1398pub enum TreeDecodeError {
1399    Decode(rmp_serde::decode::Error),
1400    Invalid(TreeError),
1401}
1402
1403impl From<rmp_serde::decode::Error> for TreeDecodeError {
1404    fn from(error: rmp_serde::decode::Error) -> Self {
1405        Self::Decode(error)
1406    }
1407}
1408
1409impl From<TreeError> for TreeDecodeError {
1410    fn from(error: TreeError) -> Self {
1411        Self::Invalid(error)
1412    }
1413}
1414
1415impl From<&Tree> for EncodedTreeV2 {
1416    fn from(tree: &Tree) -> Self {
1417        let (version, salts) = match tree.scheme {
1418            TreeScheme::V3Flat => (TREE_FORMAT_VERSION, None),
1419            TreeScheme::V4Salted => (TREE_FORMAT_VERSION_V4, Some(tree.salts.as_ref().clone())),
1420        };
1421        Self {
1422            version,
1423            entries: tree.entries.iter().map(EncodedTreeEntryV2::from).collect(),
1424            salts,
1425            source_positions: (!tree.source_positions.is_empty())
1426                .then(|| tree.source_positions.as_ref().clone()),
1427        }
1428    }
1429}
1430
1431impl From<&TreeEntry> for EncodedTreeEntryV2 {
1432    fn from(entry: &TreeEntry) -> Self {
1433        let name = entry.name.clone();
1434        let git_mode = entry.git_mode.map(|mode| {
1435            let mut digits = Vec::new();
1436            mode.write_digits(&mut digits);
1437            String::from_utf8_lossy(&digits).into_owned()
1438        });
1439        let empty = Self {
1440            name,
1441            kind: ENTRY_KIND_BLOB,
1442            hash: None,
1443            executable: None,
1444            git_format: None,
1445            git_oid: None,
1446            spool_id: None,
1447            spool_state_id: None,
1448            git_mode,
1449        };
1450        match entry.target() {
1451            TreeEntryTarget::Blob { hash, executable } => Self {
1452                kind: ENTRY_KIND_BLOB,
1453                hash: Some(*hash),
1454                executable: Some(*executable),
1455                ..empty
1456            },
1457            TreeEntryTarget::Tree { hash } => Self {
1458                kind: ENTRY_KIND_TREE,
1459                hash: Some(*hash),
1460                ..empty
1461            },
1462            TreeEntryTarget::Symlink { hash } => Self {
1463                kind: ENTRY_KIND_SYMLINK,
1464                hash: Some(*hash),
1465                ..empty
1466            },
1467            TreeEntryTarget::Gitlink { target } => Self {
1468                kind: ENTRY_KIND_GITLINK,
1469                git_format: Some(git_format_to_tag(target.format())),
1470                git_oid: Some(target.as_bytes().to_vec()),
1471                ..empty
1472            },
1473            TreeEntryTarget::Spoollink { spool_id, state_id } => Self {
1474                kind: ENTRY_KIND_SPOOLLINK,
1475                spool_id: Some(spool_id.clone()),
1476                spool_state_id: Some(*state_id),
1477                ..empty
1478            },
1479        }
1480    }
1481}
1482
1483impl TryFrom<EncodedTreeV2> for Tree {
1484    type Error = TreeError;
1485
1486    fn try_from(encoded: EncodedTreeV2) -> Result<Self, Self::Error> {
1487        let mut entries = Vec::with_capacity(encoded.entries.len());
1488        for entry in encoded.entries {
1489            entries.push(TreeEntry::try_from(entry)?);
1490        }
1491        let source_positions = encoded.source_positions.unwrap_or_default();
1492        match encoded.version {
1493            TREE_FORMAT_VERSION => {
1494                if encoded.salts.is_some_and(|salts| !salts.is_empty()) {
1495                    return Err(TreeError::InvalidStructure(
1496                        "v3 tree body must not carry salts".into(),
1497                    ));
1498                }
1499                Tree::try_from_decoded_layout(entries, source_positions)
1500            }
1501            TREE_FORMAT_VERSION_V4 => {
1502                if !source_positions.is_empty() {
1503                    return Err(TreeError::InvalidStructure(
1504                        "v4 tree body must not carry source positions".into(),
1505                    ));
1506                }
1507                let salts = encoded.salts.ok_or_else(|| {
1508                    TreeError::InvalidStructure("v4 tree body is missing its salts".into())
1509                })?;
1510                // `try_from_decoded_entries_salted_v4` re-checks len parity and
1511                // strict name ordering.
1512                Tree::try_from_decoded_entries_salted_v4(entries, salts)
1513            }
1514            other => Err(TreeError::InvalidStructure(format!(
1515                "unsupported tree format version {other}; this binary writes {TREE_FORMAT_VERSION} (v3) or {TREE_FORMAT_VERSION_V4} (v4)"
1516            ))),
1517        }
1518    }
1519}
1520
1521impl Tree {
1522    pub fn decode_current_msgpack(data: &[u8]) -> Result<Self, TreeDecodeError> {
1523        let encoded: EncodedTreeV2 = rmp_serde::from_slice(data)?;
1524        Ok(Tree::try_from(encoded)?)
1525    }
1526}
1527
1528impl TryFrom<EncodedTreeEntryV2> for TreeEntry {
1529    type Error = TreeError;
1530
1531    fn try_from(encoded: EncodedTreeEntryV2) -> Result<Self, Self::Error> {
1532        let git_mode = encoded
1533            .git_mode
1534            .as_deref()
1535            .map(|digits| RawGitMode::parse(digits.as_bytes()))
1536            .transpose()?;
1537        let entry = Self::try_from_encoded_target(encoded)?;
1538        match git_mode {
1539            Some(mode) => {
1540                entry.check_raw_git_mode(mode)?;
1541                Ok(entry.with_checked_raw_git_mode(mode))
1542            }
1543            None => Ok(entry),
1544        }
1545    }
1546}
1547
1548impl TreeEntry {
1549    fn try_from_encoded_target(encoded: EncodedTreeEntryV2) -> Result<Self, TreeError> {
1550        match encoded.kind {
1551            ENTRY_KIND_BLOB => TreeEntry::file(
1552                encoded.name,
1553                required_hash(encoded.hash, ENTRY_KIND_BLOB)?,
1554                encoded.executable.unwrap_or(false),
1555            ),
1556            ENTRY_KIND_TREE => {
1557                TreeEntry::directory(encoded.name, required_hash(encoded.hash, ENTRY_KIND_TREE)?)
1558            }
1559            ENTRY_KIND_SYMLINK => TreeEntry::symlink(
1560                encoded.name,
1561                required_hash(encoded.hash, ENTRY_KIND_SYMLINK)?,
1562            ),
1563            ENTRY_KIND_GITLINK => {
1564                let format = git_format_from_tag(required_git_format(
1565                    encoded.git_format,
1566                    ENTRY_KIND_GITLINK,
1567                )?)?;
1568                let oid = encoded.git_oid.ok_or_else(|| {
1569                    TreeError::InvalidStructure("gitlink entry is missing git_oid".into())
1570                })?;
1571                let target = GitObjectId::from_raw(format, &oid).map_err(|err| {
1572                    TreeError::InvalidStructure(format!("invalid gitlink target: {err}"))
1573                })?;
1574                TreeEntry::gitlink(encoded.name, target)
1575            }
1576            ENTRY_KIND_SPOOLLINK => {
1577                let spool_id = encoded.spool_id.ok_or_else(|| {
1578                    TreeError::InvalidStructure("spoollink entry is missing spool_id".into())
1579                })?;
1580                let state_id = encoded.spool_state_id.ok_or_else(|| {
1581                    TreeError::InvalidStructure("spoollink entry is missing spool_state_id".into())
1582                })?;
1583                TreeEntry::spoollink(encoded.name, spool_id, state_id)
1584            }
1585            other => Err(TreeError::InvalidStructure(format!(
1586                "unknown tree entry kind {other}"
1587            ))),
1588        }
1589    }
1590}
1591
1592fn required_hash(hash: Option<ContentHash>, kind: u8) -> Result<ContentHash, TreeError> {
1593    hash.ok_or_else(|| TreeError::InvalidStructure(format!("entry kind {kind} is missing hash")))
1594}
1595
1596fn required_git_format(format: Option<u8>, kind: u8) -> Result<u8, TreeError> {
1597    format.ok_or_else(|| {
1598        TreeError::InvalidStructure(format!("entry kind {kind} is missing git_format"))
1599    })
1600}
1601
1602pub(crate) fn git_format_to_tag(format: GitObjectFormat) -> u8 {
1603    match format {
1604        GitObjectFormat::Sha1 => GIT_OBJECT_FORMAT_SHA1,
1605        GitObjectFormat::Sha256 => GIT_OBJECT_FORMAT_SHA256,
1606    }
1607}
1608
1609pub(crate) fn git_format_from_tag(tag: u8) -> Result<GitObjectFormat, TreeError> {
1610    match tag {
1611        GIT_OBJECT_FORMAT_SHA1 => Ok(GitObjectFormat::Sha1),
1612        GIT_OBJECT_FORMAT_SHA256 => Ok(GitObjectFormat::Sha256),
1613        other => Err(TreeError::InvalidStructure(format!(
1614            "unknown git object format tag {other}"
1615        ))),
1616    }
1617}
1618
1619impl Default for Tree {
1620    fn default() -> Self {
1621        Self::new()
1622    }
1623}
1624
1625impl IntoIterator for Tree {
1626    type Item = TreeEntry;
1627    type IntoIter = std::vec::IntoIter<TreeEntry>;
1628
1629    fn into_iter(self) -> Self::IntoIter {
1630        Arc::try_unwrap(self.entries)
1631            .unwrap_or_else(|entries| (*entries).clone())
1632            .into_iter()
1633    }
1634}
1635
1636impl<'a> IntoIterator for &'a Tree {
1637    type Item = &'a TreeEntry;
1638    type IntoIter = std::slice::Iter<'a, TreeEntry>;
1639
1640    fn into_iter(self) -> Self::IntoIter {
1641        self.entries.iter()
1642    }
1643}
1644
1645#[cfg(test)]
1646mod spoollink_tests {
1647    use super::*;
1648
1649    #[test]
1650    fn spoollink_entry_shape() {
1651        let spool_id = SpoolId::parse("acme/child").unwrap();
1652        let state_id = StateId::from_bytes([9u8; 32]);
1653        let entry = TreeEntry::spoollink("child", spool_id.clone(), state_id).unwrap();
1654
1655        assert!(entry.is_spoollink());
1656        assert_eq!(entry.entry_type(), EntryType::Spoollink);
1657        assert_eq!(entry.mode(), FileMode::Spoollink);
1658        // Native edge carries no Heddle content hash and no git OID.
1659        assert_eq!(entry.content_hash(), None);
1660        assert_eq!(entry.leaf_content_hash(), None);
1661        assert_eq!(entry.gitlink_target(), None);
1662        assert_eq!(entry.spoollink_target(), Some((&spool_id, state_id)));
1663    }
1664
1665    #[test]
1666    fn spoollink_roundtrips_through_encoded_tree_v2() {
1667        let spool_id = SpoolId::parse("acme/child").unwrap();
1668        let state_id = StateId::from_bytes([2u8; 32]);
1669
1670        // Mix a spoollink alongside the existing kinds so the round-trip also
1671        // proves existing entries are undisturbed.
1672        let blob_hash = ContentHash::compute(b"hello");
1673        let tree = Tree::from_entries(vec![
1674            TreeEntry::file("a_blob", blob_hash, false).unwrap(),
1675            TreeEntry::spoollink("z_child", spool_id.clone(), state_id).unwrap(),
1676        ]);
1677
1678        let bytes = rmp_serde::to_vec(&tree).unwrap();
1679        let decoded = Tree::decode_current_msgpack(&bytes).unwrap();
1680
1681        assert_eq!(decoded, tree, "tree round-trip must be lossless");
1682
1683        let child = decoded
1684            .get("z_child")
1685            .expect("spoollink survives round-trip");
1686        assert_eq!(child.spoollink_target(), Some((&spool_id, state_id)));
1687        assert_eq!(child.entry_type(), EntryType::Spoollink);
1688
1689        // Hash is stable and distinct from a same-name gitlink/blob shape.
1690        assert_eq!(decoded.hash(), tree.hash());
1691    }
1692
1693    #[test]
1694    fn file_mode_spoollink_has_no_git_mode() {
1695        // The whole point of a dedicated kind: it must NOT masquerade as a
1696        // git submodule (160000) or any other real git mode.
1697        assert_eq!(FileMode::Spoollink.to_unix_mode(), 0);
1698        assert_ne!(FileMode::Spoollink.to_unix_mode(), 0o160000);
1699        assert_eq!(
1700            FileMode::from_byte(FileMode::Spoollink.to_byte()),
1701            Some(FileMode::Spoollink)
1702        );
1703        assert_eq!(
1704            EntryType::from_byte(EntryType::Spoollink.to_byte()),
1705            Some(EntryType::Spoollink)
1706        );
1707    }
1708}
1709
1710#[cfg(test)]
1711#[path = "tree_v4_tests.rs"]
1712mod tree_v4_tests;
1713
1714#[cfg(test)]
1715mod cow_tests {
1716    use super::*;
1717
1718    fn fixture() -> Tree {
1719        Tree::from_entries(vec![
1720            TreeEntry::file("a", ContentHash::compute(b"a"), false).unwrap(),
1721            TreeEntry::file("b", ContentHash::compute(b"b"), true).unwrap(),
1722        ])
1723    }
1724
1725    #[test]
1726    fn clone_shares_entries_until_mutated() {
1727        let original = fixture();
1728        let mut clone = original.clone();
1729        assert!(Arc::ptr_eq(&original.entries, &clone.entries));
1730
1731        clone.insert(TreeEntry::file("c", ContentHash::compute(b"c"), false).unwrap());
1732
1733        assert!(!Arc::ptr_eq(&original.entries, &clone.entries));
1734        assert!(original.get("c").is_none());
1735        assert!(clone.get("c").is_some());
1736    }
1737
1738    #[test]
1739    fn clone_mutation_preserves_original_hash_and_encoding() {
1740        let original = fixture();
1741        let original_hash = original.hash();
1742        let original_bytes = rmp_serde::to_vec_named(&original).unwrap();
1743        let mut clone = original.clone();
1744
1745        assert!(clone.remove("a").is_some());
1746
1747        assert_eq!(original.hash(), original_hash);
1748        assert_eq!(rmp_serde::to_vec_named(&original).unwrap(), original_bytes);
1749        assert_ne!(clone.hash(), original_hash);
1750    }
1751
1752    #[test]
1753    fn clone_and_mutate_roundtrips_through_durable_encoding() {
1754        let mut tree = fixture().clone();
1755        tree.insert(TreeEntry::directory("dir", ContentHash::compute(b"dir")).unwrap());
1756        let encoded = rmp_serde::to_vec_named(&tree).unwrap();
1757        let decoded: Tree = rmp_serde::from_slice(&encoded).unwrap();
1758
1759        assert_eq!(decoded, tree);
1760        assert_eq!(decoded.hash(), tree.hash());
1761    }
1762}
1763
1764#[cfg(test)]
1765#[path = "tree_golden_tests.rs"]
1766mod tree_golden_tests;