SHA256
14
0
forked from pool/python-uv
Files
python-uv/CVE-2025-54368.patch

1579 lines
62 KiB
Diff
Raw Permalink Normal View History

2025-08-20 12:36:31 +02:00
From 7f1eaf48c193e045ca2c62c4581048765c55505f Mon Sep 17 00:00:00 2001
From: Charlie Marsh <charlie.r.marsh@gmail.com>
Date: Thu, 7 Aug 2025 15:31:48 +0100
Subject: [PATCH] Harden ZIP streaming to reject repeated entries and other
malformed ZIP files (#15136)
## Summary
uv will now reject ZIP files that meet any of the following conditions:
- Multiple local header entries exist for the same file with different
contents.
- A local header entry exists for a file that isn't included in the
end-of-central directory record.
- An entry exists in the end-of-central directory record that does not
have a corresponding local header.
- The ZIP file contains contents after the first end-of-central
directory record.
- The CRC32 doesn't match between the local file header and the
end-of-central directory record.
- The compressed size doesn't match between the local file header and
the end-of-central directory record.
- The uncompressed size doesn't match between the local file header and
the end-of-central directory record.
- The reported central directory offset (in the end-of-central-directory
header) does not match the actual offset.
- The reported ZIP64 end of central directory locator offset does not
match the actual offset.
We also validate the above for files with data descriptors, which we
previously ignored.
Wheels from the most recent releases of the top 15,000 packages on PyPI
have been confirmed to pass these checks, and PyPI will also reject ZIPs
under many of the same conditions (at upload time) in the future.
In rare cases, this validation can be disabled by setting
`UV_INSECURE_NO_ZIP_VALIDATION=1`. Any validations should be reported to
the uv issue tracker and to the upstream package maintainer.
---
Cargo.lock | 7 +-
Cargo.toml | 2 +-
crates/uv-dev/Cargo.toml | 7 +-
crates/uv-dev/src/lib.rs | 5 +
crates/uv-dev/src/validate_zip.rs | 43 +++
crates/uv-extract/Cargo.toml | 1 +
crates/uv-extract/src/error.rs | 67 ++++
crates/uv-extract/src/stream.rs | 512 ++++++++++++++++++++++++++----
crates/uv-metadata/src/lib.rs | 2 +-
crates/uv-static/src/env_vars.rs | 8 +
crates/uv/Cargo.toml | 3 +-
crates/uv/tests/it/extract.rs | 382 ++++++++++++++++++++++
crates/uv/tests/it/main.rs | 1 +
crates/uv/tests/it/pip_install.rs | 171 +++++++++-
docs/reference/environment.md | 9 +
15 files changed, 1147 insertions(+), 73 deletions(-)
create mode 100644 crates/uv-dev/src/validate_zip.rs
create mode 100644 crates/uv/tests/it/extract.rs
Index: uv-0.7.18/Cargo.lock
===================================================================
--- uv-0.7.18.orig/Cargo.lock
+++ uv-0.7.18/Cargo.lock
@@ -4658,6 +4658,7 @@ dependencies = [
"textwrap",
"thiserror 2.0.12",
"tokio",
+ "tokio-util",
"toml",
"toml_edit",
"tracing",
@@ -5041,6 +5042,7 @@ dependencies = [
"anyhow",
"clap",
"fs-err",
+ "futures",
"itertools 0.14.0",
"markdown",
"owo-colors",
@@ -5053,8 +5055,10 @@ dependencies = [
"serde_json",
"serde_yaml",
"tagu",
+ "tempfile",
"textwrap",
"tokio",
+ "tokio-util",
"tracing",
"tracing-durations-export",
"tracing-subscriber",
@@ -5247,6 +5251,7 @@ dependencies = [
"uv-configuration",
"uv-distribution-filename",
"uv-pypi-types",
+ "uv-static",
"xz2",
"zip",
]
Index: uv-0.7.18/Cargo.toml
===================================================================
--- uv-0.7.18.orig/Cargo.toml
+++ uv-0.7.18/Cargo.toml
@@ -80,7 +80,7 @@ async-channel = { version = "2.3.1" }
async-compression = { version = "0.4.12", features = ["bzip2", "gzip", "xz", "zstd"] }
async-trait = { version = "0.1.82" }
async_http_range_reader = { version = "0.9.1" }
-async_zip = { git = "https://github.com/charliermarsh/rs-async-zip", rev = "c909fda63fcafe4af496a07bfda28a5aae97e58d", features = ["bzip2", "deflate", "lzma", "tokio", "xz", "zstd"] }
+async_zip = { git = "https://github.com/astral-sh/rs-async-zip", rev = "285e48742b74ab109887d62e1ae79e7c15fd4878", features = ["bzip2", "deflate", "lzma", "tokio", "xz", "zstd"] }
axoupdater = { version = "0.9.0", default-features = false }
backon = { version = "1.3.0" }
base64 = { version = "0.22.1" }
@@ -110,6 +110,7 @@ glob = { version = "0.3.1" }
globset = { version = "0.4.15" }
globwalk = { version = "0.9.1" }
goblin = { version = "0.10.0", default-features = false, features = ["std", "elf32", "elf64", "endian_fd"] }
+h2 = { version = "0.4.7" }
hashbrown = { version = "0.15.1" }
hex = { version = "0.4.3" }
home = { version = "0.5.9" }
Index: uv-0.7.18/crates/uv-dev/Cargo.toml
===================================================================
--- uv-0.7.18.orig/crates/uv-dev/Cargo.toml
+++ uv-0.7.18/crates/uv-dev/Cargo.toml
@@ -22,7 +22,7 @@ uv-client = { workspace = true }
uv-configuration = { workspace = true }
uv-distribution-filename = { workspace = true }
uv-distribution-types = { workspace = true }
-uv-extract = { workspace = true, optional = true }
+uv-extract = { workspace = true }
uv-installer = { workspace = true }
uv-macros = { workspace = true }
uv-options-metadata = { workspace = true }
@@ -39,20 +39,23 @@ anstream = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive", "wrap_help"] }
fs-err = { workspace = true, features = ["tokio"] }
+futures = { workspace = true }
itertools = { workspace = true }
markdown = { version = "1.0.0" }
owo-colors = { workspace = true }
poloto = { version = "19.1.2", optional = true }
pretty_assertions = { version = "1.4.1" }
-reqwest = { workspace = true }
+reqwest = { workspace = true, features = ["stream"] }
resvg = { version = "0.29.0", optional = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { version = "0.9.34" }
tagu = { version = "0.1.6", optional = true }
+tempfile = { workspace = true }
textwrap = { workspace = true }
tokio = { workspace = true }
+tokio-util = { workspace = true }
tracing = { workspace = true }
tracing-durations-export = { workspace = true, features = ["plot"] }
tracing-subscriber = { workspace = true }
Index: uv-0.7.18/crates/uv-dev/src/lib.rs
===================================================================
--- uv-0.7.18.orig/crates/uv-dev/src/lib.rs
+++ uv-0.7.18/crates/uv-dev/src/lib.rs
@@ -14,6 +14,7 @@ use crate::generate_options_reference::A
use crate::generate_sysconfig_mappings::Args as GenerateSysconfigMetadataArgs;
#[cfg(feature = "render")]
use crate::render_benchmarks::RenderBenchmarksArgs;
+use crate::validate_zip::ValidateZipArgs;
use crate::wheel_metadata::WheelMetadataArgs;
mod clear_compile;
@@ -25,6 +26,7 @@ mod generate_json_schema;
mod generate_options_reference;
mod generate_sysconfig_mappings;
mod render_benchmarks;
+mod validate_zip;
mod wheel_metadata;
const ROOT_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../");
@@ -33,6 +35,8 @@ const ROOT_DIR: &str = concat!(env!("CAR
enum Cli {
/// Display the metadata for a `.whl` at a given URL.
WheelMetadata(WheelMetadataArgs),
+ /// Validate that a `.whl` or `.zip` file at a given URL is a valid ZIP file.
+ ValidateZip(ValidateZipArgs),
/// Compile all `.py` to `.pyc` files in the tree.
Compile(CompileArgs),
/// Remove all `.pyc` in the tree.
@@ -59,6 +63,7 @@ pub async fn run() -> Result<()> {
let cli = Cli::parse();
match cli {
Cli::WheelMetadata(args) => wheel_metadata::wheel_metadata(args).await?,
+ Cli::ValidateZip(args) => validate_zip::validate_zip(args).await?,
Cli::Compile(args) => compile::compile(args).await?,
Cli::ClearCompile(args) => clear_compile::clear_compile(&args)?,
Cli::GenerateAll(args) => generate_all::main(&args).await?,
Index: uv-0.7.18/crates/uv-dev/src/validate_zip.rs
===================================================================
--- /dev/null
+++ uv-0.7.18/crates/uv-dev/src/validate_zip.rs
@@ -0,0 +1,43 @@
+use std::ops::Deref;
+
+use anyhow::{Result, bail};
+use clap::Parser;
+use futures::TryStreamExt;
+use tokio_util::compat::FuturesAsyncReadCompatExt;
+
+use uv_cache::{Cache, CacheArgs};
+use uv_client::RegistryClientBuilder;
+use uv_pep508::VerbatimUrl;
+use uv_pypi_types::ParsedUrl;
+
+#[derive(Parser)]
+pub(crate) struct ValidateZipArgs {
+ url: VerbatimUrl,
+ #[command(flatten)]
+ cache_args: CacheArgs,
+}
+
+pub(crate) async fn validate_zip(args: ValidateZipArgs) -> Result<()> {
+ let cache = Cache::try_from(args.cache_args)?.init()?;
+ let client = RegistryClientBuilder::new(cache).build();
+
+ let ParsedUrl::Archive(archive) = ParsedUrl::try_from(args.url.to_url())? else {
+ bail!("Only archive URLs are supported");
+ };
+
+ let response = client
+ .uncached_client(&archive.url)
+ .get(archive.url.deref().clone())
+ .send()
+ .await?;
+ let reader = response
+ .bytes_stream()
+ .map_err(std::io::Error::other)
+ .into_async_read();
+
+ let target = tempfile::TempDir::new()?;
+
+ uv_extract::stream::unzip(reader.compat(), target.path()).await?;
+
+ Ok(())
+}
Index: uv-0.7.18/crates/uv-extract/Cargo.toml
===================================================================
--- uv-0.7.18.orig/crates/uv-extract/Cargo.toml
+++ uv-0.7.18/crates/uv-extract/Cargo.toml
@@ -19,6 +19,7 @@ workspace = true
uv-configuration = { workspace = true }
uv-distribution-filename = { workspace = true }
uv-pypi-types = { workspace = true }
+uv-static = { workspace = true }
astral-tokio-tar = { workspace = true }
async-compression = { workspace = true, features = ["bzip2", "gzip", "zstd", "xz"] }
Index: uv-0.7.18/crates/uv-extract/src/error.rs
===================================================================
--- uv-0.7.18.orig/crates/uv-extract/src/error.rs
+++ uv-0.7.18/crates/uv-extract/src/error.rs
@@ -14,12 +14,79 @@ pub enum Error {
NonSingularArchive(Vec<OsString>),
#[error("The top-level of the archive must only contain a list directory, but it's empty")]
EmptyArchive,
+ #[error("ZIP local header filename at offset {offset} does not use UTF-8 encoding")]
+ LocalHeaderNotUtf8 { offset: u64 },
+ #[error("ZIP central directory entry filename at index {index} does not use UTF-8 encoding")]
+ CentralDirectoryEntryNotUtf8 { index: u64 },
#[error("Bad CRC (got {computed:08x}, expected {expected:08x}) for file: {}", path.display())]
BadCrc32 {
path: PathBuf,
computed: u32,
expected: u32,
},
+ #[error("Bad uncompressed size (got {computed:08x}, expected {expected:08x}) for file: {}", path.display())]
+ BadUncompressedSize {
+ path: PathBuf,
+ computed: u64,
+ expected: u64,
+ },
+ #[error("Bad compressed size (got {computed:08x}, expected {expected:08x}) for file: {}", path.display())]
+ BadCompressedSize {
+ path: PathBuf,
+ computed: u64,
+ expected: u64,
+ },
+ #[error("ZIP file contains multiple entries with different contents for: {}", path.display())]
+ DuplicateLocalFileHeader { path: PathBuf },
+ #[error("ZIP file contains a local file header without a corresponding central-directory record entry for: {} ({offset})", path.display())]
+ MissingCentralDirectoryEntry { path: PathBuf, offset: u64 },
+ #[error("ZIP file contains an end-of-central-directory record entry, but no local file header for: {} ({offset}", path.display())]
+ MissingLocalFileHeader { path: PathBuf, offset: u64 },
+ #[error("ZIP file uses conflicting paths for the local file header at {} (got {}, expected {})", offset, local_path.display(), central_directory_path.display())]
+ ConflictingPaths {
+ offset: u64,
+ local_path: PathBuf,
+ central_directory_path: PathBuf,
+ },
+ #[error("ZIP file uses conflicting checksums for the local file header and central-directory record (got {local_crc32}, expected {central_directory_crc32}) for: {} ({offset})", path.display())]
+ ConflictingChecksums {
+ path: PathBuf,
+ offset: u64,
+ local_crc32: u32,
+ central_directory_crc32: u32,
+ },
+ #[error("ZIP file uses conflicting compressed sizes for the local file header and central-directory record (got {local_compressed_size}, expected {central_directory_compressed_size}) for: {} ({offset})", path.display())]
+ ConflictingCompressedSizes {
+ path: PathBuf,
+ offset: u64,
+ local_compressed_size: u64,
+ central_directory_compressed_size: u64,
+ },
+ #[error("ZIP file uses conflicting uncompressed sizes for the local file header and central-directory record (got {local_uncompressed_size}, expected {central_directory_uncompressed_size}) for: {} ({offset})", path.display())]
+ ConflictingUncompressedSizes {
+ path: PathBuf,
+ offset: u64,
+ local_uncompressed_size: u64,
+ central_directory_uncompressed_size: u64,
+ },
+ #[error("ZIP file contains trailing contents after the end-of-central-directory record")]
+ TrailingContents,
+ #[error(
+ "ZIP file reports a number of entries in the central directory that conflicts with the actual number of entries (got {actual}, expected {expected})"
+ )]
+ ConflictingNumberOfEntries { actual: u64, expected: u64 },
+ #[error("Data descriptor is missing for file: {}", path.display())]
+ MissingDataDescriptor { path: PathBuf },
+ #[error("File contains an unexpected data descriptor: {}", path.display())]
+ UnexpectedDataDescriptor { path: PathBuf },
+ #[error(
+ "ZIP file end-of-central-directory record contains a comment that appears to be an embedded ZIP file"
+ )]
+ ZipInZip,
+ #[error("ZIP64 end-of-central-directory record contains unsupported extensible data")]
+ ExtensibleData,
+ #[error("ZIP file end-of-central-directory record contains multiple entries with the same path, but conflicting modes: {}", path.display())]
+ DuplicateExecutableFileHeader { path: PathBuf },
}
impl Error {
Index: uv-0.7.18/crates/uv-extract/src/stream.rs
===================================================================
--- uv-0.7.18.orig/crates/uv-extract/src/stream.rs
+++ uv-0.7.18/crates/uv-extract/src/stream.rs
@@ -1,17 +1,59 @@
use std::path::{Component, Path, PathBuf};
use std::pin::Pin;
-use futures::StreamExt;
-use rustc_hash::FxHashSet;
+use async_zip::base::read::cd::Entry;
+use async_zip::error::ZipError;
+use futures::{AsyncReadExt, StreamExt};
+use rustc_hash::{FxHashMap, FxHashSet};
use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt};
-use tracing::warn;
+use tracing::{debug, warn};
use uv_distribution_filename::SourceDistExtension;
+use uv_static::EnvVars;
use crate::Error;
const DEFAULT_BUF_SIZE: usize = 128 * 1024;
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct LocalHeaderEntry {
+ /// The relative path of the entry, as computed from the local file header.
+ relpath: PathBuf,
+ /// The computed CRC32 checksum of the entry.
+ crc32: u32,
+ /// The computed compressed size of the entry.
+ compressed_size: u64,
+ /// The computed uncompressed size of the entry.
+ uncompressed_size: u64,
+ /// Whether the entry has a data descriptor.
+ data_descriptor: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct ComputedEntry {
+ /// The computed CRC32 checksum of the entry.
+ crc32: u32,
+ /// The computed uncompressed size of the entry.
+ uncompressed_size: u64,
+ /// The computed compressed size of the entry.
+ compressed_size: u64,
+}
+
+/// Returns `true` if ZIP validation is disabled.
+fn insecure_no_validate() -> bool {
+ // TODO(charlie) Parse this in `EnvironmentOptions`.
+ let Some(value) = std::env::var_os(EnvVars::UV_INSECURE_NO_ZIP_VALIDATION) else {
+ return false;
+ };
+ let Some(value) = value.to_str() else {
+ return false;
+ };
+ matches!(
+ value.to_lowercase().as_str(),
+ "y" | "yes" | "t" | "true" | "on" | "1"
+ )
+}
+
/// Unpack a `.zip` archive into the target directory, without requiring `Seek`.
///
/// This is useful for unzipping files as they're being downloaded. If the archive
@@ -41,15 +83,24 @@ pub async fn unzip<R: tokio::io::AsyncRe
Some(path)
}
+ // Determine whether ZIP validation is disabled.
+ let skip_validation = insecure_no_validate();
+
let target = target.as_ref();
let mut reader = futures::io::BufReader::with_capacity(DEFAULT_BUF_SIZE, reader.compat());
let mut zip = async_zip::base::read::stream::ZipFileReader::new(&mut reader);
let mut directories = FxHashSet::default();
+ let mut local_headers = FxHashMap::default();
+ let mut offset = 0;
while let Some(mut entry) = zip.next_with_entry().await? {
// Construct the (expected) path to the file on-disk.
- let path = entry.reader().entry().filename().as_str()?;
+ let path = match entry.reader().entry().filename().as_str() {
+ Ok(path) => path,
+ Err(ZipError::StringNotUtf8) => return Err(Error::LocalHeaderNotUtf8 { offset }),
+ Err(err) => return Err(err.into()),
+ };
// Sanitize the file name to prevent directory traversal attacks.
let Some(relpath) = enclosed_name(path) else {
@@ -57,17 +108,54 @@ pub async fn unzip<R: tokio::io::AsyncRe
// Close current file prior to proceeding, as per:
// https://docs.rs/async_zip/0.0.16/async_zip/base/read/stream/
- zip = entry.skip().await?;
+ (.., zip) = entry.skip().await?;
+
+ // Store the current offset.
+ offset = zip.offset();
+
continue;
};
- let path = target.join(&relpath);
- let is_dir = entry.reader().entry().dir()?;
+
+ let file_offset = entry.reader().entry().file_offset();
+ let expected_compressed_size = entry.reader().entry().compressed_size();
+ let expected_uncompressed_size = entry.reader().entry().uncompressed_size();
+ let expected_data_descriptor = entry.reader().entry().data_descriptor();
// Either create the directory or write the file to disk.
- if is_dir {
+ let path = target.join(&relpath);
+ let is_dir = entry.reader().entry().dir()?;
+ let computed = if is_dir {
if directories.insert(path.clone()) {
fs_err::tokio::create_dir_all(path).await?;
}
+
+ // If this is a directory, we expect the CRC32 to be 0.
+ if entry.reader().entry().crc32() != 0 {
+ if !skip_validation {
+ return Err(Error::BadCrc32 {
+ path: relpath.clone(),
+ computed: 0,
+ expected: entry.reader().entry().crc32(),
+ });
+ }
+ }
+
+ // If this is a directory, we expect the uncompressed size to be 0.
+ if entry.reader().entry().uncompressed_size() != 0 {
+ if !skip_validation {
+ return Err(Error::BadUncompressedSize {
+ path: relpath.clone(),
+ computed: 0,
+ expected: entry.reader().entry().uncompressed_size(),
+ });
+ }
+ }
+
+ ComputedEntry {
+ crc32: 0,
+ uncompressed_size: 0,
+ compressed_size: 0,
+ }
} else {
if let Some(parent) = path.parent() {
if directories.insert(parent.to_path_buf()) {
@@ -76,82 +164,374 @@ pub async fn unzip<R: tokio::io::AsyncRe
}
// 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 size = entry.reader().entry().uncompressed_size();
- let mut writer = if let Ok(size) = usize::try_from(size) {
- tokio::io::BufWriter::with_capacity(std::cmp::min(size, 1024 * 1024), file)
- } else {
- tokio::io::BufWriter::new(file)
+ let (actual_uncompressed_size, reader) = match fs_err::tokio::File::create_new(&path)
+ .await
+ {
+ Ok(file) => {
+ // Write the file to disk.
+ let size = entry.reader().entry().uncompressed_size();
+ let mut writer = if let Ok(size) = usize::try_from(size) {
+ tokio::io::BufWriter::with_capacity(std::cmp::min(size, 1024 * 1024), file)
+ } else {
+ tokio::io::BufWriter::new(file)
+ };
+ let mut reader = entry.reader_mut().compat();
+ let bytes_read = tokio::io::copy(&mut reader, &mut writer).await?;
+ let reader = reader.into_inner();
+
+ (bytes_read, reader)
+ }
+ Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
+ debug!(
+ "Found duplicate local file header for: {}",
+ relpath.display()
+ );
+
+ // Read the existing file into memory.
+ let existing_contents = fs_err::tokio::read(&path).await?;
+
+ // Read the entry into memory.
+ let mut expected_contents = Vec::with_capacity(existing_contents.len());
+ let entry_reader = entry.reader_mut();
+ let bytes_read = entry_reader.read_to_end(&mut expected_contents).await?;
+
+ // Verify that the existing file contents match the expected contents.
+ if existing_contents != expected_contents {
+ return Err(Error::DuplicateLocalFileHeader {
+ path: relpath.clone(),
+ });
+ }
+
+ (bytes_read as u64, entry_reader)
+ }
+ Err(err) => return Err(err.into()),
};
- let mut reader = entry.reader_mut().compat();
- tokio::io::copy(&mut reader, &mut writer).await?;
+
+ // Validate the uncompressed size.
+ if actual_uncompressed_size != expected_uncompressed_size {
+ if !(expected_compressed_size == 0 && expected_data_descriptor) {
+ if !skip_validation {
+ return Err(Error::BadUncompressedSize {
+ path: relpath.clone(),
+ computed: actual_uncompressed_size,
+ expected: expected_uncompressed_size,
+ });
+ }
+ }
+ }
+
+ // Validate the compressed size.
+ let actual_compressed_size = reader.bytes_read();
+ if actual_compressed_size != expected_compressed_size {
+ if !(expected_compressed_size == 0 && expected_data_descriptor) {
+ if !skip_validation {
+ return Err(Error::BadCompressedSize {
+ path: relpath.clone(),
+ computed: actual_compressed_size,
+ expected: expected_compressed_size,
+ });
+ }
+ }
+ }
// Validate the CRC of any file we unpack
// (It would be nice if async_zip made it harder to Not do this...)
- let reader = reader.into_inner();
- let computed = reader.compute_hash();
- let expected = reader.entry().crc32();
- if computed != expected {
- let error = Error::BadCrc32 {
- path: relpath,
- computed,
- expected,
- };
- // There are some cases where we fail to get a proper CRC.
- // This is probably connected to out-of-line data descriptors
- // which are problematic to access in a streaming context.
- // In those cases the CRC seems to reliably be stubbed inline as 0,
- // so we downgrade this to a (hidden-by-default) warning.
- if expected == 0 {
- warn!("presumed missing CRC: {error}");
- } else {
- return Err(error);
+ let actual_crc32 = reader.compute_hash();
+ let expected_crc32 = reader.entry().crc32();
+ if actual_crc32 != expected_crc32 {
+ if !(expected_crc32 == 0 && expected_data_descriptor) {
+ if !skip_validation {
+ return Err(Error::BadCrc32 {
+ path: relpath.clone(),
+ computed: actual_crc32,
+ expected: expected_crc32,
+ });
+ }
}
}
- }
+
+ ComputedEntry {
+ crc32: actual_crc32,
+ uncompressed_size: actual_uncompressed_size,
+ compressed_size: actual_compressed_size,
+ }
+ };
// Close current file prior to proceeding, as per:
// https://docs.rs/async_zip/0.0.16/async_zip/base/read/stream/
- zip = entry.skip().await?;
+ let (descriptor, next) = entry.skip().await?;
+
+ // Verify that the data descriptor field is consistent with the presence (or absence) of a
+ // data descriptor in the local file header.
+ if expected_data_descriptor && descriptor.is_none() {
+ if !skip_validation {
+ return Err(Error::MissingDataDescriptor {
+ path: relpath.clone(),
+ });
+ }
+ }
+ if !expected_data_descriptor && descriptor.is_some() {
+ if !skip_validation {
+ return Err(Error::UnexpectedDataDescriptor {
+ path: relpath.clone(),
+ });
+ }
+ }
+
+ // If we have a data descriptor, validate it.
+ if let Some(descriptor) = descriptor {
+ if descriptor.crc != computed.crc32 {
+ if !skip_validation {
+ return Err(Error::BadCrc32 {
+ path: relpath.clone(),
+ computed: computed.crc32,
+ expected: descriptor.crc,
+ });
+ }
+ }
+ if descriptor.uncompressed_size != computed.uncompressed_size {
+ if !skip_validation {
+ return Err(Error::BadUncompressedSize {
+ path: relpath.clone(),
+ computed: computed.uncompressed_size,
+ expected: descriptor.uncompressed_size,
+ });
+ }
+ }
+ if descriptor.compressed_size != computed.compressed_size {
+ if !skip_validation {
+ return Err(Error::BadCompressedSize {
+ path: relpath.clone(),
+ computed: computed.compressed_size,
+ expected: descriptor.compressed_size,
+ });
+ }
+ }
+ }
+
+ // Store the offset, for validation, and error if we see a duplicate file.
+ match local_headers.entry(file_offset) {
+ std::collections::hash_map::Entry::Vacant(entry) => {
+ entry.insert(LocalHeaderEntry {
+ relpath,
+ crc32: computed.crc32,
+ uncompressed_size: computed.uncompressed_size,
+ compressed_size: expected_compressed_size,
+ data_descriptor: expected_data_descriptor,
+ });
+ }
+ std::collections::hash_map::Entry::Occupied(..) => {
+ if !skip_validation {
+ return Err(Error::DuplicateLocalFileHeader {
+ path: relpath.clone(),
+ });
+ }
+ }
+ }
+
+ // Advance the reader to the next entry.
+ zip = next;
+
+ // Store the current offset.
+ offset = zip.offset();
}
- // On Unix, we need to set file permissions, which are stored in the central directory, at the
- // end of the archive. The `ZipFileReader` reads until it sees a central directory signature,
- // which indicates the first entry in the central directory. So we continue reading from there.
+ // Record the actual number of entries in the central directory.
+ let mut num_entries = 0;
+
+ // Track the file modes on Unix, to ensure that they're consistent across duplicates.
#[cfg(unix)]
- {
- use std::fs::Permissions;
- use std::os::unix::fs::PermissionsExt;
-
- let mut directory = async_zip::base::read::cd::CentralDirectoryReader::new(&mut reader);
- while let Some(entry) = directory.next().await? {
- if entry.dir()? {
- continue;
- }
+ let mut modes =
+ FxHashMap::with_capacity_and_hasher(local_headers.len(), rustc_hash::FxBuildHasher);
- let Some(mode) = entry.unix_permissions() else {
- continue;
- };
+ let mut directory = async_zip::base::read::cd::CentralDirectoryReader::new(&mut reader, offset);
+ loop {
+ match directory.next().await? {
+ Entry::CentralDirectoryEntry(entry) => {
+ // Count the number of entries in the central directory.
+ num_entries += 1;
- // 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 Some(path) = enclosed_name(path) else {
+ let path = match entry.filename().as_str() {
+ Ok(path) => path,
+ Err(ZipError::StringNotUtf8) => {
+ return Err(Error::CentralDirectoryEntryNotUtf8 {
+ index: num_entries - 1,
+ });
+ }
+ Err(err) => return Err(err.into()),
+ };
+
+ // Sanitize the file name to prevent directory traversal attacks.
+ let Some(relpath) = enclosed_name(path) else {
continue;
};
- let path = target.join(path);
- let permissions = fs_err::tokio::metadata(&path).await?.permissions();
- if permissions.mode() & 0o111 != 0o111 {
- fs_err::tokio::set_permissions(
- &path,
- Permissions::from_mode(permissions.mode() | 0o111),
- )
- .await?;
+ // Validate that various fields are consistent between the local file header and the
+ // central directory entry.
+ match local_headers.remove(&entry.file_offset()) {
+ Some(local_header) => {
+ if local_header.relpath != relpath {
+ if !skip_validation {
+ return Err(Error::ConflictingPaths {
+ offset: entry.file_offset(),
+ local_path: local_header.relpath.clone(),
+ central_directory_path: relpath.clone(),
+ });
+ }
+ }
+ if local_header.crc32 != entry.crc32() {
+ if !skip_validation {
+ return Err(Error::ConflictingChecksums {
+ path: relpath.clone(),
+ offset: entry.file_offset(),
+ local_crc32: local_header.crc32,
+ central_directory_crc32: entry.crc32(),
+ });
+ }
+ }
+ if local_header.uncompressed_size != entry.uncompressed_size() {
+ if !skip_validation {
+ return Err(Error::ConflictingUncompressedSizes {
+ path: relpath.clone(),
+ offset: entry.file_offset(),
+ local_uncompressed_size: local_header.uncompressed_size,
+ central_directory_uncompressed_size: entry.uncompressed_size(),
+ });
+ }
+ }
+ if local_header.compressed_size != entry.compressed_size() {
+ if !local_header.data_descriptor {
+ if !skip_validation {
+ return Err(Error::ConflictingCompressedSizes {
+ path: relpath.clone(),
+ offset: entry.file_offset(),
+ local_compressed_size: local_header.compressed_size,
+ central_directory_compressed_size: entry.compressed_size(),
+ });
+ }
+ }
+ }
+ }
+ None => {
+ if !skip_validation {
+ return Err(Error::MissingLocalFileHeader {
+ path: relpath.clone(),
+ offset: entry.file_offset(),
+ });
+ }
+ }
}
+
+ // On Unix, we need to set file permissions, which are stored in the central directory, at the
+ // end of the archive. The `ZipFileReader` reads until it sees a central directory signature,
+ // which indicates the first entry in the central directory. So we continue reading from there.
+ #[cfg(unix)]
+ {
+ use std::fs::Permissions;
+ use std::os::unix::fs::PermissionsExt;
+
+ if entry.dir()? {
+ continue;
+ }
+
+ let Some(mode) = entry.unix_permissions() else {
+ continue;
+ };
+
+ // If the file is included multiple times, ensure that the mode is consistent.
+ match modes.entry(relpath.clone()) {
+ std::collections::hash_map::Entry::Vacant(entry) => {
+ entry.insert(mode);
+ }
+ std::collections::hash_map::Entry::Occupied(entry) => {
+ if mode != *entry.get() {
+ return Err(Error::DuplicateExecutableFileHeader {
+ path: relpath.clone(),
+ });
+ }
+ }
+ }
+
+ // 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 {
+ let path = target.join(relpath);
+ let permissions = fs_err::tokio::metadata(&path).await?.permissions();
+ if permissions.mode() & 0o111 != 0o111 {
+ fs_err::tokio::set_permissions(
+ &path,
+ Permissions::from_mode(permissions.mode() | 0o111),
+ )
+ .await?;
+ }
+ }
+ }
+ }
+ Entry::EndOfCentralDirectoryRecord {
+ record,
+ comment,
+ extensible,
+ } => {
+ // Reject ZIP64 end-of-central-directory records with extensible data, as the safety
+ // tradeoffs don't outweigh the usefulness. We don't ever expect to encounter wheels
+ // that leverage this feature anyway.
+ if extensible {
+ if !skip_validation {
+ return Err(Error::ExtensibleData);
+ }
+ }
+
+ // Sanitize the comment by rejecting bytes `01` to `08`. If the comment contains an
+ // embedded ZIP file, it _must_ contain one of these bytes, which are otherwise
+ // very rare (non-printing) characters.
+ if comment.as_bytes().iter().any(|&b| (1..=8).contains(&b)) {
+ if !skip_validation {
+ return Err(Error::ZipInZip);
+ }
+ }
+
+ // Validate that the reported number of entries match what we experienced while
+ // reading the local file headers.
+ if record.num_entries() != num_entries {
+ if !skip_validation {
+ return Err(Error::ConflictingNumberOfEntries {
+ expected: num_entries,
+ actual: record.num_entries(),
+ });
+ }
+ }
+
+ break;
+ }
+ }
+ }
+
+ // If we didn't see the file in the central directory, it means it was not present in the
+ // archive.
+ if !skip_validation {
+ if let Some((key, value)) = local_headers.iter().next() {
+ return Err(Error::MissingCentralDirectoryEntry {
+ offset: *key,
+ path: value.relpath.clone(),
+ });
+ }
+ }
+
+ // Determine whether the reader is exhausted.
+ if !skip_validation {
+ let mut buffer = [0; 1];
+ if reader.read(&mut buffer).await? > 0 {
+ // If the buffer contains a single null byte, ignore it.
+ if buffer[0] == 0 {
+ if reader.read(&mut buffer).await? > 0 {
+ return Err(Error::TrailingContents);
+ }
+
+ warn!("Ignoring trailing null byte in ZIP archive");
+ } else {
+ return Err(Error::TrailingContents);
}
}
}
Index: uv-0.7.18/crates/uv-metadata/src/lib.rs
===================================================================
--- uv-0.7.18.orig/crates/uv-metadata/src/lib.rs
+++ uv-0.7.18/crates/uv-metadata/src/lib.rs
@@ -282,7 +282,7 @@ pub async fn read_metadata_async_stream<
// Close current file to get access to the next one. See docs:
// https://docs.rs/async_zip/0.0.16/async_zip/base/read/stream/
- zip = entry.skip().await?;
+ (.., zip) = entry.skip().await?;
}
Err(Error::MissingDistInfo)
Index: uv-0.7.18/crates/uv-static/src/env_vars.rs
===================================================================
--- uv-0.7.18.orig/crates/uv-static/src/env_vars.rs
+++ uv-0.7.18/crates/uv-static/src/env_vars.rs
@@ -228,6 +228,14 @@ impl EnvVars {
/// Equivalent to the `--allow-insecure-host` argument.
pub const UV_INSECURE_HOST: &'static str = "UV_INSECURE_HOST";
+ /// Disable ZIP validation for streamed wheels and ZIP-based source distributions.
+ ///
+ /// WARNING: Disabling ZIP validation can expose your system to security risks by bypassing
+ /// integrity checks and allowing uv to install potentially malicious ZIP files. If uv rejects
+ /// a ZIP file due to failing validation, it is likely that the file is malformed; consider
+ /// filing an issue with the package maintainer.
+ pub const UV_INSECURE_NO_ZIP_VALIDATION: &'static str = "UV_INSECURE_NO_ZIP_VALIDATION";
+
/// Sets the maximum number of in-flight concurrent downloads that uv will
/// perform at any given time.
pub const UV_CONCURRENT_DOWNLOADS: &'static str = "UV_CONCURRENT_DOWNLOADS";
Index: uv-0.7.18/crates/uv/Cargo.toml
===================================================================
--- uv-0.7.18.orig/crates/uv/Cargo.toml
+++ uv-0.7.18/crates/uv/Cargo.toml
@@ -79,6 +79,7 @@ indexmap = { workspace = true }
indicatif = { workspace = true }
indoc = { workspace = true }
itertools = { workspace = true }
+h2 = {workspace = true }
jiff = { workspace = true }
miette = { workspace = true, features = ["fancy-no-backtrace"] }
owo-colors = { workspace = true }
@@ -93,6 +94,7 @@ tempfile = { workspace = true }
textwrap = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
+tokio-util = { workspace = true }
toml = { workspace = true }
toml_edit = { workspace = true }
tracing = { workspace = true }
Index: uv-0.7.18/crates/uv/tests/it/extract.rs
===================================================================
--- /dev/null
+++ uv-0.7.18/crates/uv/tests/it/extract.rs
@@ -0,0 +1,382 @@
+use futures::TryStreamExt;
+use tokio_util::compat::FuturesAsyncReadCompatExt;
+
+async fn unzip(url: &str) -> anyhow::Result<(), uv_extract::Error> {
+ let response = reqwest::get(url).await.unwrap();
+ let reader = response
+ .bytes_stream()
+ .map_err(std::io::Error::other)
+ .into_async_read();
+
+ let target = tempfile::TempDir::new()?;
+ uv_extract::stream::unzip(reader.compat(), target.path()).await
+}
+
+#[tokio::test]
+async fn malo_accept_comment() {
+ unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/accept/comment.zip").await.unwrap();
+ insta::assert_debug_snapshot!((), @"()");
+}
+
+#[tokio::test]
+async fn malo_accept_data_descriptor_zip64() {
+ unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/accept/data_descriptor_zip64.zip").await.unwrap();
+ insta::assert_debug_snapshot!((), @"()");
+}
+
+#[tokio::test]
+async fn malo_accept_data_descriptor() {
+ unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/accept/data_descriptor.zip").await.unwrap();
+ insta::assert_debug_snapshot!((), @"()");
+}
+
+#[tokio::test]
+async fn malo_accept_deflate() {
+ unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/accept/deflate.zip").await.unwrap();
+ insta::assert_debug_snapshot!((), @"()");
+}
+
+#[tokio::test]
+async fn malo_accept_normal_deflate_zip64_extra() {
+ unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/accept/normal_deflate_zip64_extra.zip").await.unwrap();
+ insta::assert_debug_snapshot!((), @"()");
+}
+
+#[tokio::test]
+async fn malo_accept_normal_deflate() {
+ unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/accept/normal_deflate.zip").await.unwrap();
+ insta::assert_debug_snapshot!((), @"()");
+}
+
+#[tokio::test]
+async fn malo_accept_store() {
+ unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/accept/store.zip").await.unwrap();
+ insta::assert_debug_snapshot!((), @"()");
+}
+
+#[tokio::test]
+async fn malo_accept_subdir() {
+ unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/accept/subdir.zip").await.unwrap();
+ insta::assert_debug_snapshot!((), @"()");
+}
+
+#[tokio::test]
+async fn malo_accept_zip64_eocd() {
+ unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/accept/zip64_eocd.zip").await.unwrap();
+ insta::assert_debug_snapshot!((), @"()");
+}
+
+#[tokio::test]
+async fn malo_iffy_8bitcomment() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/iffy/8bitcomment.zip").await;
+ insta::assert_debug_snapshot!(result, @r"
+ Err(
+ ZipInZip,
+ )
+ ");
+}
+
+#[tokio::test]
+async fn malo_iffy_extra3byte() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/iffy/extra3byte.zip").await;
+ insta::assert_debug_snapshot!(result, @r"
+ Ok(
+ (),
+ )
+ ");
+}
+
+#[tokio::test]
+async fn malo_iffy_non_ascii_original_name() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/iffy/non_ascii_original_name.zip").await;
+ insta::assert_debug_snapshot!(result, @r"
+ Err(
+ LocalHeaderNotUtf8 {
+ offset: 0,
+ },
+ )
+ ");
+}
+
+#[tokio::test]
+async fn malo_iffy_nosubdir() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/iffy/nosubdir.zip").await;
+ insta::assert_debug_snapshot!(result, @r"
+ Ok(
+ (),
+ )
+ ");
+}
+
+#[tokio::test]
+async fn malo_iffy_prefix() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/iffy/prefix.zip").await;
+ insta::assert_debug_snapshot!(result, @r"
+ Err(
+ AsyncZip(
+ UnexpectedHeaderError(
+ 1482184792,
+ 67324752,
+ ),
+ ),
+ )
+ ");
+}
+
+#[tokio::test]
+async fn malo_iffy_suffix_not_comment() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/iffy/suffix_not_comment.zip").await;
+ insta::assert_debug_snapshot!(result, @r"
+ Err(
+ TrailingContents,
+ )
+ ");
+}
+
+#[tokio::test]
+async fn malo_iffy_zip64_eocd_extensible_data() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/iffy/zip64_eocd_extensible_data.zip").await;
+ insta::assert_debug_snapshot!(result, @r"
+ Err(
+ ExtensibleData,
+ )
+ ");
+}
+
+#[tokio::test]
+async fn malo_iffy_zip64_extra_too_long() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/iffy/zip64_extra_too_long.zip").await;
+ insta::assert_debug_snapshot!(result, @r"
+ Err(
+ AsyncZip(
+ Zip64ExtendedInformationFieldTooLong {
+ expected: 16,
+ actual: 8,
+ },
+ ),
+ )
+ ");
+}
+
+#[tokio::test]
+async fn malo_iffy_zip64_extra_too_short() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/iffy/zip64_extra_too_short.zip").await;
+ insta::assert_debug_snapshot!(result, @r#"
+ Err(
+ BadCompressedSize {
+ path: "fixme",
+ computed: 7,
+ expected: 4294967295,
+ },
+ )
+ "#);
+}
+
+#[tokio::test]
+async fn malo_reject_cd_extra_entry() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/reject/cd_extra_entry.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r#"
+ MissingLocalFileHeader {
+ path: "fixme",
+ offset: 0,
+ }
+ "#);
+}
+
+#[tokio::test]
+async fn malo_reject_cd_missing_entry() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/reject/cd_missing_entry.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r#"
+ MissingCentralDirectoryEntry {
+ path: "two",
+ offset: 42,
+ }
+ "#);
+}
+
+#[tokio::test]
+async fn malo_reject_data_descriptor_bad_crc_0() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/reject/data_descriptor_bad_crc_0.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r#"
+ BadCrc32 {
+ path: "fixme",
+ computed: 2183870971,
+ expected: 0,
+ }
+ "#);
+}
+
+#[tokio::test]
+async fn malo_reject_data_descriptor_bad_crc() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/reject/data_descriptor_bad_crc.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r#"
+ BadCrc32 {
+ path: "fixme",
+ computed: 907060870,
+ expected: 1,
+ }
+ "#);
+}
+
+#[tokio::test]
+async fn malo_reject_data_descriptor_bad_csize() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/reject/data_descriptor_bad_csize.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r#"
+ BadCompressedSize {
+ path: "fixme",
+ computed: 7,
+ expected: 8,
+ }
+ "#);
+}
+
+#[tokio::test]
+async fn malo_reject_data_descriptor_bad_usize_no_sig() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/reject/data_descriptor_bad_usize_no_sig.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r#"
+ BadUncompressedSize {
+ path: "fixme",
+ computed: 5,
+ expected: 6,
+ }
+ "#);
+}
+
+#[tokio::test]
+async fn malo_reject_data_descriptor_bad_usize() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/reject/data_descriptor_bad_usize.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r#"
+ BadUncompressedSize {
+ path: "fixme",
+ computed: 5,
+ expected: 6,
+ }
+ "#);
+}
+
+#[tokio::test]
+async fn malo_reject_data_descriptor_zip64_csize() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/reject/data_descriptor_zip64_csize.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r#"
+ BadCompressedSize {
+ path: "fixme",
+ computed: 7,
+ expected: 8,
+ }
+ "#);
+}
+
+#[tokio::test]
+async fn malo_reject_data_descriptor_zip64_usize() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/reject/data_descriptor_zip64_usize.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r#"
+ BadUncompressedSize {
+ path: "fixme",
+ computed: 5,
+ expected: 6,
+ }
+ "#);
+}
+
+#[tokio::test]
+async fn malo_reject_dupe_eocd() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/reject/dupe_eocd.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @"TrailingContents");
+}
+
+#[tokio::test]
+async fn malo_reject_shortextra() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/reject/shortextra.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r"
+ AsyncZip(
+ InvalidExtraFieldHeader(
+ 9,
+ ),
+ )
+ ");
+}
+
+#[tokio::test]
+async fn malo_reject_zip64_extra_csize() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/reject/zip64_extra_csize.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r#"
+ BadCompressedSize {
+ path: "fixme",
+ computed: 7,
+ expected: 8,
+ }
+ "#);
+}
+
+#[tokio::test]
+async fn malo_reject_zip64_extra_usize() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/reject/zip64_extra_usize.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r#"
+ BadUncompressedSize {
+ path: "fixme",
+ computed: 5,
+ expected: 6,
+ }
+ "#);
+}
+
+#[tokio::test]
+async fn malo_malicious_second_unicode_extra() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/malicious/second_unicode_extra.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r"
+ AsyncZip(
+ DuplicateExtraFieldHeader(
+ 28789,
+ ),
+ )
+ ");
+}
+
+#[tokio::test]
+async fn malo_malicious_short_usize_zip64() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/malicious/short_usize_zip64.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r"
+ AsyncZip(
+ Zip64ExtendedInformationFieldTooLong {
+ expected: 16,
+ actual: 0,
+ },
+ )
+ ");
+}
+
+#[tokio::test]
+async fn malo_malicious_short_usize() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/malicious/short_usize.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r#"
+ BadUncompressedSize {
+ path: "file",
+ computed: 51,
+ expected: 9,
+ }
+ "#);
+}
+
+#[tokio::test]
+async fn malo_malicious_zip64_eocd_confusion() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/malicious/zip64_eocd_confusion.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @"ExtensibleData");
+}
+
+#[tokio::test]
+async fn malo_malicious_unicode_extra_chain() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/malicious/unicode_extra_chain.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @r"
+ AsyncZip(
+ DuplicateExtraFieldHeader(
+ 28789,
+ ),
+ )
+ ");
+}
+
+#[tokio::test]
+async fn malo_malicious_zipinzip() {
+ let result = unzip("https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/0723f54ceb33a4fdc7f2eddc19635cd704d61c84/malicious/zipinzip.zip").await.unwrap_err();
+ insta::assert_debug_snapshot!(result, @"ZipInZip");
+}
Index: uv-0.7.18/crates/uv/tests/it/main.rs
===================================================================
--- uv-0.7.18.orig/crates/uv/tests/it/main.rs
+++ uv-0.7.18/crates/uv/tests/it/main.rs
@@ -128,4 +128,5 @@ mod version;
#[cfg(all(feature = "python", feature = "pypi"))]
mod workflow;
+mod extract;
mod workspace;
Index: uv-0.7.18/crates/uv/tests/it/pip_install.rs
===================================================================
--- uv-0.7.18.orig/crates/uv/tests/it/pip_install.rs
+++ uv-0.7.18/crates/uv/tests/it/pip_install.rs
@@ -9239,7 +9239,7 @@ fn bad_crc32() -> Result<()> {
Resolved 7 packages in [TIME]
× Failed to download `osqp @ https://files.pythonhosted.org/packages/00/04/5959347582ab970e9b922f27585d34f7c794ed01125dac26fb4e7dd80205/osqp-1.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl`
├─▶ Failed to extract archive: osqp-1.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- ╰─▶ Bad CRC (got ca5f1131, expected d5c95dfa) for file: osqp/ext_builtin.cpython-311-x86_64-linux-gnu.so
+ ╰─▶ Bad uncompressed size (got 0007b829, expected 0007b828) for file: osqp/ext_builtin.cpython-311-x86_64-linux-gnu.so
"
);
@@ -11508,3 +11508,173 @@ fn conflicting_flags_clap_bug() {
"
);
}
+
+#[test]
+fn reject_invalid_streaming_zip() {
+ let context = TestContext::new("3.12").with_exclude_newer("2025-07-10T00:00:00Z");
+
+ uv_snapshot!(context.filters(), context.pip_install()
+ .arg("cbwheelstreamtest==0.0.1"), @r"
+ success: false
+ exit_code: 1
+ ----- stdout -----
+
+ ----- stderr -----
+ Resolved 1 package in [TIME]
+ × Failed to download `cbwheelstreamtest==0.0.1`
+ ├─▶ Failed to extract archive: cbwheelstreamtest-0.0.1-py2.py3-none-any.whl
+ ╰─▶ ZIP file contains multiple entries with different contents for: cbwheelstreamtest/__init__.py
+ "
+ );
+}
+
+#[test]
+fn reject_invalid_double_zip() {
+ let context = TestContext::new("3.12").with_exclude_newer("2025-07-10T00:00:00Z");
+
+ uv_snapshot!(context.filters(), context.pip_install()
+ .arg("cbwheelziptest==0.0.2"), @r"
+ success: false
+ exit_code: 1
+ ----- stdout -----
+
+ ----- stderr -----
+ Resolved 2 packages in [TIME]
+ × Failed to download `cbwheelziptest==0.0.2`
+ ├─▶ Failed to extract archive: cbwheelziptest-0.0.2-py2.py3-none-any.whl
+ ╰─▶ ZIP file contains trailing contents after the end-of-central-directory record
+ "
+ );
+}
+
+#[test]
+fn reject_invalid_central_directory_offset() {
+ let context = TestContext::new("3.12");
+
+ uv_snapshot!(context.filters(), context.pip_install()
+ .arg("attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip1/attrs-25.3.0-py3-none-any.whl"), @r"
+ success: false
+ exit_code: 1
+ ----- stdout -----
+
+ ----- stderr -----
+ Resolved 1 package in [TIME]
+ × Failed to download `attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip1/attrs-25.3.0-py3-none-any.whl`
+ ├─▶ Failed to extract archive: attrs-25.3.0-py3-none-any.whl
+ ├─▶ Failed to read from zip file
+ ╰─▶ the end of central directory offset (0xf0d9) did not match the actual offset (0xf9ac)
+ "
+ );
+}
+
+#[test]
+fn reject_invalid_crc32_mismatch() {
+ let context = TestContext::new("3.12");
+
+ uv_snapshot!(context.filters(), context.pip_install()
+ .arg("attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip2/attrs-25.3.0-py3-none-any.whl"), @r"
+ success: false
+ exit_code: 1
+ ----- stdout -----
+
+ ----- stderr -----
+ Resolved 1 package in [TIME]
+ × Failed to download `attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip2/attrs-25.3.0-py3-none-any.whl`
+ ├─▶ Failed to extract archive: attrs-25.3.0-py3-none-any.whl
+ ╰─▶ Bad uncompressed size (got 0000001b, expected 0000000c) for file: sitecustomize.py
+ "
+ );
+}
+
+#[test]
+fn reject_invalid_crc32_non_data_descriptor() {
+ let context = TestContext::new("3.12");
+
+ uv_snapshot!(context.filters(), context.pip_install()
+ .arg("attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip3/attrs-25.3.0-py3-none-any.whl"), @r"
+ success: false
+ exit_code: 1
+ ----- stdout -----
+
+ ----- stderr -----
+ Resolved 1 package in [TIME]
+ × Failed to download `attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip3/attrs-25.3.0-py3-none-any.whl`
+ ├─▶ Failed to extract archive: attrs-25.3.0-py3-none-any.whl
+ ╰─▶ Bad uncompressed size (got 0000001b, expected 0000000c) for file: sitecustomize.py
+ "
+ );
+}
+
+#[test]
+fn reject_invalid_duplicate_extra_field() {
+ let context = TestContext::new("3.12");
+
+ uv_snapshot!(context.filters(), context.pip_install()
+ .arg("attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip4/attrs-25.3.0-py3-none-any.whl"), @r"
+ success: false
+ exit_code: 1
+ ----- stdout -----
+
+ ----- stderr -----
+ × Failed to download `attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip4/attrs-25.3.0-py3-none-any.whl`
+ ├─▶ Failed to unzip wheel: attrs-25.3.0-py3-none-any.whl
+ ╰─▶ an extra field with id 0x7075 was duplicated in the header
+ "
+ );
+}
+
+#[test]
+fn reject_invalid_short_usize() {
+ let context = TestContext::new("3.12");
+
+ uv_snapshot!(context.filters(), context.pip_install()
+ .arg("attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip5/attrs-25.3.0-py3-none-any.whl"), @r"
+ success: false
+ exit_code: 1
+ ----- stdout -----
+
+ ----- stderr -----
+ Resolved 1 package in [TIME]
+ × Failed to download `attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip5/attrs-25.3.0-py3-none-any.whl`
+ ├─▶ Failed to extract archive: attrs-25.3.0-py3-none-any.whl
+ ╰─▶ Bad CRC (got 5100f20e, expected de0ffd6e) for file: attr/_make.py
+ "
+ );
+}
+
+#[test]
+fn reject_invalid_chained_extra_field() {
+ let context = TestContext::new("3.12");
+
+ uv_snapshot!(context.filters(), context.pip_install()
+ .arg("attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip6/attrs-25.3.0-py3-none-any.whl"), @r"
+ success: false
+ exit_code: 1
+ ----- stdout -----
+
+ ----- stderr -----
+ × Failed to download `attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip6/attrs-25.3.0-py3-none-any.whl`
+ ├─▶ Failed to unzip wheel: attrs-25.3.0-py3-none-any.whl
+ ╰─▶ an extra field with id 0x7075 was duplicated in the header
+ "
+ );
+}
+
+#[test]
+fn reject_invalid_short_usize_zip64() {
+ let context = TestContext::new("3.12");
+
+ uv_snapshot!(context.filters(), context.pip_install()
+ .arg("attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip7/attrs-25.3.0-py3-none-any.whl"), @r"
+ success: false
+ exit_code: 1
+ ----- stdout -----
+
+ ----- stderr -----
+ × Failed to download `attrs @ https://pub-c6f28d316acd406eae43501e51ad30fa.r2.dev/zip7/attrs-25.3.0-py3-none-any.whl`
+ ├─▶ Failed to unzip wheel: attrs-25.3.0-py3-none-any.whl
+ ╰─▶ zip64 extended information field was too long: expected 16 bytes, but 0 bytes were provided
+ "
+ );
+}
+
Index: uv-0.7.18/docs/reference/environment.md
===================================================================
--- uv-0.7.18.orig/docs/reference/environment.md
+++ uv-0.7.18/docs/reference/environment.md
@@ -143,6 +143,15 @@ the environment variable key would be `U
Equivalent to the `--allow-insecure-host` argument.
+### `UV_INSECURE_NO_ZIP_VALIDATION`
+
+Disable ZIP validation for streamed wheels and ZIP-based source distributions.
+
+WARNING: Disabling ZIP validation can expose your system to security risks by bypassing
+integrity checks and allowing uv to install potentially malicious ZIP files. If uv rejects
+a ZIP file due to failing validation, it is likely that the file is malformed; consider
+filing an issue with the package maintainer.
+
### `UV_INSTALLER_GHE_BASE_URL`
The URL from which to download uv using the standalone installer and `self update` feature,