1use std::borrow::{Borrow, Cow};
4use std::error::Error;
5use std::fmt;
6use std::str::FromStr;
7
8#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub struct VPath(String);
11
12impl Borrow<str> for VPath {
15 fn borrow(&self) -> &str {
16 self.as_str()
17 }
18}
19
20impl VPath {
21 #[must_use]
23 pub fn root() -> Self {
24 Self(".".to_owned())
25 }
26
27 pub fn parse(input: &str) -> Result<Self, VPathError> {
38 if input.is_empty() {
39 return Err(VPathError::Empty);
40 }
41 if input.contains('\0') {
42 return Err(VPathError::NulByte);
43 }
44
45 let portable = if input.contains('\\') {
46 Cow::Owned(input.replace('\\', "/"))
47 } else {
48 Cow::Borrowed(input)
49 };
50 if portable.starts_with('/') {
51 return Err(VPathError::Absolute);
52 }
53
54 let first = portable.split('/').next().unwrap_or_default();
55 if is_windows_prefix(first) {
56 return Err(VPathError::PlatformPrefix);
57 }
58
59 if portable == "."
62 || portable
63 .split('/')
64 .all(|component| !matches!(component, "" | "." | ".."))
65 {
66 return Ok(Self(portable.into_owned()));
67 }
68
69 let mut components = Vec::new();
70 for component in portable.split('/') {
71 match component {
72 "" | "." => {}
73 ".." => {
74 if components.pop().is_none() {
75 return Err(VPathError::EscapesRoot);
76 }
77 }
78 value => components.push(value),
79 }
80 }
81
82 let normalized = if components.is_empty() {
83 ".".to_owned()
84 } else {
85 components.join("/")
86 };
87 Ok(Self(normalized))
88 }
89
90 #[must_use]
92 pub fn as_str(&self) -> &str {
93 &self.0
94 }
95
96 #[must_use]
98 pub fn is_root(&self) -> bool {
99 self.0 == "."
100 }
101
102 #[must_use]
104 pub fn parent(&self) -> Option<Self> {
105 if self.is_root() {
106 return None;
107 }
108 match self.0.rsplit_once('/') {
109 Some((parent, _)) => Some(Self(parent.to_owned())),
110 None => Some(Self(".".to_owned())),
111 }
112 }
113
114 #[must_use]
116 pub fn file_name(&self) -> Option<&str> {
117 (!self.is_root()).then(|| self.0.rsplit('/').next().unwrap_or(self.as_str()))
118 }
119
120 pub fn join(&self, child: &str) -> Result<Self, VPathError> {
127 let combined = if self.is_root() {
128 child.to_owned()
129 } else {
130 format!("{}/{child}", self.as_str())
131 };
132 Self::parse(&combined)
133 }
134
135 #[must_use]
137 pub fn is_within(&self, ancestor: &Self) -> bool {
138 ancestor.is_root()
139 || self == ancestor
140 || self
141 .0
142 .strip_prefix(ancestor.as_str())
143 .is_some_and(|suffix| suffix.starts_with('/'))
144 }
145
146 #[must_use]
148 pub fn relative_to<'a>(&'a self, ancestor: &Self) -> Option<&'a str> {
149 if self == ancestor {
150 return Some("");
151 }
152 if ancestor.is_root() {
153 return Some(self.as_str());
154 }
155 self.0
156 .strip_prefix(ancestor.as_str())
157 .and_then(|suffix| suffix.strip_prefix('/'))
158 }
159
160 pub fn rebase(&self, from: &Self, to: &Self) -> Result<Option<Self>, VPathError> {
166 let Some(suffix) = self.relative_to(from) else {
167 return Ok(None);
168 };
169 if suffix.is_empty() {
170 return Ok(Some(to.clone()));
171 }
172 to.join(suffix).map(Some)
173 }
174}
175
176impl fmt::Display for VPath {
177 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
178 formatter.write_str(self.as_str())
179 }
180}
181
182impl TryFrom<&str> for VPath {
183 type Error = VPathError;
184
185 fn try_from(value: &str) -> Result<Self, Self::Error> {
186 Self::parse(value)
187 }
188}
189
190fn is_windows_prefix(component: &str) -> bool {
191 let bytes = component.as_bytes();
192 bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
193}
194
195#[derive(Clone, Copy, Debug, Eq, PartialEq)]
197#[non_exhaustive]
198pub enum VPathError {
199 Empty,
201 Absolute,
203 EscapesRoot,
205 NulByte,
207 PlatformPrefix,
209}
210
211impl fmt::Display for VPathError {
212 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
213 formatter.write_str(match self {
214 Self::Empty => "virtual path must not be empty",
215 Self::Absolute => "virtual path must be relative",
216 Self::EscapesRoot => "virtual path escapes the workspace root",
217 Self::NulByte => "virtual path contains a NUL byte",
218 Self::PlatformPrefix => "virtual path contains an absolute platform prefix",
219 })
220 }
221}
222
223impl Error for VPathError {}
224
225macro_rules! digest_id {
226 ($name:ident, $description:literal) => {
227 #[doc = $description]
228 #[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
229 pub struct $name([u8; 32]);
230
231 impl $name {
232 #[doc = "Construct an identifier from its canonical 32 bytes."]
233 #[must_use]
234 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
235 Self(bytes)
236 }
237
238 #[doc = "Return the canonical identifier bytes."]
239 #[must_use]
240 pub const fn as_bytes(&self) -> &[u8; 32] {
241 &self.0
242 }
243 }
244
245 impl fmt::Debug for $name {
246 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
247 fmt::Display::fmt(self, formatter)
248 }
249 }
250
251 impl fmt::Display for $name {
252 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
253 for byte in self.0 {
254 write!(formatter, "{byte:02x}")?;
255 }
256 Ok(())
257 }
258 }
259
260 impl FromStr for $name {
261 type Err = ParseDigestError;
262
263 fn from_str(value: &str) -> Result<Self, Self::Err> {
264 decode_digest(value).map(Self::from_bytes)
265 }
266 }
267 };
268}
269
270#[derive(Clone, Copy, Debug, Eq, PartialEq)]
272#[non_exhaustive]
273pub enum ParseDigestError {
274 InvalidLength {
276 observed: usize,
278 },
279 InvalidHex {
281 index: usize,
283 },
284}
285
286impl fmt::Display for ParseDigestError {
287 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
288 match self {
289 Self::InvalidLength { observed } => {
290 write!(
291 formatter,
292 "digest must be 64 hexadecimal bytes, got {observed}"
293 )
294 }
295 Self::InvalidHex { index } => {
296 write!(
297 formatter,
298 "digest contains non-hexadecimal byte at index {index}"
299 )
300 }
301 }
302 }
303}
304
305impl Error for ParseDigestError {}
306
307fn decode_digest(value: &str) -> Result<[u8; 32], ParseDigestError> {
308 let bytes = value.as_bytes();
309 if bytes.len() != 64 {
310 return Err(ParseDigestError::InvalidLength {
311 observed: bytes.len(),
312 });
313 }
314 let mut decoded = [0_u8; 32];
315 for (index, pair) in bytes.chunks_exact(2).enumerate() {
316 let high =
317 decode_hex_digit(pair[0]).ok_or(ParseDigestError::InvalidHex { index: index * 2 })?;
318 let low = decode_hex_digit(pair[1]).ok_or(ParseDigestError::InvalidHex {
319 index: index * 2 + 1,
320 })?;
321 decoded[index] = (high << 4) | low;
322 }
323 Ok(decoded)
324}
325
326const fn decode_hex_digit(value: u8) -> Option<u8> {
327 match value {
328 b'0'..=b'9' => Some(value - b'0'),
329 b'a'..=b'f' => Some(value - b'a' + 10),
330 b'A'..=b'F' => Some(value - b'A' + 10),
331 _ => None,
332 }
333}
334
335digest_id!(BlobId, "The content digest of an immutable blob.");
336digest_id!(
337 SnapshotId,
338 "The digest identity of an immutable base snapshot."
339);
340digest_id!(
341 TransactionId,
342 "The digest identity of an exact VSH transaction."
343);
344digest_id!(
345 DiffDigest,
346 "The digest identity of one canonical virtual filesystem diff."
347);
348digest_id!(
349 DirectoryDigest,
350 "The digest identity of one observed directory listing."
351);
352digest_id!(
353 ProgramDigest,
354 "The digest identity of the exact untrusted program source."
355);
356digest_id!(
357 ReadSetDigest,
358 "The digest identity of one canonical transaction read set."
359);
360digest_id!(
361 WriteSetDigest,
362 "The digest identity of one canonical transaction write set."
363);
364digest_id!(
365 PolicyDigest,
366 "The digest identity of one deterministic policy configuration."
367);
368digest_id!(
369 RuntimeConfigDigest,
370 "The digest identity of security-relevant runtime configuration."
371);
372digest_id!(
373 IntentDigest,
374 "The digest identity of transaction intent supplied out of band."
375);
376digest_id!(
377 PrincipalId,
378 "The opaque digest identity of an independent approval principal."
379);
380digest_id!(
381 ApprovalId,
382 "The digest identity of one exact bounded approval grant."
383);
384
385impl BlobId {
386 #[must_use]
388 pub fn digest(bytes: &[u8]) -> Self {
389 Self::from_bytes(*blake3::hash(bytes).as_bytes())
390 }
391}
392
393impl SnapshotId {
394 #[must_use]
396 pub fn digest_manifest(canonical_manifest: &[u8]) -> Self {
397 Self::from_bytes(domain_hash(b"snapshot-v1", canonical_manifest))
398 }
399}
400
401impl DiffDigest {
402 #[must_use]
404 pub fn digest_canonical(canonical_diff: &[u8]) -> Self {
405 Self::from_bytes(domain_hash(b"diff-v1", canonical_diff))
406 }
407}
408
409impl DirectoryDigest {
410 #[must_use]
412 pub fn digest_canonical(canonical_listing: &[u8]) -> Self {
413 Self::from_bytes(domain_hash(b"directory-v1", canonical_listing))
414 }
415
416 #[must_use]
422 pub fn digest_entries<'a>(entries: impl IntoIterator<Item = (&'a VPath, NodeState)>) -> Self {
423 let mut canonical = Vec::new();
424 for (path, state) in entries {
425 encode_vpath(path, &mut canonical);
426 state.encode_canonical(&mut canonical);
427 }
428 Self::digest_canonical(&canonical)
429 }
430}
431
432impl ProgramDigest {
433 #[must_use]
435 pub fn digest_source(source: &str) -> Self {
436 Self::from_bytes(domain_hash(b"program-v1", source.as_bytes()))
437 }
438}
439
440impl ReadSetDigest {
441 #[must_use]
443 pub fn digest_canonical(canonical_read_set: &[u8]) -> Self {
444 Self::from_bytes(domain_hash(b"read-set-v1", canonical_read_set))
445 }
446}
447
448impl WriteSetDigest {
449 #[must_use]
451 pub fn digest_canonical(canonical_write_set: &[u8]) -> Self {
452 Self::from_bytes(domain_hash(b"write-set-v1", canonical_write_set))
453 }
454}
455
456impl PolicyDigest {
457 #[must_use]
459 pub fn digest_canonical(canonical_policy: &[u8]) -> Self {
460 Self::from_bytes(domain_hash(b"policy-v1", canonical_policy))
461 }
462}
463
464impl RuntimeConfigDigest {
465 #[must_use]
467 pub fn digest_canonical(canonical_config: &[u8]) -> Self {
468 Self::from_bytes(domain_hash(b"runtime-config-v1", canonical_config))
469 }
470}
471
472impl IntentDigest {
473 #[must_use]
475 pub fn digest_text(intent: &str) -> Self {
476 Self::from_bytes(domain_hash(b"intent-v1", intent.as_bytes()))
477 }
478}
479
480impl PrincipalId {
481 #[must_use]
483 pub fn digest_label(label: &str) -> Self {
484 Self::from_bytes(domain_hash(b"principal-v1", label.as_bytes()))
485 }
486}
487
488#[derive(Clone, Copy, Debug, Eq, PartialEq)]
490pub struct ApprovalBinding {
491 pub transaction: TransactionId,
493 pub principal: PrincipalId,
495 pub issued_at_unix_ms: u64,
497 pub expires_at_unix_ms: u64,
499}
500
501impl ApprovalBinding {
502 #[must_use]
504 pub fn approval_id(self) -> ApprovalId {
505 let mut canonical = Vec::with_capacity(32 * 2 + 16);
506 canonical.extend_from_slice(self.transaction.as_bytes());
507 canonical.extend_from_slice(self.principal.as_bytes());
508 canonical.extend_from_slice(&self.issued_at_unix_ms.to_le_bytes());
509 canonical.extend_from_slice(&self.expires_at_unix_ms.to_le_bytes());
510 ApprovalId::from_bytes(domain_hash(b"approval-v1", &canonical))
511 }
512}
513
514#[derive(Clone, Copy, Debug, Eq, PartialEq)]
516pub struct TransactionBinding {
517 pub base_snapshot: SnapshotId,
519 pub diff: DiffDigest,
521 pub read_set: ReadSetDigest,
523 pub write_set: WriteSetDigest,
525 pub program: ProgramDigest,
527 pub policy: PolicyDigest,
529 pub runtime_config: RuntimeConfigDigest,
531 pub intent: Option<IntentDigest>,
533}
534
535impl TransactionBinding {
536 #[must_use]
538 pub fn transaction_id(self) -> TransactionId {
539 let mut canonical = Vec::with_capacity(32 * 8 + 1);
540 canonical.extend_from_slice(self.base_snapshot.as_bytes());
541 canonical.extend_from_slice(self.diff.as_bytes());
542 canonical.extend_from_slice(self.read_set.as_bytes());
543 canonical.extend_from_slice(self.write_set.as_bytes());
544 canonical.extend_from_slice(self.program.as_bytes());
545 canonical.extend_from_slice(self.policy.as_bytes());
546 canonical.extend_from_slice(self.runtime_config.as_bytes());
547 match self.intent {
548 Some(intent) => {
549 canonical.push(1);
550 canonical.extend_from_slice(intent.as_bytes());
551 }
552 None => canonical.push(0),
553 }
554 TransactionId::from_bytes(domain_hash(b"transaction-v1", &canonical))
555 }
556}
557
558fn domain_hash(domain: &[u8], payload: &[u8]) -> [u8; 32] {
559 let mut hasher = blake3::Hasher::new();
560 hasher.update(b"vsh\0");
561 hasher.update(&(domain.len() as u64).to_le_bytes());
562 hasher.update(domain);
563 hasher.update(&(payload.len() as u64).to_le_bytes());
564 hasher.update(payload);
565 *hasher.finalize().as_bytes()
566}
567
568fn encode_vpath(path: &VPath, output: &mut Vec<u8>) {
569 let bytes = path.as_str().as_bytes();
570 output.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
571 output.extend_from_slice(bytes);
572}
573
574#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
576pub enum NodeKind {
577 File,
579 Directory,
581 Symlink,
583}
584
585impl NodeKind {
586 #[must_use]
588 pub const fn canonical_tag(self) -> u8 {
589 match self {
590 Self::File => 1,
591 Self::Directory => 2,
592 Self::Symlink => 3,
593 }
594 }
595}
596
597#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
602pub struct PlatformFileId {
603 pub high: u64,
605 pub low: u64,
607}
608
609#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
611pub struct FileStamp {
612 pub kind: NodeKind,
614 pub size: u64,
616 pub mode: u32,
618 pub mtime_ns: i128,
620 pub ctime_ns: Option<i128>,
622 pub file_id: PlatformFileId,
624}
625
626#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
628#[non_exhaustive]
629pub enum ContentVersion {
630 Blob(BlobId),
632 Stamp(FileStamp),
634}
635
636#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
638pub struct NodeState {
639 kind: NodeKind,
640 size: u64,
641 mode: u32,
642 content: Option<ContentVersion>,
643}
644
645impl NodeState {
646 #[must_use]
648 pub const fn directory(mode: u32) -> Self {
649 Self {
650 kind: NodeKind::Directory,
651 size: 0,
652 mode,
653 content: None,
654 }
655 }
656
657 #[must_use]
659 pub const fn file(blob: BlobId, size: u64, mode: u32) -> Self {
660 Self {
661 kind: NodeKind::File,
662 size,
663 mode,
664 content: Some(ContentVersion::Blob(blob)),
665 }
666 }
667
668 #[must_use]
670 pub const fn symlink(blob: BlobId, size: u64, mode: u32) -> Self {
671 Self {
672 kind: NodeKind::Symlink,
673 size,
674 mode,
675 content: Some(ContentVersion::Blob(blob)),
676 }
677 }
678
679 #[must_use]
681 pub const fn from_stamp(stamp: FileStamp) -> Self {
682 Self {
683 kind: stamp.kind,
684 size: stamp.size,
685 mode: stamp.mode,
686 content: Some(ContentVersion::Stamp(stamp)),
687 }
688 }
689
690 #[must_use]
692 pub const fn kind(self) -> NodeKind {
693 self.kind
694 }
695
696 #[must_use]
698 pub const fn size(self) -> u64 {
699 self.size
700 }
701
702 #[must_use]
704 pub const fn mode(self) -> u32 {
705 self.mode
706 }
707
708 #[must_use]
710 pub const fn content(self) -> Option<ContentVersion> {
711 self.content
712 }
713
714 #[must_use]
716 pub const fn with_blob(self, blob: BlobId, size: u64) -> Option<Self> {
717 match self.kind {
718 NodeKind::Directory => None,
719 NodeKind::File | NodeKind::Symlink => Some(Self {
720 kind: self.kind,
721 size,
722 mode: self.mode,
723 content: Some(ContentVersion::Blob(blob)),
724 }),
725 }
726 }
727
728 pub fn encode_canonical(self, output: &mut Vec<u8>) {
730 output.push(self.kind.canonical_tag());
731 output.extend_from_slice(&self.size.to_le_bytes());
732 output.extend_from_slice(&self.mode.to_le_bytes());
733 match self.content {
734 None => output.push(0),
735 Some(ContentVersion::Blob(blob)) => {
736 output.push(1);
737 output.extend_from_slice(blob.as_bytes());
738 }
739 Some(ContentVersion::Stamp(stamp)) => {
740 output.push(2);
741 encode_stamp(stamp, output);
742 }
743 }
744 }
745
746 #[must_use]
748 pub fn content_equivalent(self, other: Self) -> bool {
749 self.kind == other.kind && self.size == other.size && self.content == other.content
750 }
751}
752
753fn encode_stamp(stamp: FileStamp, output: &mut Vec<u8>) {
754 output.push(stamp.kind.canonical_tag());
755 output.extend_from_slice(&stamp.size.to_le_bytes());
756 output.extend_from_slice(&stamp.mode.to_le_bytes());
757 output.extend_from_slice(&stamp.mtime_ns.to_le_bytes());
758 match stamp.ctime_ns {
759 Some(value) => {
760 output.push(1);
761 output.extend_from_slice(&value.to_le_bytes());
762 }
763 None => output.push(0),
764 }
765 output.extend_from_slice(&stamp.file_id.high.to_le_bytes());
766 output.extend_from_slice(&stamp.file_id.low.to_le_bytes());
767}
768
769#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
771pub enum DiffKind {
772 Create,
774 Delete,
776 Modify,
778 MetadataChange,
780}
781
782#[derive(Clone, Debug, Eq, PartialEq)]
784pub struct DiffEntry {
785 pub path: VPath,
787 pub before: Option<NodeState>,
789 pub after: Option<NodeState>,
791 pub kind: DiffKind,
793}
794
795#[derive(Clone, Copy, Debug, Eq, PartialEq)]
797#[non_exhaustive]
798pub enum TransactionState {
799 Created,
801 Running,
803 VirtualComplete,
805 Denied,
807 AutoApproved,
809 PendingApproval,
811 Approved,
813 Reserved,
815 Revalidating,
817 Committing,
819 Committed,
821 Stale,
823 Expired,
825 RecoveryRequired,
827 Failed,
829}
830
831impl TransactionState {
832 #[must_use]
834 pub const fn can_transition_to(self, next: Self) -> bool {
835 matches!(
836 (self, next),
837 (Self::Created, Self::Running)
838 | (Self::Running, Self::VirtualComplete | Self::Failed)
839 | (
840 Self::VirtualComplete,
841 Self::Denied | Self::AutoApproved | Self::PendingApproval | Self::Failed
842 )
843 | (
844 Self::PendingApproval,
845 Self::Approved | Self::Denied | Self::Expired | Self::Failed
846 )
847 | (Self::AutoApproved | Self::Approved, Self::Reserved)
848 | (Self::Approved, Self::Expired)
849 | (Self::Reserved, Self::Revalidating | Self::Failed)
850 | (
851 Self::Revalidating,
852 Self::Committing | Self::Stale | Self::Failed
853 )
854 | (Self::Committing, Self::Committed | Self::RecoveryRequired)
855 | (Self::RecoveryRequired, Self::Committed | Self::Failed)
856 )
857 }
858
859 #[must_use]
861 pub const fn is_terminal(self) -> bool {
862 matches!(
863 self,
864 Self::Denied | Self::Committed | Self::Stale | Self::Expired | Self::Failed
865 )
866 }
867}
868
869#[derive(Clone, Copy, Debug, Eq, PartialEq)]
871pub struct TransitionError {
872 pub from: TransactionState,
874 pub to: TransactionState,
876}
877
878impl fmt::Display for TransitionError {
879 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
880 write!(
881 formatter,
882 "invalid transaction transition: {:?} -> {:?}",
883 self.from, self.to
884 )
885 }
886}
887
888impl Error for TransitionError {}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893
894 #[test]
895 fn canonical_fast_path_matches_normalization_oracle() {
896 fn oracle(input: &str) -> Result<VPath, VPathError> {
897 if input.is_empty() {
898 return Err(VPathError::Empty);
899 }
900 if input.contains('\0') {
901 return Err(VPathError::NulByte);
902 }
903 let portable = input.replace('\\', "/");
904 if portable.starts_with('/') {
905 return Err(VPathError::Absolute);
906 }
907 if is_windows_prefix(portable.split('/').next().unwrap_or_default()) {
908 return Err(VPathError::PlatformPrefix);
909 }
910 let mut components = Vec::new();
911 for component in portable.split('/') {
912 match component {
913 "" | "." => {}
914 ".." => {
915 if components.pop().is_none() {
916 return Err(VPathError::EscapesRoot);
917 }
918 }
919 value => components.push(value),
920 }
921 }
922 Ok(VPath(if components.is_empty() {
923 ".".to_owned()
924 } else {
925 components.join("/")
926 }))
927 }
928 let components = ["", ".", "..", "a", "é", "C:", "x:y", "a\0b", "a\\b"];
929 for first in components {
930 assert_eq!(VPath::parse(first), oracle(first));
931 for second in components {
932 for third in components {
933 for separator in ["/", "\\"] {
934 let candidate = [first, second, third].join(separator);
935 assert_eq!(
936 VPath::parse(&candidate),
937 oracle(&candidate),
938 "{candidate:?}"
939 );
940 let combined = format!("parent/{candidate}");
943 assert_eq!(
944 VPath::parse("parent").unwrap().join(&candidate),
945 oracle(&combined),
946 "joined {candidate:?}"
947 );
948 }
949 }
950 }
951 }
952 }
953
954 #[test]
955 fn vpath_normalizes_portable_components() {
956 let path = VPath::parse("src\\vsh/./core/../lib.rs").unwrap();
957 assert_eq!(path.as_str(), "src/vsh/lib.rs");
958 assert!(!path.is_root());
959 assert_eq!(path.to_string(), "src/vsh/lib.rs");
960 }
961
962 #[test]
963 fn vpath_normalizes_root() {
964 for candidate in [".", "./", "a/..", "a//../."] {
965 let path = VPath::parse(candidate).unwrap();
966 assert!(path.is_root(), "candidate: {candidate}");
967 assert_eq!(path.as_str(), ".");
968 }
969 }
970
971 #[test]
972 fn vpath_rejects_ambiguous_or_escaping_paths() {
973 let cases = [
974 ("", VPathError::Empty),
975 ("/etc/passwd", VPathError::Absolute),
976 ("\\\\server\\share", VPathError::Absolute),
977 ("C:\\Windows", VPathError::PlatformPrefix),
978 ("../secret", VPathError::EscapesRoot),
979 ("a/../../secret", VPathError::EscapesRoot),
980 ("a\0b", VPathError::NulByte),
981 ];
982
983 for (candidate, expected) in cases {
984 assert_eq!(
985 VPath::parse(candidate),
986 Err(expected),
987 "candidate: {candidate}"
988 );
989 }
990 }
991
992 #[test]
993 fn vpath_parent_join_and_rebase_preserve_root_contract() {
994 let root = VPath::parse(".").unwrap();
995 let source = VPath::parse("src/tree/file.txt").unwrap();
996 let subtree = VPath::parse("src/tree").unwrap();
997 let destination = VPath::parse("lib").unwrap();
998
999 assert_eq!(source.parent().unwrap().as_str(), "src/tree");
1000 assert_eq!(root.parent(), None);
1001 assert_eq!(root.join("a/b").unwrap().as_str(), "a/b");
1002 assert_eq!(subtree.join("child").unwrap().as_str(), "src/tree/child");
1003 assert!(source.is_within(&root));
1004 assert!(source.is_within(&subtree));
1005 assert!(!subtree.is_within(&source));
1006 assert_eq!(source.relative_to(&subtree), Some("file.txt"));
1007 assert_eq!(
1008 source.rebase(&subtree, &destination).unwrap().unwrap(),
1009 VPath::parse("lib/file.txt").unwrap()
1010 );
1011 assert_eq!(source.rebase(&destination, &subtree).unwrap(), None);
1012 assert_eq!(root.relative_to(&root), Some(""));
1013 assert_eq!(source.relative_to(&root), Some("src/tree/file.txt"));
1014 assert_eq!(VPath::try_from("src/tree/file.txt").unwrap(), source);
1015 }
1016
1017 #[test]
1018 fn public_value_errors_and_optional_stamp_encoding_are_stable() {
1019 let path_messages = [
1020 VPathError::Empty,
1021 VPathError::Absolute,
1022 VPathError::EscapesRoot,
1023 VPathError::NulByte,
1024 VPathError::PlatformPrefix,
1025 ]
1026 .map(|error| error.to_string());
1027 assert!(path_messages.iter().all(|message| !message.is_empty()));
1028
1029 assert_eq!(
1030 ParseDigestError::InvalidLength { observed: 1 }.to_string(),
1031 "digest must be 64 hexadecimal bytes, got 1"
1032 );
1033 assert_eq!(
1034 ParseDigestError::InvalidHex { index: 2 }.to_string(),
1035 "digest contains non-hexadecimal byte at index 2"
1036 );
1037
1038 let stamp = FileStamp {
1039 kind: NodeKind::File,
1040 size: 0,
1041 mode: 0o644,
1042 mtime_ns: 0,
1043 ctime_ns: None,
1044 file_id: PlatformFileId { high: 0, low: 0 },
1045 };
1046 let mut encoded = Vec::new();
1047 NodeState::from_stamp(stamp).encode_canonical(&mut encoded);
1048 assert!(!encoded.is_empty());
1049 }
1050
1051 #[test]
1052 fn digest_ids_are_fixed_width_lower_hex() {
1053 let id = BlobId::from_bytes([0xab; 32]);
1054 assert_eq!(id.as_bytes(), &[0xab; 32]);
1055 assert_eq!(id.to_string(), "ab".repeat(32));
1056 assert_eq!(format!("{id:?}"), id.to_string());
1057
1058 let snapshot = SnapshotId::from_bytes([1; 32]);
1059 let transaction = TransactionId::from_bytes([2; 32]);
1060 assert_ne!(snapshot.to_string(), transaction.to_string());
1061 }
1062
1063 #[test]
1064 fn digest_ids_parse_exact_hex_without_a_dependency() {
1065 let expected = TransactionId::from_bytes([0xab; 32]);
1066 assert_eq!("ab".repeat(32).parse::<TransactionId>().unwrap(), expected);
1067 assert_eq!("AB".repeat(32).parse::<TransactionId>().unwrap(), expected);
1068 assert_eq!(
1069 "ab".parse::<TransactionId>(),
1070 Err(ParseDigestError::InvalidLength { observed: 2 })
1071 );
1072 let mut invalid = "ab".repeat(32);
1073 invalid.replace_range(7..8, "z");
1074 assert_eq!(
1075 invalid.parse::<TransactionId>(),
1076 Err(ParseDigestError::InvalidHex { index: 7 })
1077 );
1078 }
1079
1080 #[test]
1081 fn content_and_domain_hashes_are_deterministic_and_separated() {
1082 let payload = b"canonical bytes";
1083 assert_eq!(BlobId::digest(payload), BlobId::digest(payload));
1084 assert_ne!(
1085 SnapshotId::digest_manifest(payload).as_bytes(),
1086 DiffDigest::digest_canonical(payload).as_bytes()
1087 );
1088 assert_ne!(
1089 DiffDigest::digest_canonical(payload).as_bytes(),
1090 DirectoryDigest::digest_canonical(payload).as_bytes()
1091 );
1092 }
1093
1094 #[test]
1095 fn node_state_canonical_encoding_captures_metadata_and_content() {
1096 let blob = BlobId::digest(b"data");
1097 let file = NodeState::file(blob, 4, 0o644);
1098 let changed_mode = NodeState::file(blob, 4, 0o600);
1099 let changed_content = NodeState::file(BlobId::digest(b"else"), 4, 0o644);
1100 let mut encoded = Vec::new();
1101 file.encode_canonical(&mut encoded);
1102
1103 assert!(!encoded.is_empty());
1104 assert!(file.content_equivalent(changed_mode));
1105 assert!(!file.content_equivalent(changed_content));
1106 assert_eq!(file.kind(), NodeKind::File);
1107 assert_eq!(file.size(), 4);
1108 assert_eq!(file.mode(), 0o644);
1109 assert_eq!(file.content(), Some(ContentVersion::Blob(blob)));
1110 assert!(NodeState::directory(0o755).with_blob(blob, 4).is_none());
1111 }
1112
1113 #[test]
1114 fn transaction_happy_path_is_valid() {
1115 let states = [
1116 TransactionState::Created,
1117 TransactionState::Running,
1118 TransactionState::VirtualComplete,
1119 TransactionState::AutoApproved,
1120 TransactionState::Reserved,
1121 TransactionState::Revalidating,
1122 TransactionState::Committing,
1123 TransactionState::Committed,
1124 ];
1125 for pair in states.windows(2) {
1126 assert!(pair[0].can_transition_to(pair[1]), "pair: {pair:?}");
1127 }
1128 assert!(TransactionState::Committed.is_terminal());
1129 }
1130
1131 #[test]
1132 fn transaction_rejects_replay_and_skipped_states() {
1133 assert!(!TransactionState::Approved.can_transition_to(TransactionState::Committed));
1134 assert!(!TransactionState::Committed.can_transition_to(TransactionState::Reserved));
1135 assert!(!TransactionState::Denied.can_transition_to(TransactionState::Approved));
1136 assert!(TransactionState::Denied.is_terminal());
1137 assert!(!TransactionState::RecoveryRequired.is_terminal());
1138 }
1139}