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);
384digest_id!(
385 HookId,
386 "The opaque digest identity of one configured commit hook."
387);
388digest_id!(
389 RequestEventId,
390 "The digest identity of one exact commit-hook request event."
391);
392
393impl BlobId {
394 #[must_use]
396 pub fn digest(bytes: &[u8]) -> Self {
397 Self::from_bytes(*blake3::hash(bytes).as_bytes())
398 }
399}
400
401impl SnapshotId {
402 #[must_use]
404 pub fn digest_manifest(canonical_manifest: &[u8]) -> Self {
405 Self::from_bytes(domain_hash(b"snapshot-v1", canonical_manifest))
406 }
407}
408
409impl DiffDigest {
410 #[must_use]
412 pub fn digest_canonical(canonical_diff: &[u8]) -> Self {
413 Self::from_bytes(domain_hash(b"diff-v1", canonical_diff))
414 }
415}
416
417impl DirectoryDigest {
418 #[must_use]
420 pub fn digest_canonical(canonical_listing: &[u8]) -> Self {
421 Self::from_bytes(domain_hash(b"directory-v1", canonical_listing))
422 }
423
424 #[must_use]
430 pub fn digest_entries<'a>(entries: impl IntoIterator<Item = (&'a VPath, NodeState)>) -> Self {
431 let mut canonical = Vec::new();
432 for (path, state) in entries {
433 encode_vpath(path, &mut canonical);
434 state.encode_canonical(&mut canonical);
435 }
436 Self::digest_canonical(&canonical)
437 }
438}
439
440impl ProgramDigest {
441 #[must_use]
443 pub fn digest_source(source: &str) -> Self {
444 Self::from_bytes(domain_hash(b"program-v1", source.as_bytes()))
445 }
446}
447
448impl ReadSetDigest {
449 #[must_use]
451 pub fn digest_canonical(canonical_read_set: &[u8]) -> Self {
452 Self::from_bytes(domain_hash(b"read-set-v1", canonical_read_set))
453 }
454}
455
456impl WriteSetDigest {
457 #[must_use]
459 pub fn digest_canonical(canonical_write_set: &[u8]) -> Self {
460 Self::from_bytes(domain_hash(b"write-set-v1", canonical_write_set))
461 }
462}
463
464impl PolicyDigest {
465 #[must_use]
467 pub fn digest_canonical(canonical_policy: &[u8]) -> Self {
468 Self::from_bytes(domain_hash(b"policy-v1", canonical_policy))
469 }
470}
471
472impl RuntimeConfigDigest {
473 #[must_use]
475 pub fn digest_canonical(canonical_config: &[u8]) -> Self {
476 Self::from_bytes(domain_hash(b"runtime-config-v1", canonical_config))
477 }
478}
479
480impl IntentDigest {
481 #[must_use]
483 pub fn digest_text(intent: &str) -> Self {
484 Self::from_bytes(domain_hash(b"intent-v1", intent.as_bytes()))
485 }
486}
487
488impl PrincipalId {
489 #[must_use]
491 pub fn digest_label(label: &str) -> Self {
492 Self::from_bytes(domain_hash(b"principal-v1", label.as_bytes()))
493 }
494}
495
496impl HookId {
497 #[must_use]
499 pub fn digest_label(label: &str) -> Self {
500 Self::from_bytes(domain_hash(b"hook-v1", label.as_bytes()))
501 }
502}
503
504impl RequestEventId {
505 #[must_use]
507 pub fn derive(transaction: TransactionId, hook: HookId, scope_tag: u8) -> Self {
508 let mut canonical = Vec::with_capacity(65);
509 canonical.extend_from_slice(transaction.as_bytes());
510 canonical.extend_from_slice(hook.as_bytes());
511 canonical.push(scope_tag);
512 Self::from_bytes(domain_hash(b"request-event-v1", &canonical))
513 }
514}
515
516#[derive(Clone, Copy, Debug, Eq, PartialEq)]
518pub struct ApprovalBinding {
519 pub transaction: TransactionId,
521 pub principal: PrincipalId,
523 pub issued_at_unix_ms: u64,
525 pub expires_at_unix_ms: u64,
527}
528
529impl ApprovalBinding {
530 #[must_use]
532 pub fn approval_id(self) -> ApprovalId {
533 let mut canonical = Vec::with_capacity(32 * 2 + 16);
534 canonical.extend_from_slice(self.transaction.as_bytes());
535 canonical.extend_from_slice(self.principal.as_bytes());
536 canonical.extend_from_slice(&self.issued_at_unix_ms.to_le_bytes());
537 canonical.extend_from_slice(&self.expires_at_unix_ms.to_le_bytes());
538 ApprovalId::from_bytes(domain_hash(b"approval-v1", &canonical))
539 }
540}
541
542#[derive(Clone, Copy, Debug, Eq, PartialEq)]
544pub struct TransactionBinding {
545 pub base_snapshot: SnapshotId,
547 pub diff: DiffDigest,
549 pub read_set: ReadSetDigest,
551 pub write_set: WriteSetDigest,
553 pub program: ProgramDigest,
555 pub policy: PolicyDigest,
557 pub runtime_config: RuntimeConfigDigest,
559 pub intent: Option<IntentDigest>,
561}
562
563impl TransactionBinding {
564 #[must_use]
566 pub fn transaction_id(self) -> TransactionId {
567 let mut canonical = Vec::with_capacity(32 * 8 + 1);
568 canonical.extend_from_slice(self.base_snapshot.as_bytes());
569 canonical.extend_from_slice(self.diff.as_bytes());
570 canonical.extend_from_slice(self.read_set.as_bytes());
571 canonical.extend_from_slice(self.write_set.as_bytes());
572 canonical.extend_from_slice(self.program.as_bytes());
573 canonical.extend_from_slice(self.policy.as_bytes());
574 canonical.extend_from_slice(self.runtime_config.as_bytes());
575 match self.intent {
576 Some(intent) => {
577 canonical.push(1);
578 canonical.extend_from_slice(intent.as_bytes());
579 }
580 None => canonical.push(0),
581 }
582 TransactionId::from_bytes(domain_hash(b"transaction-v1", &canonical))
583 }
584}
585
586fn domain_hash(domain: &[u8], payload: &[u8]) -> [u8; 32] {
587 let mut hasher = blake3::Hasher::new();
588 hasher.update(b"vsh\0");
589 hasher.update(&(domain.len() as u64).to_le_bytes());
590 hasher.update(domain);
591 hasher.update(&(payload.len() as u64).to_le_bytes());
592 hasher.update(payload);
593 *hasher.finalize().as_bytes()
594}
595
596fn encode_vpath(path: &VPath, output: &mut Vec<u8>) {
597 let bytes = path.as_str().as_bytes();
598 output.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
599 output.extend_from_slice(bytes);
600}
601
602#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
604pub enum NodeKind {
605 File,
607 Directory,
609 Symlink,
611}
612
613impl NodeKind {
614 #[must_use]
616 pub const fn canonical_tag(self) -> u8 {
617 match self {
618 Self::File => 1,
619 Self::Directory => 2,
620 Self::Symlink => 3,
621 }
622 }
623}
624
625#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
630pub struct PlatformFileId {
631 pub high: u64,
633 pub low: u64,
635}
636
637#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
639pub struct FileStamp {
640 pub kind: NodeKind,
642 pub size: u64,
644 pub mode: u32,
646 pub mtime_ns: i128,
648 pub ctime_ns: Option<i128>,
650 pub file_id: PlatformFileId,
652}
653
654#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
656#[non_exhaustive]
657pub enum ContentVersion {
658 Blob(BlobId),
660 Stamp(FileStamp),
662}
663
664#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
666pub struct NodeState {
667 kind: NodeKind,
668 size: u64,
669 mode: u32,
670 content: Option<ContentVersion>,
671}
672
673impl NodeState {
674 #[must_use]
676 pub const fn directory(mode: u32) -> Self {
677 Self {
678 kind: NodeKind::Directory,
679 size: 0,
680 mode,
681 content: None,
682 }
683 }
684
685 #[must_use]
687 pub const fn file(blob: BlobId, size: u64, mode: u32) -> Self {
688 Self {
689 kind: NodeKind::File,
690 size,
691 mode,
692 content: Some(ContentVersion::Blob(blob)),
693 }
694 }
695
696 #[must_use]
698 pub const fn symlink(blob: BlobId, size: u64, mode: u32) -> Self {
699 Self {
700 kind: NodeKind::Symlink,
701 size,
702 mode,
703 content: Some(ContentVersion::Blob(blob)),
704 }
705 }
706
707 #[must_use]
709 pub const fn from_stamp(stamp: FileStamp) -> Self {
710 Self {
711 kind: stamp.kind,
712 size: stamp.size,
713 mode: stamp.mode,
714 content: Some(ContentVersion::Stamp(stamp)),
715 }
716 }
717
718 #[must_use]
720 pub const fn kind(self) -> NodeKind {
721 self.kind
722 }
723
724 #[must_use]
726 pub const fn size(self) -> u64 {
727 self.size
728 }
729
730 #[must_use]
732 pub const fn mode(self) -> u32 {
733 self.mode
734 }
735
736 #[must_use]
738 pub const fn content(self) -> Option<ContentVersion> {
739 self.content
740 }
741
742 #[must_use]
744 pub const fn with_blob(self, blob: BlobId, size: u64) -> Option<Self> {
745 match self.kind {
746 NodeKind::Directory => None,
747 NodeKind::File | NodeKind::Symlink => Some(Self {
748 kind: self.kind,
749 size,
750 mode: self.mode,
751 content: Some(ContentVersion::Blob(blob)),
752 }),
753 }
754 }
755
756 pub fn encode_canonical(self, output: &mut Vec<u8>) {
758 output.push(self.kind.canonical_tag());
759 output.extend_from_slice(&self.size.to_le_bytes());
760 output.extend_from_slice(&self.mode.to_le_bytes());
761 match self.content {
762 None => output.push(0),
763 Some(ContentVersion::Blob(blob)) => {
764 output.push(1);
765 output.extend_from_slice(blob.as_bytes());
766 }
767 Some(ContentVersion::Stamp(stamp)) => {
768 output.push(2);
769 encode_stamp(stamp, output);
770 }
771 }
772 }
773
774 #[must_use]
776 pub fn content_equivalent(self, other: Self) -> bool {
777 self.kind == other.kind && self.size == other.size && self.content == other.content
778 }
779}
780
781fn encode_stamp(stamp: FileStamp, output: &mut Vec<u8>) {
782 output.push(stamp.kind.canonical_tag());
783 output.extend_from_slice(&stamp.size.to_le_bytes());
784 output.extend_from_slice(&stamp.mode.to_le_bytes());
785 output.extend_from_slice(&stamp.mtime_ns.to_le_bytes());
786 match stamp.ctime_ns {
787 Some(value) => {
788 output.push(1);
789 output.extend_from_slice(&value.to_le_bytes());
790 }
791 None => output.push(0),
792 }
793 output.extend_from_slice(&stamp.file_id.high.to_le_bytes());
794 output.extend_from_slice(&stamp.file_id.low.to_le_bytes());
795}
796
797#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
799pub enum DiffKind {
800 Create,
802 Delete,
804 Modify,
806 MetadataChange,
808}
809
810#[derive(Clone, Debug, Eq, PartialEq)]
812pub struct DiffEntry {
813 pub path: VPath,
815 pub before: Option<NodeState>,
817 pub after: Option<NodeState>,
819 pub kind: DiffKind,
821}
822
823#[derive(Clone, Copy, Debug, Eq, PartialEq)]
825#[non_exhaustive]
826pub enum TransactionState {
827 Created,
829 Running,
831 VirtualComplete,
833 Denied,
835 AutoApproved,
837 PendingApproval,
839 Rejected,
841 Approved,
843 Reserved,
845 Revalidating,
847 Committing,
849 Committed,
851 Stale,
853 Expired,
855 RecoveryRequired,
857 Failed,
859}
860
861impl TransactionState {
862 #[must_use]
864 pub const fn can_transition_to(self, next: Self) -> bool {
865 matches!(
866 (self, next),
867 (Self::Created, Self::Running)
868 | (Self::Running, Self::VirtualComplete | Self::Failed)
869 | (
870 Self::VirtualComplete,
871 Self::Denied | Self::AutoApproved | Self::PendingApproval | Self::Failed
872 )
873 | (
874 Self::PendingApproval,
875 Self::Approved | Self::Denied | Self::Rejected | Self::Expired | Self::Failed
876 )
877 | (Self::AutoApproved, Self::PendingApproval | Self::Rejected)
878 | (Self::AutoApproved | Self::Approved, Self::Reserved)
879 | (Self::Approved, Self::Expired)
880 | (Self::Reserved, Self::Revalidating | Self::Failed)
881 | (
882 Self::Revalidating,
883 Self::Committing | Self::Stale | Self::Failed
884 )
885 | (Self::Committing, Self::Committed | Self::RecoveryRequired)
886 | (Self::RecoveryRequired, Self::Committed | Self::Failed)
887 )
888 }
889
890 #[must_use]
892 pub const fn is_terminal(self) -> bool {
893 matches!(
894 self,
895 Self::Denied
896 | Self::Rejected
897 | Self::Committed
898 | Self::Stale
899 | Self::Expired
900 | Self::Failed
901 )
902 }
903}
904
905#[derive(Clone, Copy, Debug, Eq, PartialEq)]
907pub struct TransitionError {
908 pub from: TransactionState,
910 pub to: TransactionState,
912}
913
914impl fmt::Display for TransitionError {
915 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
916 write!(
917 formatter,
918 "invalid transaction transition: {:?} -> {:?}",
919 self.from, self.to
920 )
921 }
922}
923
924impl Error for TransitionError {}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929
930 #[test]
931 fn canonical_fast_path_matches_normalization_oracle() {
932 fn oracle(input: &str) -> Result<VPath, VPathError> {
933 if input.is_empty() {
934 return Err(VPathError::Empty);
935 }
936 if input.contains('\0') {
937 return Err(VPathError::NulByte);
938 }
939 let portable = input.replace('\\', "/");
940 if portable.starts_with('/') {
941 return Err(VPathError::Absolute);
942 }
943 if is_windows_prefix(portable.split('/').next().unwrap_or_default()) {
944 return Err(VPathError::PlatformPrefix);
945 }
946 let mut components = Vec::new();
947 for component in portable.split('/') {
948 match component {
949 "" | "." => {}
950 ".." => {
951 if components.pop().is_none() {
952 return Err(VPathError::EscapesRoot);
953 }
954 }
955 value => components.push(value),
956 }
957 }
958 Ok(VPath(if components.is_empty() {
959 ".".to_owned()
960 } else {
961 components.join("/")
962 }))
963 }
964 let components = ["", ".", "..", "a", "é", "C:", "x:y", "a\0b", "a\\b"];
965 for first in components {
966 assert_eq!(VPath::parse(first), oracle(first));
967 for second in components {
968 for third in components {
969 for separator in ["/", "\\"] {
970 let candidate = [first, second, third].join(separator);
971 assert_eq!(
972 VPath::parse(&candidate),
973 oracle(&candidate),
974 "{candidate:?}"
975 );
976 let combined = format!("parent/{candidate}");
979 assert_eq!(
980 VPath::parse("parent").unwrap().join(&candidate),
981 oracle(&combined),
982 "joined {candidate:?}"
983 );
984 }
985 }
986 }
987 }
988 }
989
990 #[test]
991 fn vpath_normalizes_portable_components() {
992 let path = VPath::parse("src\\vsh/./core/../lib.rs").unwrap();
993 assert_eq!(path.as_str(), "src/vsh/lib.rs");
994 assert!(!path.is_root());
995 assert_eq!(path.to_string(), "src/vsh/lib.rs");
996 }
997
998 #[test]
999 fn vpath_normalizes_root() {
1000 for candidate in [".", "./", "a/..", "a//../."] {
1001 let path = VPath::parse(candidate).unwrap();
1002 assert!(path.is_root(), "candidate: {candidate}");
1003 assert_eq!(path.as_str(), ".");
1004 }
1005 }
1006
1007 #[test]
1008 fn vpath_rejects_ambiguous_or_escaping_paths() {
1009 let cases = [
1010 ("", VPathError::Empty),
1011 ("/etc/passwd", VPathError::Absolute),
1012 ("\\\\server\\share", VPathError::Absolute),
1013 ("C:\\Windows", VPathError::PlatformPrefix),
1014 ("../secret", VPathError::EscapesRoot),
1015 ("a/../../secret", VPathError::EscapesRoot),
1016 ("a\0b", VPathError::NulByte),
1017 ];
1018
1019 for (candidate, expected) in cases {
1020 assert_eq!(
1021 VPath::parse(candidate),
1022 Err(expected),
1023 "candidate: {candidate}"
1024 );
1025 }
1026 }
1027
1028 #[test]
1029 fn vpath_parent_join_and_rebase_preserve_root_contract() {
1030 let root = VPath::parse(".").unwrap();
1031 let source = VPath::parse("src/tree/file.txt").unwrap();
1032 let subtree = VPath::parse("src/tree").unwrap();
1033 let destination = VPath::parse("lib").unwrap();
1034
1035 assert_eq!(source.parent().unwrap().as_str(), "src/tree");
1036 assert_eq!(root.parent(), None);
1037 assert_eq!(root.join("a/b").unwrap().as_str(), "a/b");
1038 assert_eq!(subtree.join("child").unwrap().as_str(), "src/tree/child");
1039 assert!(source.is_within(&root));
1040 assert!(source.is_within(&subtree));
1041 assert!(!subtree.is_within(&source));
1042 assert_eq!(source.relative_to(&subtree), Some("file.txt"));
1043 assert_eq!(
1044 source.rebase(&subtree, &destination).unwrap().unwrap(),
1045 VPath::parse("lib/file.txt").unwrap()
1046 );
1047 assert_eq!(source.rebase(&destination, &subtree).unwrap(), None);
1048 assert_eq!(root.relative_to(&root), Some(""));
1049 assert_eq!(source.relative_to(&root), Some("src/tree/file.txt"));
1050 assert_eq!(VPath::try_from("src/tree/file.txt").unwrap(), source);
1051 }
1052
1053 #[test]
1054 fn public_value_errors_and_optional_stamp_encoding_are_stable() {
1055 let path_messages = [
1056 VPathError::Empty,
1057 VPathError::Absolute,
1058 VPathError::EscapesRoot,
1059 VPathError::NulByte,
1060 VPathError::PlatformPrefix,
1061 ]
1062 .map(|error| error.to_string());
1063 assert!(path_messages.iter().all(|message| !message.is_empty()));
1064
1065 assert_eq!(
1066 ParseDigestError::InvalidLength { observed: 1 }.to_string(),
1067 "digest must be 64 hexadecimal bytes, got 1"
1068 );
1069 assert_eq!(
1070 ParseDigestError::InvalidHex { index: 2 }.to_string(),
1071 "digest contains non-hexadecimal byte at index 2"
1072 );
1073
1074 let stamp = FileStamp {
1075 kind: NodeKind::File,
1076 size: 0,
1077 mode: 0o644,
1078 mtime_ns: 0,
1079 ctime_ns: None,
1080 file_id: PlatformFileId { high: 0, low: 0 },
1081 };
1082 let mut encoded = Vec::new();
1083 NodeState::from_stamp(stamp).encode_canonical(&mut encoded);
1084 assert!(!encoded.is_empty());
1085 }
1086
1087 #[test]
1088 fn digest_ids_are_fixed_width_lower_hex() {
1089 let id = BlobId::from_bytes([0xab; 32]);
1090 assert_eq!(id.as_bytes(), &[0xab; 32]);
1091 assert_eq!(id.to_string(), "ab".repeat(32));
1092 assert_eq!(format!("{id:?}"), id.to_string());
1093
1094 let snapshot = SnapshotId::from_bytes([1; 32]);
1095 let transaction = TransactionId::from_bytes([2; 32]);
1096 assert_ne!(snapshot.to_string(), transaction.to_string());
1097 }
1098
1099 #[test]
1100 fn digest_ids_parse_exact_hex_without_a_dependency() {
1101 let expected = TransactionId::from_bytes([0xab; 32]);
1102 assert_eq!("ab".repeat(32).parse::<TransactionId>().unwrap(), expected);
1103 assert_eq!("AB".repeat(32).parse::<TransactionId>().unwrap(), expected);
1104 assert_eq!(
1105 "ab".parse::<TransactionId>(),
1106 Err(ParseDigestError::InvalidLength { observed: 2 })
1107 );
1108 let mut invalid = "ab".repeat(32);
1109 invalid.replace_range(7..8, "z");
1110 assert_eq!(
1111 invalid.parse::<TransactionId>(),
1112 Err(ParseDigestError::InvalidHex { index: 7 })
1113 );
1114 }
1115
1116 #[test]
1117 fn content_and_domain_hashes_are_deterministic_and_separated() {
1118 let payload = b"canonical bytes";
1119 assert_eq!(BlobId::digest(payload), BlobId::digest(payload));
1120 assert_ne!(
1121 SnapshotId::digest_manifest(payload).as_bytes(),
1122 DiffDigest::digest_canonical(payload).as_bytes()
1123 );
1124 assert_ne!(
1125 DiffDigest::digest_canonical(payload).as_bytes(),
1126 DirectoryDigest::digest_canonical(payload).as_bytes()
1127 );
1128 }
1129
1130 #[test]
1131 fn node_state_canonical_encoding_captures_metadata_and_content() {
1132 let blob = BlobId::digest(b"data");
1133 let file = NodeState::file(blob, 4, 0o644);
1134 let changed_mode = NodeState::file(blob, 4, 0o600);
1135 let changed_content = NodeState::file(BlobId::digest(b"else"), 4, 0o644);
1136 let mut encoded = Vec::new();
1137 file.encode_canonical(&mut encoded);
1138
1139 assert!(!encoded.is_empty());
1140 assert!(file.content_equivalent(changed_mode));
1141 assert!(!file.content_equivalent(changed_content));
1142 assert_eq!(file.kind(), NodeKind::File);
1143 assert_eq!(file.size(), 4);
1144 assert_eq!(file.mode(), 0o644);
1145 assert_eq!(file.content(), Some(ContentVersion::Blob(blob)));
1146 assert!(NodeState::directory(0o755).with_blob(blob, 4).is_none());
1147 }
1148
1149 #[test]
1150 fn transaction_happy_path_is_valid() {
1151 let states = [
1152 TransactionState::Created,
1153 TransactionState::Running,
1154 TransactionState::VirtualComplete,
1155 TransactionState::AutoApproved,
1156 TransactionState::Reserved,
1157 TransactionState::Revalidating,
1158 TransactionState::Committing,
1159 TransactionState::Committed,
1160 ];
1161 for pair in states.windows(2) {
1162 assert!(pair[0].can_transition_to(pair[1]), "pair: {pair:?}");
1163 }
1164 assert!(TransactionState::Committed.is_terminal());
1165 }
1166
1167 #[test]
1168 fn transaction_rejects_replay_and_skipped_states() {
1169 assert!(!TransactionState::Approved.can_transition_to(TransactionState::Committed));
1170 assert!(!TransactionState::Committed.can_transition_to(TransactionState::Reserved));
1171 assert!(!TransactionState::Denied.can_transition_to(TransactionState::Approved));
1172 assert!(TransactionState::Denied.is_terminal());
1173 assert!(!TransactionState::RecoveryRequired.is_terminal());
1174 }
1175}