diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1181c7127af9e..c926ec82d3efc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,6 +60,21 @@ jobs: # the login password doesn't matter, but the keyring must be unlocked for the tests to work run: gnome-keyring-daemon --components=secrets --daemonize --unlock <<< 'foobar' + - name: "Create btrfs filesystem" + run: | + sudo apt-get install -y btrfs-progs + truncate -s 1G /tmp/btrfs.img + mkfs.btrfs /tmp/btrfs.img + sudo mkdir /btrfs + sudo mount -o loop /tmp/btrfs.img /btrfs + sudo chown "$(id -u):$(id -g)" /btrfs + + - name: "Create tmpfs filesystem" + run: | + sudo mkdir /tmpfs + sudo mount -t tmpfs -o size=256m tmpfs /tmpfs + sudo chown "$(id -u):$(id -g)" /tmpfs + - name: "Install cargo nextest" uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2.66.7 with: @@ -70,6 +85,9 @@ jobs: # Retry more than default to reduce flakes in CI UV_HTTP_RETRIES: 5 RUST_BACKTRACE: 1 + UV_INTERNAL__TEST_COW_FS: /btrfs + UV_INTERNAL__TEST_NOCOW_FS: /tmpfs + UV_INTERNAL__TEST_ALT_FS: /tmpfs run: | cargo nextest run \ --cargo-profile fast-build \ @@ -97,6 +115,12 @@ jobs: - name: "Install Rust toolchain" run: rustup show + - name: "Create HFS+ RAM disk (no reflink support)" + run: | + RAMDISK=$(hdiutil attach -nomount ram://524288 | xargs) + diskutil eraseDisk HFS+ NoReflink "$RAMDISK" + echo "HFS_MOUNT=/Volumes/NoReflink" >> "$GITHUB_ENV" + - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 with: version: "0.10.2" @@ -114,6 +138,11 @@ jobs: # Retry more than default to reduce flakes in CI UV_HTTP_RETRIES: 5 RUST_BACKTRACE: 1 + # macOS tmpdir is on APFS which supports reflink + UV_INTERNAL__TEST_COW_FS: ${{ runner.temp }} + # HFS+ RAM disk does not support copy-on-write and is on a different device + UV_INTERNAL__TEST_NOCOW_FS: ${{ env.HFS_MOUNT }} + UV_INTERNAL__TEST_ALT_FS: ${{ env.HFS_MOUNT }} run: | cargo nextest run \ --cargo-profile fast-build \ diff --git a/crates/uv-fs/src/link.rs b/crates/uv-fs/src/link.rs index 5914c6ac3982a..0f051f81af35e 100644 --- a/crates/uv-fs/src/link.rs +++ b/crates/uv-fs/src/link.rs @@ -37,7 +37,11 @@ pub enum LinkMode { impl Default for LinkMode { fn default() -> Self { - if cfg!(any(target_os = "macos", target_os = "ios")) { + if cfg!(any( + target_os = "macos", + target_os = "ios", + target_os = "linux" + )) { Self::Clone } else { Self::Hardlink @@ -77,9 +81,7 @@ where { match options.mode { LinkMode::Clone => clone_dir(src, dst, options), - LinkMode::Copy => copy_dir(src, dst, options), - LinkMode::Hardlink => hardlink_dir(src, dst, options), - LinkMode::Symlink => symlink_dir(src, dst, options), + mode => walk_and_link(src, dst, mode, options), } } @@ -213,18 +215,64 @@ impl<'a, F> LinkOptions<'a, F> { } } -/// Tracks the state of linking attempts to handle fallback gracefully. +/// Whether the current linking strategy has been confirmed to work. /// -/// Hard linking / reflinking might not be supported but we can't detect this ahead of time, -/// so we'll try the operation on the first file - if this succeeds we'll know later -/// errors are not due to lack of OS/filesystem support. If it fails, we'll switch -/// to copying for the rest of the operation. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -enum Attempt { - #[default] +/// Some linking strategies (reflink, hardlink, symlink) might not be supported on a given +/// filesystem, but we can't always detect this ahead of time. We try the operation on the +/// first file — if it succeeds, we know later errors are real failures. If it fails, we +/// switch to the next fallback strategy for the rest of the operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LinkAttempt { + /// The strategy has not yet been attempted on any file. Initial, + /// The strategy succeeded for at least one file; continue using it. Subsequent, - UseCopyFallback, +} + +/// Tracks the active linking strategy and whether it has been confirmed to work. +/// +/// When the strategy's [`LinkAttempt`] is [`Initial`](LinkAttempt::Initial) and the first +/// file operation fails, [`next_mode`](Self::next_mode) transitions to the next fallback. +/// When the attempt is [`Subsequent`](LinkAttempt::Subsequent) and a file fails, it is +/// either a hard error (the strategy was confirmed to work, so this is a real failure) or, +/// for reflink, a transition to the next fallback. +#[derive(Debug, Clone, Copy)] +struct LinkState { + /// The linking strategy currently in use. + mode: LinkMode, + /// Whether the strategy has been confirmed to work. + attempt: LinkAttempt, +} + +impl LinkState { + /// Create a new state with the given mode, not yet confirmed to work. + fn new(mode: LinkMode) -> Self { + Self { + mode, + attempt: LinkAttempt::Initial, + } + } + + /// Mark the current strategy as confirmed working on this filesystem. + fn mode_working(self) -> Self { + Self { + attempt: LinkAttempt::Subsequent, + ..self + } + } + + /// Transition to the next fallback strategy in the chain. + /// + /// - `Clone` → `Hardlink` + /// - `Hardlink` → `Copy` + /// - `Symlink` → `Copy` + /// - `Copy` → `Copy` (terminal) + fn next_mode(self) -> Self { + Self::new(match self.mode { + LinkMode::Clone => LinkMode::Hardlink, + LinkMode::Hardlink | LinkMode::Symlink | LinkMode::Copy => LinkMode::Copy, + }) + } } /// Error type for copy operations. @@ -269,9 +317,8 @@ pub enum LinkError { /// Clone a directory tree using copy-on-write. /// /// On macOS with APFS, tries to clone the entire directory in a single syscall. -/// -/// On all platforms, attempts to reflink individual files. If reflinking is not supported, -/// falls back to hard linking, then copying. +/// On all platforms, falls through to [`walk_and_link`] for per-file linking with +/// automatic fallback. fn clone_dir(src: &Path, dst: &Path, options: &LinkOptions<'_, F>) -> Result where F: Fn(&Path) -> bool, @@ -292,23 +339,23 @@ where } } - // Try per-file reflinking, with fallback to hardlink then copy - reflink_dir(src, dst, options) + walk_and_link(src, dst, LinkMode::Clone, options) } -/// Reflink individual files in a directory tree. +/// Walk a directory tree and link each file using the given starting [`LinkMode`]. /// -/// Attempts to reflink each file. If reflinking fails (e.g., unsupported filesystem), -/// falls back to hard linking, then copying. -fn reflink_dir( +/// The [`LinkState`] tracks the active strategy and automatically falls back via +/// [`LinkState::next_mode`] as needed. +fn walk_and_link( src: &Path, dst: &Path, + mode: LinkMode, options: &LinkOptions<'_, F>, ) -> Result where F: Fn(&Path) -> bool, { - let mut attempt = Attempt::Initial; + let mut state = LinkState::new(mode); for entry in WalkDir::new(src) { let entry = entry.map_err(|err| LinkError::WalkDir { @@ -328,92 +375,117 @@ where continue; } - match attempt { - Attempt::Initial => { - match reflink_copy::reflink(path, &target) { - Ok(()) => { - attempt = Attempt::Subsequent; - } - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { - if options.on_existing_directory == OnExistingDirectory::Merge { - // File exists, overwrite atomically via temp file - let parent = target.parent().unwrap(); - let tempdir = tempfile::tempdir_in(parent)?; - let tempfile = tempdir.path().join(target.file_name().unwrap()); - if reflink_copy::reflink(path, &tempfile).is_ok() { - fs_err::rename(&tempfile, &target)?; - attempt = Attempt::Subsequent; - } else { - // Reflink to temp failed, fallback to hardlink - debug!( - "Failed to reflink `{}` to temp location, falling back to hardlink", - path.display() - ); - return hardlink_dir(src, dst, options); - } - } else { - return Err(LinkError::Reflink { - from: path.to_path_buf(), - to: target, - err, - }); - } - } - Err(err) => { + state = link_file(path, &target, state, options)?; + } + + Ok(state.mode) +} + +/// Dispatch a single file to the appropriate linking strategy based on the current state. +/// +/// Returns the (possibly updated) state for the next file. When a strategy fails, it +/// transitions to [`LinkState::next_mode`] and re-dispatches through this function so the +/// fallback chain is followed automatically. +fn link_file( + path: &Path, + target: &Path, + state: LinkState, + options: &LinkOptions<'_, F>, +) -> Result +where + F: Fn(&Path) -> bool, +{ + match state.mode { + LinkMode::Clone => reflink_file_with_fallback(path, target, state, options), + LinkMode::Hardlink => hardlink_file_with_fallback(path, target, state, options), + LinkMode::Symlink => symlink_file_with_fallback(path, target, state, options), + LinkMode::Copy => { + if options.on_existing_directory == OnExistingDirectory::Merge { + atomic_copy_overwrite(path, target, options)?; + } else { + copy_file(path, target, options)?; + } + Ok(state) + } + } +} + +/// Attempt to reflink a single file, falling back via [`link_file`] on failure. +fn reflink_file_with_fallback( + path: &Path, + target: &Path, + state: LinkState, + options: &LinkOptions<'_, F>, +) -> Result +where + F: Fn(&Path) -> bool, +{ + match state.attempt { + LinkAttempt::Initial => match reflink_copy::reflink(path, target) { + Ok(()) => Ok(state.mode_working()), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { + if options.on_existing_directory == OnExistingDirectory::Merge { + // File exists, overwrite atomically via temp file + let parent = target.parent().unwrap(); + let tempdir = tempfile::tempdir_in(parent)?; + let tempfile = tempdir.path().join(target.file_name().unwrap()); + if reflink_copy::reflink(path, &tempfile).is_ok() { + fs_err::rename(&tempfile, target)?; + Ok(state.mode_working()) + } else { debug!( - "Failed to reflink `{}` to `{}`: {}, falling back to hardlink", - path.display(), - target.display(), - err + "Failed to reflink `{}` to temp location, falling back", + path.display() ); - // Fallback to hardlinking (hardlink_dir handles AlreadyExists for - // files we already reflinked when in Merge mode) - return hardlink_dir(src, dst, options); + link_file(path, target, state.next_mode(), options) } + } else { + Err(LinkError::Reflink { + from: path.to_path_buf(), + to: target.to_path_buf(), + err, + }) } } - Attempt::Subsequent => match reflink_copy::reflink(path, &target) { - Ok(()) => {} - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { - if options.on_existing_directory == OnExistingDirectory::Merge { - let parent = target.parent().unwrap(); - let tempdir = tempfile::tempdir_in(parent)?; - let tempfile = tempdir.path().join(target.file_name().unwrap()); - reflink_copy::reflink(path, &tempfile).map_err(|err| { - LinkError::Reflink { - from: path.to_path_buf(), - to: tempfile.clone(), - err, - } - })?; - fs_err::rename(&tempfile, &target)?; - } else { - return Err(LinkError::Reflink { - from: path.to_path_buf(), - to: target, - err, - }); - } - } - Err(err) => { - return Err(LinkError::Reflink { + Err(err) => { + debug!( + "Failed to reflink `{}` to `{}`: {}, falling back", + path.display(), + target.display(), + err + ); + link_file(path, target, state.next_mode(), options) + } + }, + LinkAttempt::Subsequent => match reflink_copy::reflink(path, target) { + Ok(()) => Ok(state), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { + if options.on_existing_directory == OnExistingDirectory::Merge { + let parent = target.parent().unwrap(); + let tempdir = tempfile::tempdir_in(parent)?; + let tempfile = tempdir.path().join(target.file_name().unwrap()); + reflink_copy::reflink(path, &tempfile).map_err(|err| LinkError::Reflink { from: path.to_path_buf(), - to: target, + to: tempfile.clone(), err, - }); + })?; + fs_err::rename(&tempfile, target)?; + Ok(state) + } else { + Err(LinkError::Reflink { + from: path.to_path_buf(), + to: target.to_path_buf(), + err, + }) } - }, - Attempt::UseCopyFallback => { - // We've fallen back to hardlinking; this is handled by returning - // early to hardlink_dir, so this branch should not be reached. - unreachable!( - "reflink_dir should return to hardlink_dir before reaching UseCopyFallback" - ); } - } + Err(err) => Err(LinkError::Reflink { + from: path.to_path_buf(), + to: target.to_path_buf(), + err, + }), + }, } - - Ok(LinkMode::Clone) } /// Try to clone a directory, handling `merge_directories` option. @@ -510,111 +582,139 @@ where Ok(()) } -/// Hard link or copy a directory tree from `src` to `dst`. +/// Attempt to hard link a single file, falling back via [`link_file`] on failure. /// -/// Tries hard linking first for efficiency, falling back to copying if hard links -/// are not supported (e.g., cross-filesystem operations). -fn hardlink_dir( - src: &Path, - dst: &Path, +/// Files matching the [`LinkOptions::needs_mutable_copy`] predicate are always copied +/// to avoid mutating the source through a hard link. +fn hardlink_file_with_fallback( + path: &Path, + target: &Path, + state: LinkState, options: &LinkOptions<'_, F>, -) -> Result +) -> Result where F: Fn(&Path) -> bool, { - let mut attempt = Attempt::Initial; - - for entry in WalkDir::new(src) { - let entry = entry.map_err(|err| LinkError::WalkDir { - path: src.to_path_buf(), - err, - })?; - - let path = entry.path(); - let relative = path.strip_prefix(src).expect("walkdir starts with root"); - let target = dst.join(relative); - - if entry.file_type().is_dir() { - fs_err::create_dir_all(&target).map_err(|err| LinkError::CreateDir { - path: target.clone(), - err, - })?; - continue; - } + if (options.needs_mutable_copy)(path) { + copy_file(path, target, options)?; + return Ok(state); + } - if (options.needs_mutable_copy)(path) { - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - err, - })?; - continue; + match state.attempt { + LinkAttempt::Initial => { + if let Err(err) = try_hardlink_file(path, target) { + if err.kind() == io::ErrorKind::AlreadyExists + && options.on_existing_directory == OnExistingDirectory::Merge + { + atomic_hardlink_overwrite(path, target, state, options) + } else { + debug!( + "Failed to hard link `{}` to `{}`: {}; falling back to copy", + path.display(), + target.display(), + err + ); + warn_user_once!( + "Failed to hardlink files; falling back to full copy. This may lead to degraded performance.\n \ + If the cache and target directories are on different filesystems, hardlinking may not be supported.\n \ + If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning." + ); + link_file(path, target, state.next_mode(), options) + } + } else { + Ok(state.mode_working()) + } } - - match attempt { - Attempt::Initial => { - if let Err(err) = try_hardlink_file(path, &target) { - if err.kind() == io::ErrorKind::AlreadyExists - && options.on_existing_directory == OnExistingDirectory::Merge - { - // File exists, try atomic overwrite - atomic_hardlink_overwrite(path, &target, &mut attempt, options)?; - } else { - debug!( - "Failed to hard link `{}` to `{}`: {}; falling back to copy", - path.display(), - target.display(), - err - ); - attempt = Attempt::UseCopyFallback; - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - err, - })?; - warn_user_once!( - "Failed to hardlink files; falling back to full copy. This may lead to degraded performance.\n \ - If the cache and target directories are on different filesystems, hardlinking may not be supported.\n \ - If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning." - ); - } + LinkAttempt::Subsequent => { + if let Err(err) = try_hardlink_file(path, target) { + if err.kind() == io::ErrorKind::AlreadyExists + && options.on_existing_directory == OnExistingDirectory::Merge + { + atomic_hardlink_overwrite(path, target, state, options) } else { - attempt = Attempt::Subsequent; + Err(LinkError::Io(err)) } + } else { + Ok(state) } - Attempt::Subsequent => { - if let Err(err) = try_hardlink_file(path, &target) { - if err.kind() == io::ErrorKind::AlreadyExists - && options.on_existing_directory == OnExistingDirectory::Merge - { - atomic_hardlink_overwrite(path, &target, &mut attempt, options)?; - } else { - return Err(LinkError::Io(err)); - } + } + } +} + +/// Attempt to symlink a single file, falling back via [`link_file`] on failure. +/// +/// Files matching the [`LinkOptions::needs_mutable_copy`] predicate are always copied +/// to avoid mutating the source through a symlink. +fn symlink_file_with_fallback( + path: &Path, + target: &Path, + state: LinkState, + options: &LinkOptions<'_, F>, +) -> Result +where + F: Fn(&Path) -> bool, +{ + if (options.needs_mutable_copy)(path) { + copy_file(path, target, options)?; + return Ok(state); + } + + match state.attempt { + LinkAttempt::Initial => { + if let Err(err) = create_symlink(path, target) { + if err.kind() == io::ErrorKind::AlreadyExists + && options.on_existing_directory == OnExistingDirectory::Merge + { + atomic_symlink_overwrite(path, target, state, options) + } else { + debug!( + "Failed to symlink `{}` to `{}`: {}; falling back to copy", + path.display(), + target.display(), + err + ); + warn_user_once!( + "Failed to symlink files; falling back to full copy. This may lead to degraded performance.\n \ + If the cache and target directories are on different filesystems, symlinking may not be supported.\n \ + If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning." + ); + link_file(path, target, state.next_mode(), options) } + } else { + Ok(state.mode_working()) } - Attempt::UseCopyFallback => { - if options.on_existing_directory == OnExistingDirectory::Merge { - atomic_copy_overwrite(path, &target, options)?; + } + LinkAttempt::Subsequent => { + if let Err(err) = create_symlink(path, target) { + if err.kind() == io::ErrorKind::AlreadyExists + && options.on_existing_directory == OnExistingDirectory::Merge + { + atomic_symlink_overwrite(path, target, state, options) } else { - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - err, - })?; + Err(LinkError::Symlink { + from: path.to_path_buf(), + to: target.to_path_buf(), + err, + }) } + } else { + Ok(state) } } } +} - if attempt == Attempt::UseCopyFallback { - Ok(LinkMode::Copy) - } else { - Ok(LinkMode::Hardlink) - } +/// Copy a single file, using synchronized copying if [`CopyLocks`] are configured. +fn copy_file(path: &Path, target: &Path, options: &LinkOptions<'_, F>) -> Result<(), LinkError> +where + F: Fn(&Path) -> bool, +{ + options + .copy_file(path, target) + .map_err(|err| LinkError::Copy { + to: target.to_path_buf(), + err, + }) } /// Try to create a hard link, returning the `io::Error` on failure. @@ -626,9 +726,9 @@ fn try_hardlink_file(src: &Path, dst: &Path) -> io::Result<()> { fn atomic_hardlink_overwrite( src: &Path, dst: &Path, - attempt: &mut Attempt, + state: LinkState, options: &LinkOptions<'_, F>, -) -> Result<(), LinkError> +) -> Result where F: Fn(&Path) -> bool, { @@ -640,21 +740,21 @@ where if fs_err::hard_link(src, &tempfile).is_ok() { fs_err::rename(&tempfile, dst)?; + Ok(state.mode_working()) } else { - // Hard link to temp failed, fallback to copy debug!( "Failed to hardlink `{}` to temp location, falling back to copy", src.display() ); - *attempt = Attempt::UseCopyFallback; - atomic_copy_overwrite(src, dst, options)?; warn_user_once!( "Failed to hardlink files; falling back to full copy. This may lead to degraded performance.\n \ If the cache and target directories are on different filesystems, hardlinking may not be supported.\n \ If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning." ); + let state = state.next_mode(); + atomic_copy_overwrite(src, dst, options)?; + Ok(state) } - Ok(()) } /// Atomically overwrite an existing file with a copy. @@ -682,163 +782,13 @@ where Ok(()) } -/// Copy a directory tree from `src` to `dst`. -/// -/// Always copies files (no linking). Supports synchronized copying and -/// directory merging via options. -fn copy_dir(src: &Path, dst: &Path, options: &LinkOptions<'_, F>) -> Result -where - F: Fn(&Path) -> bool, -{ - for entry in WalkDir::new(src) { - let entry = entry.map_err(|err| LinkError::WalkDir { - path: src.to_path_buf(), - err, - })?; - - let path = entry.path(); - let relative = path.strip_prefix(src).expect("walkdir starts with root"); - let target = dst.join(relative); - - if entry.file_type().is_dir() { - fs_err::create_dir_all(&target).map_err(|err| LinkError::CreateDir { - path: target.clone(), - err, - })?; - continue; - } - - if options.on_existing_directory == OnExistingDirectory::Merge { - atomic_copy_overwrite(path, &target, options)?; - } else { - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - err, - })?; - } - } - - Ok(LinkMode::Copy) -} - -/// Symbolically link a directory tree from `src` to `dst`. -/// -/// Tries creating symlinks first, falling back to copying if symlinks are not supported. -fn symlink_dir( - src: &Path, - dst: &Path, - options: &LinkOptions<'_, F>, -) -> Result -where - F: Fn(&Path) -> bool, -{ - let mut attempt = Attempt::Initial; - - for entry in WalkDir::new(src) { - let entry = entry.map_err(|err| LinkError::WalkDir { - path: src.to_path_buf(), - err, - })?; - - let path = entry.path(); - let relative = path.strip_prefix(src).expect("walkdir starts with root"); - let target = dst.join(relative); - - if entry.file_type().is_dir() { - fs_err::create_dir_all(&target).map_err(|err| LinkError::CreateDir { - path: target.clone(), - err, - })?; - continue; - } - - if (options.needs_mutable_copy)(path) { - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - err, - })?; - continue; - } - - match attempt { - Attempt::Initial => { - if let Err(err) = create_symlink(path, &target) { - if err.kind() == io::ErrorKind::AlreadyExists - && options.on_existing_directory == OnExistingDirectory::Merge - { - atomic_symlink_overwrite(path, &target, &mut attempt, options)?; - } else { - debug!( - "Failed to symlink `{}` to `{}`: {}; falling back to copy", - path.display(), - target.display(), - err - ); - attempt = Attempt::UseCopyFallback; - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - err, - })?; - warn_user_once!( - "Failed to symlink files; falling back to full copy. This may lead to degraded performance.\n \ - If the cache and target directories are on different filesystems, symlinking may not be supported.\n \ - If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning." - ); - } - } else { - attempt = Attempt::Subsequent; - } - } - Attempt::Subsequent => { - if let Err(err) = create_symlink(path, &target) { - if err.kind() == io::ErrorKind::AlreadyExists - && options.on_existing_directory == OnExistingDirectory::Merge - { - atomic_symlink_overwrite(path, &target, &mut attempt, options)?; - } else { - return Err(LinkError::Symlink { - from: path.to_path_buf(), - to: target, - err, - }); - } - } - } - Attempt::UseCopyFallback => { - if options.on_existing_directory == OnExistingDirectory::Merge { - atomic_copy_overwrite(path, &target, options)?; - } else { - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - err, - })?; - } - } - } - } - - if attempt == Attempt::UseCopyFallback { - Ok(LinkMode::Copy) - } else { - Ok(LinkMode::Symlink) - } -} - /// Atomically overwrite an existing file with a symlink. fn atomic_symlink_overwrite( src: &Path, dst: &Path, - attempt: &mut Attempt, + state: LinkState, options: &LinkOptions<'_, F>, -) -> Result<(), LinkError> +) -> Result where F: Fn(&Path) -> bool, { @@ -850,21 +800,21 @@ where if create_symlink(src, &tempfile).is_ok() { fs_err::rename(&tempfile, dst)?; + Ok(state.mode_working()) } else { - // Symlink to temp failed, fallback to copy debug!( "Failed to symlink `{}` to temp location, falling back to copy", src.display() ); - *attempt = Attempt::UseCopyFallback; - atomic_copy_overwrite(src, dst, options)?; warn_user_once!( "Failed to symlink files; falling back to full copy. This may lead to degraded performance.\n \ If the cache and target directories are on different filesystems, symlinking may not be supported.\n \ If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning." ); + let state = state.next_mode(); + atomic_copy_overwrite(src, dst, options)?; + Ok(state) } - Ok(()) } /// Create a symbolic link. @@ -889,6 +839,38 @@ mod tests { use super::*; use tempfile::TempDir; + /// Create a temporary directory on the default filesystem. + fn test_tempdir() -> TempDir { + TempDir::new().unwrap() + } + + /// Create a temporary directory on a copy-on-write filesystem. + /// + /// Returns `None` if `UV_INTERNAL__TEST_COW_FS` is not set. + fn cow_tempdir() -> Option { + let dir = std::env::var(uv_static::EnvVars::UV_INTERNAL__TEST_COW_FS).ok()?; + fs_err::create_dir_all(&dir).unwrap(); + Some(TempDir::new_in(dir).unwrap()) + } + + /// Create a temporary directory on a filesystem without copy-on-write support. + /// + /// Returns `None` if `UV_INTERNAL__TEST_NOCOW_FS` is not set. + fn nocow_tempdir() -> Option { + let dir = std::env::var(uv_static::EnvVars::UV_INTERNAL__TEST_NOCOW_FS).ok()?; + fs_err::create_dir_all(&dir).unwrap(); + Some(TempDir::new_in(dir).unwrap()) + } + + /// Create a temporary directory on an alternative filesystem. + /// + /// Returns `None` if `UV_INTERNAL__TEST_ALT_FS` is not set. + fn alt_tempdir() -> Option { + let dir = std::env::var(uv_static::EnvVars::UV_INTERNAL__TEST_ALT_FS).ok()?; + fs_err::create_dir_all(&dir).unwrap(); + Some(TempDir::new_in(dir).unwrap()) + } + /// Create a test directory structure with some files. fn create_test_tree(root: &Path) { fs_err::create_dir_all(root.join("subdir")).unwrap(); @@ -918,8 +900,8 @@ mod tests { #[test] fn test_copy_dir_basic() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -932,8 +914,8 @@ mod tests { #[test] fn test_hardlink_dir_basic() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -957,8 +939,8 @@ mod tests { #[test] #[cfg(unix)] // Symlinks require special permissions on Windows fn test_symlink_dir_basic() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -977,8 +959,8 @@ mod tests { #[test] fn test_clone_dir_basic() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1005,20 +987,21 @@ mod tests { } #[test] - fn test_reflink_file_when_supported() { - let tmp_dir = TempDir::new().unwrap(); - - if !reflink_supported(tmp_dir.path()) { - eprintln!("Skipping test: reflink not supported on this filesystem"); + fn test_reflink_file_on_reflink_fs() { + let Some(tmp_dir) = cow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set"); return; - } + }; + + assert!( + reflink_supported(tmp_dir.path()), + "UV_INTERNAL__TEST_COW_FS points to a filesystem that does not support reflink" + ); - // Create source file let src = tmp_dir.path().join("src.txt"); let dst = tmp_dir.path().join("dst.txt"); fs_err::write(&src, "reflink content").unwrap(); - // Reflink should succeed reflink_copy::reflink(&src, &dst).unwrap(); assert_eq!(fs_err::read_to_string(&dst).unwrap(), "reflink content"); @@ -1030,21 +1013,25 @@ mod tests { } #[test] - fn test_clone_dir_reflink_when_supported() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); - - if !reflink_supported(src_dir.path()) { - eprintln!("Skipping test: reflink not supported on this filesystem"); + fn test_clone_dir_reflink_on_reflink_fs() { + let Some(src_dir) = cow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set"); return; - } + }; + let Some(dst_dir) = cow_tempdir() else { + unreachable!(); + }; + + assert!( + reflink_supported(src_dir.path()), + "UV_INTERNAL__TEST_COW_FS points to a filesystem that does not support reflink" + ); create_test_tree(src_dir.path()); let options = LinkOptions::new(LinkMode::Clone); let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); - // On supported filesystems, clone should succeed assert_eq!(result, LinkMode::Clone); verify_test_tree(dst_dir.path()); @@ -1057,14 +1044,19 @@ mod tests { } #[test] - fn test_clone_merge_when_supported() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); - - if !reflink_supported(src_dir.path()) { - eprintln!("Skipping test: reflink not supported on this filesystem"); + fn test_clone_merge_on_reflink_fs() { + let Some(src_dir) = cow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set"); return; - } + }; + let Some(dst_dir) = cow_tempdir() else { + unreachable!(); + }; + + assert!( + reflink_supported(src_dir.path()), + "UV_INTERNAL__TEST_COW_FS points to a filesystem that does not support reflink" + ); create_test_tree(src_dir.path()); @@ -1091,13 +1083,245 @@ mod tests { ); } + #[test] + fn test_reflink_fails_on_tmp_fs() { + let Some(tmp_dir) = nocow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_NOCOW_FS not set"); + return; + }; + + assert!( + !reflink_supported(tmp_dir.path()), + "UV_INTERNAL__TEST_NOCOW_FS points to a filesystem that supports reflink" + ); + } + + #[test] + fn test_clone_fallback_on_tmp_fs() { + let Some(src_dir) = nocow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_NOCOW_FS not set"); + return; + }; + let Some(dst_dir) = nocow_tempdir() else { + unreachable!(); + }; + + assert!( + !reflink_supported(src_dir.path()), + "UV_INTERNAL__TEST_NOCOW_FS points to a filesystem that supports reflink" + ); + + create_test_tree(src_dir.path()); + + let options = LinkOptions::new(LinkMode::Clone); + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + + assert!( + result == LinkMode::Hardlink || result == LinkMode::Copy, + "Expected fallback to Hardlink or Copy on non-reflink fs, got {result:?}" + ); + verify_test_tree(dst_dir.path()); + } + + /// Clone from a copy-on-write filesystem to an alternative filesystem (cross-device). + /// + /// Reflink cannot work across filesystems, so the clone mode must fall back. + #[test] + fn test_clone_cross_device_reflink_to_tmp() { + let Some(src_dir) = cow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set"); + return; + }; + let Some(dst_dir) = alt_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set"); + return; + }; + + create_test_tree(src_dir.path()); + + let options = LinkOptions::new(LinkMode::Clone); + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + + // Reflink across devices must fall back + assert!( + result == LinkMode::Hardlink || result == LinkMode::Copy, + "Expected fallback to Hardlink or Copy for cross-device clone, got {result:?}" + ); + verify_test_tree(dst_dir.path()); + } + + /// Clone from an alternative filesystem to a copy-on-write filesystem (cross-device). + #[test] + fn test_clone_cross_device_tmp_to_reflink() { + let Some(src_dir) = alt_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set"); + return; + }; + let Some(dst_dir) = cow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set"); + return; + }; + + create_test_tree(src_dir.path()); + + let options = LinkOptions::new(LinkMode::Clone); + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + + // Reflink across devices must fall back + assert!( + result == LinkMode::Hardlink || result == LinkMode::Copy, + "Expected fallback to Hardlink or Copy for cross-device clone, got {result:?}" + ); + verify_test_tree(dst_dir.path()); + } + + /// Hardlink across filesystems must fall back to copy. + #[test] + fn test_hardlink_cross_device_reflink_to_tmp() { + let Some(src_dir) = cow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set"); + return; + }; + let Some(dst_dir) = alt_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set"); + return; + }; + + create_test_tree(src_dir.path()); + + let options = LinkOptions::new(LinkMode::Hardlink); + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + + // Hardlink across devices must fall back to copy + assert_eq!( + result, + LinkMode::Copy, + "Expected fallback to Copy for cross-device hardlink, got {result:?}" + ); + verify_test_tree(dst_dir.path()); + } + + /// Hardlink across filesystems (opposite direction). + #[test] + fn test_hardlink_cross_device_tmp_to_reflink() { + let Some(src_dir) = alt_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set"); + return; + }; + let Some(dst_dir) = cow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set"); + return; + }; + + create_test_tree(src_dir.path()); + + let options = LinkOptions::new(LinkMode::Hardlink); + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + + // Hardlink across devices must fall back to copy + assert_eq!( + result, + LinkMode::Copy, + "Expected fallback to Copy for cross-device hardlink, got {result:?}" + ); + verify_test_tree(dst_dir.path()); + } + + /// Clone merge across filesystems (src on copy-on-write fs, dst on alt fs). + #[test] + fn test_clone_merge_cross_device_reflink_to_tmp() { + let Some(src_dir) = cow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set"); + return; + }; + let Some(dst_dir) = alt_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set"); + return; + }; + + create_test_tree(src_dir.path()); + + // Pre-create destination with existing content + fs_err::create_dir_all(dst_dir.path()).unwrap(); + fs_err::write(dst_dir.path().join("file1.txt"), "old content").unwrap(); + fs_err::write(dst_dir.path().join("extra.txt"), "extra").unwrap(); + + let options = LinkOptions::new(LinkMode::Clone) + .with_on_existing_directory(OnExistingDirectory::Merge); + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + + // Cross-device: must fall back + assert!( + result == LinkMode::Hardlink || result == LinkMode::Copy, + "Expected fallback for cross-device clone merge, got {result:?}" + ); + + // Source files should overwrite destination + assert_eq!( + fs_err::read_to_string(dst_dir.path().join("file1.txt")).unwrap(), + "content1" + ); + // Extra file should remain + assert_eq!( + fs_err::read_to_string(dst_dir.path().join("extra.txt")).unwrap(), + "extra" + ); + } + + /// Copy mode works across filesystems (sanity check). + #[test] + fn test_copy_cross_device() { + let Some(src_dir) = cow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set"); + return; + }; + let Some(dst_dir) = alt_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set"); + return; + }; + + create_test_tree(src_dir.path()); + + let options = LinkOptions::new(LinkMode::Copy); + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + + assert_eq!(result, LinkMode::Copy); + verify_test_tree(dst_dir.path()); + } + + /// Symlink across filesystems should work (symlinks can span devices). + #[test] + #[cfg(unix)] + fn test_symlink_cross_device() { + let Some(src_dir) = cow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set"); + return; + }; + let Some(dst_dir) = alt_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set"); + return; + }; + + create_test_tree(src_dir.path()); + + let options = LinkOptions::new(LinkMode::Symlink); + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + + // Symlinks work across devices + assert!( + result == LinkMode::Symlink || result == LinkMode::Copy, + "Expected Symlink or Copy, got {result:?}" + ); + verify_test_tree(dst_dir.path()); + } + #[test] fn test_reflink_fallback_to_hardlink() { // This test verifies the fallback behavior when reflink fails. // We can't easily force reflink to fail on a supporting filesystem, // so we just verify the clone path works and returns a valid mode. - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1114,8 +1338,8 @@ mod tests { #[test] fn test_merge_overwrites_existing_files() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create source create_test_tree(src_dir.path()); @@ -1143,8 +1367,8 @@ mod tests { #[test] fn test_fail_mode_errors_on_existing_hardlink() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1172,8 +1396,8 @@ mod tests { #[test] fn test_copy_mode_overwrites_in_fail_mode() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1195,8 +1419,8 @@ mod tests { #[test] fn test_mutable_copy_filter() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); // Add a RECORD file that should be copied, not linked @@ -1232,8 +1456,8 @@ mod tests { #[test] fn test_synchronized_copy() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1247,8 +1471,8 @@ mod tests { #[test] fn test_empty_directory() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create empty subdirectory fs_err::create_dir_all(src_dir.path().join("empty_subdir")).unwrap(); @@ -1261,8 +1485,8 @@ mod tests { #[test] fn test_nested_directories() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create deeply nested structure let deep_path = src_dir.path().join("a/b/c/d/e"); @@ -1280,8 +1504,8 @@ mod tests { #[test] fn test_hardlink_merge_with_existing() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1307,8 +1531,8 @@ mod tests { use std::sync::Arc; use std::thread; - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create a file to copy fs_err::write(src_dir.path().join("file.txt"), "content").unwrap(); @@ -1346,8 +1570,8 @@ mod tests { #[test] #[cfg(unix)] fn test_symlink_merge_with_existing() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1376,8 +1600,8 @@ mod tests { #[test] #[cfg(unix)] fn test_symlink_mutable_copy_filter() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); fs_err::write(src_dir.path().join("RECORD"), "record content").unwrap(); @@ -1402,8 +1626,8 @@ mod tests { #[test] fn test_source_not_found() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Don't create any files in src_dir, just use a non-existent path let nonexistent = src_dir.path().join("nonexistent"); @@ -1418,8 +1642,8 @@ mod tests { fn test_clone_mutable_copy_filter_ignored() { // The mutable_copy filter only applies to hardlink/symlink modes. // For clone/copy modes, all files are already mutable (copy-on-write or full copy). - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); fs_err::write(src_dir.path().join("RECORD"), "record content").unwrap(); @@ -1439,8 +1663,8 @@ mod tests { #[test] fn test_copy_mutable_copy_filter_ignored() { // For copy mode, all files are already mutable, so filter is ignored - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); fs_err::write(src_dir.path().join("RECORD"), "record content").unwrap(); @@ -1458,8 +1682,8 @@ mod tests { #[test] fn test_special_characters_in_filenames() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create files with special characters (that are valid on most filesystems) fs_err::write(src_dir.path().join("file with spaces.txt"), "spaces").unwrap(); @@ -1486,8 +1710,8 @@ mod tests { #[test] fn test_hidden_files() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create hidden files (dotfiles) fs_err::write(src_dir.path().join(".hidden"), "hidden content").unwrap(); @@ -1516,8 +1740,8 @@ mod tests { #[cfg(target_os = "macos")] fn test_macos_clone_directory_recursive() { // Test the macOS-specific directory cloning via clonefile - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1534,8 +1758,8 @@ mod tests { #[cfg(target_os = "macos")] fn test_macos_clone_dir_merge_nested() { // Test the macOS clone_dir_merge with nested directory structure - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create nested structure in source fs_err::create_dir_all(src_dir.path().join("a/b/c")).unwrap(); @@ -1577,8 +1801,8 @@ mod tests { #[cfg(target_os = "macos")] fn test_macos_clone_merge_overwrites_files() { // Test that clone merge properly overwrites existing files on macOS - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); fs_err::write(src_dir.path().join("file.txt"), "new content").unwrap(); @@ -1598,8 +1822,8 @@ mod tests { #[test] fn test_clone_fail_mode_on_existing() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1626,8 +1850,8 @@ mod tests { #[test] #[cfg(unix)] fn test_symlink_fail_mode_on_existing() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1648,35 +1872,12 @@ mod tests { } } - #[test] - fn test_clone_fallback_when_reflink_unsupported() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); - - if reflink_supported(src_dir.path()) { - eprintln!("Skipping test: reflink is supported on this filesystem"); - return; - } - - create_test_tree(src_dir.path()); - - let options = LinkOptions::new(LinkMode::Clone); - let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); - - // When reflink is not supported, should fall back to hardlink or copy - assert!( - result == LinkMode::Hardlink || result == LinkMode::Copy, - "Expected fallback to Hardlink or Copy, got {result:?}" - ); - verify_test_tree(dst_dir.path()); - } - #[test] #[cfg(windows)] fn test_windows_symlink_file_vs_dir() { // Test that Windows correctly uses symlink_file for files and symlink_dir for directories - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create a file and a directory fs_err::write(src_dir.path().join("file.txt"), "content").unwrap(); diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 72b293be56567..a0f2ed208a23c 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -558,6 +558,30 @@ impl EnvVars { #[attr_added_in("0.3.4")] pub const UV_INTERNAL__TEST_DIR: &'static str = "UV_INTERNAL__TEST_DIR"; + /// Path to a directory on a filesystem that supports copy-on-write, e.g., btrfs or APFS. + /// + /// When populated, uv will run additional tests that require this functionality. + #[attr_hidden] + #[attr_added_in("next release")] + pub const UV_INTERNAL__TEST_COW_FS: &'static str = "UV_INTERNAL__TEST_COW_FS"; + + /// Path to a directory on a filesystem that does **not** support copy-on-write. + /// + /// When populated, uv will run additional tests that verify fallback behavior + /// when copy-on-write is unavailable. + #[attr_hidden] + #[attr_added_in("next release")] + pub const UV_INTERNAL__TEST_NOCOW_FS: &'static str = "UV_INTERNAL__TEST_NOCOW_FS"; + + /// Path to a directory on an alternative filesystem for testing. + /// + /// This filesystem must be a different device than the default for the test suite. + /// + /// When populated, uv will run additional tests that cover cross-filesystem linking. + #[attr_hidden] + #[attr_added_in("next release")] + pub const UV_INTERNAL__TEST_ALT_FS: &'static str = "UV_INTERNAL__TEST_ALT_FS"; + /// Used to force treating an interpreter as "managed" during tests. #[attr_hidden] #[attr_added_in("0.8.0")] diff --git a/crates/uv-test/src/lib.rs b/crates/uv-test/src/lib.rs index c0ccab398e765..1b7976d71b99e 100755 --- a/crates/uv-test/src/lib.rs +++ b/crates/uv-test/src/lib.rs @@ -166,6 +166,11 @@ pub struct TestContext { #[allow(dead_code)] _root: tempfile::TempDir, + + /// Extra temporary directories whose lifetimes are tied to this context (e.g., directories + /// on alternate filesystems created by [`Self::with_cache_on_cow_fs`] and friends). + #[allow(dead_code)] + _extra_tempdirs: Vec, } impl TestContext { @@ -684,6 +689,85 @@ impl TestContext { self } + /// Use a cache directory on the filesystem specified by + /// [`EnvVars::UV_INTERNAL__TEST_COW_FS`]. + /// + /// See the environment variable for details about the expectations. + #[must_use] + pub fn with_cache_on_cow_fs(self) -> Option { + let dir = env::var(EnvVars::UV_INTERNAL__TEST_COW_FS).ok()?; + Some(self.with_cache_on_fs(&dir)) + } + + /// Use a cache directory on the filesystem specified by + /// [`EnvVars::UV_INTERNAL__TEST_ALT_FS`]. + /// + /// See the environment variable for details about the expectations. + #[must_use] + pub fn with_cache_on_alt_fs(self) -> Option { + let dir = env::var(EnvVars::UV_INTERNAL__TEST_ALT_FS).ok()?; + Some(self.with_cache_on_fs(&dir)) + } + + /// Use a working directory on the filesystem specified by + /// [`EnvVars::UV_INTERNAL__TEST_COW_FS`]. + /// + /// Returns `None` if the environment variable is not set. + /// + /// Note a virtual environment is not created automatically. + #[must_use] + pub fn with_working_dir_on_cow_fs(self) -> Option { + let dir = env::var(EnvVars::UV_INTERNAL__TEST_COW_FS).ok()?; + Some(self.with_working_dir_on_fs(&dir)) + } + + /// Use a working directory on the filesystem specified by + /// [`EnvVars::UV_INTERNAL__TEST_ALT_FS`]. + /// + /// Returns `None` if the environment variable is not set. + /// + /// Note a virtual environment is not created automatically. + #[must_use] + pub fn with_working_dir_on_alt_fs(self) -> Option { + let dir = env::var(EnvVars::UV_INTERNAL__TEST_ALT_FS).ok()?; + Some(self.with_working_dir_on_fs(&dir)) + } + + fn with_cache_on_fs(mut self, dir: &str) -> Self { + fs_err::create_dir_all(dir).expect("Failed to create filesystem directory"); + let tmp = tempfile::TempDir::new_in(dir).expect("Failed to create tempdir"); + self.cache_dir = ChildPath::new(tmp.path()).child("cache"); + fs_err::create_dir_all(&self.cache_dir).expect("Failed to create cache directory"); + self.filters.extend( + Self::path_patterns(&self.cache_dir) + .into_iter() + .map(|pattern| (pattern, "[CACHE_DIR]/".to_string())), + ); + self._extra_tempdirs.push(tmp); + self + } + + fn with_working_dir_on_fs(mut self, dir: &str) -> Self { + fs_err::create_dir_all(dir).expect("Failed to create filesystem directory"); + let tmp = tempfile::TempDir::new_in(dir).expect("Failed to create tempdir"); + let canonical = tmp.path().canonicalize().expect("Failed to canonicalize"); + self.temp_dir = ChildPath::new(tmp.path()).child("temp"); + fs_err::create_dir_all(&self.temp_dir).expect("Failed to create working directory"); + self.venv = ChildPath::new(canonical.join(".venv")); + self.filters.extend( + Self::path_patterns(&self.temp_dir) + .into_iter() + .map(|pattern| (pattern, "[TEMP_DIR]/".to_string())), + ); + self.filters.extend( + Self::path_patterns(&self.venv) + .into_iter() + .map(|pattern| (pattern, "[VENV]/".to_string())), + ); + self._extra_tempdirs.push(tmp); + self + } + /// Default to the canonicalized path to the temp directory. We need to do this because on /// macOS (and Windows on GitHub Actions) the standard temp dir is a symlink. (On macOS, the /// temporary directory is, like `/var/...`, which resolves to `/private/var/...`.) @@ -980,6 +1064,7 @@ impl TestContext { filters, extra_env: vec![], _root: root, + _extra_tempdirs: vec![], } } diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 3d29517ca24d4..a444b844a79a4 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -14195,3 +14195,134 @@ fn warn_on_lzma_wheel() { " ); } + +/// Install a package with the cache on a reflink-capable filesystem and the venv on a +/// non-reflink filesystem. This exercises the cross-device fallback path for clone mode. +/// +/// Requires `UV_INTERNAL__TEST_COW_FS` and `UV_INTERNAL__TEST_ALT_FS`. +#[test] +fn install_cross_device_cache_reflink_venv_tmp() { + let Some(context) = uv_test::test_context!("3.12") + .with_cache_on_cow_fs() + .and_then(TestContext::with_working_dir_on_alt_fs) + .map(TestContext::with_filtered_link_mode_warning) + else { + return; + }; + context.venv().assert().success(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("--link-mode") + .arg("clone") + .arg("iniconfig"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + " + ); +} + +/// Install a package with the cache on a non-reflink filesystem and the venv on a +/// reflink-capable filesystem. This exercises the cross-device fallback path in the +/// opposite direction. +/// +/// Requires `UV_INTERNAL__TEST_COW_FS` and `UV_INTERNAL__TEST_ALT_FS`. +#[test] +fn install_cross_device_cache_tmp_venv_reflink() { + let Some(context) = uv_test::test_context!("3.12") + .with_cache_on_alt_fs() + .and_then(TestContext::with_working_dir_on_cow_fs) + .map(TestContext::with_filtered_link_mode_warning) + else { + return; + }; + context.venv().assert().success(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("--link-mode") + .arg("clone") + .arg("iniconfig"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + " + ); +} + +/// Install a package with both cache and venv on a reflink-capable filesystem. +/// Clone mode should succeed without fallback. +/// +/// Requires `UV_INTERNAL__TEST_COW_FS`. +#[test] +fn install_same_device_reflink() { + let Some(context) = uv_test::test_context!("3.12") + .with_cache_on_cow_fs() + .and_then(TestContext::with_working_dir_on_cow_fs) + else { + return; + }; + context.venv().assert().success(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("--link-mode") + .arg("clone") + .arg("iniconfig"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + " + ); +} + +/// Install with hardlink mode across filesystems — must fall back to copy. +/// +/// Requires `UV_INTERNAL__TEST_COW_FS` and `UV_INTERNAL__TEST_ALT_FS`. +#[test] +fn install_cross_device_hardlink() { + let Some(context) = uv_test::test_context!("3.12") + .with_cache_on_cow_fs() + .and_then(TestContext::with_working_dir_on_alt_fs) + .map(TestContext::with_filtered_link_mode_warning) + else { + return; + }; + context.venv().assert().success(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("--link-mode") + .arg("hardlink") + .arg("iniconfig"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + " + ); +}