Skip to main content

vsh_types/
lib.rs

1//! Security-sensitive value types shared by the VSH Rust core.
2
3use std::borrow::{Borrow, Cow};
4use std::error::Error;
5use std::fmt;
6use std::str::FromStr;
7
8/// A normalized, workspace-relative virtual path.
9#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub struct VPath(String);
11
12// Canonical string ordering is exactly VPath ordering. Borrowed lookups let
13// internal indexes inspect ancestors/prefix ranges without constructing paths.
14impl Borrow<str> for VPath {
15    fn borrow(&self) -> &str {
16        self.as_str()
17    }
18}
19
20impl VPath {
21    /// Return the canonical virtual workspace root.
22    #[must_use]
23    pub fn root() -> Self {
24        Self(".".to_owned())
25    }
26
27    /// Parse and normalize a portable virtual path.
28    ///
29    /// Both slash styles are treated as separators so a path accepted on one host
30    /// cannot become an escape on another. Parent components may simplify a path but
31    /// may never escape the virtual root.
32    ///
33    /// # Errors
34    ///
35    /// Returns [`VPathError`] when the input is empty, absolute, contains a NUL byte or
36    /// platform prefix, or would escape the virtual root during normalization.
37    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        // Snapshot traversal and guest calls predominantly use canonical paths.
60        // Keep the same validation, but avoid a component vector and re-join.
61        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    /// Return the canonical slash-separated representation.
91    #[must_use]
92    pub fn as_str(&self) -> &str {
93        &self.0
94    }
95
96    /// Return whether this path denotes the virtual workspace root.
97    #[must_use]
98    pub fn is_root(&self) -> bool {
99        self.0 == "."
100    }
101
102    /// Return the normalized parent, or `None` for the virtual root.
103    #[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    /// Return the final path component, or `None` for the virtual root.
115    #[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    /// Join and normalize a relative child path.
121    ///
122    /// # Errors
123    ///
124    /// Returns [`VPathError`] if `child` is invalid or the combined path escapes the
125    /// virtual root.
126    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    /// Return whether this path is equal to or below `ancestor`.
136    #[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    /// Return the normalized relative suffix below `ancestor`.
147    #[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    /// Rebase this path from one virtual subtree to another.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`VPathError`] if the rebased path would be invalid.
165    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/// A reason a virtual path was rejected.
196#[derive(Clone, Copy, Debug, Eq, PartialEq)]
197#[non_exhaustive]
198pub enum VPathError {
199    /// An empty string has no unambiguous virtual-path meaning.
200    Empty,
201    /// The path is absolute.
202    Absolute,
203    /// Normalization would leave the virtual workspace root.
204    EscapesRoot,
205    /// The path contains a NUL byte.
206    NulByte,
207    /// The path starts with a platform-specific absolute prefix such as `C:`.
208    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/// A canonical 32-byte lowercase/uppercase hexadecimal identifier was malformed.
271#[derive(Clone, Copy, Debug, Eq, PartialEq)]
272#[non_exhaustive]
273pub enum ParseDigestError {
274    /// The textual form was not exactly 64 ASCII bytes.
275    InvalidLength {
276        /// Observed byte length.
277        observed: usize,
278    },
279    /// One byte was not an ASCII hexadecimal digit.
280    InvalidHex {
281        /// Zero-based byte offset of the invalid digit.
282        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    /// Hash immutable blob bytes with BLAKE3.
387    #[must_use]
388    pub fn digest(bytes: &[u8]) -> Self {
389        Self::from_bytes(*blake3::hash(bytes).as_bytes())
390    }
391}
392
393impl SnapshotId {
394    /// Hash a canonical snapshot manifest with a VSH domain separator.
395    #[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    /// Hash a canonical diff encoding with a VSH domain separator.
403    #[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    /// Hash a canonical directory-listing encoding with a VSH domain separator.
411    #[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    /// Hash path-ordered direct children using VSH's canonical listing encoding.
417    ///
418    /// Callers must provide entries in canonical [`VPath`] order. Keeping this codec in
419    /// the shared type crate ensures snapshot capture, virtual reads, and trusted host
420    /// revalidation cannot silently diverge.
421    #[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    /// Hash exact program UTF-8 bytes with a VSH domain separator.
434    #[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    /// Hash a canonical read-set encoding with a VSH domain separator.
442    #[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    /// Hash a canonical write-set encoding with a VSH domain separator.
450    #[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    /// Hash a canonical deterministic-policy encoding with a VSH domain separator.
458    #[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    /// Hash security-relevant runtime configuration with a VSH domain separator.
466    #[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    /// Hash transaction intent without retaining its potentially sensitive text.
474    #[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    /// Hash a stable principal label without persisting it in transaction state.
482    #[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/// Exact fields covered by an independent approval grant.
489#[derive(Clone, Copy, Debug, Eq, PartialEq)]
490pub struct ApprovalBinding {
491    /// Exact transaction artifact approved by the principal.
492    pub transaction: TransactionId,
493    /// Opaque identity of the independent principal.
494    pub principal: PrincipalId,
495    /// Host-supplied issuance time in Unix milliseconds.
496    pub issued_at_unix_ms: u64,
497    /// Exclusive expiry time in Unix milliseconds.
498    pub expires_at_unix_ms: u64,
499}
500
501impl ApprovalBinding {
502    /// Derive the immutable grant identity.
503    #[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/// Exact immutable inputs bound into an approval and commit identity.
515#[derive(Clone, Copy, Debug, Eq, PartialEq)]
516pub struct TransactionBinding {
517    /// Immutable base snapshot observed by virtual execution.
518    pub base_snapshot: SnapshotId,
519    /// Exact canonical final diff.
520    pub diff: DiffDigest,
521    /// Exact dependencies read while deriving the result.
522    pub read_set: ReadSetDigest,
523    /// Exact preconditions for paths the transaction will write.
524    pub write_set: WriteSetDigest,
525    /// Exact untrusted program source.
526    pub program: ProgramDigest,
527    /// Exact deterministic policy configuration.
528    pub policy: PolicyDigest,
529    /// Security-relevant execution configuration and budgets.
530    pub runtime_config: RuntimeConfigDigest,
531    /// Optional out-of-band user intent shown to an approval principal.
532    pub intent: Option<IntentDigest>,
533}
534
535impl TransactionBinding {
536    /// Derive the single transaction identity to which approval and commit bind.
537    #[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/// The semantic kind of a virtual filesystem node.
575#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
576pub enum NodeKind {
577    /// A regular file.
578    File,
579    /// A directory.
580    Directory,
581    /// An opaque symbolic link; the virtual filesystem never follows it implicitly.
582    Symlink,
583}
584
585impl NodeKind {
586    /// Return the stable tag used by canonical encodings.
587    #[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/// Platform-specific identity of a host filesystem node.
598///
599/// Unix implementations encode device and inode; Windows implementations encode the
600/// volume and file identity. Keeping two opaque words avoids host paths in receipts.
601#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
602pub struct PlatformFileId {
603    /// Platform-defined high word (for example, Unix device ID).
604    pub high: u64,
605    /// Platform-defined low word (for example, Unix inode ID).
606    pub low: u64,
607}
608
609/// Metadata identity captured for one immutable base-snapshot node.
610#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
611pub struct FileStamp {
612    /// Node kind observed without following a symbolic link.
613    pub kind: NodeKind,
614    /// Byte size reported by the host.
615    pub size: u64,
616    /// Portable permission/mode bits retained by VSH.
617    pub mode: u32,
618    /// Nanosecond modification time.
619    pub mtime_ns: i128,
620    /// Nanosecond metadata-change time when the host exposes it.
621    pub ctime_ns: Option<i128>,
622    /// Stable platform file identity for race detection.
623    pub file_id: PlatformFileId,
624}
625
626/// Content identity carried by a node state.
627#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
628#[non_exhaustive]
629pub enum ContentVersion {
630    /// Exact immutable content has been captured in the blob store.
631    Blob(BlobId),
632    /// Content is lazy; this metadata stamp must still match when it is captured.
633    Stamp(FileStamp),
634}
635
636/// Canonical state of a virtual filesystem node.
637#[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    /// Construct a synthetic directory state.
647    #[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    /// Construct a regular file backed by an immutable blob.
658    #[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    /// Construct an opaque symbolic link backed by its target bytes.
669    #[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    /// Construct a lazily materialized state from verified host metadata.
680    #[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    /// Return the semantic node kind.
691    #[must_use]
692    pub const fn kind(self) -> NodeKind {
693        self.kind
694    }
695
696    /// Return the node byte size.
697    #[must_use]
698    pub const fn size(self) -> u64 {
699        self.size
700    }
701
702    /// Return retained permission/mode bits.
703    #[must_use]
704    pub const fn mode(self) -> u32 {
705        self.mode
706    }
707
708    /// Return the exact or lazy content version, if applicable.
709    #[must_use]
710    pub const fn content(self) -> Option<ContentVersion> {
711        self.content
712    }
713
714    /// Return a copy whose file/link content is now materialized.
715    #[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    /// Append this state to a stable length-delimited canonical encoding.
729    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    /// Return whether only metadata differs between two states.
747    #[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/// Semantic category of one canonical diff entry.
770#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
771pub enum DiffKind {
772    /// A path absent from the base exists in final virtual state.
773    Create,
774    /// A base path is absent from final virtual state.
775    Delete,
776    /// Node kind or content changed.
777    Modify,
778    /// Only retained metadata changed.
779    MetadataChange,
780}
781
782/// One path change in a canonical virtual filesystem diff.
783#[derive(Clone, Debug, Eq, PartialEq)]
784pub struct DiffEntry {
785    /// Changed virtual path.
786    pub path: VPath,
787    /// Base state, or `None` when this is a creation.
788    pub before: Option<NodeState>,
789    /// Final virtual state, or `None` when this is a deletion.
790    pub after: Option<NodeState>,
791    /// Semantic change category derived from `before` and `after`.
792    pub kind: DiffKind,
793}
794
795/// A persisted transaction state.
796#[derive(Clone, Copy, Debug, Eq, PartialEq)]
797#[non_exhaustive]
798pub enum TransactionState {
799    /// The transaction record exists but execution has not started.
800    Created,
801    /// The untrusted program is executing against virtual state.
802    Running,
803    /// Virtual execution and canonical diff generation completed.
804    VirtualComplete,
805    /// Deterministic policy denied the transaction.
806    Denied,
807    /// Deterministic policy approved the transaction without a judge.
808    AutoApproved,
809    /// The transaction is awaiting an independent approval decision.
810    PendingApproval,
811    /// An independent principal approved the exact transaction.
812    Approved,
813    /// The transaction acquired the single-use commit reservation.
814    Reserved,
815    /// Recorded dependencies are being revalidated.
816    Revalidating,
817    /// The trusted committer is applying the canonical diff.
818    Committing,
819    /// The committed host state passed verification.
820    Committed,
821    /// Revalidation detected stale state before commit.
822    Stale,
823    /// Approval expired before reservation.
824    Expired,
825    /// Recovery is required after an interrupted commit.
826    RecoveryRequired,
827    /// The transaction failed without a successful commit.
828    Failed,
829}
830
831impl TransactionState {
832    /// Return whether `next` is a valid persisted state transition.
833    #[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    /// Return whether no normal transition may leave this state.
860    #[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/// An invalid persisted transaction transition.
870#[derive(Clone, Copy, Debug, Eq, PartialEq)]
871pub struct TransitionError {
872    /// State before the rejected transition.
873    pub from: TransactionState,
874    /// Requested next state.
875    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                        // Joining is defined by normalizing the combined path,
941                        // not by parsing the child independently.
942                        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}