1use std::error::Error;
4use std::fmt;
5use std::str::FromStr;
6
7#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub struct VPath(String);
10
11impl VPath {
12 #[must_use]
14 pub fn root() -> Self {
15 Self(".".to_owned())
16 }
17
18 pub fn parse(input: &str) -> Result<Self, VPathError> {
29 if input.is_empty() {
30 return Err(VPathError::Empty);
31 }
32 if input.contains('\0') {
33 return Err(VPathError::NulByte);
34 }
35
36 let portable = input.replace('\\', "/");
37 if portable.starts_with('/') {
38 return Err(VPathError::Absolute);
39 }
40
41 let first = portable.split('/').next().unwrap_or_default();
42 if is_windows_prefix(first) {
43 return Err(VPathError::PlatformPrefix);
44 }
45
46 let mut components = Vec::new();
47 for component in portable.split('/') {
48 match component {
49 "" | "." => {}
50 ".." => {
51 if components.pop().is_none() {
52 return Err(VPathError::EscapesRoot);
53 }
54 }
55 value => components.push(value),
56 }
57 }
58
59 let normalized = if components.is_empty() {
60 ".".to_owned()
61 } else {
62 components.join("/")
63 };
64 Ok(Self(normalized))
65 }
66
67 #[must_use]
69 pub fn as_str(&self) -> &str {
70 &self.0
71 }
72
73 #[must_use]
75 pub fn is_root(&self) -> bool {
76 self.0 == "."
77 }
78
79 #[must_use]
81 pub fn parent(&self) -> Option<Self> {
82 if self.is_root() {
83 return None;
84 }
85 match self.0.rsplit_once('/') {
86 Some((parent, _)) => Some(Self(parent.to_owned())),
87 None => Some(Self(".".to_owned())),
88 }
89 }
90
91 #[must_use]
93 pub fn file_name(&self) -> Option<&str> {
94 (!self.is_root()).then(|| self.0.rsplit('/').next().unwrap_or(self.as_str()))
95 }
96
97 pub fn join(&self, child: &str) -> Result<Self, VPathError> {
104 let combined = if self.is_root() {
105 child.to_owned()
106 } else {
107 format!("{}/{child}", self.as_str())
108 };
109 Self::parse(&combined)
110 }
111
112 #[must_use]
114 pub fn is_within(&self, ancestor: &Self) -> bool {
115 ancestor.is_root()
116 || self == ancestor
117 || self
118 .0
119 .strip_prefix(ancestor.as_str())
120 .is_some_and(|suffix| suffix.starts_with('/'))
121 }
122
123 #[must_use]
125 pub fn relative_to<'a>(&'a self, ancestor: &Self) -> Option<&'a str> {
126 if self == ancestor {
127 return Some("");
128 }
129 if ancestor.is_root() {
130 return Some(self.as_str());
131 }
132 self.0
133 .strip_prefix(ancestor.as_str())
134 .and_then(|suffix| suffix.strip_prefix('/'))
135 }
136
137 pub fn rebase(&self, from: &Self, to: &Self) -> Result<Option<Self>, VPathError> {
143 let Some(suffix) = self.relative_to(from) else {
144 return Ok(None);
145 };
146 if suffix.is_empty() {
147 return Ok(Some(to.clone()));
148 }
149 to.join(suffix).map(Some)
150 }
151}
152
153impl fmt::Display for VPath {
154 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155 formatter.write_str(self.as_str())
156 }
157}
158
159impl TryFrom<&str> for VPath {
160 type Error = VPathError;
161
162 fn try_from(value: &str) -> Result<Self, Self::Error> {
163 Self::parse(value)
164 }
165}
166
167fn is_windows_prefix(component: &str) -> bool {
168 let bytes = component.as_bytes();
169 bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
170}
171
172#[derive(Clone, Copy, Debug, Eq, PartialEq)]
174#[non_exhaustive]
175pub enum VPathError {
176 Empty,
178 Absolute,
180 EscapesRoot,
182 NulByte,
184 PlatformPrefix,
186}
187
188impl fmt::Display for VPathError {
189 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
190 formatter.write_str(match self {
191 Self::Empty => "virtual path must not be empty",
192 Self::Absolute => "virtual path must be relative",
193 Self::EscapesRoot => "virtual path escapes the workspace root",
194 Self::NulByte => "virtual path contains a NUL byte",
195 Self::PlatformPrefix => "virtual path contains an absolute platform prefix",
196 })
197 }
198}
199
200impl Error for VPathError {}
201
202macro_rules! digest_id {
203 ($name:ident, $description:literal) => {
204 #[doc = $description]
205 #[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
206 pub struct $name([u8; 32]);
207
208 impl $name {
209 #[doc = "Construct an identifier from its canonical 32 bytes."]
210 #[must_use]
211 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
212 Self(bytes)
213 }
214
215 #[doc = "Return the canonical identifier bytes."]
216 #[must_use]
217 pub const fn as_bytes(&self) -> &[u8; 32] {
218 &self.0
219 }
220 }
221
222 impl fmt::Debug for $name {
223 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
224 fmt::Display::fmt(self, formatter)
225 }
226 }
227
228 impl fmt::Display for $name {
229 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
230 for byte in self.0 {
231 write!(formatter, "{byte:02x}")?;
232 }
233 Ok(())
234 }
235 }
236
237 impl FromStr for $name {
238 type Err = ParseDigestError;
239
240 fn from_str(value: &str) -> Result<Self, Self::Err> {
241 decode_digest(value).map(Self::from_bytes)
242 }
243 }
244 };
245}
246
247#[derive(Clone, Copy, Debug, Eq, PartialEq)]
249#[non_exhaustive]
250pub enum ParseDigestError {
251 InvalidLength {
253 observed: usize,
255 },
256 InvalidHex {
258 index: usize,
260 },
261}
262
263impl fmt::Display for ParseDigestError {
264 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
265 match self {
266 Self::InvalidLength { observed } => {
267 write!(
268 formatter,
269 "digest must be 64 hexadecimal bytes, got {observed}"
270 )
271 }
272 Self::InvalidHex { index } => {
273 write!(
274 formatter,
275 "digest contains non-hexadecimal byte at index {index}"
276 )
277 }
278 }
279 }
280}
281
282impl Error for ParseDigestError {}
283
284fn decode_digest(value: &str) -> Result<[u8; 32], ParseDigestError> {
285 let bytes = value.as_bytes();
286 if bytes.len() != 64 {
287 return Err(ParseDigestError::InvalidLength {
288 observed: bytes.len(),
289 });
290 }
291 let mut decoded = [0_u8; 32];
292 for (index, pair) in bytes.chunks_exact(2).enumerate() {
293 let high =
294 decode_hex_digit(pair[0]).ok_or(ParseDigestError::InvalidHex { index: index * 2 })?;
295 let low = decode_hex_digit(pair[1]).ok_or(ParseDigestError::InvalidHex {
296 index: index * 2 + 1,
297 })?;
298 decoded[index] = (high << 4) | low;
299 }
300 Ok(decoded)
301}
302
303const fn decode_hex_digit(value: u8) -> Option<u8> {
304 match value {
305 b'0'..=b'9' => Some(value - b'0'),
306 b'a'..=b'f' => Some(value - b'a' + 10),
307 b'A'..=b'F' => Some(value - b'A' + 10),
308 _ => None,
309 }
310}
311
312digest_id!(BlobId, "The content digest of an immutable blob.");
313digest_id!(
314 SnapshotId,
315 "The digest identity of an immutable base snapshot."
316);
317digest_id!(
318 TransactionId,
319 "The digest identity of an exact VSH transaction."
320);
321digest_id!(
322 DiffDigest,
323 "The digest identity of one canonical virtual filesystem diff."
324);
325digest_id!(
326 DirectoryDigest,
327 "The digest identity of one observed directory listing."
328);
329digest_id!(
330 ProgramDigest,
331 "The digest identity of the exact untrusted program source."
332);
333digest_id!(
334 ReadSetDigest,
335 "The digest identity of one canonical transaction read set."
336);
337digest_id!(
338 WriteSetDigest,
339 "The digest identity of one canonical transaction write set."
340);
341digest_id!(
342 PolicyDigest,
343 "The digest identity of one deterministic policy configuration."
344);
345digest_id!(
346 RuntimeConfigDigest,
347 "The digest identity of security-relevant runtime configuration."
348);
349digest_id!(
350 IntentDigest,
351 "The digest identity of transaction intent supplied out of band."
352);
353digest_id!(
354 PrincipalId,
355 "The opaque digest identity of an independent approval principal."
356);
357digest_id!(
358 ApprovalId,
359 "The digest identity of one exact bounded approval grant."
360);
361
362impl BlobId {
363 #[must_use]
365 pub fn digest(bytes: &[u8]) -> Self {
366 Self::from_bytes(*blake3::hash(bytes).as_bytes())
367 }
368}
369
370impl SnapshotId {
371 #[must_use]
373 pub fn digest_manifest(canonical_manifest: &[u8]) -> Self {
374 Self::from_bytes(domain_hash(b"snapshot-v1", canonical_manifest))
375 }
376}
377
378impl DiffDigest {
379 #[must_use]
381 pub fn digest_canonical(canonical_diff: &[u8]) -> Self {
382 Self::from_bytes(domain_hash(b"diff-v1", canonical_diff))
383 }
384}
385
386impl DirectoryDigest {
387 #[must_use]
389 pub fn digest_canonical(canonical_listing: &[u8]) -> Self {
390 Self::from_bytes(domain_hash(b"directory-v1", canonical_listing))
391 }
392
393 #[must_use]
399 pub fn digest_entries<'a>(entries: impl IntoIterator<Item = (&'a VPath, NodeState)>) -> Self {
400 let mut canonical = Vec::new();
401 for (path, state) in entries {
402 encode_vpath(path, &mut canonical);
403 state.encode_canonical(&mut canonical);
404 }
405 Self::digest_canonical(&canonical)
406 }
407}
408
409impl ProgramDigest {
410 #[must_use]
412 pub fn digest_source(source: &str) -> Self {
413 Self::from_bytes(domain_hash(b"program-v1", source.as_bytes()))
414 }
415}
416
417impl ReadSetDigest {
418 #[must_use]
420 pub fn digest_canonical(canonical_read_set: &[u8]) -> Self {
421 Self::from_bytes(domain_hash(b"read-set-v1", canonical_read_set))
422 }
423}
424
425impl WriteSetDigest {
426 #[must_use]
428 pub fn digest_canonical(canonical_write_set: &[u8]) -> Self {
429 Self::from_bytes(domain_hash(b"write-set-v1", canonical_write_set))
430 }
431}
432
433impl PolicyDigest {
434 #[must_use]
436 pub fn digest_canonical(canonical_policy: &[u8]) -> Self {
437 Self::from_bytes(domain_hash(b"policy-v1", canonical_policy))
438 }
439}
440
441impl RuntimeConfigDigest {
442 #[must_use]
444 pub fn digest_canonical(canonical_config: &[u8]) -> Self {
445 Self::from_bytes(domain_hash(b"runtime-config-v1", canonical_config))
446 }
447}
448
449impl IntentDigest {
450 #[must_use]
452 pub fn digest_text(intent: &str) -> Self {
453 Self::from_bytes(domain_hash(b"intent-v1", intent.as_bytes()))
454 }
455}
456
457impl PrincipalId {
458 #[must_use]
460 pub fn digest_label(label: &str) -> Self {
461 Self::from_bytes(domain_hash(b"principal-v1", label.as_bytes()))
462 }
463}
464
465#[derive(Clone, Copy, Debug, Eq, PartialEq)]
467pub struct ApprovalBinding {
468 pub transaction: TransactionId,
470 pub principal: PrincipalId,
472 pub issued_at_unix_ms: u64,
474 pub expires_at_unix_ms: u64,
476}
477
478impl ApprovalBinding {
479 #[must_use]
481 pub fn approval_id(self) -> ApprovalId {
482 let mut canonical = Vec::with_capacity(32 * 2 + 16);
483 canonical.extend_from_slice(self.transaction.as_bytes());
484 canonical.extend_from_slice(self.principal.as_bytes());
485 canonical.extend_from_slice(&self.issued_at_unix_ms.to_le_bytes());
486 canonical.extend_from_slice(&self.expires_at_unix_ms.to_le_bytes());
487 ApprovalId::from_bytes(domain_hash(b"approval-v1", &canonical))
488 }
489}
490
491#[derive(Clone, Copy, Debug, Eq, PartialEq)]
493pub struct TransactionBinding {
494 pub base_snapshot: SnapshotId,
496 pub diff: DiffDigest,
498 pub read_set: ReadSetDigest,
500 pub write_set: WriteSetDigest,
502 pub program: ProgramDigest,
504 pub policy: PolicyDigest,
506 pub runtime_config: RuntimeConfigDigest,
508 pub intent: Option<IntentDigest>,
510}
511
512impl TransactionBinding {
513 #[must_use]
515 pub fn transaction_id(self) -> TransactionId {
516 let mut canonical = Vec::with_capacity(32 * 8 + 1);
517 canonical.extend_from_slice(self.base_snapshot.as_bytes());
518 canonical.extend_from_slice(self.diff.as_bytes());
519 canonical.extend_from_slice(self.read_set.as_bytes());
520 canonical.extend_from_slice(self.write_set.as_bytes());
521 canonical.extend_from_slice(self.program.as_bytes());
522 canonical.extend_from_slice(self.policy.as_bytes());
523 canonical.extend_from_slice(self.runtime_config.as_bytes());
524 match self.intent {
525 Some(intent) => {
526 canonical.push(1);
527 canonical.extend_from_slice(intent.as_bytes());
528 }
529 None => canonical.push(0),
530 }
531 TransactionId::from_bytes(domain_hash(b"transaction-v1", &canonical))
532 }
533}
534
535fn domain_hash(domain: &[u8], payload: &[u8]) -> [u8; 32] {
536 let mut hasher = blake3::Hasher::new();
537 hasher.update(b"vsh\0");
538 hasher.update(&(domain.len() as u64).to_le_bytes());
539 hasher.update(domain);
540 hasher.update(&(payload.len() as u64).to_le_bytes());
541 hasher.update(payload);
542 *hasher.finalize().as_bytes()
543}
544
545fn encode_vpath(path: &VPath, output: &mut Vec<u8>) {
546 let bytes = path.as_str().as_bytes();
547 output.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
548 output.extend_from_slice(bytes);
549}
550
551#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
553pub enum NodeKind {
554 File,
556 Directory,
558 Symlink,
560}
561
562impl NodeKind {
563 #[must_use]
565 pub const fn canonical_tag(self) -> u8 {
566 match self {
567 Self::File => 1,
568 Self::Directory => 2,
569 Self::Symlink => 3,
570 }
571 }
572}
573
574#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
579pub struct PlatformFileId {
580 pub high: u64,
582 pub low: u64,
584}
585
586#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
588pub struct FileStamp {
589 pub kind: NodeKind,
591 pub size: u64,
593 pub mode: u32,
595 pub mtime_ns: i128,
597 pub ctime_ns: Option<i128>,
599 pub file_id: PlatformFileId,
601}
602
603#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
605#[non_exhaustive]
606pub enum ContentVersion {
607 Blob(BlobId),
609 Stamp(FileStamp),
611}
612
613#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
615pub struct NodeState {
616 kind: NodeKind,
617 size: u64,
618 mode: u32,
619 content: Option<ContentVersion>,
620}
621
622impl NodeState {
623 #[must_use]
625 pub const fn directory(mode: u32) -> Self {
626 Self {
627 kind: NodeKind::Directory,
628 size: 0,
629 mode,
630 content: None,
631 }
632 }
633
634 #[must_use]
636 pub const fn file(blob: BlobId, size: u64, mode: u32) -> Self {
637 Self {
638 kind: NodeKind::File,
639 size,
640 mode,
641 content: Some(ContentVersion::Blob(blob)),
642 }
643 }
644
645 #[must_use]
647 pub const fn symlink(blob: BlobId, size: u64, mode: u32) -> Self {
648 Self {
649 kind: NodeKind::Symlink,
650 size,
651 mode,
652 content: Some(ContentVersion::Blob(blob)),
653 }
654 }
655
656 #[must_use]
658 pub const fn from_stamp(stamp: FileStamp) -> Self {
659 Self {
660 kind: stamp.kind,
661 size: stamp.size,
662 mode: stamp.mode,
663 content: Some(ContentVersion::Stamp(stamp)),
664 }
665 }
666
667 #[must_use]
669 pub const fn kind(self) -> NodeKind {
670 self.kind
671 }
672
673 #[must_use]
675 pub const fn size(self) -> u64 {
676 self.size
677 }
678
679 #[must_use]
681 pub const fn mode(self) -> u32 {
682 self.mode
683 }
684
685 #[must_use]
687 pub const fn content(self) -> Option<ContentVersion> {
688 self.content
689 }
690
691 #[must_use]
693 pub const fn with_blob(self, blob: BlobId, size: u64) -> Option<Self> {
694 match self.kind {
695 NodeKind::Directory => None,
696 NodeKind::File | NodeKind::Symlink => Some(Self {
697 kind: self.kind,
698 size,
699 mode: self.mode,
700 content: Some(ContentVersion::Blob(blob)),
701 }),
702 }
703 }
704
705 pub fn encode_canonical(self, output: &mut Vec<u8>) {
707 output.push(self.kind.canonical_tag());
708 output.extend_from_slice(&self.size.to_le_bytes());
709 output.extend_from_slice(&self.mode.to_le_bytes());
710 match self.content {
711 None => output.push(0),
712 Some(ContentVersion::Blob(blob)) => {
713 output.push(1);
714 output.extend_from_slice(blob.as_bytes());
715 }
716 Some(ContentVersion::Stamp(stamp)) => {
717 output.push(2);
718 encode_stamp(stamp, output);
719 }
720 }
721 }
722
723 #[must_use]
725 pub fn content_equivalent(self, other: Self) -> bool {
726 self.kind == other.kind && self.size == other.size && self.content == other.content
727 }
728}
729
730fn encode_stamp(stamp: FileStamp, output: &mut Vec<u8>) {
731 output.push(stamp.kind.canonical_tag());
732 output.extend_from_slice(&stamp.size.to_le_bytes());
733 output.extend_from_slice(&stamp.mode.to_le_bytes());
734 output.extend_from_slice(&stamp.mtime_ns.to_le_bytes());
735 match stamp.ctime_ns {
736 Some(value) => {
737 output.push(1);
738 output.extend_from_slice(&value.to_le_bytes());
739 }
740 None => output.push(0),
741 }
742 output.extend_from_slice(&stamp.file_id.high.to_le_bytes());
743 output.extend_from_slice(&stamp.file_id.low.to_le_bytes());
744}
745
746#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
748pub enum DiffKind {
749 Create,
751 Delete,
753 Modify,
755 MetadataChange,
757}
758
759#[derive(Clone, Debug, Eq, PartialEq)]
761pub struct DiffEntry {
762 pub path: VPath,
764 pub before: Option<NodeState>,
766 pub after: Option<NodeState>,
768 pub kind: DiffKind,
770}
771
772#[derive(Clone, Copy, Debug, Eq, PartialEq)]
774#[non_exhaustive]
775pub enum TransactionState {
776 Created,
778 Running,
780 VirtualComplete,
782 Denied,
784 AutoApproved,
786 PendingApproval,
788 Approved,
790 Reserved,
792 Revalidating,
794 Committing,
796 Committed,
798 Stale,
800 Expired,
802 RecoveryRequired,
804 Failed,
806}
807
808impl TransactionState {
809 #[must_use]
811 pub const fn can_transition_to(self, next: Self) -> bool {
812 matches!(
813 (self, next),
814 (Self::Created, Self::Running)
815 | (Self::Running, Self::VirtualComplete | Self::Failed)
816 | (
817 Self::VirtualComplete,
818 Self::Denied | Self::AutoApproved | Self::PendingApproval | Self::Failed
819 )
820 | (
821 Self::PendingApproval,
822 Self::Approved | Self::Denied | Self::Expired | Self::Failed
823 )
824 | (Self::AutoApproved | Self::Approved, Self::Reserved)
825 | (Self::Approved, Self::Expired)
826 | (Self::Reserved, Self::Revalidating | Self::Failed)
827 | (
828 Self::Revalidating,
829 Self::Committing | Self::Stale | Self::Failed
830 )
831 | (Self::Committing, Self::Committed | Self::RecoveryRequired)
832 | (Self::RecoveryRequired, Self::Committed | Self::Failed)
833 )
834 }
835
836 #[must_use]
838 pub const fn is_terminal(self) -> bool {
839 matches!(
840 self,
841 Self::Denied | Self::Committed | Self::Stale | Self::Expired | Self::Failed
842 )
843 }
844}
845
846#[derive(Clone, Copy, Debug, Eq, PartialEq)]
848pub struct TransitionError {
849 pub from: TransactionState,
851 pub to: TransactionState,
853}
854
855impl fmt::Display for TransitionError {
856 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
857 write!(
858 formatter,
859 "invalid transaction transition: {:?} -> {:?}",
860 self.from, self.to
861 )
862 }
863}
864
865impl Error for TransitionError {}
866
867#[cfg(test)]
868mod tests {
869 use super::*;
870
871 #[test]
872 fn vpath_normalizes_portable_components() {
873 let path = VPath::parse("src\\vsh/./core/../lib.rs").unwrap();
874 assert_eq!(path.as_str(), "src/vsh/lib.rs");
875 assert!(!path.is_root());
876 assert_eq!(path.to_string(), "src/vsh/lib.rs");
877 }
878
879 #[test]
880 fn vpath_normalizes_root() {
881 for candidate in [".", "./", "a/..", "a//../."] {
882 let path = VPath::parse(candidate).unwrap();
883 assert!(path.is_root(), "candidate: {candidate}");
884 assert_eq!(path.as_str(), ".");
885 }
886 }
887
888 #[test]
889 fn vpath_rejects_ambiguous_or_escaping_paths() {
890 let cases = [
891 ("", VPathError::Empty),
892 ("/etc/passwd", VPathError::Absolute),
893 ("\\\\server\\share", VPathError::Absolute),
894 ("C:\\Windows", VPathError::PlatformPrefix),
895 ("../secret", VPathError::EscapesRoot),
896 ("a/../../secret", VPathError::EscapesRoot),
897 ("a\0b", VPathError::NulByte),
898 ];
899
900 for (candidate, expected) in cases {
901 assert_eq!(
902 VPath::parse(candidate),
903 Err(expected),
904 "candidate: {candidate}"
905 );
906 }
907 }
908
909 #[test]
910 fn vpath_parent_join_and_rebase_preserve_root_contract() {
911 let root = VPath::parse(".").unwrap();
912 let source = VPath::parse("src/tree/file.txt").unwrap();
913 let subtree = VPath::parse("src/tree").unwrap();
914 let destination = VPath::parse("lib").unwrap();
915
916 assert_eq!(source.parent().unwrap().as_str(), "src/tree");
917 assert_eq!(root.parent(), None);
918 assert_eq!(root.join("a/b").unwrap().as_str(), "a/b");
919 assert_eq!(subtree.join("child").unwrap().as_str(), "src/tree/child");
920 assert!(source.is_within(&root));
921 assert!(source.is_within(&subtree));
922 assert!(!subtree.is_within(&source));
923 assert_eq!(source.relative_to(&subtree), Some("file.txt"));
924 assert_eq!(
925 source.rebase(&subtree, &destination).unwrap().unwrap(),
926 VPath::parse("lib/file.txt").unwrap()
927 );
928 assert_eq!(source.rebase(&destination, &subtree).unwrap(), None);
929 assert_eq!(root.relative_to(&root), Some(""));
930 assert_eq!(source.relative_to(&root), Some("src/tree/file.txt"));
931 assert_eq!(VPath::try_from("src/tree/file.txt").unwrap(), source);
932 }
933
934 #[test]
935 fn public_value_errors_and_optional_stamp_encoding_are_stable() {
936 let path_messages = [
937 VPathError::Empty,
938 VPathError::Absolute,
939 VPathError::EscapesRoot,
940 VPathError::NulByte,
941 VPathError::PlatformPrefix,
942 ]
943 .map(|error| error.to_string());
944 assert!(path_messages.iter().all(|message| !message.is_empty()));
945
946 assert_eq!(
947 ParseDigestError::InvalidLength { observed: 1 }.to_string(),
948 "digest must be 64 hexadecimal bytes, got 1"
949 );
950 assert_eq!(
951 ParseDigestError::InvalidHex { index: 2 }.to_string(),
952 "digest contains non-hexadecimal byte at index 2"
953 );
954
955 let stamp = FileStamp {
956 kind: NodeKind::File,
957 size: 0,
958 mode: 0o644,
959 mtime_ns: 0,
960 ctime_ns: None,
961 file_id: PlatformFileId { high: 0, low: 0 },
962 };
963 let mut encoded = Vec::new();
964 NodeState::from_stamp(stamp).encode_canonical(&mut encoded);
965 assert!(!encoded.is_empty());
966 }
967
968 #[test]
969 fn digest_ids_are_fixed_width_lower_hex() {
970 let id = BlobId::from_bytes([0xab; 32]);
971 assert_eq!(id.as_bytes(), &[0xab; 32]);
972 assert_eq!(id.to_string(), "ab".repeat(32));
973 assert_eq!(format!("{id:?}"), id.to_string());
974
975 let snapshot = SnapshotId::from_bytes([1; 32]);
976 let transaction = TransactionId::from_bytes([2; 32]);
977 assert_ne!(snapshot.to_string(), transaction.to_string());
978 }
979
980 #[test]
981 fn digest_ids_parse_exact_hex_without_a_dependency() {
982 let expected = TransactionId::from_bytes([0xab; 32]);
983 assert_eq!("ab".repeat(32).parse::<TransactionId>().unwrap(), expected);
984 assert_eq!("AB".repeat(32).parse::<TransactionId>().unwrap(), expected);
985 assert_eq!(
986 "ab".parse::<TransactionId>(),
987 Err(ParseDigestError::InvalidLength { observed: 2 })
988 );
989 let mut invalid = "ab".repeat(32);
990 invalid.replace_range(7..8, "z");
991 assert_eq!(
992 invalid.parse::<TransactionId>(),
993 Err(ParseDigestError::InvalidHex { index: 7 })
994 );
995 }
996
997 #[test]
998 fn content_and_domain_hashes_are_deterministic_and_separated() {
999 let payload = b"canonical bytes";
1000 assert_eq!(BlobId::digest(payload), BlobId::digest(payload));
1001 assert_ne!(
1002 SnapshotId::digest_manifest(payload).as_bytes(),
1003 DiffDigest::digest_canonical(payload).as_bytes()
1004 );
1005 assert_ne!(
1006 DiffDigest::digest_canonical(payload).as_bytes(),
1007 DirectoryDigest::digest_canonical(payload).as_bytes()
1008 );
1009 }
1010
1011 #[test]
1012 fn node_state_canonical_encoding_captures_metadata_and_content() {
1013 let blob = BlobId::digest(b"data");
1014 let file = NodeState::file(blob, 4, 0o644);
1015 let changed_mode = NodeState::file(blob, 4, 0o600);
1016 let changed_content = NodeState::file(BlobId::digest(b"else"), 4, 0o644);
1017 let mut encoded = Vec::new();
1018 file.encode_canonical(&mut encoded);
1019
1020 assert!(!encoded.is_empty());
1021 assert!(file.content_equivalent(changed_mode));
1022 assert!(!file.content_equivalent(changed_content));
1023 assert_eq!(file.kind(), NodeKind::File);
1024 assert_eq!(file.size(), 4);
1025 assert_eq!(file.mode(), 0o644);
1026 assert_eq!(file.content(), Some(ContentVersion::Blob(blob)));
1027 assert!(NodeState::directory(0o755).with_blob(blob, 4).is_none());
1028 }
1029
1030 #[test]
1031 fn transaction_happy_path_is_valid() {
1032 let states = [
1033 TransactionState::Created,
1034 TransactionState::Running,
1035 TransactionState::VirtualComplete,
1036 TransactionState::AutoApproved,
1037 TransactionState::Reserved,
1038 TransactionState::Revalidating,
1039 TransactionState::Committing,
1040 TransactionState::Committed,
1041 ];
1042 for pair in states.windows(2) {
1043 assert!(pair[0].can_transition_to(pair[1]), "pair: {pair:?}");
1044 }
1045 assert!(TransactionState::Committed.is_terminal());
1046 }
1047
1048 #[test]
1049 fn transaction_rejects_replay_and_skipped_states() {
1050 assert!(!TransactionState::Approved.can_transition_to(TransactionState::Committed));
1051 assert!(!TransactionState::Committed.can_transition_to(TransactionState::Reserved));
1052 assert!(!TransactionState::Denied.can_transition_to(TransactionState::Approved));
1053 assert!(TransactionState::Denied.is_terminal());
1054 assert!(!TransactionState::RecoveryRequired.is_terminal());
1055 }
1056}