diff --git a/Cargo.lock b/Cargo.lock index f32236a911850..52c04f2995ae1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5177,6 +5177,7 @@ dependencies = [ "uv-configuration", "uv-distribution-filename", "uv-pypi-types", + "uv-warnings", "xz2", "zip", ] @@ -5347,9 +5348,11 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tokio-util", + "uv-configuration", "uv-distribution-filename", "uv-normalize", "uv-pypi-types", + "uv-warnings", "zip", ] diff --git a/crates/uv-configuration/src/crc.rs b/crates/uv-configuration/src/crc.rs new file mode 100644 index 0000000000000..f558e6bbe4665 --- /dev/null +++ b/crates/uv-configuration/src/crc.rs @@ -0,0 +1,21 @@ +use std::sync::LazyLock; + +use uv_static::EnvVars; + +#[derive(Debug)] +pub enum CRCMode { + /// Fail on CRC mismatch. + Enforce, + /// Warn on CRC mismatch, but continue. + Lax, + /// Skip CRC checks. + None, +} + +/// Lazily initialize CRC mode from `UV_CRC_MODE`. +pub static CURRENT_CRC_MODE: LazyLock = + LazyLock::new(|| match std::env::var(EnvVars::UV_CRC_MODE).as_deref() { + Ok("enforce") => CRCMode::Enforce, + Ok("lax") => CRCMode::Lax, + _ => CRCMode::None, + }); diff --git a/crates/uv-configuration/src/lib.rs b/crates/uv-configuration/src/lib.rs index 7878693e5c1af..2ec9315019265 100644 --- a/crates/uv-configuration/src/lib.rs +++ b/crates/uv-configuration/src/lib.rs @@ -3,6 +3,7 @@ pub use build_options::*; pub use concurrency::*; pub use config_settings::*; pub use constraints::*; +pub use crc::*; pub use dependency_groups::*; pub use dry_run::*; pub use editable::*; @@ -28,6 +29,7 @@ mod build_options; mod concurrency; mod config_settings; mod constraints; +mod crc; mod dependency_groups; mod dry_run; mod editable; diff --git a/crates/uv-extract/Cargo.toml b/crates/uv-extract/Cargo.toml index fc6c3343bf5ca..bd67096589273 100644 --- a/crates/uv-extract/Cargo.toml +++ b/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-warnings = { workspace = true } astral-tokio-tar = { workspace = true } async-compression = { workspace = true, features = ["bzip2", "gzip", "zstd", "xz"] } diff --git a/crates/uv-extract/src/stream.rs b/crates/uv-extract/src/stream.rs index 7d6b2646da1af..ab0c6f2274b47 100644 --- a/crates/uv-extract/src/stream.rs +++ b/crates/uv-extract/src/stream.rs @@ -6,7 +6,9 @@ use rustc_hash::FxHashSet; use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; use tracing::warn; +use uv_configuration::{CRCMode, CURRENT_CRC_MODE}; use uv_distribution_filename::SourceDistExtension; +use uv_warnings::warn_user; use crate::Error; @@ -86,17 +88,26 @@ pub async fn unzip( let mut reader = entry.reader_mut().compat(); tokio::io::copy(&mut reader, &mut writer).await?; - // 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 { - return Err(Error::BadCrc32 { - path: relpath, - computed, - expected, - }); + if matches!(*CURRENT_CRC_MODE, CRCMode::Enforce | CRCMode::Lax) { + // 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 { + if let CRCMode::Enforce = *CURRENT_CRC_MODE { + return Err(Error::BadCrc32 { + path: relpath, + computed, + expected, + }); + } + warn_user!( + "Bad CRC (got {computed:08x}, expected {expected:08x}) for file: {}", + relpath.display() + ); + } } } diff --git a/crates/uv-metadata/Cargo.toml b/crates/uv-metadata/Cargo.toml index ddbf5b5aab304..b1ce268d0468e 100644 --- a/crates/uv-metadata/Cargo.toml +++ b/crates/uv-metadata/Cargo.toml @@ -13,9 +13,11 @@ license.workspace = true doctest = false [dependencies] +uv-configuration = { workspace = true } uv-distribution-filename = { workspace = true } uv-normalize = { workspace = true } uv-pypi-types = { workspace = true } +uv-warnings = { workspace = true } async_zip = { workspace = true } fs-err = { workspace = true } diff --git a/crates/uv-metadata/src/lib.rs b/crates/uv-metadata/src/lib.rs index d1b38aed2eae7..ae399fd9bda51 100644 --- a/crates/uv-metadata/src/lib.rs +++ b/crates/uv-metadata/src/lib.rs @@ -9,10 +9,13 @@ use std::path::Path; use thiserror::Error; use tokio::io::AsyncReadExt; use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; +use zip::ZipArchive; + +use uv_configuration::{CRCMode, CURRENT_CRC_MODE}; use uv_distribution_filename::WheelFilename; use uv_normalize::{DistInfoName, InvalidNameError}; use uv_pypi_types::ResolutionMetadata; -use zip::ZipArchive; +use uv_warnings::warn_user; /// The caller is responsible for attaching the path or url we failed to read. #[derive(Debug, Error)] @@ -252,17 +255,25 @@ pub async fn read_metadata_async_stream( let mut contents = Vec::new(); reader.read_to_end(&mut contents).await.unwrap(); - // 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 { - return Err(Error::BadCrc32 { - path, - computed, - expected, - }); + if matches!(*CURRENT_CRC_MODE, CRCMode::Enforce | CRCMode::Lax) { + // 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 { + if let CRCMode::Enforce = *CURRENT_CRC_MODE { + return Err(Error::BadCrc32 { + path, + computed, + expected, + }); + } + warn_user!( + "Bad CRC (got {computed:08x}, expected {expected:08x}) for file: {path}" + ); + } } let metadata = ResolutionMetadata::parse_metadata(&contents) diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 7c63809ccfff0..d64b36c1962d4 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -257,6 +257,11 @@ impl EnvVars { /// Specifies the directory for storing managed Python installations. pub const UV_PYTHON_INSTALL_DIR: &'static str = "UV_PYTHON_INSTALL_DIR"; + /// Specifies how CRC validation is performed during unzipping a download stream. + /// + /// Possible values are `enforce`, `lax`, and `none` (default). + pub const UV_CRC_MODE: &'static str = "UV_CRC_MODE"; + /// Managed Python installations are downloaded from the Astral /// [`python-build-standalone`](https://github.com/astral-sh/python-build-standalone) project. /// diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index ac45d38d3588e..f3c1ce1443e4f 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -8913,27 +8913,60 @@ fn missing_subdirectory_url() -> Result<()> { // This wheel was uploaded with a bad crc32 and we weren't detecting that // (Could be replaced with a checked-in hand-crafted corrupt wheel?) #[test] -fn bad_crc32() -> Result<()> { - let context = TestContext::new("3.11"); - let requirements_txt = context.temp_dir.child("requirements.txt"); - requirements_txt.touch()?; +fn bad_crc32() { + let context = TestContext::new("3.11").with_filtered_counts(); - uv_snapshot!(context.pip_install() + uv_snapshot!(context.filters(), context.pip_install() + .env(EnvVars::UV_CRC_MODE, "enforce") .arg("--python-platform").arg("linux") - .arg("osqp @ https://files.pythonhosted.org/packages/00/04/5959347582ab970e9b922f27585d34f7c794ed01125dac26fb4e7dd80205/osqp-1.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"), @r" + .arg("osqp @ https://files.pythonhosted.org/packages/00/04/5959347582ab970e9b922f27585d34f7c794ed01125dac26fb4e7dd80205/osqp-1.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"), @r###" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- - Resolved 7 packages in [TIME] + Resolved [N] 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 - " + "### ); - Ok(()) + uv_snapshot!(context.filters(), context.pip_install() + .env(EnvVars::UV_CRC_MODE, "lax") + .arg("--python-platform").arg("linux") + .arg("osqp @ https://files.pythonhosted.org/packages/00/04/5959347582ab970e9b922f27585d34f7c794ed01125dac26fb4e7dd80205/osqp-1.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + warning: Bad CRC (got ca5f1131, expected d5c95dfa) for file: osqp/ext_builtin.cpython-311-x86_64-linux-gnu.so + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + jinja2==3.1.3 + + joblib==1.3.2 + + markupsafe==2.1.5 + + numpy==1.26.4 + + osqp==1.0.2 (from https://files.pythonhosted.org/packages/00/04/5959347582ab970e9b922f27585d34f7c794ed01125dac26fb4e7dd80205/osqp-1.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl) + + scipy==1.12.0 + + setuptools==69.2.0 + "### + ); + + uv_snapshot!(context.filters(), context.pip_install() + .env(EnvVars::UV_CRC_MODE, "none") + .arg("--python-platform").arg("linux") + .arg("osqp @ https://files.pythonhosted.org/packages/00/04/5959347582ab970e9b922f27585d34f7c794ed01125dac26fb4e7dd80205/osqp-1.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited [N] packages in [TIME] + "### + ); } #[test] diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 3a4412f8ef8bb..8da657fc0c9b2 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -51,6 +51,12 @@ local `uv.toml` file to use as the configuration file. Equivalent to the `--constraint` command-line argument. If set, uv will use this file as the constraints file. Uses space-separated list of files. +### `UV_CRC_MODE` + +Specifies how CRC validation is performed during unzipping a download stream. + +Possible values are `enforce`, `lax`, and `none` (default). + ### `UV_CUSTOM_COMPILE_COMMAND` Equivalent to the `--custom-compile-command` command-line argument.