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