Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 50 additions & 7 deletions crates/uv-fs/src/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,47 @@ where
}
}

/// Reflink a file from `from` to `to`, preserving file permissions.
///
/// On macOS, `clonefile` preserves all file metadata including permissions. On Linux,
/// `ioctl_ficlone` only clones data blocks, so we implement our own reflink that copies
/// permissions via `fchmod` on the open file descriptor, avoiding TOCTOU races.
///
/// See: <https://github.com/astral-sh/uv/issues/18181>
#[cfg(target_os = "linux")]
fn reflink_with_permissions(from: &Path, to: &Path) -> io::Result<()> {
use fs_err::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::PermissionsExt;

// Open source and read permissions from the file descriptor.
let src = fs_err::File::open(from)?;
let mode = src.metadata()?.permissions().mode();

// Create destination exclusively with the source's permissions.
let dest = fs_err::OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(to)?;

// Clone data blocks from source to destination.
if let Err(err) = rustix::fs::ioctl_ficlone(&dest, &src) {
// Clean up the destination file on failure.
let _ = fs_err::remove_file(to);
return Err(err.into());
}

Ok(())
}

/// Reflink a file from `from` to `to`, preserving file permissions.
///
/// On macOS, `clonefile` preserves all file metadata including permissions natively.
#[cfg(not(target_os = "linux"))]
fn reflink_with_permissions(from: &Path, to: &Path) -> io::Result<()> {
reflink_copy::reflink(from, to)
}

/// Attempt to reflink a single file, falling back via [`link_file`] on failure.
fn reflink_file_with_fallback<F>(
path: &Path,
Expand All @@ -426,15 +467,15 @@ where
F: Fn(&Path) -> bool,
{
match state.attempt {
LinkAttempt::Initial => match reflink_copy::reflink(path, target) {
LinkAttempt::Initial => match reflink_with_permissions(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() {
if reflink_with_permissions(path, &tempfile).is_ok() {
fs_err::rename(&tempfile, target)?;
Ok(state.mode_working())
} else {
Expand Down Expand Up @@ -462,17 +503,19 @@ where
link_file(path, target, state.next_mode(), options)
}
},
LinkAttempt::Subsequent => match reflink_copy::reflink(path, target) {
LinkAttempt::Subsequent => match reflink_with_permissions(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: tempfile.clone(),
err,
reflink_with_permissions(path, &tempfile).map_err(|err| {
LinkError::Reflink {
from: path.to_path_buf(),
to: tempfile.clone(),
err,
}
})?;
fs_err::rename(&tempfile, target)?;
Ok(state)
Expand Down
5 changes: 4 additions & 1 deletion crates/uv-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -781,7 +781,10 @@ impl TestContext {
let tmp = tempfile::TempDir::new_in(dir)?;
self.temp_dir = ChildPath::new(tmp.path()).child("temp");
fs_err::create_dir_all(&self.temp_dir)?;
self.venv = ChildPath::new(tmp.path().canonicalize()?.join(".venv"));
// Place the venv inside temp_dir (matching the default TestContext layout)
// so that `context.venv()` creates it at the same path that `VIRTUAL_ENV` points to.
let canonical_temp_dir = self.temp_dir.canonicalize()?;
self.venv = ChildPath::new(canonical_temp_dir.join(".venv"));
let temp_replacement = format!("[{name}]/[TEMP_DIR]/");
self.filters.extend(
Self::path_patterns(&self.temp_dir)
Expand Down
56 changes: 56 additions & 0 deletions crates/uv/tests/it/pip_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3347,6 +3347,62 @@ fn install_executable_hardlink() {
Command::new(executable).arg("--version").assert().success();
}

/// Install a package into a virtual environment using clone semantics, and ensure that the
/// executable permissions are retained.
///
/// Requires `UV_INTERNAL__TEST_COW_FS`.
///
/// See: <https://github.com/astral-sh/uv/issues/18181>
#[test]
fn install_executable_clone() -> anyhow::Result<()> {
let Some(context) = uv_test::test_context!("3.12").with_cache_on_cow_fs()? else {
return Ok(());
};
let Some(context) = context.with_working_dir_on_cow_fs()? else {
return Ok(());
};
context.venv().assert().success();

uv_snapshot!(context.filters(), context
.pip_install()
.arg(context.workspace_root.join("test/packages/executable_file"))
.arg("--link-mode")
.arg("clone"), @r"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ executable-file==1.0.0 (from file://[WORKSPACE]/test/packages/executable_file)
"
);

// Verify that the executable file inside the package retained its execute
// permission after being cloned. On Linux, `ioctl_ficlone` only clones data
// blocks without preserving metadata, so permissions must be copied separately.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let script = context
.site_packages()
.join("executable_file")
.join("bin")
.join("run.sh");
let mode = fs_err::metadata(&script)?.permissions().mode();
assert!(
mode & 0o111 != 0,
"Expected executable permissions on {}, got {:o}",
script.display(),
mode
);
}

Ok(())
}

/// Install a package from the command line into a virtual environment, ignoring its dependencies.
#[test]
fn no_deps() {
Expand Down
9 changes: 9 additions & 0 deletions test/packages/executable_file/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[project]
name = "executable-file"
version = "1.0.0"
description = "A test package containing a file with executable permissions"
requires-python = ">=3.12"

[build-system]
requires = ["uv_build>=0.8.0,<0.11.0"]
build-backend = "uv_build"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# A test package with an executable file.
2 changes: 2 additions & 0 deletions test/packages/executable_file/src/executable_file/bin/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/bin/sh
echo "hello from executable_file"
Loading