diff --git a/crates/uv-extract/src/stream.rs b/crates/uv-extract/src/stream.rs index b7b6b21f9..7186c8563 100644 --- a/crates/uv-extract/src/stream.rs +++ b/crates/uv-extract/src/stream.rs @@ -37,6 +37,7 @@ pub async fn unzip( } } + // We don't know the file permissions here, because we haven't seen the central directory yet. let file = fs_err::tokio::File::create(path).await?; let mut writer = if let Ok(size) = usize::try_from(entry.reader().entry().uncompressed_size()) { @@ -70,12 +71,24 @@ pub async fn unzip( continue; } - // Construct the (expected) path to the file on-disk. - let path = entry.filename().as_str()?; - let path = target.as_ref().join(path); + let Some(mode) = entry.unix_permissions() else { + continue; + }; - if let Some(mode) = entry.unix_permissions() { - fs_err::set_permissions(&path, Permissions::from_mode(mode))?; + // The executable bit is the only permission we preserve, otherwise we use the OS defaults. + // https://github.com/pypa/pip/blob/3898741e29b7279e7bffe044ecfbe20f6a438b1e/src/pip/_internal/utils/unpacking.py#L88-L100 + let has_any_executable_bit = mode & 0o111; + if has_any_executable_bit != 0 { + // Construct the (expected) path to the file on-disk. + let path = entry.filename().as_str()?; + let path = target.as_ref().join(path); + + let permissions = fs_err::tokio::metadata(&path).await?.permissions(); + fs_err::tokio::set_permissions( + &path, + Permissions::from_mode(permissions.mode() | 0o111), + ) + .await?; } } } diff --git a/crates/uv-extract/src/sync.rs b/crates/uv-extract/src/sync.rs index 7c5addd8f..0489cf063 100644 --- a/crates/uv-extract/src/sync.rs +++ b/crates/uv-extract/src/sync.rs @@ -1,4 +1,3 @@ -use std::fs::OpenOptions; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -45,24 +44,30 @@ pub fn unzip( } } - // Create the file, with the correct permissions (on Unix). - let mut options = OpenOptions::new(); - options.write(true); - options.create_new(true); + // Copy the file contents. + let mut outfile = fs_err::File::create(&path)?; + std::io::copy(&mut file, &mut outfile)?; + // See `uv_extract::stream::unzip`. For simplicity, this is identical with the code there except for being + // sync. #[cfg(unix)] { - use std::os::unix::fs::OpenOptionsExt; + use std::fs::Permissions; + use std::os::unix::fs::PermissionsExt; if let Some(mode) = file.unix_mode() { - options.mode(mode); + // https://github.com/pypa/pip/blob/3898741e29b7279e7bffe044ecfbe20f6a438b1e/src/pip/_internal/utils/unpacking.py#L88-L100 + let has_any_executable_bit = mode & 0o111; + if has_any_executable_bit != 0 { + let permissions = fs_err::metadata(&path)?.permissions(); + fs_err::set_permissions( + &path, + Permissions::from_mode(permissions.mode() | 0o111), + )?; + } } } - // Copy the file contents. - let mut outfile = options.open(&path)?; - std::io::copy(&mut file, &mut outfile)?; - Ok(()) }) .collect::>() diff --git a/crates/uv/tests/pip_sync.rs b/crates/uv/tests/pip_sync.rs index 8c752c951..cb12e347c 100644 --- a/crates/uv/tests/pip_sync.rs +++ b/crates/uv/tests/pip_sync.rs @@ -2734,3 +2734,26 @@ fn tar_dont_preserve_mtime() -> Result<()> { Ok(()) } + +/// Avoid creating a file with 000 permissions +#[test] +fn set_read_permissions() -> Result<()> { + let context = TestContext::new("3.12"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("databricks==0.2")?; + + uv_snapshot!(command(&context) + .arg("requirements.in"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Downloaded 1 package in [TIME] + Installed 1 package in [TIME] + + databricks==0.2 + "###); + + Ok(()) +}