1use 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#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct CapturedContent {
36 pub bytes: Vec<u8>,
38 pub before: FileStamp,
40 pub after: FileStamp,
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct ContentLoadError {
47 message: String,
48}
49
50impl ContentLoadError {
51 #[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
68pub trait ContentLoader: Send + Sync {
74 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
250pub struct SnapshotBuilder {
252 store: BlobStore,
253 nodes: BTreeMap<VPath, Arc<SnapshotNode>>,
254}
255
256impl SnapshotBuilder {
257 #[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 #[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 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 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 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 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 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 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#[derive(Clone)]
433pub struct BaseSnapshot {
434 inner: Arc<SnapshotInner>,
435}
436
437impl BaseSnapshot {
438 #[must_use]
440 pub fn id(&self) -> SnapshotId {
441 self.inner.id
442 }
443
444 #[must_use]
446 pub fn len(&self) -> usize {
447 self.inner.nodes.len()
448 }
449
450 #[must_use]
452 pub fn is_empty(&self) -> bool {
453 self.len() == 1
454 }
455
456 #[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#[derive(Debug)]
522#[non_exhaustive]
523pub enum SnapshotError {
524 Store(BlobStoreError),
526 DuplicatePath {
528 path: VPath,
530 },
531 MissingParent {
533 path: VPath,
535 parent: VPath,
537 },
538 ParentNotDirectory {
540 path: VPath,
542 parent: VPath,
544 },
545 LazyDirectory {
547 path: VPath,
549 },
550 ExpectedDirectoryStamp {
552 path: VPath,
554 stamp: FileStamp,
556 },
557 ContentUnavailable {
559 path: VPath,
561 kind: NodeKind,
563 },
564 ContentLoad {
566 path: VPath,
568 source: ContentLoadError,
570 },
571 StaleContent {
573 path: VPath,
575 expected: Box<FileStamp>,
577 before: Box<FileStamp>,
579 after: Box<FileStamp>,
581 },
582 ContentSizeMismatch {
584 path: VPath,
586 expected: u64,
588 actual: usize,
590 },
591 LazyStatePoisoned {
593 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
670pub struct SnapshotMetrics {
671 pub node_count: usize,
673 pub lazy_content_nodes: usize,
675 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#[derive(Clone, Debug, Default, Eq, PartialEq)]
693pub struct ReadObservation {
694 pub metadata: Option<Option<NodeState>>,
696 pub content: Option<BlobId>,
698 pub directory: Option<DirectoryDigest>,
700}
701
702#[derive(Clone, Copy, Debug, Eq, PartialEq)]
704pub struct WritePrecondition {
705 pub expected: Option<NodeState>,
707}
708
709#[derive(Clone, Copy, Debug, Eq, PartialEq)]
711#[non_exhaustive]
712pub enum EffectOrigin {
713 VirtualFs,
715 MontyOsCall,
717 MontyToolCall,
719}
720
721#[derive(Clone, Debug, Eq, PartialEq)]
723#[non_exhaustive]
724pub enum Effect {
725 MetadataRead {
727 path: VPath,
729 state: Option<NodeState>,
731 },
732 ContentRead {
734 path: VPath,
736 blob: BlobId,
738 },
739 DirectoryRead {
741 path: VPath,
743 digest: DirectoryDigest,
745 },
746 Create {
748 path: VPath,
750 after: NodeState,
752 },
753 ModifyContent {
755 path: VPath,
757 before: NodeState,
759 after: NodeState,
761 },
762 Delete {
764 path: VPath,
766 before: NodeState,
768 },
769 Rename {
771 from: VPath,
773 to: VPath,
775 before: NodeState,
777 after: NodeState,
779 },
780}
781
782#[derive(Clone, Debug, Eq, PartialEq)]
784pub struct EffectEvent {
785 pub sequence: u64,
787 pub origin: EffectOrigin,
789 pub effect: Effect,
791}
792
793#[derive(Clone, Debug, Eq, PartialEq)]
795pub struct CanonicalDiff {
796 entries: Vec<DiffEntry>,
797 digest: DiffDigest,
798 metrics: CanonicalDiffMetrics,
799}
800
801impl CanonicalDiff {
802 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 #[must_use]
861 pub fn entries(&self) -> &[DiffEntry] {
862 &self.entries
863 }
864
865 #[must_use]
867 pub const fn digest(&self) -> DiffDigest {
868 self.digest
869 }
870
871 #[must_use]
873 pub fn is_empty(&self) -> bool {
874 self.entries.is_empty()
875 }
876
877 #[must_use]
879 pub const fn metrics(&self) -> CanonicalDiffMetrics {
880 self.metrics
881 }
882}
883
884#[derive(Clone, Copy, Debug, Eq, PartialEq)]
886pub struct CanonicalDiffMetrics {
887 pub candidate_paths: usize,
889 pub expanded_delete_paths: usize,
891 pub changed_paths: usize,
893 pub materialized_after_bytes: u64,
895}
896
897pub 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 #[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 #[must_use]
925 pub fn base_node_count(&self) -> usize {
926 self.base.len()
927 }
928
929 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 #[must_use]
948 pub fn base_snapshot_id(&self) -> SnapshotId {
949 self.base.id()
950 }
951
952 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[must_use]
1371 pub fn effects(&self) -> &[EffectEvent] {
1372 &self.effects
1373 }
1374
1375 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 #[must_use]
1409 pub fn read_set(&self) -> &BTreeMap<VPath, ReadObservation> {
1410 &self.read_set
1411 }
1412
1413 #[must_use]
1415 pub fn write_set(&self) -> &BTreeMap<VPath, WritePrecondition> {
1416 &self.write_set
1417 }
1418
1419 #[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 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 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 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#[derive(Debug)]
1649#[non_exhaustive]
1650pub enum VfsError {
1651 Snapshot(SnapshotError),
1653 Store(BlobStoreError),
1655 Path(VPathError),
1657 NotFound {
1659 path: VPath,
1661 },
1662 AlreadyExists {
1664 path: VPath,
1666 },
1667 NotDirectory {
1669 path: VPath,
1671 actual: NodeKind,
1673 },
1674 NotFile {
1676 path: VPath,
1678 actual: NodeKind,
1680 },
1681 NotSymlink {
1683 path: VPath,
1685 actual: NodeKind,
1687 },
1688 IsDirectory {
1690 path: VPath,
1692 },
1693 DirectoryNotEmpty {
1695 path: VPath,
1697 },
1698 RootMutation,
1700 InvalidRename {
1702 from: VPath,
1704 to: VPath,
1706 },
1707 RenameTypeMismatch {
1709 from: VPath,
1711 to: VPath,
1713 },
1714 InvalidCanonicalDiff {
1716 path: Option<VPath>,
1718 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1806pub struct VfsMetrics {
1807 pub overlay_entries: usize,
1809 pub overlay_bytes: u64,
1811 pub effect_events: usize,
1813 pub read_dependencies: usize,
1815 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}