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);
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    /// Hash immutable blob bytes with BLAKE3.
395    #[must_use]
396    pub fn digest(bytes: &[u8]) -> Self {
397        Self::from_bytes(*blake3::hash(bytes).as_bytes())
398    }
399}
400
401impl SnapshotId {
402    /// Hash a canonical snapshot manifest with a VSH domain separator.
403    #[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    /// Hash a canonical diff encoding with a VSH domain separator.
411    #[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    /// Hash a canonical directory-listing encoding with a VSH domain separator.
419    #[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    /// Hash path-ordered direct children using VSH's canonical listing encoding.
425    ///
426    /// Callers must provide entries in canonical [`VPath`] order. Keeping this codec in
427    /// the shared type crate ensures snapshot capture, virtual reads, and trusted host
428    /// revalidation cannot silently diverge.
429    #[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    /// Hash exact program UTF-8 bytes with a VSH domain separator.
442    #[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    /// Hash a canonical read-set encoding with a VSH domain separator.
450    #[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    /// Hash a canonical write-set encoding with a VSH domain separator.
458    #[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    /// Hash a canonical deterministic-policy encoding with a VSH domain separator.
466    #[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    /// Hash security-relevant runtime configuration with a VSH domain separator.
474    #[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    /// Hash transaction intent without retaining its potentially sensitive text.
482    #[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    /// Hash a stable principal label without persisting it in transaction state.
490    #[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    /// Hash a stable hook label without retaining configuration text.
498    #[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    /// Bind an event to one transaction, hook, and configured scope.
506    #[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/// Exact fields covered by an independent approval grant.
517#[derive(Clone, Copy, Debug, Eq, PartialEq)]
518pub struct ApprovalBinding {
519    /// Exact transaction artifact approved by the principal.
520    pub transaction: TransactionId,
521    /// Opaque identity of the independent principal.
522    pub principal: PrincipalId,
523    /// Host-supplied issuance time in Unix milliseconds.
524    pub issued_at_unix_ms: u64,
525    /// Exclusive expiry time in Unix milliseconds.
526    pub expires_at_unix_ms: u64,
527}
528
529impl ApprovalBinding {
530    /// Derive the immutable grant identity.
531    #[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/// Exact immutable inputs bound into an approval and commit identity.
543#[derive(Clone, Copy, Debug, Eq, PartialEq)]
544pub struct TransactionBinding {
545    /// Immutable base snapshot observed by virtual execution.
546    pub base_snapshot: SnapshotId,
547    /// Exact canonical final diff.
548    pub diff: DiffDigest,
549    /// Exact dependencies read while deriving the result.
550    pub read_set: ReadSetDigest,
551    /// Exact preconditions for paths the transaction will write.
552    pub write_set: WriteSetDigest,
553    /// Exact untrusted program source.
554    pub program: ProgramDigest,
555    /// Exact deterministic policy configuration.
556    pub policy: PolicyDigest,
557    /// Security-relevant execution configuration and budgets.
558    pub runtime_config: RuntimeConfigDigest,
559    /// Optional out-of-band user intent shown to an approval principal.
560    pub intent: Option<IntentDigest>,
561}
562
563impl TransactionBinding {
564    /// Derive the single transaction identity to which approval and commit bind.
565    #[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/// The semantic kind of a virtual filesystem node.
603#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
604pub enum NodeKind {
605    /// A regular file.
606    File,
607    /// A directory.
608    Directory,
609    /// An opaque symbolic link; the virtual filesystem never follows it implicitly.
610    Symlink,
611}
612
613impl NodeKind {
614    /// Return the stable tag used by canonical encodings.
615    #[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/// Platform-specific identity of a host filesystem node.
626///
627/// Unix implementations encode device and inode; Windows implementations encode the
628/// volume and file identity. Keeping two opaque words avoids host paths in receipts.
629#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
630pub struct PlatformFileId {
631    /// Platform-defined high word (for example, Unix device ID).
632    pub high: u64,
633    /// Platform-defined low word (for example, Unix inode ID).
634    pub low: u64,
635}
636
637/// Metadata identity captured for one immutable base-snapshot node.
638#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
639pub struct FileStamp {
640    /// Node kind observed without following a symbolic link.
641    pub kind: NodeKind,
642    /// Byte size reported by the host.
643    pub size: u64,
644    /// Portable permission/mode bits retained by VSH.
645    pub mode: u32,
646    /// Nanosecond modification time.
647    pub mtime_ns: i128,
648    /// Nanosecond metadata-change time when the host exposes it.
649    pub ctime_ns: Option<i128>,
650    /// Stable platform file identity for race detection.
651    pub file_id: PlatformFileId,
652}
653
654/// Content identity carried by a node state.
655#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
656#[non_exhaustive]
657pub enum ContentVersion {
658    /// Exact immutable content has been captured in the blob store.
659    Blob(BlobId),
660    /// Content is lazy; this metadata stamp must still match when it is captured.
661    Stamp(FileStamp),
662}
663
664/// Canonical state of a virtual filesystem node.
665#[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    /// Construct a synthetic directory state.
675    #[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    /// Construct a regular file backed by an immutable blob.
686    #[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    /// Construct an opaque symbolic link backed by its target bytes.
697    #[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    /// Construct a lazily materialized state from verified host metadata.
708    #[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    /// Return the semantic node kind.
719    #[must_use]
720    pub const fn kind(self) -> NodeKind {
721        self.kind
722    }
723
724    /// Return the node byte size.
725    #[must_use]
726    pub const fn size(self) -> u64 {
727        self.size
728    }
729
730    /// Return retained permission/mode bits.
731    #[must_use]
732    pub const fn mode(self) -> u32 {
733        self.mode
734    }
735
736    /// Return the exact or lazy content version, if applicable.
737    #[must_use]
738    pub const fn content(self) -> Option<ContentVersion> {
739        self.content
740    }
741
742    /// Return a copy whose file/link content is now materialized.
743    #[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    /// Append this state to a stable length-delimited canonical encoding.
757    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    /// Return whether only metadata differs between two states.
775    #[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/// Semantic category of one canonical diff entry.
798#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
799pub enum DiffKind {
800    /// A path absent from the base exists in final virtual state.
801    Create,
802    /// A base path is absent from final virtual state.
803    Delete,
804    /// Node kind or content changed.
805    Modify,
806    /// Only retained metadata changed.
807    MetadataChange,
808}
809
810/// One path change in a canonical virtual filesystem diff.
811#[derive(Clone, Debug, Eq, PartialEq)]
812pub struct DiffEntry {
813    /// Changed virtual path.
814    pub path: VPath,
815    /// Base state, or `None` when this is a creation.
816    pub before: Option<NodeState>,
817    /// Final virtual state, or `None` when this is a deletion.
818    pub after: Option<NodeState>,
819    /// Semantic change category derived from `before` and `after`.
820    pub kind: DiffKind,
821}
822
823/// A persisted transaction state.
824#[derive(Clone, Copy, Debug, Eq, PartialEq)]
825#[non_exhaustive]
826pub enum TransactionState {
827    /// The transaction record exists but execution has not started.
828    Created,
829    /// The untrusted program is executing against virtual state.
830    Running,
831    /// Virtual execution and canonical diff generation completed.
832    VirtualComplete,
833    /// Deterministic policy denied the transaction.
834    Denied,
835    /// Deterministic policy approved the transaction without a judge.
836    AutoApproved,
837    /// The transaction is awaiting an independent approval decision.
838    PendingApproval,
839    /// A configured commit hook rejected the exact transaction.
840    Rejected,
841    /// An independent principal approved the exact transaction.
842    Approved,
843    /// The transaction acquired the single-use commit reservation.
844    Reserved,
845    /// Recorded dependencies are being revalidated.
846    Revalidating,
847    /// The trusted committer is applying the canonical diff.
848    Committing,
849    /// The committed host state passed verification.
850    Committed,
851    /// Revalidation detected stale state before commit.
852    Stale,
853    /// Approval expired before reservation.
854    Expired,
855    /// Recovery is required after an interrupted commit.
856    RecoveryRequired,
857    /// The transaction failed without a successful commit.
858    Failed,
859}
860
861impl TransactionState {
862    /// Return whether `next` is a valid persisted state transition.
863    #[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    /// Return whether no normal transition may leave this state.
891    #[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/// An invalid persisted transaction transition.
906#[derive(Clone, Copy, Debug, Eq, PartialEq)]
907pub struct TransitionError {
908    /// State before the rejected transition.
909    pub from: TransactionState,
910    /// Requested next state.
911    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                        // Joining is defined by normalizing the combined path,
977                        // not by parsing the child independently.
978                        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}