1use 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
11const TREE_FORMAT_VERSION: u8 = 3;
15const TREE_FORMAT_VERSION_V4: u8 = 4;
19const TREE_V4_LEAF_PREFIX: &str = "tree-v4-leaf";
22const TREE_V4_NODE_PREFIX: &str = "tree-v4-node";
24const 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;
32const ENTRY_KIND_SPOOLLINK: u8 = 4;
36const GIT_OBJECT_FORMAT_SHA1: u8 = 1;
37const GIT_OBJECT_FORMAT_SHA256: u8 = 2;
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum TreeScheme {
46 V3Flat,
48 V4Salted,
51}
52
53#[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#[repr(u8)]
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
76pub enum FileMode {
77 Normal,
78 Executable,
79 Symlink,
80 Gitlink,
81 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 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#[repr(u8)]
128#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
129pub enum EntryType {
130 Blob,
131 Tree,
132 Symlink,
133 Gitlink,
134 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#[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 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 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 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
281pub 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 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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
526pub struct Tree {
527 entries: Arc<Vec<TreeEntry>>,
531 scheme: TreeScheme,
533 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 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 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 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 pub fn scheme(&self) -> TreeScheme {
620 self.scheme
621 }
622
623 pub fn salts(&self) -> &[[u8; 32]] {
625 &self.salts
626 }
627
628 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 self.insert_salted(entry, rand::random());
696 }
697 }
698 }
699
700 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 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 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 fn v4_leaf_preimage(entry: &TreeEntry, salt: &[u8; 32]) -> Vec<u8> {
767 let name = entry.name.as_bytes();
768 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 buf.extend_from_slice(&(name.len() as u16).to_le_bytes());
777 buf.extend_from_slice(name);
778 buf
779 }
780
781 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 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 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
830fn merkle_root_from_leaves(leaves: &mut [ContentHash]) -> ContentHash {
842 leaves.sort_unstable();
843 merkle_tree_hash(leaves)
844}
845
846fn merkle_tree_hash(leaves: &[ContentHash]) -> ContentHash {
848 match leaves.len() {
849 0 => ContentHash::compute_typed(TREE_EMPTY_PREFIX, b""),
851 1 => leaves[0],
852 n => {
853 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#[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 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#[derive(Clone, Debug, PartialEq, Eq)]
899pub struct PartialTree {
900 declared_root: ContentHash,
901 leaves: Vec<PartialTreeLeaf>,
902}
903
904impl PartialTree {
905 pub fn new(declared_root: ContentHash, leaves: Vec<PartialTreeLeaf>) -> Self {
909 Self {
910 declared_root,
911 leaves,
912 }
913 }
914
915 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 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 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 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 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 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#[derive(Serialize, Deserialize)]
1042struct EncodedTreeV2 {
1043 version: u8,
1044 entries: Vec<EncodedTreeEntryV2>,
1045 #[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 #[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 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 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 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 assert_eq!(decoded.hash(), tree.hash());
1361 }
1362
1363 #[test]
1364 fn file_mode_spoollink_has_no_git_mode() {
1365 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}