Add UV_SKIP_WHEEL_FILENAME_CHECK to allow installing invalid wheels (#16046)

## Summary

This PR adds a user setting to allow (in rare cases) accepting wheels
with mismatched filenames and internal metadata.

Closes https://github.com/astral-sh/uv/issues/8082.

Closes https://github.com/astral-sh/uv/issues/15647.
This commit is contained in:
Charlie Marsh 2025-09-29 19:54:25 -04:00 committed by GitHub
parent 170ab1cd7f
commit 7d9ea797b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 159 additions and 101 deletions

2
Cargo.lock generated
View file

@ -6070,6 +6070,7 @@ dependencies = [
"fs-err",
"indoc",
"mailparse",
"owo-colors",
"pathdiff",
"reflink-copy",
"regex",
@ -6090,6 +6091,7 @@ dependencies = [
"uv-preview",
"uv-pypi-types",
"uv-shell",
"uv-static",
"uv-trampoline-builder",
"uv-warnings",
"walkdir",

View file

@ -28,6 +28,7 @@ uv-pep440 = { workspace = true }
uv-preview = { workspace = true }
uv-pypi-types = { workspace = true }
uv-shell = { workspace = true }
uv-static = { workspace = true }
uv-trampoline-builder = { workspace = true }
uv-warnings = { workspace = true }
@ -37,6 +38,7 @@ csv = { workspace = true }
data-encoding = { workspace = true }
fs-err = { workspace = true }
mailparse = { workspace = true }
owo-colors = { workspace = true }
pathdiff = { workspace = true }
reflink-copy = { workspace = true }
regex = { workspace = true }

View file

@ -11,6 +11,7 @@ use tracing::{instrument, trace};
use uv_distribution_filename::WheelFilename;
use uv_pep440::Version;
use uv_pypi_types::{DirectUrl, Metadata10};
use uv_static::{EnvVars, parse_boolish_environment_variable};
use crate::linker::{LinkMode, Locks};
use crate::wheel::{
@ -50,11 +51,21 @@ pub fn install_wheel<Cache: serde::Serialize, Build: serde::Serialize>(
// Validate the wheel name and version.
{
if name != filename.name {
return Err(Error::MismatchedName(name, filename.name.clone()));
if !matches!(
parse_boolish_environment_variable(EnvVars::UV_SKIP_WHEEL_FILENAME_CHECK),
Ok(Some(true))
) {
return Err(Error::MismatchedName(name, filename.name.clone()));
}
}
if version != filename.version && version != filename.version.clone().without_local() {
return Err(Error::MismatchedVersion(version, filename.version.clone()));
if !matches!(
parse_boolish_environment_variable(EnvVars::UV_SKIP_WHEEL_FILENAME_CHECK),
Ok(Some(true))
) {
return Err(Error::MismatchedVersion(version, filename.version.clone()));
}
}
}

View file

@ -3,6 +3,7 @@
use std::io;
use std::path::PathBuf;
use owo_colors::OwoColorize;
use thiserror::Error;
use uv_fs::Simplified;
@ -74,9 +75,9 @@ pub enum Error {
MissingTopLevel(PathBuf),
#[error("Invalid package version")]
InvalidVersion(#[from] uv_pep440::VersionParseError),
#[error("Wheel package name does not match filename: {0} != {1}")]
#[error("Wheel package name does not match filename ({0} != {1}), which indicates a malformed wheel. If this is intentional, set `{env_var}`.", env_var = "UV_SKIP_WHEEL_FILENAME_CHECK=1".green())]
MismatchedName(PackageName, PackageName),
#[error("Wheel version does not match filename: {0} != {1}")]
#[error("Wheel version does not match filename ({0} != {1}), which indicates a malformed wheel. If this is intentional, set `{env_var}`.", env_var = "UV_SKIP_WHEEL_FILENAME_CHECK=1".green())]
MismatchedVersion(Version, Version),
#[error("Invalid egg-link")]
InvalidEggLink(PathBuf),

View file

@ -46,6 +46,7 @@ use uv_pypi_types::{
};
use uv_redacted::DisplaySafeUrl;
use uv_small_str::SmallString;
use uv_static::{EnvVars, parse_boolish_environment_variable};
use uv_types::{BuildContext, HashStrategy};
use uv_workspace::{Editability, WorkspaceMember};
@ -3245,11 +3246,16 @@ impl PackageWire {
if *version != wheel.filename.version
&& *version != wheel.filename.version.clone().without_local()
{
return Err(LockError::from(LockErrorKind::InconsistentVersions {
name: self.id.name,
version: version.clone(),
wheel: wheel.clone(),
}));
if !matches!(
parse_boolish_environment_variable(EnvVars::UV_SKIP_WHEEL_FILENAME_CHECK),
Ok(Some(true))
) {
return Err(LockError::from(LockErrorKind::InconsistentVersions {
name: self.id.name,
version: version.clone(),
wheel: wheel.clone(),
}));
}
}
}
// We can't check the source dist version since it does not need to contain the version
@ -5866,7 +5872,7 @@ enum LockErrorKind {
},
/// A package has inconsistent versions in a single entry
// Using name instead of id since the version in the id is part of the conflict.
#[error("The entry for package `{name}` v{version} has wheel `{wheel_filename}` with inconsistent version: v{wheel_version} ", name = name.cyan(), wheel_filename = wheel.filename, wheel_version = wheel.filename.version)]
#[error("The entry for package `{name}` ({version}) has wheel `{wheel_filename}` with inconsistent version ({wheel_version}), which indicates a malformed wheel. If this is intentional, set `{env_var}`.", name = name.cyan(), wheel_filename = wheel.filename, wheel_version = wheel.filename.version, env_var = "UV_SKIP_WHEEL_FILENAME_CHECK=1".green())]
InconsistentVersions {
/// The name of the package with the inconsistent entry.
name: PackageName,

View file

@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use uv_dirs::{system_config_file, user_config_dir};
use uv_fs::Simplified;
use uv_static::EnvVars;
use uv_static::{EnvVars, parse_boolish_environment_variable, parse_string_environment_variable};
use uv_warnings::warn_user;
pub use crate::combine::*;
@ -554,12 +554,8 @@ pub enum Error {
#[error("Failed to parse: `{}`. The `{}` field is not allowed in a `uv.toml` file. `{}` is only applicable in the context of a project, and should be placed in a `pyproject.toml` file instead.", _0.user_display(), _1, _1)]
PyprojectOnlyField(PathBuf, &'static str),
#[error("Failed to parse environment variable `{name}` with invalid value `{value}`: {err}")]
InvalidEnvironmentVariable {
name: String,
value: String,
err: String,
},
#[error("{0}")]
InvalidEnvironmentVariable(String),
}
/// Options loaded from environment variables.
@ -578,95 +574,28 @@ impl EnvironmentOptions {
/// Create a new [`EnvironmentOptions`] from environment variables.
pub fn new() -> Result<Self, Error> {
Ok(Self {
python_install_bin: parse_boolish_environment_variable(EnvVars::UV_PYTHON_INSTALL_BIN)?,
python_install_bin: parse_boolish_environment_variable(EnvVars::UV_PYTHON_INSTALL_BIN)
.map_err(Error::InvalidEnvironmentVariable)?,
python_install_registry: parse_boolish_environment_variable(
EnvVars::UV_PYTHON_INSTALL_REGISTRY,
)?,
)
.map_err(Error::InvalidEnvironmentVariable)?,
install_mirrors: PythonInstallMirrors {
python_install_mirror: parse_string_environment_variable(
EnvVars::UV_PYTHON_INSTALL_MIRROR,
)?,
)
.map_err(Error::InvalidEnvironmentVariable)?,
pypy_install_mirror: parse_string_environment_variable(
EnvVars::UV_PYPY_INSTALL_MIRROR,
)?,
)
.map_err(Error::InvalidEnvironmentVariable)?,
python_downloads_json_url: parse_string_environment_variable(
EnvVars::UV_PYTHON_DOWNLOADS_JSON_URL,
)?,
)
.map_err(Error::InvalidEnvironmentVariable)?,
},
log_context: parse_boolish_environment_variable(EnvVars::UV_LOG_CONTEXT)?,
log_context: parse_boolish_environment_variable(EnvVars::UV_LOG_CONTEXT)
.map_err(Error::InvalidEnvironmentVariable)?,
})
}
}
/// Parse a boolean environment variable.
///
/// Adapted from Clap's `BoolishValueParser` which is dual licensed under the MIT and Apache-2.0.
fn parse_boolish_environment_variable(name: &'static str) -> Result<Option<bool>, Error> {
// See `clap_builder/src/util/str_to_bool.rs`
// We want to match Clap's accepted values
// True values are `y`, `yes`, `t`, `true`, `on`, and `1`.
const TRUE_LITERALS: [&str; 6] = ["y", "yes", "t", "true", "on", "1"];
// False values are `n`, `no`, `f`, `false`, `off`, and `0`.
const FALSE_LITERALS: [&str; 6] = ["n", "no", "f", "false", "off", "0"];
// Converts a string literal representation of truth to true or false.
//
// `false` values are `n`, `no`, `f`, `false`, `off`, and `0` (case insensitive).
//
// Any other value will be considered as `true`.
fn str_to_bool(val: impl AsRef<str>) -> Option<bool> {
let pat: &str = &val.as_ref().to_lowercase();
if TRUE_LITERALS.contains(&pat) {
Some(true)
} else if FALSE_LITERALS.contains(&pat) {
Some(false)
} else {
None
}
}
let Some(value) = std::env::var_os(name) else {
return Ok(None);
};
let Some(value) = value.to_str() else {
return Err(Error::InvalidEnvironmentVariable {
name: name.to_string(),
value: value.to_string_lossy().to_string(),
err: "expected a valid UTF-8 string".to_string(),
});
};
let Some(value) = str_to_bool(value) else {
return Err(Error::InvalidEnvironmentVariable {
name: name.to_string(),
value: value.to_string(),
err: "expected a boolish value".to_string(),
});
};
Ok(Some(value))
}
/// Parse a string environment variable.
fn parse_string_environment_variable(name: &'static str) -> Result<Option<String>, Error> {
match std::env::var(name) {
Ok(v) => {
if v.is_empty() {
Ok(None)
} else {
Ok(Some(v))
}
}
Err(e) => match e {
std::env::VarError::NotPresent => Ok(None),
std::env::VarError::NotUnicode(err) => Err(Error::InvalidEnvironmentVariable {
name: name.to_string(),
value: err.to_string_lossy().to_string(),
err: "expected a valid UTF-8 string".to_string(),
}),
},
}
}

View file

@ -937,4 +937,10 @@ impl EnvVars {
/// The AWS shared credentials file to use when signing S3 requests.
pub const AWS_SHARED_CREDENTIALS_FILE: &'static str = "AWS_SHARED_CREDENTIALS_FILE";
/// Avoid verifying that wheel filenames match their contents when installing wheels. This
/// is not recommended, as wheels with inconsistent filenames should be considered invalid and
/// corrected by the relevant package maintainers; however, this option can be used to work
/// around invalid artifacts in rare cases.
pub const UV_SKIP_WHEEL_FILENAME_CHECK: &'static str = "UV_SKIP_WHEEL_FILENAME_CHECK";
}

View file

@ -1,3 +1,74 @@
pub use env_vars::*;
mod env_vars;
/// Parse a boolean environment variable.
///
/// Adapted from Clap's `BoolishValueParser` which is dual licensed under the MIT and Apache-2.0.
pub fn parse_boolish_environment_variable(name: &'static str) -> Result<Option<bool>, String> {
// See `clap_builder/src/util/str_to_bool.rs`
// We want to match Clap's accepted values
// True values are `y`, `yes`, `t`, `true`, `on`, and `1`.
const TRUE_LITERALS: [&str; 6] = ["y", "yes", "t", "true", "on", "1"];
// False values are `n`, `no`, `f`, `false`, `off`, and `0`.
const FALSE_LITERALS: [&str; 6] = ["n", "no", "f", "false", "off", "0"];
// Converts a string literal representation of truth to true or false.
//
// `false` values are `n`, `no`, `f`, `false`, `off`, and `0` (case insensitive).
//
// Any other value will be considered as `true`.
fn str_to_bool(val: impl AsRef<str>) -> Option<bool> {
let pat: &str = &val.as_ref().to_lowercase();
if TRUE_LITERALS.contains(&pat) {
Some(true)
} else if FALSE_LITERALS.contains(&pat) {
Some(false)
} else {
None
}
}
let Some(value) = std::env::var_os(name) else {
return Ok(None);
};
let Some(value) = value.to_str() else {
return Err(format!(
"Failed to parse environment variable `{}` with invalid value `{}`: expected a valid UTF-8 string",
name,
value.to_string_lossy()
));
};
let Some(value) = str_to_bool(value) else {
return Err(format!(
"Failed to parse environment variable `{name}` with invalid value `{value}`: expected a boolish value"
));
};
Ok(Some(value))
}
/// Parse a string environment variable.
pub fn parse_string_environment_variable(name: &'static str) -> Result<Option<String>, String> {
match std::env::var(name) {
Ok(v) => {
if v.is_empty() {
Ok(None)
} else {
Ok(Some(v))
}
}
Err(e) => match e {
std::env::VarError::NotPresent => Ok(None),
std::env::VarError::NotUnicode(err) => Err(format!(
"Failed to parse environment variable `{}` with invalid value `{}`: expected a valid UTF-8 string",
name,
err.to_string_lossy()
)),
},
}
}

View file

@ -1233,7 +1233,7 @@ fn mismatched_version() -> Result<()> {
uv_snapshot!(context.filters(), context.pip_sync()
.arg("requirements.txt")
.arg("--strict"), @r###"
.arg("--strict"), @r"
success: false
exit_code: 2
----- stdout -----
@ -1242,8 +1242,23 @@ fn mismatched_version() -> Result<()> {
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
error: Failed to install: tomli-3.7.2-py3-none-any.whl (tomli==3.7.2 (from file://[TEMP_DIR]/tomli-3.7.2-py3-none-any.whl))
Caused by: Wheel version does not match filename: 2.0.1 != 3.7.2
"###
Caused by: Wheel version does not match filename (2.0.1 != 3.7.2), which indicates a malformed wheel. If this is intentional, set `UV_SKIP_WHEEL_FILENAME_CHECK=1`.
"
);
uv_snapshot!(context.filters(), context.pip_sync()
.arg("requirements.txt")
.arg("--strict")
.env(EnvVars::UV_SKIP_WHEEL_FILENAME_CHECK, "1"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
Installed 1 package in [TIME]
+ tomli==3.7.2 (from file://[TEMP_DIR]/tomli-3.7.2-py3-none-any.whl)
"
);
Ok(())

View file

@ -11538,7 +11538,7 @@ fn locked_version_coherence() -> Result<()> {
----- stderr -----
error: Failed to parse `uv.lock`
Caused by: The entry for package `iniconfig` v1.0.0 has wheel `iniconfig-2.0.0-py3-none-any.whl` with inconsistent version: v2.0.0
Caused by: The entry for package `iniconfig` (1.0.0) has wheel `iniconfig-2.0.0-py3-none-any.whl` with inconsistent version (2.0.0), which indicates a malformed wheel. If this is intentional, set `UV_SKIP_WHEEL_FILENAME_CHECK=1`.
");
// Without `--locked`, we could fail or recreate the lockfile, currently, we fail.
@ -11549,7 +11549,7 @@ fn locked_version_coherence() -> Result<()> {
----- stderr -----
error: Failed to parse `uv.lock`
Caused by: The entry for package `iniconfig` v1.0.0 has wheel `iniconfig-2.0.0-py3-none-any.whl` with inconsistent version: v2.0.0
Caused by: The entry for package `iniconfig` (1.0.0) has wheel `iniconfig-2.0.0-py3-none-any.whl` with inconsistent version (2.0.0), which indicates a malformed wheel. If this is intentional, set `UV_SKIP_WHEEL_FILENAME_CHECK=1`.
");
Ok(())

View file

@ -481,3 +481,11 @@ is.
For example, `uv pip install foo bar` prioritizes newer versions of `foo` over `bar` and could
result in a different resolution than `uv pip install bar foo`. Similarly, this behavior applies to
the ordering of requirements in input files for `uv pip compile`.
## Wheel filename and metadata validation
By default, uv will reject wheels whose filenames are inconsistent with the wheel metadata inside
the file. For example, a wheel named `foo-1.0.0-py3-none-any.whl` that contains metadata indicating
the version is `1.0.1` will be rejected by uv, but accepted by pip.
To force uv to accept such wheels, set `UV_SKIP_WHEEL_FILENAME_CHECK=1` in the environment.

View file

@ -499,6 +499,13 @@ The URL to treat as an S3-compatible storage endpoint. Requests to this endpoint
will be signed using AWS Signature Version 4 based on the `AWS_ACCESS_KEY_ID`,
`AWS_SECRET_ACCESS_KEY`, `AWS_PROFILE`, and `AWS_CONFIG_FILE` environment variables.
### `UV_SKIP_WHEEL_FILENAME_CHECK`
Avoid verifying that wheel filenames match their contents when installing wheels. This
is not recommended, as wheels with inconsistent filenames should be considered invalid and
corrected by the relevant package maintainers; however, this option can be used to work
around invalid artifacts in rare cases.
### `UV_STACK_SIZE`
Use to set the stack size used by uv.