1use crate::kernel::FileMetadata;
8#[cfg(any(target_os = "linux", target_os = "macos"))]
9use crate::kernel::MetadataField;
10use crate::{FileId, MountId, VolumeId};
11use bytes::Bytes;
12use std::fs::{File, OpenOptions};
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15#[cfg(any(target_os = "linux", target_os = "macos"))]
16use std::time::SystemTime;
17#[cfg(target_os = "linux")]
18use std::time::{Duration, Instant};
19use thiserror::Error;
20
21mod adapter;
22pub use adapter::{CheckoutMountSource, SharedCheckout, SharedCheckoutGuard, SharedCheckoutState};
23
24mod view_gate;
25
26mod lazy;
27pub use lazy::LazyMountSource;
28
29mod routed;
30pub use routed::RoutedMountSource;
31
32mod customer;
33pub use customer::{
34 LazyMount, LazyWorkingSet, Mount, MountLifecycleError, MountOptions, MountPublication,
35};
36
37#[cfg(any(target_os = "linux", target_os = "macos"))]
38mod device;
39
40pub use crate::{
41 CaptureError, CaptureOptions, CaptureReceipt, WatchCaptureReceipt, capture_baseline,
42 capture_paths, capture_root_identity, capture_subtree, capture_watch_batch,
43};
44
45mod materialize;
46pub use materialize::{
47 HostPathReplacement, HostPathRestore, MaterializationReceipt, MaterializeError,
48 MaterializeOptions, materialize_checkout, materialize_checkout_host_path,
49 materialize_checkout_path, materialize_checkout_paths, restore_checkout_host_path,
50};
51pub(crate) use materialize::{
52 MaterializeMode, materialize_checkout_paths_with_mode, materialize_checkout_with_mode,
53};
54
55mod publication;
56pub use publication::{seal_checkout, seal_checkout_with_permit};
57
58mod storage_capabilities;
59pub use storage_capabilities::{
60 NativeStorageCapabilities, NativeStorageCapabilityError, probe_native_storage_capabilities,
61};
62
63mod storage_accelerations;
64pub use storage_accelerations::{
65 NativeBlockCloneAccelerationEvidence, NativeSparseAccelerationEvidence,
66 NativeStorageAccelerationError, NativeStorageAccelerationEvidence,
67 probe_native_storage_accelerations,
68};
69
70#[cfg(target_os = "macos")]
71mod darwin_mount;
72#[cfg(target_os = "linux")]
73mod fuse;
74
75#[cfg(any(target_os = "linux", target_os = "macos"))]
76fn metadata_or<T>(field: MetadataField<T>, unavailable: T) -> T {
77 match field {
78 MetadataField::Unavailable => unavailable,
79 MetadataField::Value(value) => value,
80 }
81}
82
83#[cfg(any(target_os = "linux", target_os = "macos"))]
84fn system_time_ns(value: SystemTime) -> Result<i64, i32> {
85 match value.duration_since(SystemTime::UNIX_EPOCH) {
86 Ok(duration) => i64::try_from(duration.as_nanos()).map_err(|_| libc::EOVERFLOW),
87 Err(error) => i64::try_from(error.duration().as_nanos())
88 .map(|nanos| -nanos)
89 .map_err(|_| libc::EOVERFLOW),
90 }
91}
92
93#[cfg(target_os = "windows")]
94mod projfs;
95
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
98pub enum NativeMountKind {
99 LinuxFuse,
101 MacOsNfs,
103 WindowsProjFs,
105}
106
107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub enum NativeMountSessionIsolation {
110 SharedProcess,
112 ProcessIsolated,
114}
115
116#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct NativeMountCapabilities {
119 pub kind: Option<NativeMountKind>,
121 pub available: bool,
123 pub writable: bool,
126 pub provider_process_io_observable: bool,
132 pub session_isolation: NativeMountSessionIsolation,
134 pub unavailable_reason: Option<String>,
136}
137
138#[derive(Clone, Debug, Eq, PartialEq)]
140pub struct NativeMountRequest {
141 pub mount_id: MountId,
143 pub volume_id: VolumeId,
145 pub destination: PathBuf,
147 pub writable: bool,
149}
150
151#[derive(Clone, Copy, Debug, Eq, PartialEq)]
153pub struct MountNode {
154 pub file_id: FileId,
156 pub kind: MountNodeKind,
158 pub logical_bytes: u64,
160 pub link_count: u64,
162 pub device: Option<(u32, u32)>,
164}
165
166#[derive(Clone, Copy, Debug, Eq, PartialEq)]
168pub struct MountLookup {
169 pub node: MountNode,
171 pub metadata: FileMetadata,
173}
174
175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
177pub enum MountNodeKind {
178 Regular,
180 Directory,
182 SymbolicLink,
184 Fifo,
186 Socket,
188 CharacterDevice,
190 BlockDevice,
192 Unsupported,
194}
195
196#[derive(Clone, Copy, Debug, Eq, PartialEq)]
198pub enum MountSeekTarget {
199 Data,
201 Hole,
203}
204
205#[derive(Clone, Copy, Debug, Eq, PartialEq)]
207pub enum MountRangeAllocation {
208 PunchHole,
210 ZeroRange {
212 extend: bool,
214 },
215 Preallocate {
217 keep_size: bool,
219 },
220}
221
222#[derive(Clone, Debug, Eq, PartialEq)]
224pub struct MountSparseSpan {
225 pub relative_offset: u64,
227 pub bytes: Bytes,
229}
230
231#[derive(Clone, Debug, Eq, PartialEq)]
233pub struct MountSparseRange {
234 pub logical_bytes: u64,
236 pub spans: Vec<MountSparseSpan>,
238}
239
240pub trait MountOpenFile: Send + Sync + 'static {
247 fn lookup(&self) -> Result<MountLookup, MountSourceError>;
253 fn read_range(&self, offset: u64, length: u32) -> Result<Bytes, MountSourceError>;
259 fn read_up_to(&self, offset: u64, maximum_bytes: u32) -> Result<Bytes, MountSourceError>;
265 fn seek(&self, offset: u64, target: MountSeekTarget) -> Result<Option<u64>, MountSourceError>;
271 fn write_range(&self, offset: u64, bytes: Bytes) -> Result<(), MountSourceError>;
277 fn resize(&self, logical_bytes: u64) -> Result<(), MountSourceError>;
283 fn allocate_range(
289 &self,
290 offset: u64,
291 length: u64,
292 operation: MountRangeAllocation,
293 ) -> Result<(), MountSourceError>;
294 fn set_attributes(
301 &self,
302 metadata: FileMetadata,
303 logical_bytes: Option<u64>,
304 ) -> Result<(), MountSourceError>;
305 fn read_attribute(&self, name: &[u8]) -> Result<Option<Bytes>, MountSourceError>;
311 fn list_attributes(
317 &self,
318 cursor: Option<&[u8]>,
319 maximum_entries: u32,
320 ) -> Result<MountAttributePage, MountSourceError>;
321 fn write_attribute(
327 &self,
328 name: &[u8],
329 value: Bytes,
330 mode: MountAttributeWriteMode,
331 ) -> Result<(), MountSourceError>;
332 fn remove_attribute(&self, name: &[u8]) -> Result<(), MountSourceError>;
338
339 fn read_sparse_range(
346 &self,
347 offset: u64,
348 length: u32,
349 ) -> Result<MountSparseRange, MountSourceError> {
350 let logical_bytes = u64::from(length);
351 let end = offset.checked_add(logical_bytes).ok_or_else(|| {
352 MountSourceError::Invalid("sparse range overflows the file address space".to_owned())
353 })?;
354 let mut spans = Vec::new();
355 let mut cursor = offset;
356 while cursor < end {
357 let Some(data_offset) = self.seek(cursor, MountSeekTarget::Data)? else {
358 break;
359 };
360 if data_offset >= end {
361 break;
362 }
363 let hole_offset = self
364 .seek(data_offset, MountSeekTarget::Hole)?
365 .unwrap_or(end)
366 .min(end);
367 if hole_offset <= data_offset {
368 return Err(MountSourceError::Engine(
369 "sparse seek made no forward progress".to_owned(),
370 ));
371 }
372 let span_length = u32::try_from(hole_offset - data_offset).map_err(|_| {
373 MountSourceError::Invalid("sparse span exceeds callback bounds".to_owned())
374 })?;
375 let bytes = self.read_range(data_offset, span_length)?;
376 if bytes.len() != usize::try_from(span_length).unwrap_or(usize::MAX) {
377 return Err(MountSourceError::Stale);
378 }
379 spans.push(MountSparseSpan {
380 relative_offset: data_offset - offset,
381 bytes,
382 });
383 cursor = hole_offset;
384 }
385 Ok(MountSparseRange {
386 logical_bytes,
387 spans,
388 })
389 }
390
391 fn write_sparse_range(
399 &self,
400 offset: u64,
401 range: &MountSparseRange,
402 ) -> Result<(), MountSourceError> {
403 let end = offset.checked_add(range.logical_bytes).ok_or_else(|| {
404 MountSourceError::Invalid("sparse range overflows the file address space".to_owned())
405 })?;
406 let current = self.lookup()?.node.logical_bytes;
407 if end > current {
408 self.resize(end)?;
409 }
410 self.allocate_range(offset, range.logical_bytes, MountRangeAllocation::PunchHole)?;
411 for span in &range.spans {
412 let destination = offset.checked_add(span.relative_offset).ok_or_else(|| {
413 MountSourceError::Invalid("sparse span offset overflows".to_owned())
414 })?;
415 self.write_range(destination, span.bytes.clone())?;
416 }
417 Ok(())
418 }
419}
420
421#[derive(Clone, Debug, Eq, PartialEq)]
423pub struct MountDirectoryEntry {
424 pub name: Vec<u8>,
426 pub node: MountNode,
428 pub metadata: FileMetadata,
430}
431
432#[derive(Clone, Debug, Eq, PartialEq)]
434pub struct MountDirectoryPage {
435 pub entries: Vec<MountDirectoryEntry>,
437 pub next_cursor: Option<Vec<u8>>,
439}
440
441#[derive(Clone, Debug, Eq, PartialEq)]
443pub struct MountAttributePage {
444 pub names: Vec<Vec<u8>>,
446 pub next_cursor: Option<Vec<u8>>,
448}
449
450#[derive(Clone, Copy, Debug, Eq, PartialEq)]
452pub enum MountAttributeWriteMode {
453 Upsert,
455 Create,
457 Replace,
459}
460
461#[derive(Clone, Debug, Eq, Hash, PartialEq)]
467pub struct MountPath {
468 components: Vec<Vec<u8>>,
469}
470
471impl MountPath {
472 #[must_use]
474 pub const fn root() -> Self {
475 Self {
476 components: Vec::new(),
477 }
478 }
479
480 #[must_use]
482 pub fn child(&self, component: Vec<u8>) -> Self {
483 let mut components = self.components.clone();
484 components.push(component);
485 Self { components }
486 }
487
488 #[must_use]
490 pub fn components(&self) -> &[Vec<u8>] {
491 &self.components
492 }
493}
494
495#[derive(Clone, Debug, Error)]
497pub enum MountSourceError {
498 #[error("path is absent")]
500 NotFound,
501 #[error("filesystem object already exists")]
503 AlreadyExists,
504 #[error("invalid filesystem operation: {0}")]
506 Invalid(String),
507 #[error("filesystem operation is unsupported: {0}")]
509 Unsupported(String),
510 #[error("filesystem engine failure: {0}")]
512 Engine(String),
513 #[error("mount callback is stale or cancelled")]
515 Stale,
516}
517
518pub trait MountViewLease: Send + 'static {}
524
525impl MountViewLease for () {}
526
527pub trait MountFilesystem: Send + Sync + 'static {
533 fn supports_posix_named_attributes(&self) -> bool {
539 false
540 }
541
542 fn flush_on_handle_close(&self) -> bool {
547 true
548 }
549
550 fn view_is_stable(&self) -> bool {
553 true
554 }
555
556 fn view_epoch(&self) -> Option<u64> {
563 None
564 }
565
566 fn binding_epoch(&self) -> Option<u64> {
572 self.view_epoch()
573 }
574
575 fn acquire_view_lease(
581 &self,
582 expected_epoch: Option<u64>,
583 ) -> Result<Box<dyn MountViewLease>, MountSourceError> {
584 if !self.view_is_stable() || self.view_epoch() != expected_epoch {
585 return Err(MountSourceError::Stale);
586 }
587 Ok(Box::new(()))
588 }
589
590 fn acquire_binding_lease(
594 &self,
595 expected_epoch: Option<u64>,
596 ) -> Result<Box<dyn MountViewLease>, MountSourceError> {
597 if !self.view_is_stable() || self.binding_epoch() != expected_epoch {
598 return Err(MountSourceError::Stale);
599 }
600 Ok(Box::new(()))
601 }
602
603 fn lookup(&self, path: &MountPath) -> Result<Option<MountLookup>, MountSourceError>;
609 fn open_file(&self, path: &MountPath) -> Result<Arc<dyn MountOpenFile>, MountSourceError>;
616 fn detach_file(&self, path: &MountPath) -> Result<Arc<dyn MountOpenFile>, MountSourceError>;
626 fn read_link(&self, path: &MountPath) -> Result<Bytes, MountSourceError>;
632 fn read_range(
638 &self,
639 path: &MountPath,
640 offset: u64,
641 length: u32,
642 ) -> Result<Bytes, MountSourceError>;
643 fn seek(
649 &self,
650 path: &MountPath,
651 offset: u64,
652 target: MountSeekTarget,
653 ) -> Result<Option<u64>, MountSourceError>;
654 fn read_directory(
660 &self,
661 path: &MountPath,
662 cursor: Option<&[u8]>,
663 maximum_entries: u32,
664 ) -> Result<MountDirectoryPage, MountSourceError>;
665 fn create_file(
671 &self,
672 path: &MountPath,
673 metadata: FileMetadata,
674 ) -> Result<MountLookup, MountSourceError>;
675 fn create_directory(
681 &self,
682 path: &MountPath,
683 metadata: FileMetadata,
684 ) -> Result<MountLookup, MountSourceError>;
685 fn create_symbolic_link(
691 &self,
692 path: &MountPath,
693 target: Bytes,
694 metadata: FileMetadata,
695 ) -> Result<MountLookup, MountSourceError>;
696 fn create_special(
702 &self,
703 path: &MountPath,
704 kind: MountNodeKind,
705 device: Option<(u32, u32)>,
706 metadata: FileMetadata,
707 ) -> Result<MountLookup, MountSourceError>;
708 fn set_attributes(
714 &self,
715 path: &MountPath,
716 metadata: FileMetadata,
717 logical_bytes: Option<u64>,
718 ) -> Result<(), MountSourceError>;
719 fn read_attribute(
725 &self,
726 path: &MountPath,
727 name: &[u8],
728 ) -> Result<Option<Bytes>, MountSourceError>;
729 fn list_attributes(
735 &self,
736 path: &MountPath,
737 cursor: Option<&[u8]>,
738 maximum_entries: u32,
739 ) -> Result<MountAttributePage, MountSourceError>;
740 fn write_attribute(
746 &self,
747 path: &MountPath,
748 name: &[u8],
749 value: Bytes,
750 mode: MountAttributeWriteMode,
751 ) -> Result<(), MountSourceError>;
752 fn remove_attribute(&self, path: &MountPath, name: &[u8]) -> Result<(), MountSourceError>;
758 fn write_range(
764 &self,
765 path: &MountPath,
766 offset: u64,
767 bytes: Bytes,
768 ) -> Result<(), MountSourceError>;
769 fn resize(&self, path: &MountPath, logical_bytes: u64) -> Result<(), MountSourceError>;
775 fn allocate_range(
781 &self,
782 path: &MountPath,
783 offset: u64,
784 length: u64,
785 operation: MountRangeAllocation,
786 ) -> Result<(), MountSourceError>;
787 fn clone_range(
793 &self,
794 source: &MountPath,
795 source_offset: u64,
796 destination: &MountPath,
797 destination_offset: u64,
798 length: u64,
799 ) -> Result<(), MountSourceError>;
800 #[allow(clippy::too_many_arguments)]
807 fn clone_range_by_id(
808 &self,
809 source_file_id: FileId,
810 source_offset: u64,
811 destination_file_id: FileId,
812 destination_offset: u64,
813 length: u64,
814 ) -> Result<(), MountSourceError>;
815 fn remove(&self, path: &MountPath, expected: Option<FileId>) -> Result<(), MountSourceError>;
821 fn rename(
827 &self,
828 source: &MountPath,
829 destination: &MountPath,
830 replace: bool,
831 ) -> Result<(), MountSourceError>;
832 fn hard_link(
838 &self,
839 source: &MountPath,
840 destination: &MountPath,
841 ) -> Result<(), MountSourceError>;
842 fn flush(&self) -> Result<(), MountSourceError>;
850 fn capture_host_path(
860 &self,
861 source_root: &Path,
862 path: &MountPath,
863 ) -> Result<(), MountSourceError>;
864 fn capture_host_paths(
872 &self,
873 source_root: &Path,
874 paths: &[MountPath],
875 ) -> Result<(), MountSourceError>;
876 fn capture_host_subtree(
882 &self,
883 source_root: &Path,
884 path: &MountPath,
885 ) -> Result<(), MountSourceError>;
886}
887
888#[derive(Debug, Error)]
890pub enum NativeMountError {
891 #[error("native mounts are unsupported on this target")]
893 UnsupportedTarget,
894 #[error("native mount capability is unavailable: {0}")]
896 CapabilityUnavailable(String),
897 #[error("mount destination must be an existing empty directory")]
899 InvalidDestination,
900 #[error("mount destination is already owned by another process")]
902 DestinationBusy,
903 #[error("writable native mount is unavailable: {0}")]
905 WritableUnavailable(String),
906 #[error("native mount cannot represent the {profile} volume profile on {platform}")]
908 ProfileUnavailable {
909 profile: &'static str,
911 platform: &'static str,
913 },
914 #[error("native mount driver failed: {0}")]
916 Driver(String),
917}
918
919enum DriverSession {
920 #[cfg(target_os = "linux")]
921 Fuse(fuse::FuseSession),
922 #[cfg(target_os = "macos")]
923 DarwinMount(darwin_mount::DarwinMountSession),
924 #[cfg(target_os = "windows")]
925 ProjFs(projfs::ProjFsSession),
926 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
927 Unsupported,
928}
929
930struct DriverStartFailure {
931 error: NativeMountError,
932 preserve_destination_fence: bool,
933}
934
935impl DriverStartFailure {
936 #[cfg(any(target_os = "windows", target_os = "macos"))]
937 fn preserving_destination_fence(error: NativeMountError) -> Self {
938 Self {
939 error,
940 preserve_destination_fence: true,
941 }
942 }
943}
944
945impl From<NativeMountError> for DriverStartFailure {
946 fn from(error: NativeMountError) -> Self {
947 Self {
948 error,
949 preserve_destination_fence: false,
950 }
951 }
952}
953
954pub struct NativeMountSession {
956 mount_id: MountId,
957 destination: PathBuf,
958 driver: Option<DriverSession>,
959 destination_guard: Option<MountDestinationGuard>,
960}
961
962impl NativeMountSession {
963 #[must_use]
965 pub const fn mount_id(&self) -> MountId {
966 self.mount_id
967 }
968
969 #[must_use]
971 pub fn destination(&self) -> &Path {
972 &self.destination
973 }
974
975 pub(crate) fn flush_callbacks(&self) -> Result<(), NativeMountError> {
976 match self.driver.as_ref() {
977 #[cfg(target_os = "windows")]
978 Some(DriverSession::ProjFs(session)) => session.flush_callbacks(),
979 #[cfg(any(target_os = "linux", target_os = "macos"))]
980 Some(_) => Ok(()),
981 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
982 Some(DriverSession::Unsupported) => Err(NativeMountError::UnsupportedTarget),
983 None => Ok(()),
984 }
985 }
986
987 pub fn invalidate(&self, path: &[u8]) -> Result<(), NativeMountError> {
999 match &self.driver {
1000 #[cfg(target_os = "linux")]
1001 Some(DriverSession::Fuse(session)) => session.invalidate(path),
1002 #[cfg(target_os = "macos")]
1003 Some(DriverSession::DarwinMount(session)) => session.invalidate(path),
1004 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1005 Some(_) => {
1006 let _ = path;
1007 Err(NativeMountError::Driver(
1008 "invalidation is not implemented for this transport".to_owned(),
1009 ))
1010 }
1011 None => Err(NativeMountError::Driver("session is stopped".to_owned())),
1012 }
1013 }
1014
1015 pub fn stop(&mut self) -> Result<bool, NativeMountError> {
1022 stop_owned_session(
1023 &mut self.driver,
1024 &mut self.destination_guard,
1025 stop_driver,
1026 MountDestinationGuard::release,
1027 )
1028 }
1029}
1030
1031fn stop_owned_session<D, G, E>(
1032 driver: &mut Option<D>,
1033 destination_guard: &mut Option<G>,
1034 mut stop: impl FnMut(&mut D) -> Result<(), E>,
1035 mut release: impl FnMut(&mut G) -> Result<(), E>,
1036) -> Result<bool, E> {
1037 if driver.is_none() && destination_guard.is_none() {
1038 return Ok(false);
1039 }
1040 if let Some(active) = driver.as_mut() {
1041 stop(active)?;
1042 driver.take();
1043 }
1044 if let Some(guard) = destination_guard.as_mut() {
1045 release(guard)?;
1046 destination_guard.take();
1047 }
1048 Ok(true)
1049}
1050
1051fn drop_owned_session<D, G, E>(
1052 driver: &mut Option<D>,
1053 destination_guard: &mut Option<G>,
1054 stop: impl FnMut(&mut D) -> Result<(), E>,
1055 release: impl FnMut(&mut G) -> Result<(), E>,
1056) {
1057 if stop_owned_session(driver, destination_guard, stop, release).is_err() {
1058 if let Some(active) = driver.take() {
1062 std::mem::forget(active);
1063 }
1064 if let Some(guard) = destination_guard.take() {
1065 std::mem::forget(guard);
1066 }
1067 }
1068}
1069
1070impl Drop for NativeMountSession {
1071 fn drop(&mut self) {
1072 drop_owned_session(
1073 &mut self.driver,
1074 &mut self.destination_guard,
1075 stop_driver,
1076 MountDestinationGuard::release,
1077 );
1078 }
1079}
1080
1081#[must_use]
1083pub fn probe_native_mount() -> NativeMountCapabilities {
1084 platform::probe()
1085}
1086
1087pub fn mount_native(
1094 request: NativeMountRequest,
1095 source: Arc<dyn MountFilesystem>,
1096) -> Result<NativeMountSession, NativeMountError> {
1097 let capabilities = probe_native_mount();
1098 if !capabilities.available {
1099 return Err(NativeMountError::CapabilityUnavailable(
1100 capabilities
1101 .unavailable_reason
1102 .unwrap_or_else(|| "unavailable".to_owned()),
1103 ));
1104 }
1105 if request.writable && !capabilities.writable {
1106 return Err(NativeMountError::WritableUnavailable(
1107 "the selected kernel API cannot prove exact authored effects".to_owned(),
1108 ));
1109 }
1110 let (driver, destination_guard) =
1111 start_owned_session(&request.destination, || start_driver(&request, source))?;
1112 Ok(NativeMountSession {
1113 mount_id: request.mount_id,
1114 destination: request.destination,
1115 driver: Some(driver),
1116 destination_guard: Some(destination_guard),
1117 })
1118}
1119
1120pub fn mount_native_over_existing(
1138 request: NativeMountRequest,
1139 source: Arc<dyn MountFilesystem>,
1140) -> Result<NativeMountSession, NativeMountError> {
1141 #[cfg(windows)]
1142 {
1143 let _ = (request, source);
1144 Err(NativeMountError::WritableUnavailable(
1145 "Windows ProjFS cannot shadow an existing directory".to_owned(),
1146 ))
1147 }
1148 #[cfg(not(windows))]
1149 {
1150 let capabilities = probe_native_mount();
1151 if !capabilities.available {
1152 return Err(NativeMountError::CapabilityUnavailable(
1153 capabilities
1154 .unavailable_reason
1155 .unwrap_or_else(|| "unavailable".to_owned()),
1156 ));
1157 }
1158 if request.writable && !capabilities.writable {
1159 return Err(NativeMountError::WritableUnavailable(
1160 "the selected kernel API cannot prove exact authored effects".to_owned(),
1161 ));
1162 }
1163 let (driver, destination_guard) =
1164 start_owned_session_over_existing(&request.destination, || {
1165 start_driver(&request, source)
1166 })?;
1167 Ok(NativeMountSession {
1168 mount_id: request.mount_id,
1169 destination: request.destination,
1170 driver: Some(driver),
1171 destination_guard: Some(destination_guard),
1172 })
1173 }
1174}
1175
1176pub fn reclaim_native_mount_destination_fence(destination: &Path) -> Result<(), NativeMountError> {
1190 let canonical = destination
1191 .canonicalize()
1192 .map_err(|_| NativeMountError::InvalidDestination)?;
1193 MountDestinationGuard::reclaim_canonical(&canonical)
1194}
1195
1196pub fn reclaim_stale_native_mount_destination_fences(
1208 parent: &Path,
1209) -> Result<u32, NativeMountError> {
1210 const MAXIMUM_PARENT_ENTRIES: usize = 4_096;
1211 const PREFIX: &str = ".acyclic-fs-mount-";
1212 const SUFFIX: &str = ".lock";
1213
1214 let parent = parent
1215 .canonicalize()
1216 .map_err(|_| NativeMountError::InvalidDestination)?;
1217 let metadata =
1218 std::fs::symlink_metadata(&parent).map_err(|_| NativeMountError::InvalidDestination)?;
1219 if !metadata.is_dir() || metadata.file_type().is_symlink() {
1220 return Err(NativeMountError::InvalidDestination);
1221 }
1222 let registry_path = parent.join(".acyclic-fs-mount-registry.lock");
1223 let registry = acquire_mount_lock(®istry_path, true)?;
1224 let mut reclaimed = 0_u32;
1225 for (index, entry) in std::fs::read_dir(&parent)
1226 .map_err(|error| NativeMountError::Driver(error.to_string()))?
1227 .enumerate()
1228 {
1229 if index >= MAXIMUM_PARENT_ENTRIES {
1230 return Err(NativeMountError::Driver(
1231 "native mount parent exceeds its bounded entry count".to_owned(),
1232 ));
1233 }
1234 let entry = entry.map_err(|error| NativeMountError::Driver(error.to_string()))?;
1235 let name = entry.file_name();
1236 let Some(name) = name.to_str() else {
1237 continue;
1238 };
1239 if name == ".acyclic-fs-mount-registry.lock" || !name.starts_with(PREFIX) {
1240 continue;
1241 }
1242 let digest = name
1243 .strip_prefix(PREFIX)
1244 .and_then(|value| value.strip_suffix(SUFFIX))
1245 .ok_or(NativeMountError::InvalidDestination)?;
1246 if digest.len() != 64
1247 || !digest
1248 .bytes()
1249 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1250 {
1251 return Err(NativeMountError::InvalidDestination);
1252 }
1253 let path = entry.path();
1254 let lock = match acquire_existing_mount_lock(&path) {
1255 Ok(lock) => lock,
1256 Err(NativeMountError::DestinationBusy) => continue,
1257 Err(error) => return Err(error),
1258 };
1259 drop(lock);
1260 match std::fs::remove_file(&path) {
1261 Ok(()) => {
1262 reclaimed = reclaimed.checked_add(1).ok_or_else(|| {
1263 NativeMountError::Driver("native mount fence count overflow".to_owned())
1264 })?;
1265 }
1266 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1267 Err(error) => return Err(NativeMountError::Driver(error.to_string())),
1268 }
1269 }
1270 drop(registry);
1271 Ok(reclaimed)
1272}
1273
1274pub fn recover_native_mount_destination(destination: &Path) -> Result<(), NativeMountError> {
1289 let mut guard = MountDestinationGuard::acquire_for_recovery(destination)?;
1290 recover_platform_destination(destination).map_err(|error| {
1291 NativeMountError::Driver(format!("native platform recovery failed: {error}"))
1292 })?;
1293 match std::fs::remove_dir(destination) {
1294 Ok(()) => {}
1295 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1296 Err(error) => {
1297 return Err(NativeMountError::Driver(format!(
1298 "native recovered destination removal failed for {}: {error}",
1299 destination.display()
1300 )));
1301 }
1302 }
1303 guard.release()
1304}
1305
1306#[cfg(windows)]
1318pub fn recover_native_mount_destination_preserving_residue(
1319 destination: &Path,
1320) -> Result<Option<PathBuf>, NativeMountError> {
1321 let name = destination
1322 .file_name()
1323 .ok_or(NativeMountError::InvalidDestination)?;
1324 let parent = destination
1325 .parent()
1326 .ok_or(NativeMountError::InvalidDestination)?
1327 .canonicalize()
1328 .map_err(|_| NativeMountError::InvalidDestination)?;
1329 let destination = parent.join(name);
1330 let mut guard = MountDestinationGuard::acquire_for_recovery(&destination)?;
1331 let preserved = projfs::quarantine_crashed_destination(&destination)?;
1332 guard.release()?;
1333 Ok(preserved)
1334}
1335
1336pub fn detach_native_mount_destination_after_crash(
1349 destination: &Path,
1350) -> Result<(), NativeMountError> {
1351 let mut guard = MountDestinationGuard::acquire_for_recovery(destination)?;
1352 detach_platform_destination_after_crash(destination)?;
1353 guard.release()
1354}
1355
1356#[cfg(any(target_os = "linux", target_os = "macos"))]
1357fn detach_platform_destination_after_crash(destination: &Path) -> Result<(), NativeMountError> {
1358 recover_platform_destination(destination)
1359}
1360
1361#[cfg(target_os = "windows")]
1362#[allow(clippy::unnecessary_wraps)]
1363fn detach_platform_destination_after_crash(_destination: &Path) -> Result<(), NativeMountError> {
1364 Ok(())
1368}
1369
1370#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1371fn detach_platform_destination_after_crash(_destination: &Path) -> Result<(), NativeMountError> {
1372 Err(NativeMountError::UnsupportedTarget)
1373}
1374
1375fn start_owned_session<D>(
1376 destination: &Path,
1377 start: impl FnOnce() -> Result<D, DriverStartFailure>,
1378) -> Result<(D, MountDestinationGuard), NativeMountError> {
1379 let destination_guard = admit_destination(destination)?;
1380 finish_started_session(destination_guard, start())
1381}
1382
1383fn finish_started_session<D>(
1384 destination_guard: MountDestinationGuard,
1385 result: Result<D, DriverStartFailure>,
1386) -> Result<(D, MountDestinationGuard), NativeMountError> {
1387 match result {
1388 Ok(driver) => Ok((driver, destination_guard)),
1389 Err(failure) => {
1390 if failure.preserve_destination_fence {
1391 std::mem::forget(destination_guard);
1395 }
1396 Err(failure.error)
1397 }
1398 }
1399}
1400
1401#[cfg(not(windows))]
1404fn start_owned_session_over_existing<D>(
1405 destination: &Path,
1406 start: impl FnOnce() -> Result<D, DriverStartFailure>,
1407) -> Result<(D, MountDestinationGuard), NativeMountError> {
1408 let destination_guard = admit_destination_over_existing(destination)?;
1409 finish_started_session(destination_guard, start())
1410}
1411
1412fn admit_destination(destination: &Path) -> Result<MountDestinationGuard, NativeMountError> {
1413 let guard = MountDestinationGuard::acquire(destination)?;
1414 validate_destination(destination)?;
1415 Ok(guard)
1416}
1417
1418#[cfg(not(windows))]
1421fn admit_destination_over_existing(
1422 destination: &Path,
1423) -> Result<MountDestinationGuard, NativeMountError> {
1424 let guard = MountDestinationGuard::acquire(destination)?;
1425 validate_directory(destination)?;
1426 Ok(guard)
1427}
1428
1429#[derive(Debug)]
1430struct MountDestinationGuard {
1431 file: Option<File>,
1432 lock_path: PathBuf,
1433 registry_path: PathBuf,
1434}
1435
1436impl MountDestinationGuard {
1437 fn acquire(destination: &Path) -> Result<Self, NativeMountError> {
1438 let canonical = destination
1439 .canonicalize()
1440 .map_err(|_| NativeMountError::InvalidDestination)?;
1441 Self::acquire_canonical(&canonical)
1442 }
1443
1444 fn acquire_for_recovery(destination: &Path) -> Result<Self, NativeMountError> {
1445 let name = destination
1446 .file_name()
1447 .ok_or(NativeMountError::InvalidDestination)?;
1448 let parent = destination
1449 .parent()
1450 .ok_or(NativeMountError::InvalidDestination)?
1451 .canonicalize()
1452 .map_err(|_| NativeMountError::InvalidDestination)?;
1453 Self::acquire_canonical(&parent.join(name))
1454 }
1455
1456 fn acquire_canonical(canonical: &Path) -> Result<Self, NativeMountError> {
1457 let (lock_path, registry_path) = Self::paths_for_canonical(canonical)?;
1458 let registry = acquire_mount_lock(®istry_path, true)?;
1459 let file = acquire_mount_lock(&lock_path, false)?;
1460 drop(registry);
1461 Ok(Self {
1462 file: Some(file),
1463 lock_path,
1464 registry_path,
1465 })
1466 }
1467
1468 fn paths_for_canonical(canonical: &Path) -> Result<(PathBuf, PathBuf), NativeMountError> {
1469 let parent = canonical
1470 .parent()
1471 .ok_or(NativeMountError::InvalidDestination)?;
1472 let mut identity = blake3::Hasher::new();
1473 identity.update(b"acyclic-fs/native-mount-destination/v1\0");
1474 update_path_identity(&mut identity, canonical);
1475 let lock_path = parent.join(format!(
1476 ".acyclic-fs-mount-{}.lock",
1477 identity.finalize().to_hex()
1478 ));
1479 let registry_path = parent.join(".acyclic-fs-mount-registry.lock");
1480 Ok((lock_path, registry_path))
1481 }
1482
1483 fn reclaim_canonical(canonical: &Path) -> Result<(), NativeMountError> {
1484 let (lock_path, registry_path) = Self::paths_for_canonical(canonical)?;
1485 let registry = acquire_mount_lock(®istry_path, true)?;
1486 if !lock_path
1487 .try_exists()
1488 .map_err(|error| NativeMountError::Driver(error.to_string()))?
1489 {
1490 return Ok(());
1491 }
1492 let file = acquire_existing_mount_lock(&lock_path)?;
1493 drop(file);
1494 match std::fs::remove_file(&lock_path) {
1495 Ok(()) => {}
1496 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1497 Err(error) => return Err(NativeMountError::Driver(error.to_string())),
1498 }
1499 drop(registry);
1500 Ok(())
1501 }
1502
1503 fn release(&mut self) -> Result<(), NativeMountError> {
1504 let registry = acquire_mount_lock(&self.registry_path, true)?;
1505 release_owned_fence(&mut self.file, || {
1506 match std::fs::remove_file(&self.lock_path) {
1507 Ok(()) => {}
1508 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1509 Err(error) => return Err(NativeMountError::Driver(error.to_string())),
1510 }
1511 Ok(())
1512 })?;
1513 drop(registry);
1514 Ok(())
1515 }
1516}
1517
1518fn release_owned_fence<T, E>(
1519 owner: &mut Option<T>,
1520 release: impl FnOnce() -> Result<(), E>,
1521) -> Result<(), E> {
1522 release()?;
1523 owner.take();
1524 Ok(())
1525}
1526
1527#[cfg(target_os = "linux")]
1528fn recover_platform_destination(destination: &Path) -> Result<(), NativeMountError> {
1529 use std::process::{Command, Stdio};
1530
1531 if !linux_destination_is_mounted(destination)? {
1532 return Ok(());
1533 }
1534 let helper = [
1535 "/usr/bin/fusermount3",
1536 "/bin/fusermount3",
1537 "/usr/bin/fusermount",
1538 "/bin/fusermount",
1539 ]
1540 .into_iter()
1541 .find(|candidate| Path::new(candidate).is_file())
1542 .ok_or_else(|| {
1543 NativeMountError::CapabilityUnavailable(
1544 "no canonical fusermount helper is available for crash recovery".to_owned(),
1545 )
1546 })?;
1547 let mut child = Command::new(helper)
1548 .args(["-u", "-q", "-z", "--"])
1549 .arg(destination)
1550 .stdin(Stdio::null())
1551 .stdout(Stdio::null())
1552 .stderr(Stdio::null())
1553 .spawn()
1554 .map_err(|error| NativeMountError::Driver(error.to_string()))?;
1555 let deadline = Instant::now() + Duration::from_secs(30);
1556 loop {
1557 if child
1558 .try_wait()
1559 .map_err(|error| NativeMountError::Driver(error.to_string()))?
1560 .is_some()
1561 {
1562 break;
1563 }
1564 if Instant::now() >= deadline {
1565 let _ = child.kill();
1566 let _ = child.wait();
1567 return Err(NativeMountError::Driver(
1568 "fusermount crash recovery exceeded 30 seconds".to_owned(),
1569 ));
1570 }
1571 std::thread::sleep(Duration::from_millis(10));
1572 }
1573 if linux_destination_is_mounted(destination)? {
1574 return Err(NativeMountError::Driver(
1575 "FUSE destination remained mounted after crash recovery".to_owned(),
1576 ));
1577 }
1578 Ok(())
1579}
1580
1581#[cfg(target_os = "linux")]
1582fn linux_destination_is_mounted(destination: &Path) -> Result<bool, NativeMountError> {
1583 use std::os::unix::fs::MetadataExt;
1584
1585 let parent = destination
1586 .parent()
1587 .ok_or(NativeMountError::InvalidDestination)?
1588 .metadata()
1589 .map_err(|error| NativeMountError::Driver(error.to_string()))?;
1590 match destination.metadata() {
1591 Ok(metadata) => Ok(metadata.dev() != parent.dev()),
1592 Err(error) if error.raw_os_error() == Some(libc::ENOTCONN) => Ok(true),
1593 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1594 Err(error) => Err(NativeMountError::Driver(error.to_string())),
1595 }
1596}
1597
1598#[cfg(target_os = "macos")]
1599fn recover_platform_destination(destination: &Path) -> Result<(), NativeMountError> {
1600 darwin_mount::recover_destination(destination)
1601}
1602
1603#[cfg(target_os = "windows")]
1604fn recover_platform_destination(destination: &Path) -> Result<(), NativeMountError> {
1605 projfs::recover_cache_only_destination(destination)
1606}
1607
1608#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1609fn recover_platform_destination(_destination: &Path) -> Result<(), NativeMountError> {
1610 Err(NativeMountError::UnsupportedTarget)
1611}
1612
1613impl Drop for MountDestinationGuard {
1614 fn drop(&mut self) {
1615 let _ = self.release();
1616 }
1617}
1618
1619fn acquire_mount_lock(path: &Path, blocking: bool) -> Result<File, NativeMountError> {
1620 let file = OpenOptions::new()
1621 .create(true)
1622 .truncate(false)
1623 .read(true)
1624 .write(true)
1625 .open(path)
1626 .map_err(|error| NativeMountError::Driver(error.to_string()))?;
1627 let lock_metadata = std::fs::symlink_metadata(path)
1628 .map_err(|error| NativeMountError::Driver(error.to_string()))?;
1629 if lock_metadata.file_type().is_symlink() || !file.metadata().is_ok_and(|value| value.is_file())
1630 {
1631 return Err(NativeMountError::InvalidDestination);
1632 }
1633 if blocking {
1634 fs2::FileExt::lock_exclusive(&file)
1635 .map_err(|error| NativeMountError::Driver(error.to_string()))?;
1636 } else {
1637 fs2::FileExt::try_lock_exclusive(&file).map_err(|error| {
1638 if lock_is_contended(&error) {
1639 NativeMountError::DestinationBusy
1640 } else {
1641 NativeMountError::Driver(error.to_string())
1642 }
1643 })?;
1644 }
1645 Ok(file)
1646}
1647
1648fn acquire_existing_mount_lock(path: &Path) -> Result<File, NativeMountError> {
1649 let file = OpenOptions::new()
1650 .read(true)
1651 .write(true)
1652 .open(path)
1653 .map_err(|error| NativeMountError::Driver(error.to_string()))?;
1654 let lock_metadata = std::fs::symlink_metadata(path)
1655 .map_err(|error| NativeMountError::Driver(error.to_string()))?;
1656 if lock_metadata.file_type().is_symlink() || !file.metadata().is_ok_and(|value| value.is_file())
1657 {
1658 return Err(NativeMountError::InvalidDestination);
1659 }
1660 fs2::FileExt::try_lock_exclusive(&file).map_err(|error| {
1661 if lock_is_contended(&error) {
1662 NativeMountError::DestinationBusy
1663 } else {
1664 NativeMountError::Driver(error.to_string())
1665 }
1666 })?;
1667 Ok(file)
1668}
1669
1670fn lock_is_contended(error: &std::io::Error) -> bool {
1671 if error.kind() == std::io::ErrorKind::WouldBlock {
1672 return true;
1673 }
1674 #[cfg(windows)]
1675 {
1676 error.raw_os_error() == Some(33)
1678 }
1679 #[cfg(not(windows))]
1680 {
1681 false
1682 }
1683}
1684
1685#[cfg(unix)]
1686fn update_path_identity(hasher: &mut blake3::Hasher, path: &Path) {
1687 use std::os::unix::ffi::OsStrExt;
1688 hasher.update(path.as_os_str().as_bytes());
1689}
1690
1691#[cfg(windows)]
1692fn update_path_identity(hasher: &mut blake3::Hasher, path: &Path) {
1693 use std::os::windows::ffi::OsStrExt;
1694 for unit in path.as_os_str().encode_wide() {
1695 hasher.update(&unit.to_le_bytes());
1696 }
1697}
1698
1699#[cfg(not(any(unix, windows)))]
1700fn update_path_identity(hasher: &mut blake3::Hasher, path: &Path) {
1701 hasher.update(path.to_string_lossy().as_bytes());
1702}
1703
1704fn validate_destination(destination: &Path) -> Result<(), NativeMountError> {
1705 validate_directory(destination)?;
1706 let mut entries =
1707 std::fs::read_dir(destination).map_err(|_| NativeMountError::InvalidDestination)?;
1708 if entries.next().is_some() {
1709 return Err(NativeMountError::InvalidDestination);
1710 }
1711 Ok(())
1712}
1713
1714fn validate_directory(destination: &Path) -> Result<(), NativeMountError> {
1718 let metadata =
1719 std::fs::symlink_metadata(destination).map_err(|_| NativeMountError::InvalidDestination)?;
1720 if !metadata.is_dir() || metadata.file_type().is_symlink() {
1721 return Err(NativeMountError::InvalidDestination);
1722 }
1723 Ok(())
1724}
1725
1726#[cfg(target_os = "linux")]
1727fn start_driver(
1728 request: &NativeMountRequest,
1729 source: Arc<dyn MountFilesystem>,
1730) -> Result<DriverSession, DriverStartFailure> {
1731 fuse::FuseSession::start(request, source)
1732 .map(DriverSession::Fuse)
1733 .map_err(Into::into)
1734}
1735
1736#[cfg(target_os = "macos")]
1737fn start_driver(
1738 request: &NativeMountRequest,
1739 source: Arc<dyn MountFilesystem>,
1740) -> Result<DriverSession, DriverStartFailure> {
1741 darwin_mount::DarwinMountSession::start(request, source).map(DriverSession::DarwinMount)
1742}
1743
1744#[cfg(target_os = "windows")]
1745fn start_driver(
1746 request: &NativeMountRequest,
1747 source: Arc<dyn MountFilesystem>,
1748) -> Result<DriverSession, DriverStartFailure> {
1749 projfs::ProjFsSession::start(request, source).map(DriverSession::ProjFs)
1750}
1751
1752#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1753fn start_driver(
1754 _request: &NativeMountRequest,
1755 _source: Arc<dyn MountFilesystem>,
1756) -> Result<DriverSession, DriverStartFailure> {
1757 Err(NativeMountError::UnsupportedTarget.into())
1758}
1759
1760fn stop_driver(driver: &mut DriverSession) -> Result<(), NativeMountError> {
1761 match driver {
1762 #[cfg(target_os = "linux")]
1763 DriverSession::Fuse(session) => session.stop(),
1764 #[cfg(target_os = "macos")]
1765 DriverSession::DarwinMount(session) => session.stop(),
1766 #[cfg(target_os = "windows")]
1767 DriverSession::ProjFs(session) => session.stop(),
1768 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1769 DriverSession::Unsupported => Err(NativeMountError::UnsupportedTarget),
1770 }
1771}
1772
1773#[cfg(target_os = "linux")]
1774mod platform {
1775 use super::{NativeMountCapabilities, NativeMountKind, NativeMountSessionIsolation};
1776
1777 pub(super) fn probe() -> NativeMountCapabilities {
1778 let available = std::fs::OpenOptions::new()
1779 .read(true)
1780 .write(true)
1781 .open("/dev/fuse")
1782 .is_ok();
1783 NativeMountCapabilities {
1784 kind: Some(NativeMountKind::LinuxFuse),
1785 available,
1786 writable: available,
1787 provider_process_io_observable: available,
1788 session_isolation: NativeMountSessionIsolation::SharedProcess,
1789 unavailable_reason: (!available).then(|| "/dev/fuse is not usable".to_owned()),
1790 }
1791 }
1792}
1793
1794#[cfg(target_os = "macos")]
1795mod platform {
1796 use super::{NativeMountCapabilities, NativeMountKind, NativeMountSessionIsolation};
1797
1798 pub(super) fn probe() -> NativeMountCapabilities {
1799 let available = std::path::Path::new("/sbin/mount_nfs").is_file()
1800 && std::path::Path::new("/sbin/umount").is_file();
1801 NativeMountCapabilities {
1802 kind: Some(NativeMountKind::MacOsNfs),
1803 available,
1804 writable: available,
1805 provider_process_io_observable: available,
1806 session_isolation: NativeMountSessionIsolation::SharedProcess,
1807 unavailable_reason: (!available)
1808 .then(|| "the built-in macOS NFS mount utilities are unavailable".to_owned()),
1809 }
1810 }
1811}
1812
1813#[cfg(target_os = "windows")]
1814mod platform {
1815 use super::{NativeMountCapabilities, NativeMountKind, NativeMountSessionIsolation};
1816
1817 pub(super) fn probe() -> NativeMountCapabilities {
1818 let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
1819 let available = std::path::Path::new(&system_root)
1820 .join("System32")
1821 .join("ProjectedFSLib.dll")
1822 .is_file();
1823 NativeMountCapabilities {
1824 kind: Some(NativeMountKind::WindowsProjFs),
1825 available,
1826 writable: available,
1827 provider_process_io_observable: false,
1828 session_isolation: NativeMountSessionIsolation::SharedProcess,
1829 unavailable_reason: (!available)
1830 .then(|| "ProjectedFSLib.dll is unavailable".to_owned()),
1831 }
1832 }
1833}
1834
1835#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1836mod platform {
1837 use super::{NativeMountCapabilities, NativeMountSessionIsolation};
1838
1839 pub(super) fn probe() -> NativeMountCapabilities {
1840 NativeMountCapabilities {
1841 kind: None,
1842 available: false,
1843 writable: false,
1844 provider_process_io_observable: false,
1845 session_isolation: NativeMountSessionIsolation::ProcessIsolated,
1846 unavailable_reason: Some("unsupported target".to_owned()),
1847 }
1848 }
1849}
1850
1851#[cfg(test)]
1852mod tests {
1853 use super::*;
1854 use crate::kernel::{LogicalName, NameEncoding, NamespacePath};
1855 use crate::model::{
1856 AccessMode, CaseSensitivity, CheckoutMode, ConcurrencyMode, ConsistencyMode,
1857 FilesystemProfile, GenerationSelector, Lifecycle, MutationMode, UnicodePolicy,
1858 VolumeConfig, VolumeLimits,
1859 };
1860 use crate::path::PortablePath;
1861 use crate::{
1862 ByteRange, CancellationToken, Fs, VolumeId, WatchBatch, WatchChange, WatchEpoch,
1863 WatchSequence, WorkBudget,
1864 };
1865 use bytes::Bytes;
1866 use std::time::{Duration, Instant};
1867
1868 #[cfg(windows)]
1869 #[test]
1870 fn projfs_cannot_shadow_an_existing_directory() -> Result<(), Box<dyn std::error::Error>> {
1871 let root = tempfile::tempdir()?;
1872 std::fs::write(root.path().join("existing.txt"), b"real")?;
1873 let request = NativeMountRequest {
1874 mount_id: MountId::new(),
1875 volume_id: VolumeId::new(),
1876 destination: root.path().to_path_buf(),
1877 writable: true,
1878 };
1879 let source = Arc::new(RoutedMountSource::new());
1880 assert!(matches!(
1881 mount_native_over_existing(request, source),
1882 Err(NativeMountError::WritableUnavailable(_))
1883 ));
1884 assert_eq!(std::fs::read(root.path().join("existing.txt"))?, b"real");
1885 Ok(())
1886 }
1887
1888 fn portable_path(
1889 limits: VolumeLimits,
1890 components: &[&[u8]],
1891 ) -> Result<NamespacePath, Box<dyn std::error::Error>> {
1892 Ok(NamespacePath::new(
1893 components
1894 .iter()
1895 .map(|name| {
1896 LogicalName::new(
1897 NameEncoding::Utf8,
1898 name.to_vec(),
1899 limits.maximum_component_bytes,
1900 )
1901 })
1902 .collect::<Result<Vec<_>, _>>()?,
1903 limits,
1904 )?)
1905 }
1906
1907 #[test]
1908 fn probe_names_exactly_one_target_driver() {
1909 let probe = probe_native_mount();
1910 assert!(probe.kind.is_some());
1911 assert_eq!(probe.available, probe.unavailable_reason.is_none());
1912 }
1913
1914 #[test]
1915 fn destination_admission_rejects_nonempty_directories() {
1916 let root =
1917 std::env::temp_dir().join(format!("acyclic-fs-mount-admission-{}", std::process::id()));
1918 let _ = std::fs::remove_dir_all(&root);
1919 assert!(std::fs::create_dir_all(&root).is_ok());
1920 assert!(std::fs::write(root.join("occupied"), b"x").is_ok());
1921 assert!(matches!(
1922 validate_destination(&root),
1923 Err(NativeMountError::InvalidDestination)
1924 ));
1925 let _ = std::fs::remove_dir_all(root);
1926 }
1927
1928 #[test]
1929 fn validate_directory_admits_nonempty_directories_but_still_rejects_non_directories()
1930 -> Result<(), Box<dyn std::error::Error>> {
1931 let root = std::env::temp_dir().join(format!(
1932 "acyclic-fs-mount-over-existing-{}",
1933 std::process::id()
1934 ));
1935 let _ = std::fs::remove_dir_all(&root);
1936 std::fs::create_dir_all(&root)?;
1937 std::fs::write(root.join("occupied"), b"x")?;
1938 assert!(validate_directory(&root).is_ok());
1939
1940 let file = root.join("occupied");
1941 assert!(matches!(
1942 validate_directory(&file),
1943 Err(NativeMountError::InvalidDestination)
1944 ));
1945
1946 let missing = root.join("does-not-exist");
1947 assert!(matches!(
1948 validate_directory(&missing),
1949 Err(NativeMountError::InvalidDestination)
1950 ));
1951
1952 let _ = std::fs::remove_dir_all(&root);
1953 Ok(())
1954 }
1955
1956 #[test]
1957 fn crash_recovery_never_removes_live_or_contaminated_destinations()
1958 -> Result<(), Box<dyn std::error::Error>> {
1959 let parent = tempfile::tempdir()?;
1960 let destination = parent.path().join("native-destination");
1961 std::fs::create_dir(&destination)?;
1962
1963 let live_guard = MountDestinationGuard::acquire(&destination)?;
1964 assert!(matches!(
1965 recover_native_mount_destination(&destination),
1966 Err(NativeMountError::DestinationBusy)
1967 ));
1968 assert!(destination.is_dir());
1969 drop(live_guard);
1970
1971 std::fs::write(destination.join("unowned-content"), b"retain")?;
1972 assert!(matches!(
1973 recover_native_mount_destination(&destination),
1974 Err(NativeMountError::Driver(_))
1975 ));
1976 assert_eq!(
1977 std::fs::read(destination.join("unowned-content"))?,
1978 b"retain"
1979 );
1980 std::fs::remove_file(destination.join("unowned-content"))?;
1981
1982 recover_native_mount_destination(&destination)?;
1983 assert!(!destination.exists());
1984 let residue = std::fs::read_dir(parent.path())?
1985 .filter_map(Result::ok)
1986 .map(|entry| entry.file_name())
1987 .filter(|name| name != ".acyclic-fs-mount-registry.lock")
1988 .collect::<Vec<_>>();
1989 assert!(residue.is_empty());
1990 Ok(())
1991 }
1992
1993 #[cfg(windows)]
1994 #[test]
1995 fn preserving_recovery_fences_live_owner_and_retains_ordinary_residue()
1996 -> Result<(), Box<dyn std::error::Error>> {
1997 let parent = tempfile::tempdir()?;
1998 let destination = parent.path().join("mount");
1999 std::fs::create_dir(&destination)?;
2000 let live_guard = MountDestinationGuard::acquire(&destination)?;
2001 assert!(matches!(
2002 recover_native_mount_destination_preserving_residue(&destination),
2003 Err(NativeMountError::DestinationBusy)
2004 ));
2005 drop(live_guard);
2006
2007 std::fs::write(destination.join("authored.txt"), b"retain")?;
2008 let preserved = recover_native_mount_destination_preserving_residue(&destination)?
2009 .ok_or("nonempty destination was not preserved")?;
2010 assert!(!destination.exists());
2011 assert_eq!(std::fs::read(preserved.join("authored.txt"))?, b"retain");
2012 assert!(recover_native_mount_destination_preserving_residue(&destination)?.is_none());
2013 Ok(())
2014 }
2015
2016 #[test]
2017 fn failed_destination_admission_releases_ownership() -> Result<(), Box<dyn std::error::Error>> {
2018 let temporary = tempfile::tempdir()?;
2019 let destination = temporary.path().join("mount");
2020 std::fs::create_dir(&destination)?;
2021 std::fs::write(destination.join("occupied"), b"x")?;
2022 assert!(matches!(
2023 admit_destination(&destination),
2024 Err(NativeMountError::InvalidDestination)
2025 ));
2026 std::fs::remove_file(destination.join("occupied"))?;
2027 let _recovered = admit_destination(&destination)?;
2028 Ok(())
2029 }
2030
2031 #[test]
2032 fn failed_driver_start_releases_destination_ownership() -> Result<(), Box<dyn std::error::Error>>
2033 {
2034 let temporary = tempfile::tempdir()?;
2035 let destination = temporary.path().join("mount");
2036 std::fs::create_dir(&destination)?;
2037
2038 let failed = start_owned_session::<()>(&destination, || {
2039 Err(NativeMountError::Driver("injected startup failure".to_owned()).into())
2040 });
2041 assert!(matches!(failed, Err(NativeMountError::Driver(_))));
2042 let recovered_after_error = MountDestinationGuard::acquire(&destination)?;
2043 drop(recovered_after_error);
2044
2045 Ok(())
2046 }
2047
2048 #[test]
2049 fn destination_ownership_is_process_external_and_recoverable()
2050 -> Result<(), Box<dyn std::error::Error>> {
2051 let temporary = tempfile::tempdir()?;
2052 let destination = temporary.path().join("mount");
2053 std::fs::create_dir(&destination)?;
2054 let first = MountDestinationGuard::acquire(&destination)?;
2055 let second = MountDestinationGuard::acquire(&destination);
2056 assert!(
2057 matches!(second, Err(NativeMountError::DestinationBusy)),
2058 "unexpected second-owner result: {second:?}"
2059 );
2060 let independent = temporary.path().join("independent");
2061 std::fs::create_dir(&independent)?;
2062 let _independent = MountDestinationGuard::acquire(&independent)?;
2063 drop(first);
2064 let _recovered = MountDestinationGuard::acquire(&destination)?;
2065 Ok(())
2066 }
2067
2068 #[test]
2069 fn orderly_destination_release_removes_per_mount_fence()
2070 -> Result<(), Box<dyn std::error::Error>> {
2071 let temporary = tempfile::tempdir()?;
2072 let destination = temporary.path().join("mount");
2073 std::fs::create_dir(&destination)?;
2074 let guard = MountDestinationGuard::acquire(&destination)?;
2075 drop(guard);
2076
2077 let mut residue = std::fs::read_dir(temporary.path())?
2078 .map(|entry| entry.map(|value| value.file_name()))
2079 .collect::<Result<Vec<_>, _>>()?;
2080 residue.sort();
2081 assert_eq!(
2082 residue,
2083 vec![
2084 std::ffi::OsString::from(".acyclic-fs-mount-registry.lock"),
2085 std::ffi::OsString::from("mount"),
2086 ]
2087 );
2088 Ok(())
2089 }
2090
2091 #[test]
2092 fn repeated_admission_teardown_has_constant_persistent_resource_set()
2093 -> Result<(), Box<dyn std::error::Error>> {
2094 let temporary = tempfile::tempdir()?;
2095 let destinations = [
2096 temporary.path().join("mount-a"),
2097 temporary.path().join("mount-b"),
2098 ];
2099 for destination in &destinations {
2100 std::fs::create_dir(destination)?;
2101 }
2102 for ordinal in 0..1_024 {
2103 let guard = MountDestinationGuard::acquire(&destinations[ordinal % 2])?;
2104 drop(guard);
2105 }
2106 let mut residue = std::fs::read_dir(temporary.path())?
2107 .map(|entry| entry.map(|value| value.file_name()))
2108 .collect::<Result<Vec<_>, _>>()?;
2109 residue.sort();
2110 assert_eq!(
2111 residue,
2112 vec![
2113 std::ffi::OsString::from(".acyclic-fs-mount-registry.lock"),
2114 std::ffi::OsString::from("mount-a"),
2115 std::ffi::OsString::from("mount-b"),
2116 ]
2117 );
2118 for destination in destinations {
2119 std::fs::remove_dir(destination)?;
2120 }
2121 Ok(())
2122 }
2123
2124 #[test]
2125 fn bounded_parent_sweep_reclaims_stale_fences_and_preserves_live_owners()
2126 -> Result<(), Box<dyn std::error::Error>> {
2127 let temporary = tempfile::tempdir()?;
2128 let live_destination = temporary.path().join("live");
2129 let stale_destination = temporary.path().join("stale");
2130 std::fs::create_dir(&live_destination)?;
2131 std::fs::create_dir(&stale_destination)?;
2132 let live = MountDestinationGuard::acquire(&live_destination)?;
2133 let mut stale = MountDestinationGuard::acquire(&stale_destination)?;
2134 let live_path = live.lock_path.clone();
2135 let stale_path = stale.lock_path.clone();
2136 stale.file.take();
2137 std::mem::forget(stale);
2138
2139 assert_eq!(
2140 reclaim_stale_native_mount_destination_fences(temporary.path())?,
2141 1
2142 );
2143 assert!(live_path.is_file());
2144 assert!(!stale_path.exists());
2145 drop(live);
2146 assert_eq!(
2147 reclaim_stale_native_mount_destination_fences(temporary.path())?,
2148 0
2149 );
2150 Ok(())
2151 }
2152
2153 #[test]
2154 fn parent_sweep_rejects_malformed_reserved_fence_names()
2155 -> Result<(), Box<dyn std::error::Error>> {
2156 let temporary = tempfile::tempdir()?;
2157 std::fs::write(temporary.path().join(".acyclic-fs-mount-invalid.lock"), b"")?;
2158 assert!(matches!(
2159 reclaim_stale_native_mount_destination_fences(temporary.path()),
2160 Err(NativeMountError::InvalidDestination)
2161 ));
2162 Ok(())
2163 }
2164
2165 #[test]
2166 fn destination_reclaim_child_waits_for_external_release()
2167 -> Result<(), Box<dyn std::error::Error>> {
2168 let Some(destination) = std::env::var_os("ACYCLIC_FS_RECLAIM_CHILD_DESTINATION") else {
2169 return Ok(());
2170 };
2171 let ready = std::env::var_os("ACYCLIC_FS_RECLAIM_CHILD_READY")
2172 .ok_or("reclaim child readiness path is absent")?;
2173 let release = std::env::var_os("ACYCLIC_FS_RECLAIM_CHILD_RELEASE")
2174 .ok_or("reclaim child release path is absent")?;
2175 std::fs::write(ready, b"ready")?;
2176 let deadline = Instant::now() + Duration::from_secs(10);
2177 while !Path::new(&release).is_file() {
2178 if Instant::now() >= deadline {
2179 return Err("reclaim child release timed out".into());
2180 }
2181 std::thread::sleep(Duration::from_millis(10));
2182 }
2183 reclaim_native_mount_destination_fence(Path::new(&destination))?;
2184 Ok(())
2185 }
2186
2187 #[test]
2188 fn independent_supervisors_concurrently_reclaim_one_crash_left_fence()
2189 -> Result<(), Box<dyn std::error::Error>> {
2190 struct KillChildren(Vec<std::process::Child>);
2191
2192 impl Drop for KillChildren {
2193 fn drop(&mut self) {
2194 for child in &mut self.0 {
2195 let _ = child.kill();
2196 let _ = child.wait();
2197 }
2198 }
2199 }
2200
2201 let temporary = tempfile::tempdir()?;
2202 let destination = temporary.path().join("mount");
2203 let release = temporary.path().join("release");
2204 std::fs::create_dir(&destination)?;
2205 let mut crashed = MountDestinationGuard::acquire(&destination)?;
2206 let crash_left_path = crashed.lock_path.clone();
2207 crashed.file.take();
2208 std::mem::forget(crashed);
2209
2210 let executable = std::env::current_exe()?;
2211 let mut children = KillChildren(Vec::new());
2212 let mut readiness = Vec::new();
2213 for ordinal in 0..2 {
2214 let ready = temporary.path().join(format!("ready-{ordinal}"));
2215 let child = std::process::Command::new(&executable)
2216 .args([
2217 "--exact",
2218 "native_mount::tests::destination_reclaim_child_waits_for_external_release",
2219 "--nocapture",
2220 ])
2221 .env("ACYCLIC_FS_RECLAIM_CHILD_DESTINATION", &destination)
2222 .env("ACYCLIC_FS_RECLAIM_CHILD_READY", &ready)
2223 .env("ACYCLIC_FS_RECLAIM_CHILD_RELEASE", &release)
2224 .spawn()?;
2225 readiness.push(ready);
2226 children.0.push(child);
2227 }
2228 let deadline = Instant::now() + Duration::from_secs(10);
2229 while readiness.iter().any(|path| !path.is_file()) {
2230 for child in &mut children.0 {
2231 if let Some(status) = child.try_wait()? {
2232 return Err(format!("reclaim child exited before release: {status}").into());
2233 }
2234 }
2235 if Instant::now() >= deadline {
2236 return Err("reclaim children did not become ready".into());
2237 }
2238 std::thread::sleep(Duration::from_millis(10));
2239 }
2240 std::fs::write(&release, b"release")?;
2241 for child in &mut children.0 {
2242 let status = child.wait()?;
2243 if !status.success() {
2244 return Err(format!("reclaim child failed: {status}").into());
2245 }
2246 }
2247 children.0.clear();
2248 assert!(!crash_left_path.exists());
2249 assert!(destination.is_dir());
2250 Ok(())
2251 }
2252
2253 #[test]
2254 fn destination_ownership_child_holds_lock_until_killed()
2255 -> Result<(), Box<dyn std::error::Error>> {
2256 let Some(destination) = std::env::var_os("ACYCLIC_FS_LOCK_CHILD_DESTINATION") else {
2257 return Ok(());
2258 };
2259 let ready = std::env::var_os("ACYCLIC_FS_LOCK_CHILD_READY")
2260 .ok_or("lock child readiness path is absent")?;
2261 let _guard = MountDestinationGuard::acquire(Path::new(&destination))?;
2262 std::fs::write(ready, b"ready")?;
2263 loop {
2264 std::thread::park();
2265 }
2266 }
2267
2268 #[test]
2269 fn destination_ownership_is_reclaimed_after_process_death()
2270 -> Result<(), Box<dyn std::error::Error>> {
2271 struct KillOnDrop(std::process::Child);
2272
2273 impl Drop for KillOnDrop {
2274 fn drop(&mut self) {
2275 let _ = self.0.kill();
2276 let _ = self.0.wait();
2277 }
2278 }
2279
2280 let temporary = tempfile::tempdir()?;
2281 let destination = temporary.path().join("mount");
2282 let ready = temporary.path().join("ready");
2283 std::fs::create_dir(&destination)?;
2284 let child = std::process::Command::new(std::env::current_exe()?)
2285 .args([
2286 "--exact",
2287 "native_mount::tests::destination_ownership_child_holds_lock_until_killed",
2288 "--nocapture",
2289 ])
2290 .env("ACYCLIC_FS_LOCK_CHILD_DESTINATION", &destination)
2291 .env("ACYCLIC_FS_LOCK_CHILD_READY", &ready)
2292 .spawn()?;
2293 let mut child = KillOnDrop(child);
2294 let deadline = Instant::now() + Duration::from_secs(10);
2295 while !ready.is_file() {
2296 if let Some(status) = child.0.try_wait()? {
2297 return Err(format!("lock child exited before readiness: {status}").into());
2298 }
2299 if Instant::now() >= deadline {
2300 return Err("lock child readiness timed out".into());
2301 }
2302 std::thread::sleep(Duration::from_millis(10));
2303 }
2304 assert!(matches!(
2305 MountDestinationGuard::acquire(&destination),
2306 Err(NativeMountError::DestinationBusy)
2307 ));
2308 assert!(matches!(
2309 reclaim_native_mount_destination_fence(&destination),
2310 Err(NativeMountError::DestinationBusy)
2311 ));
2312 child.0.kill()?;
2313 let status = child.0.wait()?;
2314 assert!(
2315 !status.success(),
2316 "killed lock child unexpectedly succeeded"
2317 );
2318 reclaim_native_mount_destination_fence(&destination)?;
2319 reclaim_native_mount_destination_fence(&destination)?;
2320 let _reclaimed = MountDestinationGuard::acquire(&destination)?;
2321 Ok(())
2322 }
2323
2324 #[test]
2325 fn failed_stop_retains_driver_and_destination_ownership_for_exact_retry() {
2326 let mut driver = Some(7_u8);
2327 let mut destination_guard = Some(11_u8);
2328 let failure = stop_owned_session(
2329 &mut driver,
2330 &mut destination_guard,
2331 |_| Err("busy"),
2332 |_| Ok(()),
2333 );
2334 assert_eq!(failure, Err("busy"));
2335 assert_eq!(driver, Some(7));
2336 assert_eq!(destination_guard, Some(11));
2337
2338 let stopped = stop_owned_session(
2339 &mut driver,
2340 &mut destination_guard,
2341 |_| Ok::<_, &str>(()),
2342 |_| Ok(()),
2343 );
2344 assert_eq!(stopped, Ok(true));
2345 assert_eq!(driver, None);
2346 assert_eq!(destination_guard, None);
2347 assert_eq!(
2348 stop_owned_session(
2349 &mut driver,
2350 &mut destination_guard,
2351 |_| Ok::<_, &str>(()),
2352 |_| Ok(()),
2353 ),
2354 Ok(false)
2355 );
2356 }
2357
2358 #[test]
2359 fn failed_final_stop_keeps_driver_and_destination_fence_until_process_exit() {
2360 struct DropProbe(Arc<std::sync::atomic::AtomicUsize>);
2361
2362 impl Drop for DropProbe {
2363 fn drop(&mut self) {
2364 self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2365 }
2366 }
2367
2368 let drops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2369 let mut driver = Some(DropProbe(Arc::clone(&drops)));
2370 let mut destination_guard = Some(DropProbe(Arc::clone(&drops)));
2371 drop_owned_session(
2372 &mut driver,
2373 &mut destination_guard,
2374 |_| Err("kernel detach failed"),
2375 |_| Ok(()),
2376 );
2377 assert!(driver.is_none());
2378 assert!(destination_guard.is_none());
2379 assert_eq!(drops.load(std::sync::atomic::Ordering::SeqCst), 0);
2380 }
2381
2382 #[test]
2383 fn failed_fence_unlink_retains_the_operating_system_lock() {
2384 let mut fence = Some(7_u8);
2385 let failure = release_owned_fence(&mut fence, || Err("unlink failed"));
2386 assert_eq!(failure, Err("unlink failed"));
2387 assert_eq!(fence, Some(7));
2388 assert_eq!(
2389 release_owned_fence(&mut fence, || Ok::<_, &str>(())),
2390 Ok(())
2391 );
2392 assert_eq!(fence, None);
2393 }
2394
2395 #[test]
2396 fn failed_fence_release_is_retried_without_restarting_the_driver() {
2397 let mut driver = Some(7_u8);
2398 let mut destination_guard = Some(11_u8);
2399 let mut first_release = true;
2400 let failure = stop_owned_session(
2401 &mut driver,
2402 &mut destination_guard,
2403 |_| Ok::<_, &str>(()),
2404 |_| {
2405 if first_release {
2406 first_release = false;
2407 Err("registry busy")
2408 } else {
2409 Ok(())
2410 }
2411 },
2412 );
2413 assert_eq!(failure, Err("registry busy"));
2414 assert_eq!(driver, None);
2415 assert_eq!(destination_guard, Some(11));
2416
2417 let stopped = stop_owned_session(
2418 &mut driver,
2419 &mut destination_guard,
2420 |_| Err("driver must not restart"),
2421 |_| Ok(()),
2422 );
2423 assert_eq!(stopped, Ok(true));
2424 assert_eq!(destination_guard, None);
2425 }
2426
2427 #[tokio::test]
2428 #[allow(clippy::too_many_lines)]
2429 async fn capture_host_hard_links_preserves_shared_file_identity()
2430 -> Result<(), Box<dyn std::error::Error>> {
2431 let limits = VolumeLimits::default();
2432 let config = VolumeConfig {
2433 profile: FilesystemProfile::Portable,
2434 concurrency: ConcurrencyMode::Optimistic,
2435 lifecycle: Lifecycle::Ephemeral,
2436 case_sensitivity: CaseSensitivity::Sensitive,
2437 unicode: UnicodePolicy::Preserve,
2438 symbolic_links: true,
2439 hard_links: true,
2440 sparse_files: true,
2441 limits,
2442 };
2443 let cancellation = CancellationToken::new();
2444 let fs = Fs::memory();
2445 let volume = fs
2446 .create_volume_with_id(
2447 VolumeId::from_bytes([92; 16]),
2448 config,
2449 WorkBudget::UNBOUNDED,
2450 &cancellation,
2451 )
2452 .await?
2453 .value;
2454 let mut checkout = volume
2455 .checkout(
2456 GenerationSelector::Head,
2457 CheckoutMode {
2458 access: AccessMode::ReadWrite,
2459 consistency: ConsistencyMode::Pinned,
2460 mutations: MutationMode::PrivateOverlay,
2461 },
2462 WorkBudget::UNBOUNDED,
2463 &cancellation,
2464 )
2465 .await?
2466 .value;
2467 let host = tempfile::tempdir()?;
2468 std::fs::write(host.path().join("a"), b"shared")?;
2469 std::fs::hard_link(host.path().join("a"), host.path().join("b"))?;
2470 let path = |name: &str| -> Result<NamespacePath, Box<dyn std::error::Error>> {
2471 Ok(NamespacePath::new(
2472 vec![LogicalName::new(
2473 NameEncoding::Utf8,
2474 name.as_bytes().to_vec(),
2475 limits.maximum_component_bytes,
2476 )?],
2477 limits,
2478 )?)
2479 };
2480 let paths = [path("a")?, path("b")?];
2481 crate::native_capture::capture_paths_batched_with_baseline(
2482 &mut checkout,
2483 &paths,
2484 &CaptureOptions {
2485 expected_root_identity: capture_root_identity(host.path())?,
2486 source_root: host.path().to_path_buf(),
2487 maximum_paths: 2,
2488 maximum_extent_spans: 16,
2489 },
2490 1,
2491 WorkBudget::UNBOUNDED,
2492 &cancellation,
2493 None,
2494 )
2495 .await?;
2496 let first = checkout
2497 .lookup_no_follow(&paths[0], WorkBudget::UNBOUNDED, &cancellation)
2498 .await?
2499 .value
2500 .record
2501 .ok_or("first captured link missing")?;
2502 let second = checkout
2503 .lookup_no_follow(&paths[1], WorkBudget::UNBOUNDED, &cancellation)
2504 .await?
2505 .value
2506 .record
2507 .ok_or("second captured link missing")?;
2508 assert_eq!(first.file_id, second.file_id);
2509 std::fs::hard_link(host.path().join("a"), host.path().join("z"))?;
2510 let additional = path("z")?;
2511 capture_paths(
2512 &mut checkout,
2513 &[paths[0].clone(), additional.clone()],
2514 &CaptureOptions {
2515 expected_root_identity: capture_root_identity(host.path())?,
2516 source_root: host.path().to_path_buf(),
2517 maximum_paths: 2,
2518 maximum_extent_spans: 16,
2519 },
2520 WorkBudget::UNBOUNDED,
2521 &cancellation,
2522 )
2523 .await?;
2524 let additional_id = checkout
2525 .lookup_no_follow(&additional, WorkBudget::UNBOUNDED, &cancellation)
2526 .await?
2527 .value
2528 .record
2529 .ok_or("additional link missing")?
2530 .file_id;
2531 assert_eq!(additional_id, first.file_id);
2532 std::fs::hard_link(host.path().join("a"), host.path().join("0"))?;
2533 let earlier_alias = path("0")?;
2534 capture_paths(
2535 &mut checkout,
2536 &[paths[0].clone(), earlier_alias.clone()],
2537 &CaptureOptions {
2538 expected_root_identity: capture_root_identity(host.path())?,
2539 source_root: host.path().to_path_buf(),
2540 maximum_paths: 2,
2541 maximum_extent_spans: 16,
2542 },
2543 WorkBudget::UNBOUNDED,
2544 &cancellation,
2545 )
2546 .await?;
2547 let earlier_id = checkout
2548 .lookup_no_follow(&earlier_alias, WorkBudget::UNBOUNDED, &cancellation)
2549 .await?
2550 .value
2551 .record
2552 .ok_or("earlier alias missing")?
2553 .file_id;
2554 assert_eq!(earlier_id, first.file_id);
2555 std::fs::hard_link(host.path().join("a"), host.path().join("c"))?;
2556 let third = path("c")?;
2557 let watch = capture_watch_batch(
2558 &mut checkout,
2559 WatchBatch::Changes {
2560 epoch: WatchEpoch::from_u64(1),
2561 first_sequence: WatchSequence::from_u64(1),
2562 next_sequence: WatchSequence::from_u64(2),
2563 changes: vec![WatchChange::Created(third.clone())],
2564 },
2565 &CaptureOptions {
2566 expected_root_identity: capture_root_identity(host.path())?,
2567 source_root: host.path().to_path_buf(),
2568 maximum_paths: 1,
2569 maximum_extent_spans: 16,
2570 },
2571 WorkBudget::UNBOUNDED,
2572 &cancellation,
2573 )
2574 .await;
2575 assert!(matches!(
2576 watch,
2577 Err(crate::OperationFailure {
2578 error: CaptureError::RescanRequired { .. },
2579 ..
2580 })
2581 ));
2582 assert!(
2583 checkout
2584 .lookup_no_follow(&third, WorkBudget::UNBOUNDED, &cancellation)
2585 .await?
2586 .value
2587 .record
2588 .is_none()
2589 );
2590 std::fs::write(host.path().join("existing"), b"old")?;
2591 let existing = path("existing")?;
2592 capture_paths(
2593 &mut checkout,
2594 std::slice::from_ref(&existing),
2595 &CaptureOptions {
2596 expected_root_identity: capture_root_identity(host.path())?,
2597 source_root: host.path().to_path_buf(),
2598 maximum_paths: 1,
2599 maximum_extent_spans: 16,
2600 },
2601 WorkBudget::UNBOUNDED,
2602 &cancellation,
2603 )
2604 .await?;
2605 let old_id = checkout
2606 .lookup_no_follow(&existing, WorkBudget::UNBOUNDED, &cancellation)
2607 .await?
2608 .value
2609 .record
2610 .ok_or("existing path missing")?
2611 .file_id;
2612 std::fs::remove_file(host.path().join("existing"))?;
2613 std::fs::hard_link(host.path().join("a"), host.path().join("existing"))?;
2614 let watch = capture_watch_batch(
2615 &mut checkout,
2616 WatchBatch::Changes {
2617 epoch: WatchEpoch::from_u64(1),
2618 first_sequence: WatchSequence::from_u64(2),
2619 next_sequence: WatchSequence::from_u64(3),
2620 changes: vec![WatchChange::Created(existing.clone())],
2621 },
2622 &CaptureOptions {
2623 expected_root_identity: capture_root_identity(host.path())?,
2624 source_root: host.path().to_path_buf(),
2625 maximum_paths: 1,
2626 maximum_extent_spans: 16,
2627 },
2628 WorkBudget::UNBOUNDED,
2629 &cancellation,
2630 )
2631 .await;
2632 assert!(matches!(
2633 watch,
2634 Err(crate::OperationFailure {
2635 error: CaptureError::RescanRequired { .. },
2636 ..
2637 })
2638 ));
2639 assert_eq!(
2640 checkout
2641 .lookup_no_follow(&existing, WorkBudget::UNBOUNDED, &cancellation)
2642 .await?
2643 .value
2644 .record
2645 .ok_or("replacement changed checkout")?
2646 .file_id,
2647 old_id
2648 );
2649 Ok(())
2650 }
2651
2652 #[tokio::test]
2653 async fn baseline_path_limit_stops_unbounded_traversal()
2654 -> Result<(), Box<dyn std::error::Error>> {
2655 let cancellation = CancellationToken::new();
2656 let fs = Fs::memory();
2657 let volume = fs
2658 .create_volume_with_id(
2659 VolumeId::from_bytes([94; 16]),
2660 VolumeConfig::portable(Lifecycle::Ephemeral),
2661 WorkBudget::UNBOUNDED,
2662 &cancellation,
2663 )
2664 .await?
2665 .value;
2666 let mut checkout = volume
2667 .checkout(
2668 GenerationSelector::Head,
2669 CheckoutMode {
2670 access: AccessMode::ReadWrite,
2671 consistency: ConsistencyMode::Pinned,
2672 mutations: MutationMode::PrivateOverlay,
2673 },
2674 WorkBudget::UNBOUNDED,
2675 &cancellation,
2676 )
2677 .await?
2678 .value;
2679 let host = tempfile::tempdir()?;
2680 for index in 0..3 {
2681 std::fs::write(host.path().join(format!("file-{index}")), b"x")?;
2682 }
2683 let result = capture_baseline(
2684 &mut checkout,
2685 &CaptureOptions {
2686 expected_root_identity: capture_root_identity(host.path())?,
2687 source_root: host.path().to_path_buf(),
2688 maximum_paths: 2,
2689 maximum_extent_spans: 16,
2690 },
2691 WorkBudget::UNBOUNDED,
2692 &cancellation,
2693 )
2694 .await;
2695 let Err(error) = result else {
2696 return Err("path cap unexpectedly permitted traversal".into());
2697 };
2698 assert!(matches!(error.error, CaptureError::InvalidOptions));
2699 assert!(error.work.items_examined <= 2);
2700 Ok(())
2701 }
2702
2703 #[tokio::test]
2704 #[allow(clippy::too_many_lines)]
2705 async fn explicit_materialization_and_capture_round_trip_sparse_checkout()
2706 -> Result<(), Box<dyn std::error::Error>> {
2707 Box::pin(explicit_materialization_and_capture_round_trip_sparse_checkout_inner()).await
2708 }
2709
2710 #[allow(clippy::too_many_lines)]
2711 async fn explicit_materialization_and_capture_round_trip_sparse_checkout_inner()
2712 -> Result<(), Box<dyn std::error::Error>> {
2713 let limits = VolumeLimits::default();
2714 let config = VolumeConfig {
2715 profile: FilesystemProfile::Portable,
2716 concurrency: ConcurrencyMode::Optimistic,
2717 lifecycle: Lifecycle::Ephemeral,
2718 case_sensitivity: CaseSensitivity::Sensitive,
2719 unicode: UnicodePolicy::Preserve,
2720 symbolic_links: true,
2721 hard_links: true,
2722 sparse_files: true,
2723 limits,
2724 };
2725 let cancellation = CancellationToken::new();
2726 let fs = Fs::memory();
2727 let volume = fs
2728 .create_volume_with_id(
2729 VolumeId::from_bytes([91; 16]),
2730 config,
2731 WorkBudget::UNBOUNDED,
2732 &cancellation,
2733 )
2734 .await?
2735 .value;
2736 let mut checkout = volume
2737 .checkout(
2738 GenerationSelector::Head,
2739 CheckoutMode {
2740 access: AccessMode::ReadWrite,
2741 consistency: ConsistencyMode::Pinned,
2742 mutations: MutationMode::PrivateOverlay,
2743 },
2744 WorkBudget::UNBOUNDED,
2745 &cancellation,
2746 )
2747 .await?
2748 .value;
2749 let file = NamespacePath::new(
2750 vec![LogicalName::new(
2751 NameEncoding::Utf8,
2752 b"sparse.bin".to_vec(),
2753 limits.maximum_component_bytes,
2754 )?],
2755 limits,
2756 )?;
2757 checkout
2758 .create_file(
2759 file.clone(),
2760 Bytes::from_static(b"head"),
2761 WorkBudget::UNBOUNDED,
2762 &cancellation,
2763 )
2764 .await?;
2765 checkout
2766 .resize_file(
2767 file.clone(),
2768 1024 * 1024,
2769 WorkBudget::UNBOUNDED,
2770 &cancellation,
2771 )
2772 .await?;
2773 checkout
2774 .write_file(
2775 file.clone(),
2776 1024 * 1024 - 4,
2777 Bytes::from_static(b"tail"),
2778 WorkBudget::UNBOUNDED,
2779 &cancellation,
2780 )
2781 .await?;
2782
2783 let temporary = tempfile::tempdir()?;
2784 let destination = temporary.path().join("view");
2785 std::fs::create_dir(&destination)?;
2786 let materialized = Box::pin(materialize_checkout(
2787 &mut checkout,
2788 &MaterializeOptions {
2789 destination: destination.clone(),
2790 maximum_directory_entries: 16,
2791 maximum_extent_spans: 16,
2792 transfer_bytes: 64 * 1024,
2793 },
2794 WorkBudget::UNBOUNDED,
2795 &cancellation,
2796 ))
2797 .await
2798 .map_err(|error| std::io::Error::other(format!("sparse materialization: {error}")))?;
2799 assert_eq!(materialized.value.files, 1);
2800 let host_file = destination.join("sparse.bin");
2801 assert_eq!(
2802 std::fs::metadata(&host_file)
2803 .map_err(|error| std::io::Error::other(format!("materialized metadata: {error}")))?
2804 .len(),
2805 1024 * 1024
2806 );
2807 let body = std::fs::read(&host_file)
2808 .map_err(|error| std::io::Error::other(format!("materialized read: {error}")))?;
2809 assert_eq!(&body[..4], b"head");
2810 assert_eq!(&body[body.len() - 4..], b"tail");
2811
2812 let sparse_capture = capture_paths(
2813 &mut checkout,
2814 std::slice::from_ref(&file),
2815 &CaptureOptions {
2816 expected_root_identity: capture_root_identity(&destination)?,
2817 source_root: destination.clone(),
2818 maximum_paths: 1,
2819 maximum_extent_spans: 16,
2820 },
2821 WorkBudget::UNBOUNDED,
2822 &cancellation,
2823 )
2824 .await
2825 .map_err(|error| std::io::Error::other(format!("sparse recapture: {error}")))?;
2826 assert!(sparse_capture.value.staged_file_bytes < 1024 * 1024);
2827 let sparse_plan = checkout
2828 .plan_file_extents(
2829 &file,
2830 ByteRange {
2831 offset: 0,
2832 length: 1024 * 1024,
2833 },
2834 16,
2835 WorkBudget::UNBOUNDED,
2836 &cancellation,
2837 )
2838 .await?
2839 .value
2840 .ok_or("captured sparse file returned an inline plan")?;
2841 assert!(
2842 sparse_plan
2843 .spans
2844 .iter()
2845 .any(|span| matches!(span.kind, crate::kernel::ExtentKind::Hole))
2846 );
2847
2848 std::fs::write(&host_file, b"captured")?;
2849 let captured = capture_paths(
2850 &mut checkout,
2851 std::slice::from_ref(&file),
2852 &CaptureOptions {
2853 expected_root_identity: capture_root_identity(&destination)?,
2854 source_root: destination.clone(),
2855 maximum_paths: 1,
2856 maximum_extent_spans: 16,
2857 },
2858 WorkBudget::UNBOUNDED,
2859 &cancellation,
2860 )
2861 .await?;
2862 assert_eq!(captured.value.changed_paths, 1);
2863 let read = checkout
2864 .read_file_range(
2865 &file,
2866 ByteRange {
2867 offset: 0,
2868 length: 8,
2869 },
2870 WorkBudget::UNBOUNDED,
2871 &cancellation,
2872 )
2873 .await?;
2874 assert_eq!(read.value.bytes.as_ref(), b"captured");
2875
2876 let before_rename = checkout
2877 .lookup_no_follow(&file, WorkBudget::UNBOUNDED, &cancellation)
2878 .await?
2879 .value
2880 .record
2881 .ok_or("captured file disappeared")?;
2882 let renamed = NamespacePath::new(
2883 vec![LogicalName::new(
2884 NameEncoding::Utf8,
2885 b"renamed.bin".to_vec(),
2886 limits.maximum_component_bytes,
2887 )?],
2888 limits,
2889 )?;
2890 std::fs::rename(&host_file, destination.join("renamed.bin"))?;
2891 let watch = capture_watch_batch(
2892 &mut checkout,
2893 WatchBatch::Changes {
2894 epoch: WatchEpoch::from_u64(7),
2895 first_sequence: WatchSequence::from_u64(11),
2896 next_sequence: WatchSequence::from_u64(12),
2897 changes: vec![WatchChange::Renamed {
2898 from: file.clone(),
2899 to: renamed.clone(),
2900 }],
2901 },
2902 &CaptureOptions {
2903 expected_root_identity: capture_root_identity(&destination)?,
2904 source_root: destination.clone(),
2905 maximum_paths: 1,
2906 maximum_extent_spans: 16,
2907 },
2908 WorkBudget::UNBOUNDED,
2909 &cancellation,
2910 )
2911 .await?;
2912 assert_eq!(watch.value.next_sequence.get(), 12);
2913 let old = checkout
2914 .lookup_no_follow(&file, WorkBudget::UNBOUNDED, &cancellation)
2915 .await?;
2916 assert!(old.value.record.is_none());
2917 let new = checkout
2918 .lookup_no_follow(&renamed, WorkBudget::UNBOUNDED, &cancellation)
2919 .await?
2920 .value
2921 .record
2922 .ok_or("renamed file disappeared")?;
2923 assert_eq!(new.file_id, before_rename.file_id);
2924
2925 let moved = NamespacePath::new(
2926 vec![LogicalName::new(
2927 NameEncoding::Utf8,
2928 b"moved.bin".to_vec(),
2929 limits.maximum_component_bytes,
2930 )?],
2931 limits,
2932 )?;
2933 std::fs::rename(
2934 destination.join("renamed.bin"),
2935 destination.join("moved.bin"),
2936 )?;
2937 std::fs::write(destination.join("renamed.bin"), b"replacement")?;
2938 capture_watch_batch(
2939 &mut checkout,
2940 WatchBatch::Changes {
2941 epoch: WatchEpoch::from_u64(7),
2942 first_sequence: WatchSequence::from_u64(12),
2943 next_sequence: WatchSequence::from_u64(14),
2944 changes: vec![
2945 WatchChange::Renamed {
2946 from: renamed.clone(),
2947 to: moved.clone(),
2948 },
2949 WatchChange::Created(renamed.clone()),
2950 ],
2951 },
2952 &CaptureOptions {
2953 expected_root_identity: capture_root_identity(&destination)?,
2954 source_root: destination.clone(),
2955 maximum_paths: 2,
2956 maximum_extent_spans: 16,
2957 },
2958 WorkBudget::UNBOUNDED,
2959 &cancellation,
2960 )
2961 .await?;
2962 let moved_record = checkout
2963 .lookup_no_follow(&moved, WorkBudget::UNBOUNDED, &cancellation)
2964 .await?
2965 .value
2966 .record
2967 .ok_or("moved file disappeared")?;
2968 let replacement_record = checkout
2969 .lookup_no_follow(&renamed, WorkBudget::UNBOUNDED, &cancellation)
2970 .await?
2971 .value
2972 .record
2973 .ok_or("replacement file disappeared")?;
2974 assert_eq!(moved_record.file_id, before_rename.file_id);
2975 assert_ne!(replacement_record.file_id, before_rename.file_id);
2976 let replacement = checkout
2977 .read_file_range(
2978 &renamed,
2979 ByteRange {
2980 offset: 0,
2981 length: 11,
2982 },
2983 WorkBudget::UNBOUNDED,
2984 &cancellation,
2985 )
2986 .await?;
2987 assert_eq!(replacement.value.bytes.as_ref(), b"replacement");
2988
2989 std::fs::write(destination.join("renamed.bin"), b"replaced-again")?;
2990 capture_watch_batch(
2991 &mut checkout,
2992 WatchBatch::Changes {
2993 epoch: WatchEpoch::from_u64(7),
2994 first_sequence: WatchSequence::from_u64(14),
2995 next_sequence: WatchSequence::from_u64(15),
2996 changes: vec![WatchChange::Created(renamed.clone())],
2997 },
2998 &CaptureOptions {
2999 expected_root_identity: capture_root_identity(&destination)?,
3000 source_root: destination.clone(),
3001 maximum_paths: 1,
3002 maximum_extent_spans: 16,
3003 },
3004 WorkBudget::UNBOUNDED,
3005 &cancellation,
3006 )
3007 .await?;
3008 let replaced_again = checkout
3009 .lookup_no_follow(&renamed, WorkBudget::UNBOUNDED, &cancellation)
3010 .await?
3011 .value
3012 .record
3013 .ok_or("standalone create replacement disappeared")?;
3014 assert_ne!(replaced_again.file_id, replacement_record.file_id);
3015
3016 std::fs::write(destination.join("renamed.bin"), b"compound-replacement")?;
3017 capture_watch_batch(
3018 &mut checkout,
3019 WatchBatch::Changes {
3020 epoch: WatchEpoch::from_u64(7),
3021 first_sequence: WatchSequence::from_u64(15),
3022 next_sequence: WatchSequence::from_u64(17),
3023 changes: vec![
3024 WatchChange::Created(renamed.clone()),
3025 WatchChange::Modified(renamed.clone()),
3026 ],
3027 },
3028 &CaptureOptions {
3029 expected_root_identity: capture_root_identity(&destination)?,
3030 source_root: destination.clone(),
3031 maximum_paths: 2,
3032 maximum_extent_spans: 16,
3033 },
3034 WorkBudget::UNBOUNDED,
3035 &cancellation,
3036 )
3037 .await?;
3038 let compound_replacement = checkout
3039 .lookup_no_follow(&renamed, WorkBudget::UNBOUNDED, &cancellation)
3040 .await?
3041 .value
3042 .record
3043 .ok_or("compound replacement disappeared")?;
3044 assert_ne!(compound_replacement.file_id, replaced_again.file_id);
3045
3046 let dirty_baseline = capture_baseline(
3047 &mut checkout,
3048 &CaptureOptions {
3049 expected_root_identity: capture_root_identity(&destination)?,
3050 source_root: destination.clone(),
3051 maximum_paths: 16,
3052 maximum_extent_spans: 16,
3053 },
3054 WorkBudget::UNBOUNDED,
3055 &cancellation,
3056 )
3057 .await;
3058 let Err(dirty_baseline) = dirty_baseline else {
3059 return Err("dirty baseline replaced private mutations".into());
3060 };
3061 assert!(matches!(dirty_baseline.error, CaptureError::DirtyCheckout));
3062
3063 seal_checkout(
3064 &mut checkout,
3065 crate::OperationId::new(),
3066 WorkBudget::UNBOUNDED,
3067 &cancellation,
3068 )
3069 .await?;
3070
3071 std::fs::remove_file(destination.join("renamed.bin"))?;
3072 std::fs::remove_file(destination.join("moved.bin"))?;
3073 std::fs::create_dir(destination.join("nested"))?;
3074 std::fs::write(destination.join("nested").join("new.txt"), b"baseline")?;
3075 let baseline = capture_baseline(
3076 &mut checkout,
3077 &CaptureOptions {
3078 expected_root_identity: capture_root_identity(&destination)?,
3079 source_root: destination.clone(),
3080 maximum_paths: 16,
3081 maximum_extent_spans: 16,
3082 },
3083 WorkBudget::UNBOUNDED,
3084 &cancellation,
3085 )
3086 .await?;
3087 assert_eq!(baseline.value.examined_paths, 4);
3088 let removed = checkout
3089 .lookup_no_follow(&renamed, WorkBudget::UNBOUNDED, &cancellation)
3090 .await?;
3091 assert!(removed.value.record.is_none());
3092 let removed_moved = checkout
3093 .lookup_no_follow(&moved, WorkBudget::UNBOUNDED, &cancellation)
3094 .await?;
3095 assert!(removed_moved.value.record.is_none());
3096 let nested = PortablePath::parse("/nested/new.txt", limits)?;
3097 let nested = NamespacePath::from_portable(&nested, limits)?;
3098 let nested_read = checkout
3099 .read_file_range(
3100 &nested,
3101 ByteRange {
3102 offset: 0,
3103 length: 8,
3104 },
3105 WorkBudget::UNBOUNDED,
3106 &cancellation,
3107 )
3108 .await?;
3109 assert_eq!(nested_read.value.bytes.as_ref(), b"baseline");
3110
3111 let missing =
3112 NamespacePath::from_portable(&PortablePath::parse("/missing.tmp", limits)?, limits)?;
3113 let arrived =
3114 NamespacePath::from_portable(&PortablePath::parse("/arrived.txt", limits)?, limits)?;
3115 std::fs::write(destination.join("arrived.txt"), b"arrival")?;
3116 capture_watch_batch(
3117 &mut checkout,
3118 WatchBatch::Changes {
3119 epoch: WatchEpoch::from_u64(8),
3120 first_sequence: WatchSequence::from_u64(0),
3121 next_sequence: WatchSequence::from_u64(1),
3122 changes: vec![WatchChange::Renamed {
3123 from: missing,
3124 to: arrived.clone(),
3125 }],
3126 },
3127 &CaptureOptions {
3128 expected_root_identity: capture_root_identity(&destination)?,
3129 source_root: destination.clone(),
3130 maximum_paths: 1,
3131 maximum_extent_spans: 16,
3132 },
3133 WorkBudget::UNBOUNDED,
3134 &cancellation,
3135 )
3136 .await?;
3137 assert_eq!(
3138 checkout
3139 .read_file_range(
3140 &arrived,
3141 ByteRange {
3142 offset: 0,
3143 length: 7,
3144 },
3145 WorkBudget::UNBOUNDED,
3146 &cancellation,
3147 )
3148 .await?
3149 .value
3150 .bytes
3151 .as_ref(),
3152 b"arrival"
3153 );
3154
3155 let prior_arrival = checkout
3156 .lookup_no_follow(&arrived, WorkBudget::UNBOUNDED, &cancellation)
3157 .await?
3158 .value
3159 .record
3160 .ok_or("arrival disappeared")?;
3161 let temporary_name =
3162 NamespacePath::from_portable(&PortablePath::parse("/arrival.tmp", limits)?, limits)?;
3163 std::fs::write(destination.join("arrival.tmp"), b"replacement-arrival")?;
3164 std::fs::remove_file(destination.join("arrived.txt"))?;
3165 std::fs::rename(
3166 destination.join("arrival.tmp"),
3167 destination.join("arrived.txt"),
3168 )?;
3169 capture_watch_batch(
3170 &mut checkout,
3171 WatchBatch::Changes {
3172 epoch: WatchEpoch::from_u64(8),
3173 first_sequence: WatchSequence::from_u64(1),
3174 next_sequence: WatchSequence::from_u64(3),
3175 changes: vec![
3176 WatchChange::Created(temporary_name.clone()),
3177 WatchChange::Renamed {
3178 from: temporary_name,
3179 to: arrived.clone(),
3180 },
3181 ],
3182 },
3183 &CaptureOptions {
3184 expected_root_identity: capture_root_identity(&destination)?,
3185 source_root: destination.clone(),
3186 maximum_paths: 2,
3187 maximum_extent_spans: 16,
3188 },
3189 WorkBudget::UNBOUNDED,
3190 &cancellation,
3191 )
3192 .await?;
3193 let replaced_arrival = checkout
3194 .lookup_no_follow(&arrived, WorkBudget::UNBOUNDED, &cancellation)
3195 .await?
3196 .value
3197 .record
3198 .ok_or("replacement arrival disappeared")?;
3199 assert_ne!(replaced_arrival.file_id, prior_arrival.file_id);
3200
3201 let created_and_modified = NamespacePath::from_portable(
3202 &PortablePath::parse("/created-and-modified.txt", limits)?,
3203 limits,
3204 )?;
3205 std::fs::write(
3206 destination.join("created-and-modified.txt"),
3207 b"created-and-modified",
3208 )?;
3209 capture_watch_batch(
3210 &mut checkout,
3211 WatchBatch::Changes {
3212 epoch: WatchEpoch::from_u64(8),
3213 first_sequence: WatchSequence::from_u64(3),
3214 next_sequence: WatchSequence::from_u64(5),
3215 changes: vec![
3216 WatchChange::Created(created_and_modified.clone()),
3217 WatchChange::Modified(created_and_modified.clone()),
3218 ],
3219 },
3220 &CaptureOptions {
3221 expected_root_identity: capture_root_identity(&destination)?,
3222 source_root: destination.clone(),
3223 maximum_paths: 2,
3224 maximum_extent_spans: 16,
3225 },
3226 WorkBudget::UNBOUNDED,
3227 &cancellation,
3228 )
3229 .await?;
3230 assert_eq!(
3231 checkout
3232 .read_file_range(
3233 &created_and_modified,
3234 ByteRange {
3235 offset: 0,
3236 length: 20,
3237 },
3238 WorkBudget::UNBOUNDED,
3239 &cancellation,
3240 )
3241 .await?
3242 .value
3243 .bytes
3244 .as_ref(),
3245 b"created-and-modified"
3246 );
3247
3248 let expected_root_identity = capture_root_identity(&destination)?;
3249 let displaced = temporary.path().join("displaced-view");
3250 std::fs::rename(&destination, &displaced)?;
3251 std::fs::create_dir(&destination)?;
3252 std::fs::write(destination.join("arrived.txt"), b"untrusted-root")?;
3253 let swapped = capture_paths(
3254 &mut checkout,
3255 std::slice::from_ref(&arrived),
3256 &CaptureOptions {
3257 source_root: destination,
3258 expected_root_identity,
3259 maximum_paths: 1,
3260 maximum_extent_spans: 16,
3261 },
3262 WorkBudget::UNBOUNDED,
3263 &cancellation,
3264 )
3265 .await;
3266 let Err(swapped) = swapped else {
3267 return Err("capture accepted a replacement root".into());
3268 };
3269 assert!(matches!(swapped.error, CaptureError::RootChanged));
3270 Ok(())
3271 }
3272
3273 #[tokio::test]
3274 async fn coalesced_parent_child_removals_capture_deepest_first()
3275 -> Result<(), Box<dyn std::error::Error>> {
3276 let config = VolumeConfig::portable(Lifecycle::Ephemeral);
3277 let limits = config.limits;
3278 let cancellation = CancellationToken::new();
3279 let fs = Fs::memory();
3280 let volume = fs
3281 .create_volume(config, WorkBudget::UNBOUNDED, &cancellation)
3282 .await?
3283 .value;
3284 let mut checkout = volume
3285 .checkout(
3286 GenerationSelector::Head,
3287 CheckoutMode {
3288 access: AccessMode::ReadWrite,
3289 consistency: ConsistencyMode::Pinned,
3290 mutations: MutationMode::PrivateOverlay,
3291 },
3292 WorkBudget::UNBOUNDED,
3293 &cancellation,
3294 )
3295 .await?
3296 .value;
3297 let temporary = tempfile::tempdir()?;
3298 let root = temporary.path().join("capture");
3299 std::fs::create_dir(&root)?;
3300 let options = CaptureOptions {
3301 expected_root_identity: capture_root_identity(&root)?,
3302 source_root: root.clone(),
3303 maximum_paths: 8,
3304 maximum_extent_spans: 8,
3305 };
3306 capture_baseline(
3307 &mut checkout,
3308 &options,
3309 WorkBudget::UNBOUNDED,
3310 &cancellation,
3311 )
3312 .await?;
3313 let parent = portable_path(limits, &[b"parent"])?;
3314 let child = portable_path(limits, &[b"parent", b"child.txt"])?;
3315 std::fs::create_dir(root.join("parent"))?;
3316 std::fs::write(root.join("parent").join("child.txt"), b"child")?;
3317 capture_watch_batch(
3318 &mut checkout,
3319 WatchBatch::Changes {
3320 epoch: WatchEpoch::from_u64(1),
3321 first_sequence: WatchSequence::from_u64(0),
3322 next_sequence: WatchSequence::from_u64(1),
3323 changes: vec![WatchChange::Created(parent.clone())],
3324 },
3325 &options,
3326 WorkBudget::UNBOUNDED,
3327 &cancellation,
3328 )
3329 .await?;
3330 assert!(
3331 checkout
3332 .lookup_no_follow(&child, WorkBudget::UNBOUNDED, &cancellation)
3333 .await?
3334 .value
3335 .record
3336 .is_some()
3337 );
3338 std::fs::remove_file(root.join("parent").join("child.txt"))?;
3339 std::fs::remove_dir(root.join("parent"))?;
3340 capture_watch_batch(
3341 &mut checkout,
3342 WatchBatch::Changes {
3343 epoch: WatchEpoch::from_u64(1),
3344 first_sequence: WatchSequence::from_u64(1),
3345 next_sequence: WatchSequence::from_u64(3),
3346 changes: vec![
3347 WatchChange::Removed(parent.clone()),
3348 WatchChange::Removed(child.clone()),
3349 ],
3350 },
3351 &options,
3352 WorkBudget::UNBOUNDED,
3353 &cancellation,
3354 )
3355 .await?;
3356 assert!(
3357 checkout
3358 .lookup_no_follow(&parent, WorkBudget::UNBOUNDED, &cancellation)
3359 .await?
3360 .value
3361 .record
3362 .is_none()
3363 );
3364 Ok(())
3365 }
3366
3367 #[tokio::test]
3372 async fn stale_created_hints_for_existing_directory_capture_exactly()
3373 -> Result<(), Box<dyn std::error::Error>> {
3374 let config = VolumeConfig::portable(Lifecycle::Ephemeral);
3375 let limits = config.limits;
3376 let cancellation = CancellationToken::new();
3377 let fs = Fs::memory();
3378 let volume = fs
3379 .create_volume(config, WorkBudget::UNBOUNDED, &cancellation)
3380 .await?
3381 .value;
3382 let mut checkout = volume
3383 .checkout(
3384 GenerationSelector::Head,
3385 CheckoutMode {
3386 access: AccessMode::ReadWrite,
3387 consistency: ConsistencyMode::Pinned,
3388 mutations: MutationMode::PrivateOverlay,
3389 },
3390 WorkBudget::UNBOUNDED,
3391 &cancellation,
3392 )
3393 .await?
3394 .value;
3395 let nested = portable_path(limits, &[b"nested"])?;
3396 let child = portable_path(limits, &[b"nested", b"child.txt"])?;
3397 let created = portable_path(limits, &[b"watch.txt"])?;
3398
3399 let temporary = tempfile::tempdir()?;
3400 std::fs::create_dir(temporary.path().join("nested"))?;
3401 std::fs::write(temporary.path().join("nested/child.txt"), b"child")?;
3402 let options = CaptureOptions {
3403 source_root: temporary.path().to_path_buf(),
3404 expected_root_identity: capture_root_identity(temporary.path())?,
3405 maximum_paths: 16,
3406 maximum_extent_spans: 16,
3407 };
3408 capture_baseline(
3409 &mut checkout,
3410 &options,
3411 WorkBudget::UNBOUNDED,
3412 &cancellation,
3413 )
3414 .await?;
3415 let directory_before = checkout
3416 .lookup_no_follow(&nested, WorkBudget::UNBOUNDED, &cancellation)
3417 .await?
3418 .value
3419 .record
3420 .ok_or("captured directory disappeared")?;
3421
3422 std::fs::write(temporary.path().join("watch.txt"), b"created")?;
3423 capture_watch_batch(
3424 &mut checkout,
3425 WatchBatch::Changes {
3426 epoch: WatchEpoch::from_u64(1),
3427 first_sequence: WatchSequence::from_u64(0),
3428 next_sequence: WatchSequence::from_u64(6),
3429 changes: vec![
3430 WatchChange::Created(child.clone()),
3431 WatchChange::MetadataChanged(child.clone()),
3432 WatchChange::Created(nested.clone()),
3433 WatchChange::MetadataChanged(nested.clone()),
3434 WatchChange::Created(created.clone()),
3435 WatchChange::Modified(created.clone()),
3436 ],
3437 },
3438 &options,
3439 WorkBudget::UNBOUNDED,
3440 &cancellation,
3441 )
3442 .await?;
3443 let directory_after = checkout
3444 .lookup_no_follow(&nested, WorkBudget::UNBOUNDED, &cancellation)
3445 .await?
3446 .value
3447 .record
3448 .ok_or("directory disappeared after stale hints")?;
3449 assert_eq!(directory_after.file_id, directory_before.file_id);
3450 for (path, expected) in [(&child, b"child".as_slice()), (&created, b"created")] {
3451 let read = checkout
3452 .read_file_range(
3453 path,
3454 ByteRange {
3455 offset: 0,
3456 length: u64::try_from(expected.len())?,
3457 },
3458 WorkBudget::UNBOUNDED,
3459 &cancellation,
3460 )
3461 .await?;
3462 assert_eq!(read.value.bytes.as_ref(), expected);
3463 }
3464 Ok(())
3465 }
3466
3467 #[tokio::test]
3468 async fn windows_created_and_modified_hint_captures_one_new_file()
3469 -> Result<(), Box<dyn std::error::Error>> {
3470 let mut config = VolumeConfig::portable(Lifecycle::Ephemeral);
3471 config.profile = FilesystemProfile::Windows;
3472 let limits = config.limits;
3473 let cancellation = CancellationToken::new();
3474 let fs = Fs::memory();
3475 let volume = fs
3476 .create_volume(config, WorkBudget::UNBOUNDED, &cancellation)
3477 .await?
3478 .value;
3479 let mut checkout = volume
3480 .checkout(
3481 GenerationSelector::Head,
3482 CheckoutMode {
3483 access: AccessMode::ReadWrite,
3484 consistency: ConsistencyMode::Pinned,
3485 mutations: MutationMode::PrivateOverlay,
3486 },
3487 WorkBudget::UNBOUNDED,
3488 &cancellation,
3489 )
3490 .await?
3491 .value;
3492 let name = "watch.txt"
3493 .encode_utf16()
3494 .flat_map(u16::to_le_bytes)
3495 .collect();
3496 let path = NamespacePath::new(
3497 vec![LogicalName::new(
3498 NameEncoding::WindowsUtf16Le,
3499 name,
3500 limits.maximum_component_bytes,
3501 )?],
3502 limits,
3503 )?;
3504 let temporary = tempfile::tempdir()?;
3505 std::fs::write(temporary.path().join("watch.txt"), b"created")?;
3506 capture_watch_batch(
3507 &mut checkout,
3508 WatchBatch::Changes {
3509 epoch: WatchEpoch::from_u64(1),
3510 first_sequence: WatchSequence::from_u64(0),
3511 next_sequence: WatchSequence::from_u64(2),
3512 changes: vec![
3513 WatchChange::Created(path.clone()),
3514 WatchChange::Modified(path.clone()),
3515 ],
3516 },
3517 &CaptureOptions {
3518 source_root: temporary.path().to_path_buf(),
3519 expected_root_identity: capture_root_identity(temporary.path())?,
3520 maximum_paths: 16,
3521 maximum_extent_spans: 16,
3522 },
3523 WorkBudget::UNBOUNDED,
3524 &cancellation,
3525 )
3526 .await?;
3527 let read = checkout
3528 .read_file_range(
3529 &path,
3530 ByteRange {
3531 offset: 0,
3532 length: 7,
3533 },
3534 WorkBudget::UNBOUNDED,
3535 &cancellation,
3536 )
3537 .await?;
3538 assert_eq!(read.value.bytes.as_ref(), b"created");
3539 Ok(())
3540 }
3541
3542 #[cfg(unix)]
3543 #[tokio::test]
3544 #[allow(clippy::too_many_lines)]
3545 async fn posix_fifo_and_socket_materialize_and_capture_exactly()
3546 -> Result<(), Box<dyn std::error::Error>> {
3547 use std::os::unix::fs::FileTypeExt;
3548
3549 let limits = VolumeLimits::default();
3550 let config = VolumeConfig {
3551 profile: FilesystemProfile::Posix,
3552 concurrency: ConcurrencyMode::Optimistic,
3553 lifecycle: Lifecycle::Ephemeral,
3554 case_sensitivity: CaseSensitivity::Sensitive,
3555 unicode: UnicodePolicy::Preserve,
3556 symbolic_links: true,
3557 hard_links: true,
3558 sparse_files: true,
3559 limits,
3560 };
3561 let cancellation = CancellationToken::new();
3562 let fs = Fs::memory();
3563 let source = fs
3564 .create_volume_with_id(
3565 VolumeId::from_bytes([92; 16]),
3566 config,
3567 WorkBudget::UNBOUNDED,
3568 &cancellation,
3569 )
3570 .await?
3571 .value;
3572 let mut source_checkout = source
3573 .checkout(
3574 GenerationSelector::Head,
3575 CheckoutMode {
3576 access: AccessMode::ReadWrite,
3577 consistency: ConsistencyMode::Pinned,
3578 mutations: MutationMode::PrivateOverlay,
3579 },
3580 WorkBudget::UNBOUNDED,
3581 &cancellation,
3582 )
3583 .await?
3584 .value;
3585 let fifo = NamespacePath::new(
3586 vec![LogicalName::new(
3587 NameEncoding::PosixBytes,
3588 b"pipe".to_vec(),
3589 limits.maximum_component_bytes,
3590 )?],
3591 limits,
3592 )?;
3593 let socket = NamespacePath::new(
3594 vec![LogicalName::new(
3595 NameEncoding::PosixBytes,
3596 b"socket".to_vec(),
3597 limits.maximum_component_bytes,
3598 )?],
3599 limits,
3600 )?;
3601 source_checkout
3602 .create_empty_special(
3603 fifo.clone(),
3604 crate::kernel::FileKind::Fifo,
3605 WorkBudget::UNBOUNDED,
3606 &cancellation,
3607 )
3608 .await?;
3609 source_checkout
3610 .create_empty_special(
3611 socket.clone(),
3612 crate::kernel::FileKind::Socket,
3613 WorkBudget::UNBOUNDED,
3614 &cancellation,
3615 )
3616 .await?;
3617
3618 let temporary = tempfile::tempdir()?;
3619 let destination = temporary.path().join("special-view");
3620 std::fs::create_dir(&destination)?;
3621 let materialized = Box::pin(materialize_checkout(
3622 &mut source_checkout,
3623 &MaterializeOptions {
3624 destination: destination.clone(),
3625 maximum_directory_entries: 16,
3626 maximum_extent_spans: 16,
3627 transfer_bytes: 64 * 1024,
3628 },
3629 WorkBudget::UNBOUNDED,
3630 &cancellation,
3631 ))
3632 .await?;
3633 assert_eq!(materialized.value.special_files, 2);
3634 assert!(
3635 std::fs::symlink_metadata(destination.join("pipe"))?
3636 .file_type()
3637 .is_fifo()
3638 );
3639 assert!(
3640 std::fs::symlink_metadata(destination.join("socket"))?
3641 .file_type()
3642 .is_socket()
3643 );
3644
3645 let captured = fs
3646 .create_volume_with_id(
3647 VolumeId::from_bytes([93; 16]),
3648 config,
3649 WorkBudget::UNBOUNDED,
3650 &cancellation,
3651 )
3652 .await?
3653 .value;
3654 let mut captured_checkout = captured
3655 .checkout(
3656 GenerationSelector::Head,
3657 CheckoutMode {
3658 access: AccessMode::ReadWrite,
3659 consistency: ConsistencyMode::Pinned,
3660 mutations: MutationMode::PrivateOverlay,
3661 },
3662 WorkBudget::UNBOUNDED,
3663 &cancellation,
3664 )
3665 .await?
3666 .value;
3667 let receipt = capture_baseline(
3668 &mut captured_checkout,
3669 &CaptureOptions {
3670 expected_root_identity: capture_root_identity(&destination)?,
3671 source_root: destination,
3672 maximum_paths: 2,
3673 maximum_extent_spans: 16,
3674 },
3675 WorkBudget::UNBOUNDED,
3676 &cancellation,
3677 )
3678 .await?;
3679 assert_eq!(receipt.value.changed_paths, 2);
3680 assert_eq!(
3681 captured_checkout
3682 .lookup_no_follow(&fifo, WorkBudget::UNBOUNDED, &cancellation)
3683 .await?
3684 .value
3685 .record
3686 .ok_or("captured FIFO is absent")?
3687 .kind,
3688 crate::kernel::FileKind::Fifo
3689 );
3690 assert_eq!(
3691 captured_checkout
3692 .lookup_no_follow(&socket, WorkBudget::UNBOUNDED, &cancellation)
3693 .await?
3694 .value
3695 .record
3696 .ok_or("captured socket is absent")?
3697 .kind,
3698 crate::kernel::FileKind::Socket
3699 );
3700 Ok(())
3701 }
3702}