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