Remove bz/lzma/xz support from ZIP handling

Signed-off-by: William Woodruff <william@astral.sh>

Remove CompressionMethod APIs entirely

Signed-off-by: William Woodruff <william@astral.sh>

Tear out the rest of the deprecation pathways

Signed-off-by: William Woodruff <william@astral.sh>

Fix `cargo shear`

Signed-off-by: William Woodruff <william@astral.sh>

Bump snapshots

Signed-off-by: William Woodruff <william@astral.sh>

`cargo fmt`

Signed-off-by: William Woodruff <william@astral.sh>

Specialize error messages for unsupported compression methods in zip files

Signed-off-by: William Woodruff <william@astral.sh>

Simplify matches

Signed-off-by: William Woodruff <william@astral.sh>
This commit is contained in:
William Woodruff
2026-04-08 13:32:11 -04:00
committed by William Woodruff
parent a032056d2d
commit 6abcbf3bb9
15 changed files with 64 additions and 138 deletions
-3
View File
@@ -110,11 +110,8 @@ async-compression = { version = "0.4.12", features = [
async-trait = { version = "0.1.82" }
async_http_range_reader = { version = "0.11.0", package = "astral_async_http_range_reader" }
async_zip = { version = "0.0.18", package = "astral_async_zip", features = [
"bzip2",
"deflate",
"lzma",
"tokio",
"xz",
"zstd",
] }
axoupdater = { version = "0.10.0", default-features = false }
+3 -8
View File
@@ -841,14 +841,9 @@ async fn download_and_unpack(
let id = reporter.on_download_start(binary.name(), version, size);
let mut progress_reader = ProgressReader::new(reader, id, reporter);
stream::archive(
&download_url,
&mut progress_reader,
format.into(),
temp_dir.path(),
)
.await
.map_err(|e| Error::Extract { source: e })?;
stream::archive(&mut progress_reader, format.into(), temp_dir.path())
.await
.map_err(|e| Error::Extract { source: e })?;
reporter.on_download_complete(id);
// Find the binary in the extracted files
+1 -1
View File
@@ -47,7 +47,7 @@ pub(crate) async fn validate_zip(
let target = tempfile::TempDir::new()?;
uv_extract::stream::unzip(args.url.to_url(), reader.compat(), target.path()).await?;
uv_extract::stream::unzip(reader.compat(), target.path()).await?;
Ok(())
}
@@ -714,8 +714,6 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
// Create an entry for the HTTP cache.
let http_entry = wheel_entry.with_file(format!("{}.http", filename.cache_key()));
let query_url = &url.clone();
let download = |response: reqwest::Response| {
async {
let size = size.or_else(|| content_length(&response));
@@ -744,7 +742,7 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
let mut reader = ProgressReader::new(&mut hasher, progress, &**reporter);
match extension {
WheelExtension::Whl => {
uv_extract::stream::unzip(query_url, &mut reader, temp_dir.path())
uv_extract::stream::unzip(&mut reader, temp_dir.path())
.await
.map_err(|err| Error::Extract(filename.to_string(), err))?
}
@@ -757,7 +755,7 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
}
None => match extension {
WheelExtension::Whl => {
uv_extract::stream::unzip(query_url, &mut hasher, temp_dir.path())
uv_extract::stream::unzip(&mut hasher, temp_dir.path())
.await
.map_err(|err| Error::Extract(filename.to_string(), err))?
}
@@ -1129,11 +1127,9 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
// Unzip the wheel to a temporary directory.
let files = match extension {
WheelExtension::Whl => {
uv_extract::stream::unzip(path.display(), &mut hasher, temp_dir.path())
.await
.map_err(|err| Error::Extract(filename.to_string(), err))?
}
WheelExtension::Whl => uv_extract::stream::unzip(&mut hasher, temp_dir.path())
.await
.map_err(|err| Error::Extract(filename.to_string(), err))?,
WheelExtension::WhlZst => {
uv_extract::stream::untar_zst(&mut hasher, temp_dir.path())
.await
+4 -9
View File
@@ -971,8 +971,6 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
};
let download = |response| {
let query_url = url.clone();
async {
// At this point, we're seeing a new or updated source distribution. Initialize a
// new revision, to collect the source and built artifacts.
@@ -983,7 +981,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
let entry = cache_shard.shard(revision.id()).entry(SOURCE);
let algorithms = http_hash_algorithms(hashes);
let hashes = self
.download_archive(query_url, response, source, ext, entry.path(), &algorithms)
.download_archive(response, source, ext, entry.path(), &algorithms)
.await?;
Ok(revision.with_hashes(HashDigests::from(hashes)))
@@ -2723,8 +2721,6 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
};
let download = |response| {
let query_url = url.clone();
async {
// Take the union of the requested and existing hash algorithms.
let algorithms = {
@@ -2738,7 +2734,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
};
let hashes = self
.download_archive(query_url, response, source, ext, entry.path(), &algorithms)
.download_archive(response, source, ext, entry.path(), &algorithms)
.await?;
for existing in revision.hashes() {
if !hashes.contains(existing) {
@@ -2772,7 +2768,6 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
/// Download and unzip a source distribution into the cache from an HTTP response.
async fn download_archive(
&self,
query_url: DisplaySafeUrl,
response: Response,
source: &BuildableSource<'_>,
ext: SourceDistExtension,
@@ -2801,7 +2796,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
// Download and unzip the source distribution into a temporary directory.
let span = info_span!("download_source_dist", source_dist = %source);
uv_extract::stream::archive(query_url, &mut hasher, ext, temp_dir.path())
uv_extract::stream::archive(&mut hasher, ext, temp_dir.path())
.await
.map_err(|err| Error::Extract(source.to_string(), err))?;
drop(span);
@@ -2870,7 +2865,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
let mut hasher = uv_extract::hash::HashReader::new(reader, &mut hashers);
// Unzip the archive into a temporary directory.
uv_extract::stream::archive(path.display(), &mut hasher, ext, &temp_dir.path())
uv_extract::stream::archive(&mut hasher, ext, &temp_dir.path())
.await
.map_err(|err| Error::Extract(temp_dir.path().to_string_lossy().into_owned(), err))?;
-1
View File
@@ -20,7 +20,6 @@ uv-configuration = { workspace = true }
uv-distribution-filename = { workspace = true }
uv-pypi-types = { workspace = true }
uv-static = { workspace = true }
uv-warnings = { workspace = true }
astral-tokio-tar = { workspace = true }
async-compression = { workspace = true, features = ["bzip2", "gzip", "zstd", "xz"] }
+15 -2
View File
@@ -5,7 +5,7 @@ pub enum Error {
#[error("I/O operation failed during extraction")]
Io(#[source] std::io::Error),
#[error("Invalid zip file structure")]
AsyncZip(#[from] async_zip::error::ZipError),
AsyncZip(#[source] async_zip::error::ZipError),
#[error("Invalid tar file")]
Tar(#[from] tokio_tar::TarError),
#[error(
@@ -91,6 +91,19 @@ pub enum Error {
EmptyFilename,
#[error("Archive contains unacceptable filename: {filename}")]
UnacceptableFilename { filename: String },
#[error(
"Archive contains a file with an unsupported compression method; files must be compressed with 'stored', 'DEFLATE', or 'zstd'"
)]
UnsupportedCompression,
}
impl From<async_zip::error::ZipError> for Error {
fn from(err: async_zip::error::ZipError) -> Self {
match err {
async_zip::error::ZipError::CompressionNotSupported(_) => Self::UnsupportedCompression,
o => Self::AsyncZip(o),
}
}
}
impl Error {
@@ -108,7 +121,7 @@ impl Error {
Err(err) => err,
};
let err = match err.downcast::<async_zip::error::ZipError>() {
Ok(zip_err) => return Self::AsyncZip(zip_err),
Ok(zip_err) => return Self::from(zip_err),
Err(err) => err,
};
Self::Io(err)
+1 -45
View File
@@ -1,4 +1,4 @@
use std::{fmt::Display, sync::LazyLock};
use std::sync::LazyLock;
pub use error::Error;
use regex::Regex;
@@ -14,50 +14,6 @@ mod vendor;
static CONTROL_CHARACTERS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\p{C}").unwrap());
static REPLACEMENT_CHARACTER: &str = "\u{FFFD}";
/// Compression methods that we consider supported.
///
/// Our underlying ZIP dependencies may support more.
pub(crate) enum CompressionMethod {
Stored,
Deflated,
Zstd,
// NOTE: This will become `Unsupported(...)` in the future.
Deprecated(&'static str),
}
impl CompressionMethod {
/// Returns `true` if this is a well-known compression method that we
/// expect other ZIP implementations to support.
fn is_well_known(&self) -> bool {
matches!(self, Self::Stored | Self::Deflated | Self::Zstd)
}
}
impl Display for CompressionMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Stored => write!(f, "stored"),
Self::Deflated => write!(f, "DEFLATE"),
Self::Zstd => write!(f, "zstd"),
Self::Deprecated(name) => write!(f, "{name}"),
}
}
}
impl From<async_zip::Compression> for CompressionMethod {
fn from(value: async_zip::Compression) -> Self {
match value {
async_zip::Compression::Stored => Self::Stored,
async_zip::Compression::Deflate => Self::Deflated,
async_zip::Compression::Zstd => Self::Zstd,
async_zip::Compression::Bz => Self::Deprecated("bzip2"),
async_zip::Compression::Lzma => Self::Deprecated("lzma"),
async_zip::Compression::Xz => Self::Deprecated("xz"),
_ => Self::Deprecated("unknown"),
}
}
}
/// Validate that a given filename (e.g. reported by a ZIP archive's
/// local file entries or central directory entries) is "safe" to use.
///
+4 -26
View File
@@ -1,4 +1,3 @@
use std::fmt::Display;
use std::path::{Component, Path, PathBuf};
use std::pin::Pin;
@@ -10,9 +9,8 @@ use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt};
use tracing::{debug, warn};
use uv_distribution_filename::SourceDistExtension;
use uv_warnings::warn_user_once;
use crate::{CompressionMethod, Error, insecure_no_validate, validate_archive_member_name};
use crate::{Error, insecure_no_validate, validate_archive_member_name};
const DEFAULT_BUF_SIZE: usize = 128 * 1024;
@@ -66,12 +64,8 @@ struct ComputedEntry {
/// is already fully on disk, consider using `unzip_archive`, which can use multiple
/// threads to work faster in that case.
///
/// `source_hint` is used for warning messages, to identify the source of the ZIP archive
/// beneath the reader. It might be a URL, a file path, or something else.
///
/// Returns the list of unpacked files and their sizes.
pub async fn unzip<D: Display, R: tokio::io::AsyncRead + Unpin>(
source_hint: D,
pub async fn unzip<R: tokio::io::AsyncRead + Unpin>(
reader: R,
target: impl AsRef<Path>,
) -> Result<Vec<(PathBuf, u64)>, Error> {
@@ -90,18 +84,6 @@ pub async fn unzip<D: Display, R: tokio::io::AsyncRead + Unpin>(
while let Some(mut entry) = zip.next_with_entry().await? {
let zip_entry = entry.reader().entry();
// Check for unexpected compression methods.
// A future version of uv will reject instead of warning about these.
let compression = CompressionMethod::from(zip_entry.compression());
if !compression.is_well_known() {
warn_user_once!(
"One or more file entries in '{source_hint}' use the '{compression}' compression method, which is not widely supported. A future version of uv will reject ZIP archives containing entries compressed with this method. Entries must be compressed with the '{stored}', '{deflate}', or '{zstd}' compression methods.",
stored = CompressionMethod::Stored,
deflate = CompressionMethod::Deflated,
zstd = CompressionMethod::Zstd,
);
}
// Construct the (expected) path to the file on-disk.
let path = match zip_entry.filename().as_str() {
Ok(path) => path,
@@ -780,18 +762,14 @@ async fn untar<R: tokio::io::AsyncRead + Unpin>(
/// Unpack a `.zip`, `.tar.gz`, `.tar.bz2`, `.tar.zst`, or `.tar.xz` archive into the target directory,
/// without requiring `Seek`.
///
/// `source_hint` is used for warning messages, to identify the source of the archive
/// beneath the reader. It might be a URL, a file path, or something else.
///
/// Returns the list of unpacked files and their sizes.
pub async fn archive<D: Display, R: tokio::io::AsyncRead + Unpin>(
source_hint: D,
pub async fn archive<R: tokio::io::AsyncRead + Unpin>(
reader: R,
ext: SourceDistExtension,
target: impl AsRef<Path>,
) -> Result<Vec<(PathBuf, u64)>, Error> {
match ext {
SourceDistExtension::Zip => unzip(source_hint, reader, target).await,
SourceDistExtension::Zip => unzip(reader, target).await,
SourceDistExtension::Tar => untar(reader, target).await,
SourceDistExtension::Tgz | SourceDistExtension::TarGz => untar_gz(reader, target).await,
SourceDistExtension::Tbz | SourceDistExtension::TarBz2 => untar_bz2(reader, target).await,
+2 -14
View File
@@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::vendor::CloneableSeekableReader;
use crate::{CompressionMethod, Error, insecure_no_validate, validate_archive_member_name};
use crate::{Error, insecure_no_validate, validate_archive_member_name};
use async_zip::base::read::seek::ZipFileReader;
use async_zip::error::ZipError;
use futures::executor::block_on;
@@ -11,13 +11,12 @@ use rayon::prelude::*;
use rustc_hash::FxHashSet;
use tracing::warn;
use uv_configuration::initialize_rayon_once;
use uv_warnings::warn_user_once;
/// Unzip a `.zip` archive into the target directory.
///
/// Returns the list of unpacked files and their sizes.
pub fn unzip(reader: fs_err::File, target: &Path) -> Result<Vec<(PathBuf, u64)>, Error> {
let (reader, filename) = reader.into_parts();
let (reader, _) = reader.into_parts();
// Parse the central directory once, then clone the archive reader per Rayon worker so
// extraction stays parallel for already-downloaded wheels.
@@ -43,17 +42,6 @@ pub fn unzip(reader: fs_err::File, target: &Path) -> Result<Vec<(PathBuf, u64)>,
Err(err) => return Err(err.into()),
};
let compression = CompressionMethod::from(entry.compression());
if !compression.is_well_known() {
warn_user_once!(
"One or more file entries in '{filename}' use the '{compression}' compression method, which is not widely supported. A future version of uv will reject ZIP archives containing entries compressed with this method. Entries must be compressed with the '{stored}', '{deflate}', or '{zstd}' compression methods.",
filename = filename.display(),
stored = CompressionMethod::Stored,
deflate = CompressionMethod::Deflated,
zstd = CompressionMethod::Zstd,
);
}
if let Err(e) = validate_archive_member_name(file_name) {
if !skip_validation {
return Err(e);
+14 -1
View File
@@ -40,12 +40,25 @@ pub enum Error {
expected: u32,
},
#[error("Failed to read from zip file")]
AsyncZip(#[from] async_zip::error::ZipError),
AsyncZip(#[source] async_zip::error::ZipError),
#[error(
"Archive contains a file with an unsupported compression method; files must be compressed with 'stored', 'DEFLATE', or 'zstd'"
)]
UnsupportedCompression,
// No `#[from]` to enforce manual review of `io::Error` sources.
#[error(transparent)]
Io(io::Error),
}
impl From<async_zip::error::ZipError> for Error {
fn from(err: async_zip::error::ZipError) -> Self {
match err {
async_zip::error::ZipError::CompressionNotSupported(_) => Self::UnsupportedCompression,
o => Self::AsyncZip(o),
}
}
}
/// Find the `.dist-info` directory in a zipped wheel.
///
/// Returns the dist info dir prefix without the `.dist-info` extension.
+2 -2
View File
@@ -1421,12 +1421,12 @@ impl ManagedPythonDownload {
if let Some(reporter) = reporter {
let progress_key = reporter.on_request_start(direction, &self.key, size);
let mut reader = ProgressReader::new(&mut hasher, progress_key, reporter);
uv_extract::stream::archive(filename, &mut reader, ext, target)
uv_extract::stream::archive(&mut reader, ext, target)
.await
.map_err(|err| Error::ExtractError(filename.to_owned(), err))?;
reporter.on_request_complete(direction, progress_key);
} else {
uv_extract::stream::archive(filename, &mut hasher, ext, target)
uv_extract::stream::archive(&mut hasher, ext, target)
.await
.map_err(|err| Error::ExtractError(filename.to_owned(), err))?;
}
+2 -3
View File
@@ -759,7 +759,7 @@ async fn build_package(
let ext = SourceDistExtension::from_path(path.as_path())
.map_err(|err| Error::InvalidSourceDistExt(path.user_display().to_string(), err))?;
let temp_dir = tempfile::tempdir_in(cache.bucket(CacheBucket::SourceDistributions))?;
uv_extract::stream::archive(path.display(), reader, ext, temp_dir.path()).await?;
uv_extract::stream::archive(reader, ext, temp_dir.path()).await?;
// Extract the top-level directory from the archive.
let extracted = match uv_extract::strip_component(temp_dir.path()) {
@@ -866,8 +866,7 @@ async fn build_package(
Error::InvalidSourceDistExt(source.path().user_display().to_string(), err)
})?;
let temp_dir = tempfile::tempdir_in(&output_dir)?;
uv_extract::stream::archive(source.path().display(), reader, ext, temp_dir.path())
.await?;
uv_extract::stream::archive(reader, ext, temp_dir.path()).await?;
// If the source distribution has a version in its filename, check the version.
let version = source
+1 -1
View File
@@ -23,7 +23,7 @@ async fn unzip(url: &str) -> anyhow::Result<(), uv_extract::Error> {
.into_async_read();
let target = tempfile::TempDir::new().map_err(uv_extract::Error::Io)?;
uv_extract::stream::unzip(url, reader.compat(), target.path()).await?;
uv_extract::stream::unzip(reader.compat(), target.path()).await?;
Ok(())
}
+10 -13
View File
@@ -16124,7 +16124,7 @@ fn abi_compatibility_on_nondebug_python_with_debug_wheel() {
}
#[test]
fn warn_on_bz2_wheel() {
fn fail_on_bz2_wheel() {
let context = uv_test::test_context!("3.14");
let vendor = FindLinksServer::vendor();
@@ -16133,22 +16133,21 @@ fn warn_on_bz2_wheel() {
context.pip_install()
.arg(format!("futzed_bz2 @ {}/futzed_bz2-0.1.0-py3-none-any.whl", vendor.url())),
@"
success: true
exit_code: 0
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
warning: One or more file entries in 'http://[LOCALHOST]/futzed_bz2-0.1.0-py3-none-any.whl' use the 'bzip2' compression method, which is not widely supported. A future version of uv will reject ZIP archives containing entries compressed with this method. Entries must be compressed with the 'stored', 'DEFLATE', or 'zstd' compression methods.
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ futzed-bz2==0.1.0 (from http://[LOCALHOST]/futzed_bz2-0.1.0-py3-none-any.whl)
WARN Range requests not supported for futzed_bz2-0.1.0-py3-none-any.whl; streaming wheel
× Failed to download `futzed-bz2 @ http://[LOCALHOST]/futzed_bz2-0.1.0-py3-none-any.whl`
Failed to read metadata: `http://[LOCALHOST]/futzed_bz2-0.1.0-py3-none-any.whl`
Archive contains a file with an unsupported compression method; files must be compressed with 'stored', 'DEFLATE', or 'zstd'
"
);
}
#[test]
fn warn_on_lzma_wheel() {
fn fail_on_lzma_wheel() {
let context = uv_test::test_context!("3.14");
let vendor = FindLinksServer::vendor();
@@ -16162,12 +16161,10 @@ fn warn_on_lzma_wheel() {
----- stdout -----
----- stderr -----
WARN Range requests not supported for futzed_lzma-0.1.0-py3-none-any.whl; streaming wheel
× Failed to download `futzed-lzma @ http://[LOCALHOST]/futzed_lzma-0.1.0-py3-none-any.whl`
Request failed after 3 retries in [TIME]
Failed to read metadata: `http://[LOCALHOST]/futzed_lzma-0.1.0-py3-none-any.whl`
Failed to read from zip file
an upstream reader returned an error: stream/file format not recognized
stream/file format not recognized
Archive contains a file with an unsupported compression method; files must be compressed with 'stored', 'DEFLATE', or 'zstd'
"
);
}