Skip to main content

vsh_types/
lib.rs

1//! Security-sensitive value types shared by the VSH Rust core.
2
3use std::error::Error;
4use std::fmt;
5use std::str::FromStr;
6
7/// A normalized, workspace-relative virtual path.
8#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub struct VPath(String);
10
11impl VPath {
12    /// Return the canonical virtual workspace root.
13    #[must_use]
14    pub fn root() -> Self {
15        Self(".".to_owned())
16    }
17
18    /// Parse and normalize a portable virtual path.
19    ///
20    /// Both slash styles are treated as separators so a path accepted on one host
21    /// cannot become an escape on another. Parent components may simplify a path but
22    /// may never escape the virtual root.
23    ///
24    /// # Errors
25    ///
26    /// Returns [`VPathError`] when the input is empty, absolute, contains a NUL byte or
27    /// platform prefix, or would escape the virtual root during normalization.
28    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    /// Return the canonical slash-separated representation.
68    #[must_use]
69    pub fn as_str(&self) -> &str {
70        &self.0
71    }
72
73    /// Return whether this path denotes the virtual workspace root.
74    #[must_use]
75    pub fn is_root(&self) -> bool {
76        self.0 == "."
77    }
78
79    /// Return the normalized parent, or `None` for the virtual root.
80    #[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    /// Return the final path component, or `None` for the virtual root.
92    #[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    /// Join and normalize a relative child path.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`VPathError`] if `child` is invalid or the combined path escapes the
102    /// virtual root.
103    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    /// Return whether this path is equal to or below `ancestor`.
113    #[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    /// Return the normalized relative suffix below `ancestor`.
124    #[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    /// Rebase this path from one virtual subtree to another.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`VPathError`] if the rebased path would be invalid.
142    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/// A reason a virtual path was rejected.
173#[derive(Clone, Copy, Debug, Eq, PartialEq)]
174#[non_exhaustive]
175pub enum VPathError {
176    /// An empty string has no unambiguous virtual-path meaning.
177    Empty,
178    /// The path is absolute.
179    Absolute,
180    /// Normalization would leave the virtual workspace root.
181    EscapesRoot,
182    /// The path contains a NUL byte.
183    NulByte,
184    /// The path starts with a platform-specific absolute prefix such as `C:`.
185    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/// A canonical 32-byte lowercase/uppercase hexadecimal identifier was malformed.
248#[derive(Clone, Copy, Debug, Eq, PartialEq)]
249#[non_exhaustive]
250pub enum ParseDigestError {
251    /// The textual form was not exactly 64 ASCII bytes.
252    InvalidLength {
253        /// Observed byte length.
254        observed: usize,
255    },
256    /// One byte was not an ASCII hexadecimal digit.
257    InvalidHex {
258        /// Zero-based byte offset of the invalid digit.
259        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    /// Hash immutable blob bytes with BLAKE3.
364    #[must_use]
365    pub fn digest(bytes: &[u8]) -> Self {
366        Self::from_bytes(*blake3::hash(bytes).as_bytes())
367    }
368}
369
370impl SnapshotId {
371    /// Hash a canonical snapshot manifest with a VSH domain separator.
372    #[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    /// Hash a canonical diff encoding with a VSH domain separator.
380    #[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    /// Hash a canonical directory-listing encoding with a VSH domain separator.
388    #[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    /// Hash path-ordered direct children using VSH's canonical listing encoding.
394    ///
395    /// Callers must provide entries in canonical [`VPath`] order. Keeping this codec in
396    /// the shared type crate ensures snapshot capture, virtual reads, and trusted host
397    /// revalidation cannot silently diverge.
398    #[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    /// Hash exact program UTF-8 bytes with a VSH domain separator.
411    #[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    /// Hash a canonical read-set encoding with a VSH domain separator.
419    #[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    /// Hash a canonical write-set encoding with a VSH domain separator.
427    #[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    /// Hash a canonical deterministic-policy encoding with a VSH domain separator.
435    #[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    /// Hash security-relevant runtime configuration with a VSH domain separator.
443    #[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    /// Hash transaction intent without retaining its potentially sensitive text.
451    #[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    /// Hash a stable principal label without persisting it in transaction state.
459    #[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/// Exact fields covered by an independent approval grant.
466#[derive(Clone, Copy, Debug, Eq, PartialEq)]
467pub struct ApprovalBinding {
468    /// Exact transaction artifact approved by the principal.
469    pub transaction: TransactionId,
470    /// Opaque identity of the independent principal.
471    pub principal: PrincipalId,
472    /// Host-supplied issuance time in Unix milliseconds.
473    pub issued_at_unix_ms: u64,
474    /// Exclusive expiry time in Unix milliseconds.
475    pub expires_at_unix_ms: u64,
476}
477
478impl ApprovalBinding {
479    /// Derive the immutable grant identity.
480    #[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/// Exact immutable inputs bound into an approval and commit identity.
492#[derive(Clone, Copy, Debug, Eq, PartialEq)]
493pub struct TransactionBinding {
494    /// Immutable base snapshot observed by virtual execution.
495    pub base_snapshot: SnapshotId,
496    /// Exact canonical final diff.
497    pub diff: DiffDigest,
498    /// Exact dependencies read while deriving the result.
499    pub read_set: ReadSetDigest,
500    /// Exact preconditions for paths the transaction will write.
501    pub write_set: WriteSetDigest,
502    /// Exact untrusted program source.
503    pub program: ProgramDigest,
504    /// Exact deterministic policy configuration.
505    pub policy: PolicyDigest,
506    /// Security-relevant execution configuration and budgets.
507    pub runtime_config: RuntimeConfigDigest,
508    /// Optional out-of-band user intent shown to an approval principal.
509    pub intent: Option<IntentDigest>,
510}
511
512impl TransactionBinding {
513    /// Derive the single transaction identity to which approval and commit bind.
514    #[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/// The semantic kind of a virtual filesystem node.
552#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
553pub enum NodeKind {
554    /// A regular file.
555    File,
556    /// A directory.
557    Directory,
558    /// An opaque symbolic link; the virtual filesystem never follows it implicitly.
559    Symlink,
560}
561
562impl NodeKind {
563    /// Return the stable tag used by canonical encodings.
564    #[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/// Platform-specific identity of a host filesystem node.
575///
576/// Unix implementations encode device and inode; Windows implementations encode the
577/// volume and file identity. Keeping two opaque words avoids host paths in receipts.
578#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
579pub struct PlatformFileId {
580    /// Platform-defined high word (for example, Unix device ID).
581    pub high: u64,
582    /// Platform-defined low word (for example, Unix inode ID).
583    pub low: u64,
584}
585
586/// Metadata identity captured for one immutable base-snapshot node.
587#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
588pub struct FileStamp {
589    /// Node kind observed without following a symbolic link.
590    pub kind: NodeKind,
591    /// Byte size reported by the host.
592    pub size: u64,
593    /// Portable permission/mode bits retained by VSH.
594    pub mode: u32,
595    /// Nanosecond modification time.
596    pub mtime_ns: i128,
597    /// Nanosecond metadata-change time when the host exposes it.
598    pub ctime_ns: Option<i128>,
599    /// Stable platform file identity for race detection.
600    pub file_id: PlatformFileId,
601}
602
603/// Content identity carried by a node state.
604#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
605#[non_exhaustive]
606pub enum ContentVersion {
607    /// Exact immutable content has been captured in the blob store.
608    Blob(BlobId),
609    /// Content is lazy; this metadata stamp must still match when it is captured.
610    Stamp(FileStamp),
611}
612
613/// Canonical state of a virtual filesystem node.
614#[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    /// Construct a synthetic directory state.
624    #[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    /// Construct a regular file backed by an immutable blob.
635    #[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    /// Construct an opaque symbolic link backed by its target bytes.
646    #[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    /// Construct a lazily materialized state from verified host metadata.
657    #[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    /// Return the semantic node kind.
668    #[must_use]
669    pub const fn kind(self) -> NodeKind {
670        self.kind
671    }
672
673    /// Return the node byte size.
674    #[must_use]
675    pub const fn size(self) -> u64 {
676        self.size
677    }
678
679    /// Return retained permission/mode bits.
680    #[must_use]
681    pub const fn mode(self) -> u32 {
682        self.mode
683    }
684
685    /// Return the exact or lazy content version, if applicable.
686    #[must_use]
687    pub const fn content(self) -> Option<ContentVersion> {
688        self.content
689    }
690
691    /// Return a copy whose file/link content is now materialized.
692    #[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    /// Append this state to a stable length-delimited canonical encoding.
706    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    /// Return whether only metadata differs between two states.
724    #[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/// Semantic category of one canonical diff entry.
747#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
748pub enum DiffKind {
749    /// A path absent from the base exists in final virtual state.
750    Create,
751    /// A base path is absent from final virtual state.
752    Delete,
753    /// Node kind or content changed.
754    Modify,
755    /// Only retained metadata changed.
756    MetadataChange,
757}
758
759/// One path change in a canonical virtual filesystem diff.
760#[derive(Clone, Debug, Eq, PartialEq)]
761pub struct DiffEntry {
762    /// Changed virtual path.
763    pub path: VPath,
764    /// Base state, or `None` when this is a creation.
765    pub before: Option<NodeState>,
766    /// Final virtual state, or `None` when this is a deletion.
767    pub after: Option<NodeState>,
768    /// Semantic change category derived from `before` and `after`.
769    pub kind: DiffKind,
770}
771
772/// A persisted transaction state.
773#[derive(Clone, Copy, Debug, Eq, PartialEq)]
774#[non_exhaustive]
775pub enum TransactionState {
776    /// The transaction record exists but execution has not started.
777    Created,
778    /// The untrusted program is executing against virtual state.
779    Running,
780    /// Virtual execution and canonical diff generation completed.
781    VirtualComplete,
782    /// Deterministic policy denied the transaction.
783    Denied,
784    /// Deterministic policy approved the transaction without a judge.
785    AutoApproved,
786    /// The transaction is awaiting an independent approval decision.
787    PendingApproval,
788    /// An independent principal approved the exact transaction.
789    Approved,
790    /// The transaction acquired the single-use commit reservation.
791    Reserved,
792    /// Recorded dependencies are being revalidated.
793    Revalidating,
794    /// The trusted committer is applying the canonical diff.
795    Committing,
796    /// The committed host state passed verification.
797    Committed,
798    /// Revalidation detected stale state before commit.
799    Stale,
800    /// Approval expired before reservation.
801    Expired,
802    /// Recovery is required after an interrupted commit.
803    RecoveryRequired,
804    /// The transaction failed without a successful commit.
805    Failed,
806}
807
808impl TransactionState {
809    /// Return whether `next` is a valid persisted state transition.
810    #[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    /// Return whether no normal transition may leave this state.
837    #[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/// An invalid persisted transaction transition.
847#[derive(Clone, Copy, Debug, Eq, PartialEq)]
848pub struct TransitionError {
849    /// State before the rejected transition.
850    pub from: TransactionState,
851    /// Requested next state.
852    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}