diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 54799e9..9fa0f0a 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -10,6 +10,8 @@ mod strategy; #[cfg(all(test, target_os = "linux"))] mod linux_filesystem_tests; +#[cfg(all(test, target_os = "linux"))] +mod test_support; use id::RiftId; use name::RiftName; diff --git a/crates/core/src/linux_filesystem_tests.rs b/crates/core/src/linux_filesystem_tests.rs index 7d793a8..a8800ef 100644 --- a/crates/core/src/linux_filesystem_tests.rs +++ b/crates/core/src/linux_filesystem_tests.rs @@ -1,3 +1,4 @@ +use crate::test_support::linux_extents::{assert_shared_extents_when_reliable, is_btrfs_subvolume}; use crate::{Create, Error, InitOutcome, Manager}; use std::fs; use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -24,6 +25,7 @@ fn production_supported_linux_filesystem_round_trip() { InitOutcome::AlreadyInitialized ); assert!(source.join(".rift").exists()); + assert_btrfs_subvolume_if_required(&source); let child = manager .create(Create { @@ -32,6 +34,7 @@ fn production_supported_linux_filesystem_round_trip() { into: None, }) .unwrap(); + assert_btrfs_subvolume_if_required(&child); assert_rich_copy(&child); assert_detached_git_copy(&source, &child); @@ -44,7 +47,13 @@ fn production_supported_linux_filesystem_round_trip() { }) .unwrap(); assert_eq!(custom, custom_parent.join("custom")); + assert_btrfs_subvolume_if_required(&custom); assert_rich_copy(&custom); + assert_native_cow_copy( + &source.join("nested/deeper/leaf.txt"), + &child.join("nested/deeper/leaf.txt"), + ); + assert_native_cow_copy(&source.join("untracked.txt"), &custom.join("untracked.txt")); let grandchild = manager .create(Create { @@ -230,6 +239,33 @@ fn assert_reflink_probe_cleaned_up(path: &Path) { ); } +fn assert_btrfs_subvolume_if_required(path: &Path) { + if std::env::var_os("RIFT_REQUIRE_BTRFS_TESTS").is_some() { + assert!( + is_btrfs_subvolume(path).unwrap(), + "{} should be a btrfs subvolume", + path.display() + ); + } +} + +fn assert_native_cow_copy(source: &Path, child: &Path) { + if std::env::var_os("RIFT_REQUIRE_REFLINK_TESTS").is_some() { + assert_shared_extents_when_reliable(source, child); + } + assert_copy_diverges_after_mutation(source, child); +} + +fn assert_copy_diverges_after_mutation(source: &Path, child: &Path) { + let original = fs::read_to_string(source).unwrap(); + assert_eq!(fs::read_to_string(child).unwrap(), original); + fs::write(source, "parent mutation").unwrap(); + assert_eq!(fs::read_to_string(child).unwrap(), original); + fs::write(child, "child mutation").unwrap(); + assert_eq!(fs::read_to_string(source).unwrap(), "parent mutation"); + assert_eq!(fs::read_to_string(child).unwrap(), "child mutation"); +} + fn same_device(left: &Path, right: &Path) -> bool { fs::metadata(left).unwrap().dev() == fs::metadata(right).unwrap().dev() } diff --git a/crates/core/src/strategy/btrfs.rs b/crates/core/src/strategy/btrfs.rs index a255f3f..7b29daa 100644 --- a/crates/core/src/strategy/btrfs.rs +++ b/crates/core/src/strategy/btrfs.rs @@ -382,16 +382,26 @@ mod linux_tests { }; let source = temp.path().join("source"); let snapshot = temp.path().join("snapshot"); + let filtered = temp.path().join("filtered"); create_btrfs_subvolume(&source).unwrap(); - fs::write(source.join("file.txt"), "hello").unwrap(); + fs::write(source.join("file.txt"), "shared before mutation").unwrap(); copy_directory_linux(&source, &snapshot, CopyMode::All).unwrap(); + assert!(is_btrfs_subvolume(&snapshot).unwrap()); assert_eq!( fs::read_to_string(snapshot.join("file.txt")).unwrap(), - "hello" + "shared before mutation" ); + assert_copy_diverges_after_mutation(&source.join("file.txt"), &snapshot.join("file.txt")); + + copy_directory_linux(&source, &filtered, CopyMode::Filtered).unwrap(); + assert!(is_btrfs_subvolume(&filtered).unwrap()); + assert_copy_diverges_after_mutation(&source.join("file.txt"), &filtered.join("file.txt")); + + remove_directory_linux(&filtered).unwrap(); remove_directory_linux(&snapshot).unwrap(); remove_directory_linux(&source).unwrap(); + assert!(!filtered.exists()); assert!(!snapshot.exists()); assert!(!source.exists()); } @@ -438,4 +448,14 @@ mod linux_tests { remove_emptyable_subvolume(&tree).unwrap(); assert!(!tree.exists()); } + + fn assert_copy_diverges_after_mutation(source: &Path, clone: &Path) { + let original = fs::read_to_string(source).unwrap(); + assert_eq!(fs::read_to_string(clone).unwrap(), original); + fs::write(source, "parent mutation").unwrap(); + assert_eq!(fs::read_to_string(clone).unwrap(), original); + fs::write(clone, "child mutation").unwrap(); + assert_eq!(fs::read_to_string(source).unwrap(), "parent mutation"); + assert_eq!(fs::read_to_string(clone).unwrap(), "child mutation"); + } } diff --git a/crates/core/src/strategy/reflink.rs b/crates/core/src/strategy/reflink.rs index 966e717..18ca519 100644 --- a/crates/core/src/strategy/reflink.rs +++ b/crates/core/src/strategy/reflink.rs @@ -292,6 +292,7 @@ pub(super) fn c_path(path: &Path) -> Result { mod tests { use super::*; use crate::strategy::linux::{Filesystem, LinuxStrategy, filesystem}; + use crate::test_support::linux_extents::assert_shared_extents_when_reliable; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use tempfile::{Builder, TempDir}; @@ -350,7 +351,9 @@ mod tests { fs::set_permissions(&source, fs::Permissions::from_mode(0o750)).unwrap(); fs::create_dir(&nested).unwrap(); let file = nested.join("file.txt"); + let cow = nested.join("cow.txt"); fs::write(&file, "hello").unwrap(); + fs::write(&cow, "shared before mutation").unwrap(); fs::set_permissions(&file, fs::Permissions::from_mode(0o640)).unwrap(); fs::hard_link(&file, nested.join("hard.txt")).unwrap(); std::os::unix::fs::symlink("file.txt", nested.join("link.txt")).unwrap(); @@ -387,6 +390,8 @@ mod tests { fs::metadata(&destination).unwrap().permissions().mode() & 0o777, 0o750 ); + assert_shared_extents_when_reliable(&cow, &destination.join("nested/cow.txt")); + assert_copy_diverges_after_mutation(&cow, &destination.join("nested/cow.txt")); LinuxStrategy.remove_directory(&destination).unwrap(); assert!(!destination.exists()); } @@ -424,4 +429,14 @@ mod tests { )); assert!(!destination.exists()); } + + fn assert_copy_diverges_after_mutation(source: &Path, clone: &Path) { + let original = fs::read_to_string(source).unwrap(); + assert_eq!(fs::read_to_string(clone).unwrap(), original); + fs::write(source, "parent mutation").unwrap(); + assert_eq!(fs::read_to_string(clone).unwrap(), original); + fs::write(clone, "child mutation").unwrap(); + assert_eq!(fs::read_to_string(source).unwrap(), "parent mutation"); + assert_eq!(fs::read_to_string(clone).unwrap(), "child mutation"); + } } diff --git a/crates/core/src/test_support/linux_extents.rs b/crates/core/src/test_support/linux_extents.rs new file mode 100644 index 0000000..f734a61 --- /dev/null +++ b/crates/core/src/test_support/linux_extents.rs @@ -0,0 +1,154 @@ +use crate::{Error, Result}; +use std::fs::File; +use std::os::fd::AsRawFd; +use std::path::Path; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum LinuxFilesystem { + Btrfs, + Xfs, + Zfs, + Other, +} + +pub(crate) fn filesystem(path: &Path) -> Result { + use std::os::unix::ffi::OsStrExt; + + const BTRFS_SUPER_MAGIC: libc::c_long = 0x9123_683e; + const XFS_SUPER_MAGIC: libc::c_long = 0x5846_5342; + const ZFS_SUPER_MAGIC: libc::c_long = 0x2fc1_2fc1; + + let path = std::ffi::CString::new(path.as_os_str().as_bytes()) + .map_err(|_| Error::Path(format!("path contains a null byte: {}", path.display())))?; + // SAFETY: `statfs` is a plain C struct that the kernel fully initializes. + let mut stat: libc::statfs = unsafe { std::mem::zeroed() }; + // SAFETY: `path` is a valid C string, and `stat` points to writable memory. + if unsafe { libc::statfs(path.as_ptr(), &mut stat) } != 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(match stat.f_type { + BTRFS_SUPER_MAGIC => LinuxFilesystem::Btrfs, + XFS_SUPER_MAGIC => LinuxFilesystem::Xfs, + ZFS_SUPER_MAGIC => LinuxFilesystem::Zfs, + _ => LinuxFilesystem::Other, + }) +} + +pub(crate) fn is_btrfs_subvolume(path: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + + Ok(filesystem(path)? == LinuxFilesystem::Btrfs && std::fs::metadata(path)?.ino() == 256) +} + +pub(crate) fn assert_shared_extents_when_reliable(source: &Path, clone: &Path) { + match ( + filesystem(source).unwrap(), + have_shared_extents(source, clone), + ) { + (_, Ok(true)) => {} + (LinuxFilesystem::Xfs, Ok(false)) => { + panic!( + "expected FIEMAP shared extents for XFS reflink copy: {} -> {}", + source.display(), + clone.display() + ); + } + (LinuxFilesystem::Xfs, Err(error)) => { + panic!( + "expected readable FIEMAP shared extents for XFS reflink copy: {} -> {}: {error}", + source.display(), + clone.display() + ); + } + (_, Ok(false) | Err(_)) => {} + } +} + +fn have_shared_extents(source: &Path, clone: &Path) -> std::io::Result { + let source = file_extents(source)?; + let clone = file_extents(clone)?; + + Ok(source.iter().any(|source| { + source.is_shared() + && clone + .iter() + .any(|clone| clone.is_shared() && source.physical_range_overlaps(clone)) + })) +} + +fn file_extents(path: &Path) -> std::io::Result> { + const FS_IOC_FIEMAP: libc::c_ulong = 0xc020_660b; + const FIEMAP_FLAG_SYNC: u32 = 0x0000_0001; + const EXTENT_COUNT: u32 = 32; + + let file = File::open(path)?; + let mut request = FiemapRequest { + map: Fiemap { + fm_start: 0, + fm_length: u64::MAX, + fm_flags: FIEMAP_FLAG_SYNC, + fm_mapped_extents: 0, + fm_extent_count: EXTENT_COUNT, + fm_reserved: 0, + }, + extents: [FiemapExtent::default(); EXTENT_COUNT as usize], + }; + + // SAFETY: `file` is open for the duration of the call, and `request` + // matches the Linux FIEMAP layout with room for `fm_extent_count` extents. + if unsafe { libc::ioctl(file.as_raw_fd(), FS_IOC_FIEMAP, &mut request) } != 0 { + return Err(std::io::Error::last_os_error()); + } + + let mapped_extents = request.map.fm_mapped_extents.min(EXTENT_COUNT) as usize; + Ok(request.extents[..mapped_extents].to_vec()) +} + +#[repr(C)] +struct Fiemap { + fm_start: u64, + fm_length: u64, + fm_flags: u32, + fm_mapped_extents: u32, + fm_extent_count: u32, + fm_reserved: u32, +} + +#[repr(C)] +struct FiemapRequest { + map: Fiemap, + extents: [FiemapExtent; 32], +} + +#[derive(Clone, Copy, Default)] +#[repr(C)] +struct FiemapExtent { + fe_logical: u64, + fe_physical: u64, + fe_length: u64, + fe_reserved64: [u64; 2], + fe_flags: u32, + fe_reserved: [u32; 3], +} + +impl FiemapExtent { + fn is_shared(self) -> bool { + const FIEMAP_EXTENT_SHARED: u32 = 0x0000_2000; + + self.fe_flags & FIEMAP_EXTENT_SHARED != 0 + } + + fn physical_range_overlaps(self, other: &Self) -> bool { + let Some(end) = self.fe_physical.checked_add(self.fe_length) else { + return false; + }; + let Some(other_end) = other.fe_physical.checked_add(other.fe_length) else { + return false; + }; + + self.fe_length > 0 + && other.fe_length > 0 + && self.fe_physical < other_end + && other.fe_physical < end + } +} diff --git a/crates/core/src/test_support/mod.rs b/crates/core/src/test_support/mod.rs new file mode 100644 index 0000000..b77db02 --- /dev/null +++ b/crates/core/src/test_support/mod.rs @@ -0,0 +1 @@ +pub(crate) mod linux_extents;