Skip to main content

vsh_vfs/
lib.rs

1//! Immutable snapshots and the copy-on-write virtual filesystem used by VSH.
2//!
3//! This crate has no host commit capability. Every mutation lands in an in-memory
4//! overlay, and the only durable writes it can perform are immutable blob-store puts.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::error::Error;
8use std::fmt;
9use std::sync::{Arc, Mutex, OnceLock};
10
11use vsh_store::{BlobStore, BlobStoreError};
12use vsh_types::{
13    BlobId, DiffDigest, DiffEntry, DiffKind, DirectoryDigest, FileStamp, NodeKind, NodeState,
14    SnapshotId, VPath, VPathError,
15};
16
17#[cfg(not(windows))]
18const DEFAULT_FILE_MODE: u32 = 0o644;
19#[cfg(windows)]
20const DEFAULT_FILE_MODE: u32 = 0o666;
21
22#[cfg(not(windows))]
23const fn platform_directory_mode(mode: u32) -> u32 {
24    mode
25}
26
27#[cfg(windows)]
28const fn platform_directory_mode(mode: u32) -> u32 {
29    if mode & 0o200 == 0 { 0o555 } else { 0o777 }
30}
31
32/// Bytes captured between two metadata observations of the same host node.
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct CapturedContent {
35    /// Captured bytes.
36    pub bytes: Vec<u8>,
37    /// Metadata immediately before the read.
38    pub before: FileStamp,
39    /// Metadata immediately after the read.
40    pub after: FileStamp,
41}
42
43/// Error returned by a lazy snapshot content loader.
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct ContentLoadError {
46    message: String,
47}
48
49impl ContentLoadError {
50    /// Construct an adapter-neutral lazy-load failure.
51    #[must_use]
52    pub fn new(message: impl Into<String>) -> Self {
53        Self {
54            message: message.into(),
55        }
56    }
57}
58
59impl fmt::Display for ContentLoadError {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        formatter.write_str(&self.message)
62    }
63}
64
65impl Error for ContentLoadError {}
66
67/// Capability-scoped provider for one lazily captured snapshot node.
68///
69/// Implementations must open relative to their already-authorized root and must not
70/// follow a different node in place of the expected one. VSH independently verifies
71/// the returned before/after stamps and byte length.
72pub trait ContentLoader: Send + Sync {
73    /// Capture stable content for `expected`.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`ContentLoadError`] when the host read cannot be completed safely.
78    fn load(&self, expected: FileStamp) -> Result<CapturedContent, ContentLoadError>;
79}
80
81impl<F> ContentLoader for F
82where
83    F: Fn(FileStamp) -> Result<CapturedContent, ContentLoadError> + Send + Sync,
84{
85    fn load(&self, expected: FileStamp) -> Result<CapturedContent, ContentLoadError> {
86        self(expected)
87    }
88}
89
90struct LazyContent {
91    expected: FileStamp,
92    loader: Arc<dyn ContentLoader>,
93    captured: OnceLock<BlobId>,
94    load_lock: Mutex<()>,
95}
96
97#[derive(Clone)]
98enum ContentHandle {
99    None,
100    Materialized(BlobId),
101    Lazy(Arc<LazyContent>),
102}
103
104#[derive(Clone)]
105struct SnapshotNode {
106    state: NodeState,
107    content: ContentHandle,
108}
109
110impl SnapshotNode {
111    fn directory(mode: u32) -> Self {
112        Self {
113            state: NodeState::directory(mode),
114            content: ContentHandle::None,
115        }
116    }
117
118    fn stamped_directory(stamp: FileStamp) -> Self {
119        debug_assert_eq!(stamp.kind, NodeKind::Directory);
120        Self {
121            state: NodeState::from_stamp(stamp),
122            content: ContentHandle::None,
123        }
124    }
125
126    fn materialized(kind: NodeKind, id: BlobId, size: u64, mode: u32) -> Self {
127        let state = match kind {
128            NodeKind::File => NodeState::file(id, size, mode),
129            NodeKind::Symlink => NodeState::symlink(id, size, mode),
130            NodeKind::Directory => NodeState::directory(mode),
131        };
132        let content = match kind {
133            NodeKind::Directory => ContentHandle::None,
134            NodeKind::File | NodeKind::Symlink => ContentHandle::Materialized(id),
135        };
136        Self { state, content }
137    }
138
139    fn lazy(stamp: FileStamp, loader: Arc<dyn ContentLoader>) -> Self {
140        Self {
141            state: NodeState::from_stamp(stamp),
142            content: ContentHandle::Lazy(Arc::new(LazyContent {
143                expected: stamp,
144                loader,
145                captured: OnceLock::new(),
146                load_lock: Mutex::new(()),
147            })),
148        }
149    }
150
151    fn state(&self) -> NodeState {
152        match &self.content {
153            ContentHandle::Lazy(lazy) => lazy
154                .captured
155                .get()
156                .copied()
157                .and_then(|blob| self.state.with_blob(blob, self.state.size()))
158                .unwrap_or(self.state),
159            ContentHandle::None | ContentHandle::Materialized(_) => self.state,
160        }
161    }
162
163    fn expected_state(&self) -> NodeState {
164        self.state
165    }
166
167    fn read(&self, path: &VPath, store: &BlobStore) -> Result<(BlobId, Vec<u8>), SnapshotError> {
168        match &self.content {
169            ContentHandle::None => Err(SnapshotError::ContentUnavailable {
170                path: path.clone(),
171                kind: self.state.kind(),
172            }),
173            ContentHandle::Materialized(id) => {
174                let bytes = store.get(*id).map_err(SnapshotError::Store)?;
175                Ok((*id, bytes))
176            }
177            ContentHandle::Lazy(lazy) => {
178                if let Some(id) = lazy.captured.get().copied() {
179                    let bytes = store.get(id).map_err(SnapshotError::Store)?;
180                    return Ok((id, bytes));
181                }
182                let _load_guard = lazy
183                    .load_lock
184                    .lock()
185                    .map_err(|_| SnapshotError::LazyStatePoisoned { path: path.clone() })?;
186                if let Some(id) = lazy.captured.get().copied() {
187                    let bytes = store.get(id).map_err(SnapshotError::Store)?;
188                    return Ok((id, bytes));
189                }
190
191                let captured = lazy.loader.load(lazy.expected).map_err(|source| {
192                    SnapshotError::ContentLoad {
193                        path: path.clone(),
194                        source,
195                    }
196                })?;
197                if captured.before != lazy.expected || captured.after != lazy.expected {
198                    return Err(SnapshotError::StaleContent {
199                        path: path.clone(),
200                        expected: Box::new(lazy.expected),
201                        before: Box::new(captured.before),
202                        after: Box::new(captured.after),
203                    });
204                }
205                if u64::try_from(captured.bytes.len()).ok() != Some(lazy.expected.size) {
206                    return Err(SnapshotError::ContentSizeMismatch {
207                        path: path.clone(),
208                        expected: lazy.expected.size,
209                        actual: captured.bytes.len(),
210                    });
211                }
212                let id = store.put(&captured.bytes).map_err(SnapshotError::Store)?;
213                lazy.captured
214                    .set(id)
215                    .map_err(|_| SnapshotError::LazyStatePoisoned { path: path.clone() })?;
216                Ok((id, captured.bytes))
217            }
218        }
219    }
220
221    fn materialized_state(
222        &self,
223        path: &VPath,
224        store: &BlobStore,
225    ) -> Result<NodeState, SnapshotError> {
226        if self.state.kind() == NodeKind::Directory {
227            return Ok(self.state);
228        }
229        let (blob, bytes) = self.read(path, store)?;
230        Ok(self
231            .state
232            .with_blob(blob, bytes.len() as u64)
233            .expect("non-directory nodes accept blob content"))
234    }
235
236    fn is_lazy(&self) -> bool {
237        matches!(self.content, ContentHandle::Lazy(_))
238    }
239
240    fn is_materialized(&self) -> bool {
241        match &self.content {
242            ContentHandle::Materialized(_) => true,
243            ContentHandle::Lazy(lazy) => lazy.captured.get().is_some(),
244            ContentHandle::None => false,
245        }
246    }
247}
248
249/// Builder for one immutable snapshot manifest.
250pub struct SnapshotBuilder {
251    store: BlobStore,
252    nodes: BTreeMap<VPath, Arc<SnapshotNode>>,
253}
254
255impl SnapshotBuilder {
256    /// Start a snapshot containing only the virtual root directory.
257    #[must_use]
258    pub fn new(store: BlobStore) -> Self {
259        let mut nodes = BTreeMap::new();
260        nodes.insert(VPath::root(), Arc::new(SnapshotNode::directory(0o755)));
261        Self { store, nodes }
262    }
263
264    /// Start a host snapshot whose root identity was captured without following links.
265    ///
266    /// # Panics
267    ///
268    /// Panics if `root_stamp` does not describe a directory. Host adapters construct
269    /// this value directly from an already-open directory capability.
270    #[must_use]
271    pub fn with_root_stamp(store: BlobStore, root_stamp: FileStamp) -> Self {
272        assert_eq!(root_stamp.kind, NodeKind::Directory);
273        let mut nodes = BTreeMap::new();
274        nodes.insert(
275            VPath::root(),
276            Arc::new(SnapshotNode::stamped_directory(root_stamp)),
277        );
278        Self { store, nodes }
279    }
280
281    /// Add a directory to the immutable manifest.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`SnapshotError::DuplicatePath`] when `path` already exists.
286    pub fn add_directory(&mut self, path: VPath, mode: u32) -> Result<&mut Self, SnapshotError> {
287        self.insert(path, SnapshotNode::directory(mode))?;
288        Ok(self)
289    }
290
291    /// Add a host directory while retaining its race-detection identity.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error for duplicate paths or a non-directory stamp.
296    pub fn add_stamped_directory(
297        &mut self,
298        path: VPath,
299        stamp: FileStamp,
300    ) -> Result<&mut Self, SnapshotError> {
301        if stamp.kind != NodeKind::Directory {
302            return Err(SnapshotError::ExpectedDirectoryStamp { path, stamp });
303        }
304        self.insert(path, SnapshotNode::stamped_directory(stamp))?;
305        Ok(self)
306    }
307
308    /// Add and content-address a regular file.
309    ///
310    /// # Errors
311    ///
312    /// Returns an error for duplicate paths or blob-store failures.
313    pub fn add_file(
314        &mut self,
315        path: VPath,
316        bytes: &[u8],
317        mode: u32,
318    ) -> Result<&mut Self, SnapshotError> {
319        let blob = self.store.put(bytes).map_err(SnapshotError::Store)?;
320        self.insert(
321            path,
322            SnapshotNode::materialized(NodeKind::File, blob, bytes.len() as u64, mode),
323        )?;
324        Ok(self)
325    }
326
327    /// Add an opaque symbolic link without following its target.
328    ///
329    /// # Errors
330    ///
331    /// Returns an error for duplicate paths or blob-store failures.
332    pub fn add_symlink(
333        &mut self,
334        path: VPath,
335        target: &[u8],
336        mode: u32,
337    ) -> Result<&mut Self, SnapshotError> {
338        let blob = self.store.put(target).map_err(SnapshotError::Store)?;
339        self.insert(
340            path,
341            SnapshotNode::materialized(NodeKind::Symlink, blob, target.len() as u64, mode),
342        )?;
343        Ok(self)
344    }
345
346    /// Add a file or symlink whose content will be captured on first read.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error for duplicate paths or a directory stamp.
351    pub fn add_lazy<L>(
352        &mut self,
353        path: VPath,
354        stamp: FileStamp,
355        loader: L,
356    ) -> Result<&mut Self, SnapshotError>
357    where
358        L: ContentLoader + 'static,
359    {
360        if stamp.kind == NodeKind::Directory {
361            return Err(SnapshotError::LazyDirectory { path });
362        }
363        self.insert(path, SnapshotNode::lazy(stamp, Arc::new(loader)))?;
364        Ok(self)
365    }
366
367    /// Validate parent relationships and freeze the snapshot.
368    ///
369    /// # Errors
370    ///
371    /// Returns an error when a parent is absent or not a directory.
372    pub fn build(self) -> Result<BaseSnapshot, SnapshotError> {
373        for path in self.nodes.keys().filter(|path| !path.is_root()) {
374            let parent = path.parent().unwrap_or_else(VPath::root);
375            let Some(parent_node) = self.nodes.get(&parent) else {
376                return Err(SnapshotError::MissingParent {
377                    path: path.clone(),
378                    parent,
379                });
380            };
381            if parent_node.state.kind() != NodeKind::Directory {
382                return Err(SnapshotError::ParentNotDirectory {
383                    path: path.clone(),
384                    parent,
385                });
386            }
387        }
388
389        let mut children: BTreeMap<VPath, BTreeSet<VPath>> = BTreeMap::new();
390        for path in self.nodes.keys().filter(|path| !path.is_root()) {
391            let parent = path.parent().unwrap_or_else(VPath::root);
392            children.entry(parent).or_default().insert(path.clone());
393        }
394        let id = snapshot_id(&self.nodes);
395        Ok(BaseSnapshot {
396            inner: Arc::new(SnapshotInner {
397                id,
398                nodes: self.nodes,
399                children,
400                store: self.store,
401            }),
402        })
403    }
404
405    fn insert(&mut self, path: VPath, node: SnapshotNode) -> Result<(), SnapshotError> {
406        if self.nodes.contains_key(&path) {
407            return Err(SnapshotError::DuplicatePath { path });
408        }
409        self.nodes.insert(path, Arc::new(node));
410        Ok(())
411    }
412}
413
414fn snapshot_id(nodes: &BTreeMap<VPath, Arc<SnapshotNode>>) -> SnapshotId {
415    let mut canonical = Vec::new();
416    for (path, node) in nodes {
417        encode_path(path, &mut canonical);
418        node.state.encode_canonical(&mut canonical);
419    }
420    SnapshotId::digest_manifest(&canonical)
421}
422
423struct SnapshotInner {
424    id: SnapshotId,
425    nodes: BTreeMap<VPath, Arc<SnapshotNode>>,
426    children: BTreeMap<VPath, BTreeSet<VPath>>,
427    store: BlobStore,
428}
429
430/// An immutable metadata manifest with content that becomes immutable on first capture.
431#[derive(Clone)]
432pub struct BaseSnapshot {
433    inner: Arc<SnapshotInner>,
434}
435
436impl BaseSnapshot {
437    /// Return the stable manifest identity.
438    #[must_use]
439    pub fn id(&self) -> SnapshotId {
440        self.inner.id
441    }
442
443    /// Return the number of manifest nodes, including the virtual root.
444    #[must_use]
445    pub fn len(&self) -> usize {
446        self.inner.nodes.len()
447    }
448
449    /// Return whether the manifest contains no user-visible nodes.
450    #[must_use]
451    pub fn is_empty(&self) -> bool {
452        self.len() == 1
453    }
454
455    /// Return snapshot materialization metrics.
456    #[must_use]
457    pub fn metrics(&self) -> SnapshotMetrics {
458        let lazy_nodes = self
459            .inner
460            .nodes
461            .values()
462            .filter(|node| node.is_lazy())
463            .count();
464        let materialized_content_nodes = self
465            .inner
466            .nodes
467            .values()
468            .filter(|node| node.is_materialized())
469            .count();
470        SnapshotMetrics {
471            node_count: self.len(),
472            lazy_content_nodes: lazy_nodes,
473            materialized_content_nodes,
474        }
475    }
476
477    fn node(&self, path: &VPath) -> Option<Arc<SnapshotNode>> {
478        self.inner.nodes.get(path).cloned()
479    }
480
481    fn direct_children(&self, path: &VPath) -> impl Iterator<Item = VPath> + '_ {
482        self.inner
483            .children
484            .get(path)
485            .into_iter()
486            .flat_map(|children| children.iter().cloned())
487    }
488
489    fn subtree_paths(&self, root: &VPath) -> Vec<VPath> {
490        if !self.inner.nodes.contains_key(root) {
491            return Vec::new();
492        }
493        let mut paths = Vec::new();
494        let mut pending = vec![root.clone()];
495        while let Some(path) = pending.pop() {
496            if let Some(children) = self.inner.children.get(&path) {
497                pending.extend(children.iter().rev().cloned());
498            }
499            paths.push(path);
500        }
501        paths.sort_unstable();
502        paths
503    }
504
505    fn directory_digest(&self, path: &VPath) -> DirectoryDigest {
506        let children = self.direct_children(path).collect::<Vec<_>>();
507        DirectoryDigest::digest_entries(
508            children
509                .iter()
510                .map(|child| (child, self.inner.nodes[child].expected_state())),
511        )
512    }
513
514    fn store(&self) -> &BlobStore {
515        &self.inner.store
516    }
517}
518
519/// Snapshot content-capture and manifest validation failure.
520#[derive(Debug)]
521#[non_exhaustive]
522pub enum SnapshotError {
523    /// Immutable blob storage failed.
524    Store(BlobStoreError),
525    /// The manifest contains the same path more than once.
526    DuplicatePath {
527        /// Duplicate path.
528        path: VPath,
529    },
530    /// A manifest node has no parent directory.
531    MissingParent {
532        /// Child path.
533        path: VPath,
534        /// Missing parent.
535        parent: VPath,
536    },
537    /// A manifest node's parent is not a directory.
538    ParentNotDirectory {
539        /// Child path.
540        path: VPath,
541        /// Non-directory parent.
542        parent: VPath,
543    },
544    /// Directories have no lazy byte content.
545    LazyDirectory {
546        /// Rejected path.
547        path: VPath,
548    },
549    /// A host-directory builder method received another node kind.
550    ExpectedDirectoryStamp {
551        /// Rejected path.
552        path: VPath,
553        /// Non-directory stamp.
554        stamp: FileStamp,
555    },
556    /// A directory was used as byte content.
557    ContentUnavailable {
558        /// Requested path.
559        path: VPath,
560        /// Actual node kind.
561        kind: NodeKind,
562    },
563    /// An authorized content loader failed.
564    ContentLoad {
565        /// Requested path.
566        path: VPath,
567        /// Adapter error.
568        source: ContentLoadError,
569    },
570    /// Metadata changed around a lazy content read.
571    StaleContent {
572        /// Requested path.
573        path: VPath,
574        /// Snapshot metadata.
575        expected: Box<FileStamp>,
576        /// Metadata before the read.
577        before: Box<FileStamp>,
578        /// Metadata after the read.
579        after: Box<FileStamp>,
580    },
581    /// Captured byte count did not match the immutable stamp.
582    ContentSizeMismatch {
583        /// Requested path.
584        path: VPath,
585        /// Size from the snapshot stamp.
586        expected: u64,
587        /// Actual byte count.
588        actual: usize,
589    },
590    /// A previous panic poisoned the lazy capture lock; VSH fails closed.
591    LazyStatePoisoned {
592        /// Affected path.
593        path: VPath,
594    },
595}
596
597impl fmt::Display for SnapshotError {
598    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
599        match self {
600            Self::Store(source) => write!(formatter, "blob store failure: {source}"),
601            Self::DuplicatePath { path } => write!(formatter, "duplicate snapshot path: {path}"),
602            Self::MissingParent { path, parent } => {
603                write!(
604                    formatter,
605                    "snapshot path {path} has missing parent {parent}"
606                )
607            }
608            Self::ParentNotDirectory { path, parent } => {
609                write!(
610                    formatter,
611                    "snapshot path {path} has non-directory parent {parent}"
612                )
613            }
614            Self::LazyDirectory { path } => {
615                write!(formatter, "directory {path} cannot have lazy byte content")
616            }
617            Self::ExpectedDirectoryStamp { path, stamp } => write!(
618                formatter,
619                "expected a directory stamp for {path}, got {:?}",
620                stamp.kind
621            ),
622            Self::ContentUnavailable { path, kind } => {
623                write!(formatter, "{path} has no readable byte content ({kind:?})")
624            }
625            Self::ContentLoad { path, source } => {
626                write!(
627                    formatter,
628                    "failed to capture snapshot content for {path}: {source}"
629                )
630            }
631            Self::StaleContent { path, .. } => {
632                write!(formatter, "snapshot content changed while reading {path}")
633            }
634            Self::ContentSizeMismatch {
635                path,
636                expected,
637                actual,
638            } => write!(
639                formatter,
640                "snapshot content size changed for {path}: expected {expected}, got {actual}"
641            ),
642            Self::LazyStatePoisoned { path } => {
643                write!(formatter, "lazy snapshot state was poisoned for {path}")
644            }
645        }
646    }
647}
648
649impl Error for SnapshotError {
650    fn source(&self) -> Option<&(dyn Error + 'static)> {
651        match self {
652            Self::Store(source) => Some(source),
653            Self::ContentLoad { source, .. } => Some(source),
654            Self::DuplicatePath { .. }
655            | Self::MissingParent { .. }
656            | Self::ParentNotDirectory { .. }
657            | Self::LazyDirectory { .. }
658            | Self::ExpectedDirectoryStamp { .. }
659            | Self::ContentUnavailable { .. }
660            | Self::StaleContent { .. }
661            | Self::ContentSizeMismatch { .. }
662            | Self::LazyStatePoisoned { .. } => None,
663        }
664    }
665}
666
667/// Observable snapshot size and lazy-materialization state.
668#[derive(Clone, Copy, Debug, Eq, PartialEq)]
669pub struct SnapshotMetrics {
670    /// Total manifest nodes including root.
671    pub node_count: usize,
672    /// Nodes configured for lazy capture.
673    pub lazy_content_nodes: usize,
674    /// File/link nodes whose bytes are already in the blob store.
675    pub materialized_content_nodes: usize,
676}
677
678#[derive(Clone)]
679struct ResolvedNode {
680    node: Arc<SnapshotNode>,
681    base_origin: Option<VPath>,
682}
683
684#[derive(Clone)]
685enum OverlayEntry {
686    Present(ResolvedNode),
687    Tombstone,
688}
689
690/// Dependency observed while virtual code was executing.
691#[derive(Clone, Debug, Default, Eq, PartialEq)]
692pub struct ReadObservation {
693    /// Base metadata expected at commit time. `Some(None)` means expected missing.
694    pub metadata: Option<Option<NodeState>>,
695    /// Exact base content read by the program.
696    pub content: Option<BlobId>,
697    /// Exact base directory listing read by the program.
698    pub directory: Option<DirectoryDigest>,
699}
700
701/// Base state that must still hold before a path may be written.
702#[derive(Clone, Copy, Debug, Eq, PartialEq)]
703pub struct WritePrecondition {
704    /// Expected base state, or `None` when the path must remain absent.
705    pub expected: Option<NodeState>,
706}
707
708/// Source of an observed virtual filesystem effect.
709#[derive(Clone, Copy, Debug, Eq, PartialEq)]
710#[non_exhaustive]
711pub enum EffectOrigin {
712    /// A direct typed virtual filesystem operation.
713    VirtualFs,
714    /// A typed Monty OS call; populated by the Monty adapter in Phase 3.
715    MontyOsCall,
716}
717
718/// Semantic event emitted by the operation that actually observed or changed state.
719#[derive(Clone, Debug, Eq, PartialEq)]
720#[non_exhaustive]
721pub enum Effect {
722    /// Metadata or existence was observed.
723    MetadataRead {
724        /// Observed path.
725        path: VPath,
726        /// State seen by the program.
727        state: Option<NodeState>,
728    },
729    /// File content was observed.
730    ContentRead {
731        /// Observed path.
732        path: VPath,
733        /// Exact content identity.
734        blob: BlobId,
735    },
736    /// Directory entries were observed.
737    DirectoryRead {
738        /// Observed directory.
739        path: VPath,
740        /// Digest of the listing seen by the program.
741        digest: DirectoryDigest,
742    },
743    /// A path was created.
744    Create {
745        /// Created path.
746        path: VPath,
747        /// New state.
748        after: NodeState,
749    },
750    /// Regular file content was replaced.
751    ModifyContent {
752        /// Changed path.
753        path: VPath,
754        /// State before the operation.
755        before: NodeState,
756        /// State after the operation.
757        after: NodeState,
758    },
759    /// A path was deleted.
760    Delete {
761        /// Deleted path.
762        path: VPath,
763        /// State before deletion.
764        before: NodeState,
765    },
766    /// A subtree was moved without host effects.
767    Rename {
768        /// Source root.
769        from: VPath,
770        /// Destination root.
771        to: VPath,
772        /// Source state before the move.
773        before: NodeState,
774        /// Destination state after the move.
775        after: NodeState,
776    },
777}
778
779/// One sequence-numbered observed effect.
780#[derive(Clone, Debug, Eq, PartialEq)]
781pub struct EffectEvent {
782    /// Monotonic transaction-local sequence number.
783    pub sequence: u64,
784    /// Adapter that originated the operation.
785    pub origin: EffectOrigin,
786    /// Observed semantic event.
787    pub effect: Effect,
788}
789
790/// Stable, path-ordered virtual filesystem diff.
791#[derive(Clone, Debug, Eq, PartialEq)]
792pub struct CanonicalDiff {
793    entries: Vec<DiffEntry>,
794    digest: DiffDigest,
795    metrics: CanonicalDiffMetrics,
796}
797
798impl CanonicalDiff {
799    /// Reconstruct a canonical diff from a trusted artifact decoder.
800    ///
801    /// Entries must be strictly path-ordered, non-root, semantically classified, and
802    /// represent an actual state change. The digest is always recomputed.
803    ///
804    /// # Errors
805    ///
806    /// Returns [`VfsError::InvalidCanonicalDiff`] when an artifact violates a
807    /// canonical invariant.
808    pub fn from_entries(entries: Vec<DiffEntry>) -> Result<Self, VfsError> {
809        let mut previous: Option<&VPath> = None;
810        let mut materialized_after_bytes = 0_u64;
811        for entry in &entries {
812            if entry.path.is_root() {
813                return Err(VfsError::InvalidCanonicalDiff {
814                    path: Some(entry.path.clone()),
815                    reason: "the workspace root cannot appear in a diff",
816                });
817            }
818            if previous.is_some_and(|path| path >= &entry.path) {
819                return Err(VfsError::InvalidCanonicalDiff {
820                    path: Some(entry.path.clone()),
821                    reason: "diff paths are not strictly ordered",
822                });
823            }
824            if entry.before == entry.after {
825                return Err(VfsError::InvalidCanonicalDiff {
826                    path: Some(entry.path.clone()),
827                    reason: "diff entry does not change state",
828                });
829            }
830            if entry.kind != classify_diff(entry.before, entry.after) {
831                return Err(VfsError::InvalidCanonicalDiff {
832                    path: Some(entry.path.clone()),
833                    reason: "diff kind does not match before and after states",
834                });
835            }
836            materialized_after_bytes =
837                materialized_after_bytes.saturating_add(entry.after.map_or(0, NodeState::size));
838            previous = Some(&entry.path);
839        }
840        let mut canonical = Vec::new();
841        for entry in &entries {
842            encode_diff_entry(entry, &mut canonical);
843        }
844        Ok(Self {
845            metrics: CanonicalDiffMetrics {
846                candidate_paths: entries.len(),
847                expanded_delete_paths: 0,
848                changed_paths: entries.len(),
849                materialized_after_bytes,
850            },
851            digest: DiffDigest::digest_canonical(&canonical),
852            entries,
853        })
854    }
855
856    /// Return canonical entries ordered by normalized path.
857    #[must_use]
858    pub fn entries(&self) -> &[DiffEntry] {
859        &self.entries
860    }
861
862    /// Return the domain-separated digest of the canonical encoding.
863    #[must_use]
864    pub const fn digest(&self) -> DiffDigest {
865        self.digest
866    }
867
868    /// Return whether final virtual state equals the base snapshot.
869    #[must_use]
870    pub fn is_empty(&self) -> bool {
871        self.entries.is_empty()
872    }
873
874    /// Return work-size metrics for performance and budget enforcement.
875    #[must_use]
876    pub const fn metrics(&self) -> CanonicalDiffMetrics {
877        self.metrics
878    }
879}
880
881/// Work performed while deriving one canonical diff.
882#[derive(Clone, Copy, Debug, Eq, PartialEq)]
883pub struct CanonicalDiffMetrics {
884    /// Unique paths compared against base state.
885    pub candidate_paths: usize,
886    /// Base paths expanded because an ancestor subtree was deleted.
887    pub expanded_delete_paths: usize,
888    /// Final changed paths emitted in the diff.
889    pub changed_paths: usize,
890    /// Bytes represented by materialized candidate after-states.
891    pub materialized_after_bytes: u64,
892}
893
894/// Copy-on-write filesystem over one immutable snapshot.
895pub struct VirtualFs {
896    base: BaseSnapshot,
897    overlay: BTreeMap<VPath, OverlayEntry>,
898    effects: Vec<EffectEvent>,
899    read_set: BTreeMap<VPath, ReadObservation>,
900    write_set: BTreeMap<VPath, WritePrecondition>,
901    next_sequence: u64,
902    effect_origin: EffectOrigin,
903}
904
905impl VirtualFs {
906    /// Begin a new isolated virtual transaction.
907    #[must_use]
908    pub fn new(base: BaseSnapshot) -> Self {
909        Self {
910            base,
911            overlay: BTreeMap::new(),
912            effects: Vec::new(),
913            read_set: BTreeMap::new(),
914            write_set: BTreeMap::new(),
915            next_sequence: 0,
916            effect_origin: EffectOrigin::VirtualFs,
917        }
918    }
919
920    /// Return the number of base manifest nodes, including the virtual root.
921    #[must_use]
922    pub fn base_node_count(&self) -> usize {
923        self.base.len()
924    }
925
926    /// Run an operation while attributing every emitted effect to `origin`.
927    ///
928    /// Adapters use this narrow scope to preserve the typed source of observations
929    /// without duplicating filesystem semantics. Nested scopes restore the previous
930    /// origin when the operation returns.
931    pub fn with_effect_origin<T>(
932        &mut self,
933        origin: EffectOrigin,
934        operation: impl FnOnce(&mut Self) -> T,
935    ) -> T {
936        let previous = self.effect_origin;
937        self.effect_origin = origin;
938        let result = operation(self);
939        self.effect_origin = previous;
940        result
941    }
942
943    /// Return the immutable base snapshot identity.
944    #[must_use]
945    pub fn base_snapshot_id(&self) -> SnapshotId {
946        self.base.id()
947    }
948
949    /// Test path existence and record the metadata dependency.
950    pub fn exists(&mut self, path: &VPath) -> bool {
951        let state = self.resolve(path).map(|resolved| resolved.node.state());
952        self.record_metadata_dependency(path);
953        self.push_effect(Effect::MetadataRead {
954            path: path.clone(),
955            state,
956        });
957        state.is_some()
958    }
959
960    /// Read virtual metadata without following symbolic links.
961    ///
962    /// # Errors
963    ///
964    /// Returns [`VfsError::NotFound`] when `path` is absent.
965    pub fn metadata(&mut self, path: &VPath) -> Result<NodeState, VfsError> {
966        let state = self.resolve(path).map(|resolved| resolved.node.state());
967        self.record_metadata_dependency(path);
968        self.push_effect(Effect::MetadataRead {
969            path: path.clone(),
970            state,
971        });
972        state.ok_or_else(|| VfsError::NotFound { path: path.clone() })
973    }
974
975    /// Read regular-file bytes from virtual state.
976    ///
977    /// # Errors
978    ///
979    /// Returns an error for absent paths, non-files, stale lazy content, or blob-store
980    /// verification failures.
981    pub fn read(&mut self, path: &VPath) -> Result<Vec<u8>, VfsError> {
982        let resolved = self
983            .resolve(path)
984            .ok_or_else(|| VfsError::NotFound { path: path.clone() })?;
985        if resolved.node.state.kind() != NodeKind::File {
986            return Err(VfsError::NotFile {
987                path: path.clone(),
988                actual: resolved.node.state.kind(),
989            });
990        }
991        let (blob, bytes) = resolved.node.read(path, self.base.store())?;
992        if let Some(origin) = resolved.base_origin {
993            let observation = self.read_set.entry(origin).or_default();
994            observation.content.get_or_insert(blob);
995        }
996        self.push_effect(Effect::ContentRead {
997            path: path.clone(),
998            blob,
999        });
1000        Ok(bytes)
1001    }
1002
1003    /// Read an opaque symbolic-link target without following it.
1004    ///
1005    /// # Errors
1006    ///
1007    /// Returns an error for absent paths, non-links, stale lazy content, or blob errors.
1008    pub fn read_link(&mut self, path: &VPath) -> Result<Vec<u8>, VfsError> {
1009        let resolved = self
1010            .resolve(path)
1011            .ok_or_else(|| VfsError::NotFound { path: path.clone() })?;
1012        if resolved.node.state.kind() != NodeKind::Symlink {
1013            return Err(VfsError::NotSymlink {
1014                path: path.clone(),
1015                actual: resolved.node.state.kind(),
1016            });
1017        }
1018        let (blob, bytes) = resolved.node.read(path, self.base.store())?;
1019        if let Some(origin) = resolved.base_origin {
1020            let observation = self.read_set.entry(origin).or_default();
1021            observation.content.get_or_insert(blob);
1022        }
1023        self.push_effect(Effect::ContentRead {
1024            path: path.clone(),
1025            blob,
1026        });
1027        Ok(bytes)
1028    }
1029
1030    /// List immediate child paths in canonical order.
1031    ///
1032    /// # Errors
1033    ///
1034    /// Returns an error when `path` is absent or not a directory.
1035    pub fn read_dir(&mut self, path: &VPath) -> Result<Vec<VPath>, VfsError> {
1036        self.require_directory(path)?;
1037        self.record_metadata_dependency(path);
1038        let children = self.visible_direct_children(path);
1039        let digest = self.listing_digest(&children);
1040        if self.base.node(path).is_some() {
1041            self.read_set.entry(path.clone()).or_default().directory =
1042                Some(self.base.directory_digest(path));
1043        }
1044        self.push_effect(Effect::DirectoryRead {
1045            path: path.clone(),
1046            digest,
1047        });
1048        Ok(children)
1049    }
1050
1051    /// Create or replace a regular file in the overlay.
1052    ///
1053    /// # Errors
1054    ///
1055    /// Returns an error when the parent is absent/non-directory, the target is a
1056    /// directory/link, or immutable blob storage fails.
1057    pub fn write(&mut self, path: &VPath, bytes: &[u8]) -> Result<(), VfsError> {
1058        Self::ensure_mutable_path(path)?;
1059        self.require_parent_directory(path)?;
1060        let before = self.resolve(path).map(|resolved| resolved.node.state());
1061        if let Some(state) = before
1062            && state.kind() != NodeKind::File
1063        {
1064            return Err(VfsError::NotFile {
1065                path: path.clone(),
1066                actual: state.kind(),
1067            });
1068        }
1069
1070        let blob = self.base.store().put(bytes)?;
1071        let mode = before.map_or(DEFAULT_FILE_MODE, NodeState::mode);
1072        let node = Arc::new(SnapshotNode::materialized(
1073            NodeKind::File,
1074            blob,
1075            bytes.len() as u64,
1076            mode,
1077        ));
1078        let after = node.state();
1079        self.record_write_precondition(path);
1080        self.overlay.insert(
1081            path.clone(),
1082            OverlayEntry::Present(ResolvedNode {
1083                node,
1084                base_origin: None,
1085            }),
1086        );
1087        self.push_effect(match before {
1088            Some(before) => Effect::ModifyContent {
1089                path: path.clone(),
1090                before,
1091                after,
1092            },
1093            None => Effect::Create {
1094                path: path.clone(),
1095                after,
1096            },
1097        });
1098        Ok(())
1099    }
1100
1101    /// Append bytes to a regular file using the same read/write semantics.
1102    ///
1103    /// # Errors
1104    ///
1105    /// Returns the same errors as [`Self::read`] or [`Self::write`].
1106    pub fn append(&mut self, path: &VPath, suffix: &[u8]) -> Result<(), VfsError> {
1107        let mut bytes = self.read(path)?;
1108        bytes.extend_from_slice(suffix);
1109        self.write(path, &bytes)
1110    }
1111
1112    /// Create one empty directory.
1113    ///
1114    /// # Errors
1115    ///
1116    /// Returns an error when the path exists or its parent is unavailable.
1117    pub fn mkdir(&mut self, path: &VPath, mode: u32) -> Result<(), VfsError> {
1118        Self::ensure_mutable_path(path)?;
1119        self.require_parent_directory(path)?;
1120        if self.resolve(path).is_some() {
1121            return Err(VfsError::AlreadyExists { path: path.clone() });
1122        }
1123        let node = Arc::new(SnapshotNode::directory(platform_directory_mode(mode)));
1124        let after = node.state();
1125        self.record_write_precondition(path);
1126        self.overlay.insert(
1127            path.clone(),
1128            OverlayEntry::Present(ResolvedNode {
1129                node,
1130                base_origin: None,
1131            }),
1132        );
1133        self.push_effect(Effect::Create {
1134            path: path.clone(),
1135            after,
1136        });
1137        Ok(())
1138    }
1139
1140    /// Delete one regular file or opaque symbolic link.
1141    ///
1142    /// # Errors
1143    ///
1144    /// Returns an error when the path is absent or is a directory/root.
1145    pub fn unlink(&mut self, path: &VPath) -> Result<(), VfsError> {
1146        Self::ensure_mutable_path(path)?;
1147        let before = self
1148            .resolve(path)
1149            .map(|resolved| resolved.node.state())
1150            .ok_or_else(|| VfsError::NotFound { path: path.clone() })?;
1151        if before.kind() == NodeKind::Directory {
1152            return Err(VfsError::IsDirectory { path: path.clone() });
1153        }
1154        self.record_write_precondition(path);
1155        self.overlay.insert(path.clone(), OverlayEntry::Tombstone);
1156        self.push_effect(Effect::Delete {
1157            path: path.clone(),
1158            before,
1159        });
1160        Ok(())
1161    }
1162
1163    /// Delete one empty directory.
1164    ///
1165    /// # Errors
1166    ///
1167    /// Returns an error when the path is absent, non-directory, non-empty, or root.
1168    pub fn rmdir(&mut self, path: &VPath) -> Result<(), VfsError> {
1169        Self::ensure_mutable_path(path)?;
1170        let before = self.require_directory(path)?;
1171        let children = self.read_dir(path)?;
1172        if !children.is_empty() {
1173            return Err(VfsError::DirectoryNotEmpty { path: path.clone() });
1174        }
1175        self.record_write_precondition(path);
1176        self.overlay.insert(path.clone(), OverlayEntry::Tombstone);
1177        self.push_effect(Effect::Delete {
1178            path: path.clone(),
1179            before,
1180        });
1181        Ok(())
1182    }
1183
1184    /// Recursively delete a virtual subtree while expanding every affected write.
1185    ///
1186    /// # Errors
1187    ///
1188    /// Returns an error when the root path is absent or targets the virtual root.
1189    pub fn remove_tree(&mut self, path: &VPath) -> Result<(), VfsError> {
1190        Self::ensure_mutable_path(path)?;
1191        let nodes = self.visible_subtree(path);
1192        if nodes.is_empty() {
1193            return Err(VfsError::NotFound { path: path.clone() });
1194        }
1195        for (node_path, resolved) in nodes.iter().rev() {
1196            self.record_write_precondition(node_path);
1197            self.push_effect(Effect::Delete {
1198                path: node_path.clone(),
1199                before: resolved.node.state(),
1200            });
1201        }
1202        for (node_path, _) in nodes {
1203            self.overlay.insert(node_path, OverlayEntry::Tombstone);
1204        }
1205        Ok(())
1206    }
1207
1208    /// Move a file, link, or directory subtree without touching the host.
1209    ///
1210    /// Existing non-directory destinations are replaced. A directory may replace only
1211    /// an empty directory. Symlinks are moved as opaque nodes and never followed.
1212    ///
1213    /// # Errors
1214    ///
1215    /// Returns an error for root/overlapping moves, absent sources, invalid parents, or
1216    /// incompatible/non-empty destinations.
1217    pub fn rename(&mut self, from: &VPath, to: &VPath) -> Result<(), VfsError> {
1218        Self::ensure_mutable_path(from)?;
1219        Self::ensure_mutable_path(to)?;
1220        if from == to {
1221            return Ok(());
1222        }
1223        if to.is_within(from) || from.is_within(to) {
1224            return Err(VfsError::InvalidRename {
1225                from: from.clone(),
1226                to: to.clone(),
1227            });
1228        }
1229        self.require_parent_directory(to)?;
1230        let source = self
1231            .resolve(from)
1232            .ok_or_else(|| VfsError::NotFound { path: from.clone() })?;
1233        let source_state = source.node.state();
1234
1235        if let Some(destination) = self.resolve(to) {
1236            let destination_state = destination.node.state();
1237            match (source_state.kind(), destination_state.kind()) {
1238                (NodeKind::Directory, NodeKind::Directory)
1239                    if !self.visible_direct_children(to).is_empty() =>
1240                {
1241                    return Err(VfsError::DirectoryNotEmpty { path: to.clone() });
1242                }
1243                (NodeKind::Directory, NodeKind::Directory) => {}
1244                (NodeKind::Directory, _) | (_, NodeKind::Directory) => {
1245                    return Err(VfsError::RenameTypeMismatch {
1246                        from: from.clone(),
1247                        to: to.clone(),
1248                    });
1249                }
1250                _ => {}
1251            }
1252            self.record_write_precondition(to);
1253            self.overlay.insert(to.clone(), OverlayEntry::Tombstone);
1254        }
1255
1256        let moving = self.visible_subtree(from);
1257        for (source_path, _) in &moving {
1258            self.record_write_precondition(source_path);
1259        }
1260        for (source_path, _) in &moving {
1261            let destination_path =
1262                source_path
1263                    .rebase(from, to)?
1264                    .ok_or_else(|| VfsError::InvalidRename {
1265                        from: from.clone(),
1266                        to: to.clone(),
1267                    })?;
1268            self.record_write_precondition(&destination_path);
1269        }
1270
1271        for (source_path, _) in &moving {
1272            self.overlay
1273                .insert(source_path.clone(), OverlayEntry::Tombstone);
1274        }
1275        for (source_path, resolved) in moving {
1276            let destination_path =
1277                source_path
1278                    .rebase(from, to)?
1279                    .ok_or_else(|| VfsError::InvalidRename {
1280                        from: from.clone(),
1281                        to: to.clone(),
1282                    })?;
1283            self.overlay
1284                .insert(destination_path, OverlayEntry::Present(resolved));
1285        }
1286        self.push_effect(Effect::Rename {
1287            from: from.clone(),
1288            to: to.clone(),
1289            before: source_state,
1290            after: source_state,
1291        });
1292        Ok(())
1293    }
1294
1295    /// Produce the exact path-ordered diff between base and final virtual state.
1296    ///
1297    /// Descendant closure is expanded for subtree tombstones, so a recursive delete is
1298    /// never represented as a misleading one-path change.
1299    ///
1300    /// # Errors
1301    ///
1302    /// Returns an error if changed lazy content cannot be captured and verified.
1303    pub fn canonical_diff(&self) -> Result<CanonicalDiff, VfsError> {
1304        let mut candidates = BTreeSet::new();
1305        let mut expanded_delete_paths = BTreeSet::new();
1306        for (path, entry) in &self.overlay {
1307            candidates.insert(path.clone());
1308            if matches!(entry, OverlayEntry::Tombstone) {
1309                expanded_delete_paths.extend(self.base.subtree_paths(path));
1310            }
1311        }
1312        candidates.extend(expanded_delete_paths.iter().cloned());
1313
1314        let candidates: Vec<VPath> = candidates
1315            .into_iter()
1316            .filter(|path| !path.is_root())
1317            .collect();
1318        let candidate_paths = candidates.len();
1319        let mut after_states = BTreeMap::new();
1320        let mut materialized_after_bytes = 0_u64;
1321        for path in &candidates {
1322            let after = match self.resolve(path) {
1323                Some(resolved) => Some(resolved.node.materialized_state(path, self.base.store())?),
1324                None => None,
1325            };
1326            materialized_after_bytes =
1327                materialized_after_bytes.saturating_add(after.map_or(0, NodeState::size));
1328            after_states.insert(path.clone(), after);
1329        }
1330
1331        let mut entries = Vec::new();
1332        for path in candidates {
1333            let before = self.base.node(&path).map(|node| node.state());
1334            let after = after_states[&path];
1335            if before == after {
1336                continue;
1337            }
1338            let kind = classify_diff(before, after);
1339            entries.push(DiffEntry {
1340                path,
1341                before,
1342                after,
1343                kind,
1344            });
1345        }
1346
1347        let mut canonical = Vec::new();
1348        for entry in &entries {
1349            encode_diff_entry(entry, &mut canonical);
1350        }
1351        Ok(CanonicalDiff {
1352            metrics: CanonicalDiffMetrics {
1353                candidate_paths,
1354                expanded_delete_paths: expanded_delete_paths.len(),
1355                changed_paths: entries.len(),
1356                materialized_after_bytes,
1357            },
1358            entries,
1359            digest: DiffDigest::digest_canonical(&canonical),
1360        })
1361    }
1362
1363    /// Return the observed effect ledger.
1364    #[must_use]
1365    pub fn effects(&self) -> &[EffectEvent] {
1366        &self.effects
1367    }
1368
1369    /// Return base dependencies observed by virtual execution.
1370    #[must_use]
1371    pub fn read_set(&self) -> &BTreeMap<VPath, ReadObservation> {
1372        &self.read_set
1373    }
1374
1375    /// Return base preconditions for every virtual write.
1376    #[must_use]
1377    pub fn write_set(&self) -> &BTreeMap<VPath, WritePrecondition> {
1378        &self.write_set
1379    }
1380
1381    /// Return bounded transaction-local size metrics.
1382    #[must_use]
1383    pub fn metrics(&self) -> VfsMetrics {
1384        let overlay_bytes = self
1385            .overlay
1386            .values()
1387            .filter_map(|entry| match entry {
1388                OverlayEntry::Present(resolved) => Some(resolved.node.state().size()),
1389                OverlayEntry::Tombstone => None,
1390            })
1391            .sum();
1392        VfsMetrics {
1393            overlay_entries: self.overlay.len(),
1394            overlay_bytes,
1395            effect_events: self.effects.len(),
1396            read_dependencies: self.read_set.len(),
1397            write_preconditions: self.write_set.len(),
1398        }
1399    }
1400
1401    /// Materialize final virtual state for verification/test harnesses.
1402    ///
1403    /// This intentionally scans the full snapshot and is not used by transaction hot
1404    /// paths; production decisions use [`Self::canonical_diff`].
1405    ///
1406    /// # Errors
1407    ///
1408    /// Returns an error when visible lazy content cannot be verified.
1409    pub fn materialized_final_state(&self) -> Result<BTreeMap<VPath, NodeState>, VfsError> {
1410        let mut candidates: BTreeSet<VPath> = self.base.inner.nodes.keys().cloned().collect();
1411        candidates.extend(self.overlay.keys().cloned());
1412        let mut final_state = BTreeMap::new();
1413        for path in candidates {
1414            if let Some(resolved) = self.resolve(&path) {
1415                let state = resolved.node.materialized_state(&path, self.base.store())?;
1416                final_state.insert(path, state);
1417            }
1418        }
1419        Ok(final_state)
1420    }
1421
1422    fn resolve(&self, path: &VPath) -> Option<ResolvedNode> {
1423        let mut ancestor = path.parent();
1424        while let Some(current) = ancestor {
1425            match self.overlay.get(&current) {
1426                Some(OverlayEntry::Tombstone) => return None,
1427                Some(OverlayEntry::Present(resolved))
1428                    if resolved.node.state.kind() != NodeKind::Directory =>
1429                {
1430                    return None;
1431                }
1432                Some(OverlayEntry::Present(_)) | None => {}
1433            }
1434            ancestor = current.parent();
1435        }
1436        if let Some(entry) = self.overlay.get(path) {
1437            return match entry {
1438                OverlayEntry::Present(resolved) => Some(resolved.clone()),
1439                OverlayEntry::Tombstone => None,
1440            };
1441        }
1442        self.base.node(path).map(|node| ResolvedNode {
1443            node,
1444            base_origin: Some(path.clone()),
1445        })
1446    }
1447
1448    fn visible_direct_children(&self, path: &VPath) -> Vec<VPath> {
1449        let mut candidates: BTreeSet<VPath> = self.base.direct_children(path).collect();
1450        candidates.extend(
1451            self.overlay
1452                .keys()
1453                .filter(|candidate| candidate.parent().as_ref() == Some(path))
1454                .cloned(),
1455        );
1456        candidates
1457            .into_iter()
1458            .filter(|candidate| self.resolve(candidate).is_some())
1459            .collect()
1460    }
1461
1462    fn visible_subtree(&self, path: &VPath) -> Vec<(VPath, ResolvedNode)> {
1463        let mut candidates: BTreeSet<VPath> = self.base.subtree_paths(path).into_iter().collect();
1464        candidates.extend(
1465            self.overlay
1466                .keys()
1467                .filter(|candidate| candidate.is_within(path))
1468                .cloned(),
1469        );
1470        candidates
1471            .into_iter()
1472            .filter_map(|candidate| self.resolve(&candidate).map(|node| (candidate, node)))
1473            .collect()
1474    }
1475
1476    fn listing_digest(&self, children: &[VPath]) -> DirectoryDigest {
1477        DirectoryDigest::digest_entries(children.iter().map(|child| {
1478            (
1479                child,
1480                self.resolve(child)
1481                    .expect("visible child resolves")
1482                    .node
1483                    .state(),
1484            )
1485        }))
1486    }
1487
1488    fn require_directory(&self, path: &VPath) -> Result<NodeState, VfsError> {
1489        let state = self
1490            .resolve(path)
1491            .map(|resolved| resolved.node.state())
1492            .ok_or_else(|| VfsError::NotFound { path: path.clone() })?;
1493        if state.kind() != NodeKind::Directory {
1494            return Err(VfsError::NotDirectory {
1495                path: path.clone(),
1496                actual: state.kind(),
1497            });
1498        }
1499        Ok(state)
1500    }
1501
1502    fn require_parent_directory(&self, path: &VPath) -> Result<(), VfsError> {
1503        let parent = path.parent().ok_or(VfsError::RootMutation)?;
1504        self.require_directory(&parent).map(|_| ())
1505    }
1506
1507    fn ensure_mutable_path(path: &VPath) -> Result<(), VfsError> {
1508        if path.is_root() {
1509            Err(VfsError::RootMutation)
1510        } else {
1511            Ok(())
1512        }
1513    }
1514
1515    fn record_metadata_dependency(&mut self, path: &VPath) {
1516        let expected = self.base.node(path).map(|node| node.expected_state());
1517        self.read_set
1518            .entry(path.clone())
1519            .or_default()
1520            .metadata
1521            .get_or_insert(expected);
1522    }
1523
1524    fn record_write_precondition(&mut self, path: &VPath) {
1525        let expected = self.base.node(path).map(|node| node.expected_state());
1526        self.write_set
1527            .entry(path.clone())
1528            .or_insert(WritePrecondition { expected });
1529        if let Some(parent) = path.parent() {
1530            self.record_metadata_dependency(&parent);
1531        }
1532    }
1533
1534    fn push_effect(&mut self, effect: Effect) {
1535        let sequence = self.next_sequence;
1536        self.next_sequence = self
1537            .next_sequence
1538            .checked_add(1)
1539            .expect("effect sequence cannot wrap within a bounded transaction");
1540        self.effects.push(EffectEvent {
1541            sequence,
1542            origin: self.effect_origin,
1543            effect,
1544        });
1545    }
1546}
1547
1548fn classify_diff(before: Option<NodeState>, after: Option<NodeState>) -> DiffKind {
1549    match (before, after) {
1550        (None, Some(_)) => DiffKind::Create,
1551        (Some(_), None) => DiffKind::Delete,
1552        (Some(before), Some(after)) if before.content_equivalent(after) => DiffKind::MetadataChange,
1553        (Some(_), Some(_)) => DiffKind::Modify,
1554        (None, None) => unreachable!("equal missing states are removed before classification"),
1555    }
1556}
1557
1558fn encode_path(path: &VPath, output: &mut Vec<u8>) {
1559    let bytes = path.as_str().as_bytes();
1560    output.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
1561    output.extend_from_slice(bytes);
1562}
1563
1564fn encode_optional_state(state: Option<NodeState>, output: &mut Vec<u8>) {
1565    match state {
1566        Some(state) => {
1567            output.push(1);
1568            state.encode_canonical(output);
1569        }
1570        None => output.push(0),
1571    }
1572}
1573
1574fn encode_diff_entry(entry: &DiffEntry, output: &mut Vec<u8>) {
1575    encode_path(&entry.path, output);
1576    output.push(match entry.kind {
1577        DiffKind::Create => 1,
1578        DiffKind::Delete => 2,
1579        DiffKind::Modify => 3,
1580        DiffKind::MetadataChange => 4,
1581    });
1582    encode_optional_state(entry.before, output);
1583    encode_optional_state(entry.after, output);
1584}
1585
1586/// Virtual filesystem operation failure.
1587#[derive(Debug)]
1588#[non_exhaustive]
1589pub enum VfsError {
1590    /// Snapshot access or lazy capture failed.
1591    Snapshot(SnapshotError),
1592    /// Immutable blob storage failed.
1593    Store(BlobStoreError),
1594    /// A virtual path was invalid during an internal rebase.
1595    Path(VPathError),
1596    /// The requested path does not exist in virtual state.
1597    NotFound {
1598        /// Missing path.
1599        path: VPath,
1600    },
1601    /// The target already exists.
1602    AlreadyExists {
1603        /// Existing path.
1604        path: VPath,
1605    },
1606    /// A directory operation targeted another node kind.
1607    NotDirectory {
1608        /// Requested path.
1609        path: VPath,
1610        /// Actual kind.
1611        actual: NodeKind,
1612    },
1613    /// A regular-file operation targeted another node kind.
1614    NotFile {
1615        /// Requested path.
1616        path: VPath,
1617        /// Actual kind.
1618        actual: NodeKind,
1619    },
1620    /// A symlink operation targeted another node kind.
1621    NotSymlink {
1622        /// Requested path.
1623        path: VPath,
1624        /// Actual kind.
1625        actual: NodeKind,
1626    },
1627    /// A file-only deletion targeted a directory.
1628    IsDirectory {
1629        /// Directory path.
1630        path: VPath,
1631    },
1632    /// A non-empty directory cannot be removed/replaced.
1633    DirectoryNotEmpty {
1634        /// Non-empty directory.
1635        path: VPath,
1636    },
1637    /// The immutable virtual root cannot be mutated.
1638    RootMutation,
1639    /// Source and destination subtrees overlap.
1640    InvalidRename {
1641        /// Source root.
1642        from: VPath,
1643        /// Destination root.
1644        to: VPath,
1645    },
1646    /// Rename source and destination kinds are incompatible.
1647    RenameTypeMismatch {
1648        /// Source path.
1649        from: VPath,
1650        /// Destination path.
1651        to: VPath,
1652    },
1653    /// A decoded durable artifact violated canonical diff invariants.
1654    InvalidCanonicalDiff {
1655        /// Affected path, when the violation is path-specific.
1656        path: Option<VPath>,
1657        /// Stable validation reason.
1658        reason: &'static str,
1659    },
1660}
1661
1662impl From<SnapshotError> for VfsError {
1663    fn from(value: SnapshotError) -> Self {
1664        Self::Snapshot(value)
1665    }
1666}
1667
1668impl From<BlobStoreError> for VfsError {
1669    fn from(value: BlobStoreError) -> Self {
1670        Self::Store(value)
1671    }
1672}
1673
1674impl From<VPathError> for VfsError {
1675    fn from(value: VPathError) -> Self {
1676        Self::Path(value)
1677    }
1678}
1679
1680impl fmt::Display for VfsError {
1681    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1682        match self {
1683            Self::Snapshot(source) => write!(formatter, "snapshot failure: {source}"),
1684            Self::Store(source) => write!(formatter, "blob store failure: {source}"),
1685            Self::Path(source) => write!(formatter, "virtual path failure: {source}"),
1686            Self::NotFound { path } => write!(formatter, "virtual path not found: {path}"),
1687            Self::AlreadyExists { path } => write!(formatter, "virtual path exists: {path}"),
1688            Self::NotDirectory { path, actual } => {
1689                write!(
1690                    formatter,
1691                    "virtual path {path} is not a directory ({actual:?})"
1692                )
1693            }
1694            Self::NotFile { path, actual } => {
1695                write!(formatter, "virtual path {path} is not a file ({actual:?})")
1696            }
1697            Self::NotSymlink { path, actual } => {
1698                write!(
1699                    formatter,
1700                    "virtual path {path} is not a symlink ({actual:?})"
1701                )
1702            }
1703            Self::IsDirectory { path } => write!(formatter, "virtual path is a directory: {path}"),
1704            Self::DirectoryNotEmpty { path } => {
1705                write!(formatter, "virtual directory is not empty: {path}")
1706            }
1707            Self::RootMutation => formatter.write_str("the virtual root cannot be mutated"),
1708            Self::InvalidRename { from, to } => {
1709                write!(formatter, "rename subtrees overlap: {from} -> {to}")
1710            }
1711            Self::RenameTypeMismatch { from, to } => {
1712                write!(formatter, "rename type mismatch: {from} -> {to}")
1713            }
1714            Self::InvalidCanonicalDiff { path, reason } => match path {
1715                Some(path) => write!(formatter, "invalid canonical diff at {path}: {reason}"),
1716                None => write!(formatter, "invalid canonical diff: {reason}"),
1717            },
1718        }
1719    }
1720}
1721
1722impl Error for VfsError {
1723    fn source(&self) -> Option<&(dyn Error + 'static)> {
1724        match self {
1725            Self::Snapshot(source) => Some(source),
1726            Self::Store(source) => Some(source),
1727            Self::Path(source) => Some(source),
1728            Self::NotFound { .. }
1729            | Self::AlreadyExists { .. }
1730            | Self::NotDirectory { .. }
1731            | Self::NotFile { .. }
1732            | Self::NotSymlink { .. }
1733            | Self::IsDirectory { .. }
1734            | Self::DirectoryNotEmpty { .. }
1735            | Self::RootMutation
1736            | Self::InvalidRename { .. }
1737            | Self::RenameTypeMismatch { .. }
1738            | Self::InvalidCanonicalDiff { .. } => None,
1739        }
1740    }
1741}
1742
1743/// Transaction-local virtual filesystem size counters.
1744#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1745pub struct VfsMetrics {
1746    /// Exact overlay entry count.
1747    pub overlay_entries: usize,
1748    /// Sum of visible overlay node sizes.
1749    pub overlay_bytes: u64,
1750    /// Observed effect count.
1751    pub effect_events: usize,
1752    /// Base read-dependency path count.
1753    pub read_dependencies: usize,
1754    /// Base write-precondition path count.
1755    pub write_preconditions: usize,
1756}
1757
1758#[cfg(test)]
1759mod tests {
1760    use std::fs;
1761    use std::io;
1762    use std::path::PathBuf;
1763    use std::sync::Barrier;
1764    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
1765
1766    use vsh_types::PlatformFileId;
1767
1768    use super::*;
1769
1770    static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1771
1772    struct TestDirectory(PathBuf);
1773
1774    impl Drop for TestDirectory {
1775        fn drop(&mut self) {
1776            let _ = fs::remove_dir_all(&self.0);
1777        }
1778    }
1779
1780    fn test_store(name: &str) -> (TestDirectory, BlobStore) {
1781        let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1782        let root = std::env::temp_dir().join(format!(
1783            "vsh-vfs-test-{}-{sequence}-{name}",
1784            std::process::id()
1785        ));
1786        let guard = TestDirectory(root.clone());
1787        let store = BlobStore::open(root).unwrap();
1788        (guard, store)
1789    }
1790
1791    fn path(value: &str) -> VPath {
1792        VPath::parse(value).unwrap()
1793    }
1794
1795    fn stamp(kind: NodeKind, size: usize, identity: u64) -> FileStamp {
1796        FileStamp {
1797            kind,
1798            size: size as u64,
1799            mode: 0o644,
1800            mtime_ns: 1_700_000_000_000_000_000,
1801            ctime_ns: Some(1_700_000_000_000_000_001),
1802            file_id: PlatformFileId {
1803                high: 7,
1804                low: identity,
1805            },
1806        }
1807    }
1808
1809    fn fixture_snapshot(store: BlobStore) -> BaseSnapshot {
1810        let mut builder = SnapshotBuilder::new(store);
1811        builder.add_directory(path("src"), 0o755).unwrap();
1812        builder.add_directory(path("src/nested"), 0o755).unwrap();
1813        builder.add_directory(path("empty"), 0o755).unwrap();
1814        builder
1815            .add_file(path("src/a.txt"), b"alpha", 0o644)
1816            .unwrap();
1817        builder
1818            .add_file(path("src/nested/b.txt"), b"beta", 0o600)
1819            .unwrap();
1820        builder
1821            .add_symlink(path("opaque-link"), b"../../outside", 0o777)
1822            .unwrap();
1823        builder.build().unwrap()
1824    }
1825
1826    #[test]
1827    fn public_snapshot_errors_have_stable_messages_and_sources() {
1828        let expected = stamp(NodeKind::File, 1, 1);
1829        let changed = FileStamp {
1830            size: 2,
1831            ..expected
1832        };
1833        let blob_error = || BlobStoreError::Io {
1834            operation: "read",
1835            path: PathBuf::from("blob"),
1836            source: io::Error::other("test"),
1837        };
1838        let snapshot_errors = [
1839            SnapshotError::Store(blob_error()),
1840            SnapshotError::DuplicatePath { path: path("file") },
1841            SnapshotError::MissingParent {
1842                path: path("dir/file"),
1843                parent: path("dir"),
1844            },
1845            SnapshotError::ParentNotDirectory {
1846                path: path("dir/file"),
1847                parent: path("dir"),
1848            },
1849            SnapshotError::LazyDirectory { path: path("dir") },
1850            SnapshotError::ExpectedDirectoryStamp {
1851                path: path("file"),
1852                stamp: expected,
1853            },
1854            SnapshotError::ContentUnavailable {
1855                path: path("dir"),
1856                kind: NodeKind::Directory,
1857            },
1858            SnapshotError::ContentLoad {
1859                path: path("file"),
1860                source: ContentLoadError::new("test"),
1861            },
1862            SnapshotError::StaleContent {
1863                path: path("file"),
1864                expected: Box::new(expected),
1865                before: Box::new(expected),
1866                after: Box::new(changed),
1867            },
1868            SnapshotError::ContentSizeMismatch {
1869                path: path("file"),
1870                expected: 1,
1871                actual: 2,
1872            },
1873            SnapshotError::LazyStatePoisoned { path: path("file") },
1874        ];
1875        for error in snapshot_errors {
1876            assert!(!error.to_string().is_empty());
1877            assert_eq!(
1878                Error::source(&error).is_some(),
1879                matches!(
1880                    error,
1881                    SnapshotError::Store(_) | SnapshotError::ContentLoad { .. }
1882                )
1883            );
1884        }
1885    }
1886
1887    #[test]
1888    fn public_vfs_errors_have_stable_messages_and_sources() {
1889        let blob_error = || BlobStoreError::Io {
1890            operation: "read",
1891            path: PathBuf::from("blob"),
1892            source: io::Error::other("test"),
1893        };
1894        let vfs_errors = [
1895            VfsError::Snapshot(SnapshotError::DuplicatePath { path: path("file") }),
1896            VfsError::Store(blob_error()),
1897            VfsError::Path(VPath::parse("").unwrap_err()),
1898            VfsError::NotFound { path: path("file") },
1899            VfsError::AlreadyExists { path: path("file") },
1900            VfsError::NotDirectory {
1901                path: path("file"),
1902                actual: NodeKind::File,
1903            },
1904            VfsError::NotFile {
1905                path: path("dir"),
1906                actual: NodeKind::Directory,
1907            },
1908            VfsError::NotSymlink {
1909                path: path("file"),
1910                actual: NodeKind::File,
1911            },
1912            VfsError::IsDirectory { path: path("dir") },
1913            VfsError::DirectoryNotEmpty { path: path("dir") },
1914            VfsError::RootMutation,
1915            VfsError::InvalidRename {
1916                from: path("dir"),
1917                to: path("dir/child"),
1918            },
1919            VfsError::RenameTypeMismatch {
1920                from: path("file"),
1921                to: path("dir"),
1922            },
1923            VfsError::InvalidCanonicalDiff {
1924                path: Some(path("file")),
1925                reason: "test",
1926            },
1927            VfsError::InvalidCanonicalDiff {
1928                path: None,
1929                reason: "test",
1930            },
1931        ];
1932        for error in vfs_errors {
1933            assert!(!error.to_string().is_empty());
1934            assert_eq!(
1935                Error::source(&error).is_some(),
1936                matches!(
1937                    error,
1938                    VfsError::Snapshot(_) | VfsError::Store(_) | VfsError::Path(_)
1939                )
1940            );
1941        }
1942    }
1943
1944    #[test]
1945    fn snapshot_identity_is_insertion_order_and_store_independent() {
1946        let (_first_guard, first_store) = test_store("snapshot-a");
1947        let (_second_guard, second_store) = test_store("snapshot-b");
1948
1949        let mut first = SnapshotBuilder::new(first_store);
1950        first.add_directory(path("dir"), 0o755).unwrap();
1951        first.add_file(path("dir/file"), b"same", 0o640).unwrap();
1952
1953        let mut second = SnapshotBuilder::new(second_store);
1954        second.add_file(path("dir/file"), b"same", 0o640).unwrap();
1955        second.add_directory(path("dir"), 0o755).unwrap();
1956
1957        let first = first.build().unwrap();
1958        let second = second.build().unwrap();
1959        assert_eq!(first.id(), second.id());
1960        assert_eq!(first.len(), 3);
1961        assert!(!first.is_empty());
1962    }
1963
1964    #[test]
1965    fn snapshot_rejects_missing_or_non_directory_parents() {
1966        let (_guard, store) = test_store("invalid-parent");
1967        let mut missing = SnapshotBuilder::new(store.clone());
1968        missing.add_file(path("missing/file"), b"x", 0o644).unwrap();
1969        assert!(matches!(
1970            missing.build(),
1971            Err(SnapshotError::MissingParent { .. })
1972        ));
1973
1974        let mut non_directory = SnapshotBuilder::new(store);
1975        non_directory.add_file(path("file"), b"x", 0o644).unwrap();
1976        non_directory
1977            .add_file(path("file/child"), b"y", 0o644)
1978            .unwrap();
1979        assert!(matches!(
1980            non_directory.build(),
1981            Err(SnapshotError::ParentNotDirectory { .. })
1982        ));
1983    }
1984
1985    #[test]
1986    fn lazy_content_is_captured_once_and_then_blob_backed() {
1987        let (_guard, store) = test_store("lazy-once");
1988        let expected = stamp(NodeKind::File, 5, 41);
1989        let calls = Arc::new(AtomicUsize::new(0));
1990        let loader_calls = Arc::clone(&calls);
1991        let mut builder = SnapshotBuilder::new(store);
1992        builder
1993            .add_lazy(path("lazy.txt"), expected, move |stamp| {
1994                loader_calls.fetch_add(1, Ordering::Relaxed);
1995                Ok(CapturedContent {
1996                    bytes: b"hello".to_vec(),
1997                    before: stamp,
1998                    after: stamp,
1999                })
2000            })
2001            .unwrap();
2002        let snapshot = builder.build().unwrap();
2003        assert_eq!(
2004            snapshot.metrics(),
2005            SnapshotMetrics {
2006                node_count: 2,
2007                lazy_content_nodes: 1,
2008                materialized_content_nodes: 0,
2009            }
2010        );
2011
2012        let mut vfs = VirtualFs::new(snapshot.clone());
2013        assert_eq!(vfs.read(&path("lazy.txt")).unwrap(), b"hello");
2014        assert_eq!(vfs.read(&path("lazy.txt")).unwrap(), b"hello");
2015        assert_eq!(calls.load(Ordering::Relaxed), 1);
2016        assert_eq!(snapshot.metrics().materialized_content_nodes, 1);
2017        assert!(vfs.read_set()[&path("lazy.txt")].content.is_some());
2018    }
2019
2020    #[test]
2021    fn concurrent_snapshot_readers_share_one_lazy_capture() {
2022        let (_guard, store) = test_store("lazy-concurrent");
2023        let expected = stamp(NodeKind::File, 5, 43);
2024        let calls = Arc::new(AtomicUsize::new(0));
2025        let loader_calls = Arc::clone(&calls);
2026        let mut builder = SnapshotBuilder::new(store);
2027        builder
2028            .add_lazy(path("lazy.txt"), expected, move |stamp| {
2029                loader_calls.fetch_add(1, Ordering::Relaxed);
2030                Ok(CapturedContent {
2031                    bytes: b"hello".to_vec(),
2032                    before: stamp,
2033                    after: stamp,
2034                })
2035            })
2036            .unwrap();
2037        let snapshot = builder.build().unwrap();
2038        let barrier = Arc::new(Barrier::new(8));
2039        let mut threads = Vec::new();
2040        for _ in 0..8 {
2041            let worker_snapshot = snapshot.clone();
2042            let worker_barrier = Arc::clone(&barrier);
2043            threads.push(std::thread::spawn(move || {
2044                let mut vfs = VirtualFs::new(worker_snapshot);
2045                worker_barrier.wait();
2046                vfs.read(&path("lazy.txt")).unwrap()
2047            }));
2048        }
2049
2050        for thread in threads {
2051            assert_eq!(thread.join().unwrap(), b"hello");
2052        }
2053        assert_eq!(calls.load(Ordering::Relaxed), 1);
2054    }
2055
2056    #[test]
2057    fn lazy_capture_fails_closed_on_stamp_or_size_drift() {
2058        let (_guard, store) = test_store("lazy-stale");
2059        let expected = stamp(NodeKind::File, 5, 42);
2060        let mut changed = expected;
2061        changed.mtime_ns += 1;
2062        let mut builder = SnapshotBuilder::new(store);
2063        builder
2064            .add_lazy(path("stale.txt"), expected, move |_| {
2065                Ok(CapturedContent {
2066                    bytes: b"hello".to_vec(),
2067                    before: expected,
2068                    after: changed,
2069                })
2070            })
2071            .unwrap();
2072        let mut vfs = VirtualFs::new(builder.build().unwrap());
2073        assert!(matches!(
2074            vfs.read(&path("stale.txt")),
2075            Err(VfsError::Snapshot(SnapshotError::StaleContent { .. }))
2076        ));
2077
2078        let (_guard, store) = test_store("lazy-size");
2079        let mut builder = SnapshotBuilder::new(store);
2080        builder
2081            .add_lazy(path("size.txt"), expected, move |stamp| {
2082                Ok(CapturedContent {
2083                    bytes: b"shorter?".to_vec(),
2084                    before: stamp,
2085                    after: stamp,
2086                })
2087            })
2088            .unwrap();
2089        let mut vfs = VirtualFs::new(builder.build().unwrap());
2090        assert!(matches!(
2091            vfs.read(&path("size.txt")),
2092            Err(VfsError::Snapshot(
2093                SnapshotError::ContentSizeMismatch { .. }
2094            ))
2095        ));
2096    }
2097
2098    #[test]
2099    fn virtual_operations_never_mutate_the_base_and_emit_exact_diff() {
2100        let (_guard, store) = test_store("operations");
2101        let snapshot = fixture_snapshot(store);
2102        let pristine = VirtualFs::new(snapshot.clone())
2103            .materialized_final_state()
2104            .unwrap();
2105        let mut vfs = VirtualFs::new(snapshot.clone());
2106
2107        assert_eq!(vfs.read(&path("src/a.txt")).unwrap(), b"alpha");
2108        vfs.append(&path("src/a.txt"), b"!").unwrap();
2109        vfs.mkdir(&path("generated"), 0o755).unwrap();
2110        vfs.write(&path("generated/out.txt"), b"output").unwrap();
2111        vfs.rename(&path("src/nested"), &path("moved")).unwrap();
2112        vfs.unlink(&path("opaque-link")).unwrap();
2113
2114        let diff = vfs.canonical_diff().unwrap();
2115        assert_eq!(diff, vfs.canonical_diff().unwrap());
2116        assert!(
2117            diff.entries()
2118                .windows(2)
2119                .all(|pair| pair[0].path < pair[1].path)
2120        );
2121
2122        let mut applied = pristine;
2123        apply_diff(&mut applied, &diff);
2124        assert_eq!(applied, vfs.materialized_final_state().unwrap());
2125
2126        let untouched = VirtualFs::new(snapshot).materialized_final_state().unwrap();
2127        assert_eq!(untouched[&path("src/a.txt")].size(), 5);
2128        assert!(untouched.contains_key(&path("opaque-link")));
2129        assert!(!untouched.contains_key(&path("generated")));
2130    }
2131
2132    #[test]
2133    fn recursive_delete_expands_full_descendant_closure() {
2134        let (_guard, store) = test_store("delete-closure");
2135        let snapshot = fixture_snapshot(store);
2136        let mut vfs = VirtualFs::new(snapshot);
2137        vfs.remove_tree(&path("src")).unwrap();
2138
2139        let diff = vfs.canonical_diff().unwrap();
2140        let deleted: Vec<&str> = diff
2141            .entries()
2142            .iter()
2143            .map(|entry| {
2144                assert_eq!(entry.kind, DiffKind::Delete);
2145                entry.path.as_str()
2146            })
2147            .collect();
2148        assert_eq!(
2149            deleted,
2150            ["src", "src/a.txt", "src/nested", "src/nested/b.txt"]
2151        );
2152        assert_eq!(
2153            diff.metrics(),
2154            CanonicalDiffMetrics {
2155                candidate_paths: 4,
2156                expanded_delete_paths: 4,
2157                changed_paths: 4,
2158                materialized_after_bytes: 0,
2159            }
2160        );
2161        assert_eq!(vfs.write_set().len(), 4);
2162    }
2163
2164    #[test]
2165    fn subtree_expansion_is_component_aware_not_lexical_prefix_based() {
2166        let (_guard, store) = test_store("subtree-order");
2167        let mut builder = SnapshotBuilder::new(store);
2168        builder.add_directory(path("a"), 0o755).unwrap();
2169        builder.add_file(path("a/in"), b"in", 0o644).unwrap();
2170        builder
2171            .add_file(path("a-foreign"), b"foreign", 0o644)
2172            .unwrap();
2173        builder.add_file(path("a.other"), b"other", 0o644).unwrap();
2174        let mut vfs = VirtualFs::new(builder.build().unwrap());
2175
2176        vfs.remove_tree(&path("a")).unwrap();
2177        let diff = vfs.canonical_diff().unwrap();
2178        let paths: Vec<&str> = diff
2179            .entries()
2180            .iter()
2181            .map(|entry| entry.path.as_str())
2182            .collect();
2183        assert_eq!(paths, ["a", "a/in"]);
2184        assert!(vfs.exists(&path("a-foreign")));
2185        assert!(vfs.exists(&path("a.other")));
2186    }
2187
2188    #[test]
2189    fn subtree_whiteouts_hide_old_and_overlay_descendants_after_recreation() {
2190        let (_guard, store) = test_store("whiteout");
2191        let snapshot = fixture_snapshot(store);
2192        let mut base_model = VirtualFs::new(snapshot.clone())
2193            .materialized_final_state()
2194            .unwrap();
2195        let mut vfs = VirtualFs::new(snapshot);
2196
2197        vfs.write(&path("src/overlay.txt"), b"overlay").unwrap();
2198        vfs.remove_tree(&path("src")).unwrap();
2199        assert!(!vfs.exists(&path("src/a.txt")));
2200        assert!(!vfs.exists(&path("src/overlay.txt")));
2201
2202        vfs.mkdir(&path("src"), 0o755).unwrap();
2203        assert!(vfs.read_dir(&path("src")).unwrap().is_empty());
2204        vfs.write(&path("src/fresh.txt"), b"fresh").unwrap();
2205        assert_eq!(vfs.read_dir(&path("src")).unwrap(), [path("src/fresh.txt")]);
2206
2207        let diff = vfs.canonical_diff().unwrap();
2208        apply_diff(&mut base_model, &diff);
2209        assert_eq!(base_model, vfs.materialized_final_state().unwrap());
2210        assert!(!base_model.contains_key(&path("src/a.txt")));
2211        assert!(!base_model.contains_key(&path("src/overlay.txt")));
2212    }
2213
2214    #[test]
2215    fn rename_whiteouts_hide_source_descendants_if_source_is_recreated() {
2216        let (_guard, store) = test_store("rename-whiteout");
2217        let mut vfs = VirtualFs::new(fixture_snapshot(store));
2218        vfs.write(&path("src/overlay.txt"), b"overlay").unwrap();
2219        vfs.rename(&path("src"), &path("destination")).unwrap();
2220
2221        assert!(!vfs.exists(&path("src/overlay.txt")));
2222        assert_eq!(
2223            vfs.read(&path("destination/overlay.txt")).unwrap(),
2224            b"overlay"
2225        );
2226        vfs.mkdir(&path("src"), 0o755).unwrap();
2227        assert!(vfs.read_dir(&path("src")).unwrap().is_empty());
2228        assert!(vfs.exists(&path("destination/nested/b.txt")));
2229    }
2230
2231    #[test]
2232    fn one_file_diff_in_large_snapshot_only_compares_touched_path() {
2233        let (_guard, store) = test_store("touched-scaling");
2234        let blob = store.put(b"x").unwrap();
2235        let mut builder = SnapshotBuilder::new(store);
2236        builder.add_directory(path("bulk"), 0o755).unwrap();
2237        for index in 0..10_000 {
2238            builder
2239                .insert(
2240                    path(&format!("bulk/file-{index:05}")),
2241                    SnapshotNode::materialized(NodeKind::File, blob, 1, 0o644),
2242                )
2243                .unwrap();
2244        }
2245        let snapshot = builder.build().unwrap();
2246        assert_eq!(snapshot.len(), 10_002);
2247
2248        let mut vfs = VirtualFs::new(snapshot);
2249        vfs.write(&path("bulk/file-05000"), b"changed").unwrap();
2250        let diff = vfs.canonical_diff().unwrap();
2251
2252        assert_eq!(
2253            diff.metrics(),
2254            CanonicalDiffMetrics {
2255                candidate_paths: 1,
2256                expanded_delete_paths: 0,
2257                changed_paths: 1,
2258                materialized_after_bytes: 7,
2259            }
2260        );
2261    }
2262
2263    #[test]
2264    fn reads_writes_and_effects_are_transaction_local_and_ordered() {
2265        let (_guard, store) = test_store("ledger");
2266        let mut vfs = VirtualFs::new(fixture_snapshot(store));
2267
2268        vfs.read(&path("src/a.txt")).unwrap();
2269        vfs.read_dir(&path("src")).unwrap();
2270        assert!(!vfs.exists(&path("missing")));
2271        vfs.write(&path("created.txt"), b"new").unwrap();
2272
2273        assert!(vfs.read_set()[&path("src/a.txt")].content.is_some());
2274        assert!(vfs.read_set()[&path("src")].directory.is_some());
2275        assert_eq!(vfs.read_set()[&path("missing")].metadata, Some(None));
2276        assert_eq!(
2277            vfs.write_set()[&path("created.txt")],
2278            WritePrecondition { expected: None }
2279        );
2280        assert!(
2281            vfs.effects()
2282                .iter()
2283                .enumerate()
2284                .all(|(index, event)| event.sequence == index as u64)
2285        );
2286        assert_eq!(vfs.metrics().effect_events, vfs.effects().len());
2287    }
2288
2289    #[test]
2290    fn symlinks_are_opaque_and_never_followed() {
2291        let (_guard, store) = test_store("symlink");
2292        let mut vfs = VirtualFs::new(fixture_snapshot(store));
2293        assert_eq!(
2294            vfs.read_link(&path("opaque-link")).unwrap(),
2295            b"../../outside"
2296        );
2297        assert!(matches!(
2298            vfs.read(&path("opaque-link")),
2299            Err(VfsError::NotFile {
2300                actual: NodeKind::Symlink,
2301                ..
2302            })
2303        ));
2304        assert!(matches!(
2305            vfs.write(&path("opaque-link/child"), b"blocked"),
2306            Err(VfsError::NotDirectory {
2307                actual: NodeKind::Symlink,
2308                ..
2309            })
2310        ));
2311    }
2312
2313    #[test]
2314    fn no_op_sequences_have_empty_canonical_diffs() {
2315        let (_guard, store) = test_store("no-op");
2316        let snapshot = fixture_snapshot(store);
2317        let mut vfs = VirtualFs::new(snapshot);
2318        vfs.write(&path("temporary"), b"x").unwrap();
2319        vfs.unlink(&path("temporary")).unwrap();
2320        vfs.write(&path("src/a.txt"), b"alpha").unwrap();
2321
2322        assert!(vfs.canonical_diff().unwrap().is_empty());
2323        assert!(!vfs.effects().is_empty());
2324    }
2325
2326    #[test]
2327    fn lazy_rename_diff_is_stable_across_repeated_generation() {
2328        let (_guard, store) = test_store("lazy-rename");
2329        let expected = stamp(NodeKind::File, 5, 91);
2330        let mut builder = SnapshotBuilder::new(store);
2331        builder
2332            .add_lazy(path("z.txt"), expected, move |stamp| {
2333                Ok(CapturedContent {
2334                    bytes: b"hello".to_vec(),
2335                    before: stamp,
2336                    after: stamp,
2337                })
2338            })
2339            .unwrap();
2340        let mut vfs = VirtualFs::new(builder.build().unwrap());
2341        vfs.rename(&path("z.txt"), &path("a.txt")).unwrap();
2342
2343        let first = vfs.canonical_diff().unwrap();
2344        let second = vfs.canonical_diff().unwrap();
2345        assert_eq!(first, second);
2346        assert_eq!(first.entries().len(), 2);
2347    }
2348
2349    #[test]
2350    fn generated_operation_sequences_replay_through_diff_to_same_state() {
2351        for seed in 0..128 {
2352            let (_guard, store) = test_store("model-property");
2353            let base = property_snapshot(store);
2354            let mut expected = VirtualFs::new(base.clone())
2355                .materialized_final_state()
2356                .unwrap();
2357            let mut vfs = VirtualFs::new(base);
2358            let mut random = Lcg::new(seed);
2359
2360            for _ in 0..48 {
2361                apply_generated_operation(&mut vfs, &mut random);
2362            }
2363
2364            let diff = vfs.canonical_diff().unwrap();
2365            apply_diff(&mut expected, &diff);
2366            assert_eq!(
2367                expected,
2368                vfs.materialized_final_state().unwrap(),
2369                "seed {seed}"
2370            );
2371            assert_eq!(diff, vfs.canonical_diff().unwrap(), "seed {seed}");
2372        }
2373    }
2374
2375    fn property_snapshot(store: BlobStore) -> BaseSnapshot {
2376        let mut builder = SnapshotBuilder::new(store);
2377        builder.add_directory(path("a"), 0o755).unwrap();
2378        builder.add_directory(path("b"), 0o755).unwrap();
2379        builder.add_directory(path("a/sub"), 0o755).unwrap();
2380        builder.add_file(path("a/f0"), b"0", 0o644).unwrap();
2381        builder.add_file(path("a/f1"), b"1", 0o644).unwrap();
2382        builder
2383            .add_file(path("a/sub/deep"), b"deep", 0o600)
2384            .unwrap();
2385        builder.add_file(path("b/f2"), b"2", 0o644).unwrap();
2386        builder.build().unwrap()
2387    }
2388
2389    fn apply_diff(state: &mut BTreeMap<VPath, NodeState>, diff: &CanonicalDiff) {
2390        for entry in diff.entries() {
2391            match entry.after {
2392                Some(after) => {
2393                    state.insert(entry.path.clone(), after);
2394                }
2395                None => {
2396                    state.remove(&entry.path);
2397                }
2398            }
2399        }
2400    }
2401
2402    fn apply_generated_operation(vfs: &mut VirtualFs, random: &mut Lcg) {
2403        const FILES: [&str; 6] = ["a/f0", "a/f1", "a/sub/deep", "b/f2", "c/f3", "d/f4"];
2404        const DIRECTORIES: [&str; 4] = ["a/sub", "c", "d", "b/sub"];
2405        const RENAMES: [(&str, &str); 6] = [
2406            ("a/f0", "b/f0"),
2407            ("b/f0", "a/f0"),
2408            ("a/sub", "b/sub"),
2409            ("b/sub", "a/sub"),
2410            ("c", "d"),
2411            ("d", "c"),
2412        ];
2413
2414        match random.next() % 7 {
2415            0 => {
2416                let target = path(FILES[random.index(FILES.len())]);
2417                let payload = random.next().to_le_bytes();
2418                let _ = vfs.write(&target, &payload);
2419            }
2420            1 => {
2421                let target = path(DIRECTORIES[random.index(DIRECTORIES.len())]);
2422                let _ = vfs.mkdir(&target, 0o755);
2423            }
2424            2 => {
2425                let target = path(FILES[random.index(FILES.len())]);
2426                let _ = vfs.unlink(&target);
2427            }
2428            3 => {
2429                let target = path(DIRECTORIES[random.index(DIRECTORIES.len())]);
2430                let _ = vfs.rmdir(&target);
2431            }
2432            4 => {
2433                let target = path(DIRECTORIES[random.index(DIRECTORIES.len())]);
2434                let _ = vfs.remove_tree(&target);
2435            }
2436            5 => {
2437                let (from, to) = RENAMES[random.index(RENAMES.len())];
2438                let _ = vfs.rename(&path(from), &path(to));
2439            }
2440            6 => {
2441                let target = path(FILES[random.index(FILES.len())]);
2442                let _ = vfs.append(&target, b"+");
2443            }
2444            _ => unreachable!(),
2445        }
2446    }
2447
2448    struct Lcg(u64);
2449
2450    impl Lcg {
2451        fn new(seed: u64) -> Self {
2452            Self(seed ^ 0x9e37_79b9_7f4a_7c15)
2453        }
2454
2455        fn next(&mut self) -> u64 {
2456            self.0 = self
2457                .0
2458                .wrapping_mul(6_364_136_223_846_793_005)
2459                .wrapping_add(1_442_695_040_888_963_407);
2460            self.0
2461        }
2462
2463        fn index(&mut self, len: usize) -> usize {
2464            usize::try_from(self.next() % len as u64).unwrap()
2465        }
2466    }
2467}