From bdff98e3c4dea96a08c95bd95f9b41ba1c67e675 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Tue, 8 Sep 2026 10:23:58 +0100 Subject: [PATCH 1/5] Verify hashes on arbitrary-equality pins --- crates/uv-types/src/hash.rs | 21 ++++++++++---- crates/uv/tests/pip_install/pip_install.rs | 33 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/uv-types/src/hash.rs b/crates/uv-types/src/hash.rs index 02dfbdd864d..6a0d13c08e1 100644 --- a/crates/uv-types/src/hash.rs +++ b/crates/uv-types/src/hash.rs @@ -184,7 +184,7 @@ impl HashStrategy { } // Every constraint must be a pinned version. - let Some(id) = Self::pin(requirement) else { + let Some(id) = Self::pin(requirement, mode) else { if mode.is_require() { return Err(HashStrategyError::UnpinnedRequirement( requirement.to_string(), @@ -227,7 +227,7 @@ impl HashStrategy { // Every requirement must be either a pinned version or a direct URL. let id = match &requirement { UnresolvedRequirement::Named(requirement) => { - if let Some(id) = Self::pin(requirement) { + if let Some(id) = Self::pin(requirement, mode) { id } else { if mode.is_require() { @@ -391,12 +391,12 @@ impl HashStrategy { return None; } digests.sort_unstable(); - let id = Self::pin(requirement)?; + let id = Self::pin(requirement, HashCheckingMode::Require)?; Some((id, digests)) } /// Pin a [`Requirement`] to a [`VersionId`], if possible. - fn pin(requirement: &Requirement) -> Option { + fn pin(requirement: &Requirement, mode: HashCheckingMode) -> Option { match &requirement.source { RequirementSource::Registry { specifier, .. } => { // Must be a single specifier. @@ -404,8 +404,17 @@ impl HashStrategy { return None; }; - // Must be pinned to a specific version. - if *specifier.operator() != uv_pep440::Operator::Equal { + // Arbitrary equality pins a single version, but `--require-hashes` needs `==`. + let is_pinned = match mode { + HashCheckingMode::Verify => { + *specifier.operator() == uv_pep440::Operator::Equal + || *specifier.operator() == uv_pep440::Operator::ExactEqual + } + HashCheckingMode::Require => { + *specifier.operator() == uv_pep440::Operator::Equal + } + }; + if !is_pinned { return None; } diff --git a/crates/uv/tests/pip_install/pip_install.rs b/crates/uv/tests/pip_install/pip_install.rs index a3ec0ef1239..be29ecb3afd 100644 --- a/crates/uv/tests/pip_install/pip_install.rs +++ b/crates/uv/tests/pip_install/pip_install.rs @@ -9075,6 +9075,39 @@ fn verify_hashes_mismatch() -> Result<()> { Ok(()) } +/// Verify hashes on arbitrary-equality pins. +#[test] +fn verify_hashes_exact_equal() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str( + "ok===1.0.0 --hash=sha256:0000000000000000000000000000000000000000000000000000000000000000", + )?; + + uv_snapshot!(context.filters(), context.pip_install() + .arg("-r") + .arg("requirements.txt") + .arg("--no-index") + .arg("--find-links") + .arg(context.workspace_root.join("test/links/")) + .arg("--verify-hashes"), @" + exit_code: 1 (failure) + ----- stderr ----- + Resolved 1 package in [TIME] + × Failed to download `ok==1.0.0` + ╰─▶ Hash mismatch for `ok==1.0.0` + + Expected: + sha256:0000000000000000000000000000000000000000000000000000000000000000 + + Computed: + sha256:79f0b33e6ce1e09eaa1784c8eee275dfe84d215d9c65c652f07c18e85fdaac5f + "); + + Ok(()) +} + /// Provide the correct hash with `--verify-hashes`. #[test] fn verify_hashes_match() -> Result<()> { From 8da3dc2cdd5a9fc8daad4b68278e3ae6e919af54 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Tue, 8 Sep 2026 14:38:37 +0100 Subject: [PATCH 2/5] Treat arbitrary-equality pins consistently in hash checking --- crates/uv-resolver/src/error.rs | 2 +- crates/uv-types/src/hash.rs | 32 ++++++++------- crates/uv/tests/build/build.rs | 2 +- crates/uv/tests/pip/pip_sync.rs | 10 ++--- crates/uv/tests/pip_install/pip_install.rs | 48 ++++++++++++---------- 5 files changed, 50 insertions(+), 44 deletions(-) diff --git a/crates/uv-resolver/src/error.rs b/crates/uv-resolver/src/error.rs index 111d8dabf5d..00290ce3084 100644 --- a/crates/uv-resolver/src/error.rs +++ b/crates/uv-resolver/src/error.rs @@ -118,7 +118,7 @@ pub enum ResolveError { InvalidVersion(#[from] uv_pep440::VersionSpecifierBuildError), #[error( - "In `--require-hashes` mode, all requirements must be pinned upfront with `==`, but found: `{0}`" + "In `--require-hashes` mode, all requirements must be pinned and hashed upfront, but found: `{0}`" )] UnhashedPackage(PackageName), diff --git a/crates/uv-types/src/hash.rs b/crates/uv-types/src/hash.rs index 6a0d13c08e1..ee287be8d90 100644 --- a/crates/uv-types/src/hash.rs +++ b/crates/uv-types/src/hash.rs @@ -11,7 +11,7 @@ use uv_distribution_types::{ Resolution, UnresolvedRequirement, VersionId, }; use uv_normalize::PackageName; -use uv_pep440::Version; +use uv_pep440::{Operator, Version}; use uv_pypi_types::{HashAlgorithm, HashDigest, HashDigests, HashError, ResolverMarkerEnvironment}; use uv_redacted::DisplaySafeUrl; @@ -184,7 +184,7 @@ impl HashStrategy { } // Every constraint must be a pinned version. - let Some(id) = Self::pin(requirement, mode) else { + let Some(id) = Self::pin(requirement) else { if mode.is_require() { return Err(HashStrategyError::UnpinnedRequirement( requirement.to_string(), @@ -227,7 +227,7 @@ impl HashStrategy { // Every requirement must be either a pinned version or a direct URL. let id = match &requirement { UnresolvedRequirement::Named(requirement) => { - if let Some(id) = Self::pin(requirement, mode) { + if let Some(id) = Self::pin(requirement) { id } else { if mode.is_require() { @@ -391,12 +391,12 @@ impl HashStrategy { return None; } digests.sort_unstable(); - let id = Self::pin(requirement, HashCheckingMode::Require)?; + let id = Self::pin(requirement)?; Some((id, digests)) } /// Pin a [`Requirement`] to a [`VersionId`], if possible. - fn pin(requirement: &Requirement, mode: HashCheckingMode) -> Option { + fn pin(requirement: &Requirement) -> Option { match &requirement.source { RequirementSource::Registry { specifier, .. } => { // Must be a single specifier. @@ -404,15 +404,17 @@ impl HashStrategy { return None; }; - // Arbitrary equality pins a single version, but `--require-hashes` needs `==`. - let is_pinned = match mode { - HashCheckingMode::Verify => { - *specifier.operator() == uv_pep440::Operator::Equal - || *specifier.operator() == uv_pep440::Operator::ExactEqual - } - HashCheckingMode::Require => { - *specifier.operator() == uv_pep440::Operator::Equal - } + // Must be pinned to a specific version. + let is_pinned = match specifier.operator() { + Operator::Equal | Operator::ExactEqual => true, + Operator::EqualStar + | Operator::NotEqual + | Operator::NotEqualStar + | Operator::TildeEqual + | Operator::LessThan + | Operator::LessThanEqual + | Operator::GreaterThan + | Operator::GreaterThanEqual => false, }; if !is_pinned { return None; @@ -522,7 +524,7 @@ pub enum HashStrategyError { #[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")] ConflictingArchiveUrlHashes(String, HashDigest, HashDigest), #[error( - "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}" + "In `{1}` mode, registry requirements must be pinned with `==` or `===`, but found: {0}" )] UnpinnedRequirement(String, HashCheckingMode), #[error( diff --git a/crates/uv/tests/build/build.rs b/crates/uv/tests/build/build.rs index 93939d3e865..15a34aa8c25 100644 --- a/crates/uv/tests/build/build.rs +++ b/crates/uv/tests/build/build.rs @@ -1442,7 +1442,7 @@ fn build_sha() -> Result<()> { error: Failed to build `[TEMP_DIR]/project` Caused by: Failed to resolve requirements from `build-system.requires` Caused by: No solution found when resolving: `hatchling` - Caused by: In `--require-hashes` mode, all requirements must be pinned upfront with `==`, but found: `hatchling` + Caused by: In `--require-hashes` mode, all requirements must be pinned and hashed upfront, but found: `hatchling` "); project diff --git a/crates/uv/tests/pip/pip_sync.rs b/crates/uv/tests/pip/pip_sync.rs index 5ce2bccbc87..25749893b0c 100644 --- a/crates/uv/tests/pip/pip_sync.rs +++ b/crates/uv/tests/pip/pip_sync.rs @@ -3293,7 +3293,7 @@ fn require_hashes_in_requirements_txt() -> Result<()> { .arg("requirements.txt"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio + error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio " ); @@ -3342,7 +3342,7 @@ fn require_hashes_missing_version() -> Result<()> { .arg("--require-hashes"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio + error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio " ); @@ -3377,7 +3377,7 @@ fn require_hashes_invalid_operator() -> Result<()> { .arg("--require-hashes"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio>4.0.0 + error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio>4.0.0 " ); @@ -4127,7 +4127,7 @@ fn require_hashes_repeated_dependency() -> Result<()> { .arg("--require-hashes"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio + error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio " ); @@ -4141,7 +4141,7 @@ fn require_hashes_repeated_dependency() -> Result<()> { .arg("--require-hashes"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio + error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio " ); diff --git a/crates/uv/tests/pip_install/pip_install.rs b/crates/uv/tests/pip_install/pip_install.rs index be29ecb3afd..9eedaaff2ff 100644 --- a/crates/uv/tests/pip_install/pip_install.rs +++ b/crates/uv/tests/pip_install/pip_install.rs @@ -1208,7 +1208,7 @@ fn install_require_hashes_in_requirements_txt() -> Result<()> { .arg("--strict"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: iniconfig + error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: iniconfig " ); @@ -8459,7 +8459,7 @@ fn require_hashes_missing_dependency() -> Result<()> { .arg("--require-hashes"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, all requirements must be pinned upfront with `==`, but found: `markupsafe` + error: In `--require-hashes` mode, all requirements must be pinned and hashed upfront, but found: `markupsafe` " ); @@ -8542,7 +8542,7 @@ fn require_hashes_constraint() -> Result<()> { .arg(constraints_txt.path()), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio + error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio " ); @@ -8754,7 +8754,7 @@ fn require_hashes_override() -> Result<()> { .arg(overrides_txt.path()), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio + error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio " ); @@ -9075,7 +9075,7 @@ fn verify_hashes_mismatch() -> Result<()> { Ok(()) } -/// Verify hashes on arbitrary-equality pins. +/// Verify hashes on arbitrary-equality pins in both checking modes. #[test] fn verify_hashes_exact_equal() -> Result<()> { let context = uv_test::test_context!("3.12"); @@ -9085,25 +9085,29 @@ fn verify_hashes_exact_equal() -> Result<()> { "ok===1.0.0 --hash=sha256:0000000000000000000000000000000000000000000000000000000000000000", )?; - uv_snapshot!(context.filters(), context.pip_install() - .arg("-r") - .arg("requirements.txt") - .arg("--no-index") - .arg("--find-links") - .arg(context.workspace_root.join("test/links/")) - .arg("--verify-hashes"), @" - exit_code: 1 (failure) - ----- stderr ----- - Resolved 1 package in [TIME] - × Failed to download `ok==1.0.0` - ╰─▶ Hash mismatch for `ok==1.0.0` + allow_duplicates! { + for hash_mode in ["--verify-hashes", "--require-hashes"] { + uv_snapshot!(context.filters(), context.pip_install() + .arg("-r") + .arg("requirements.txt") + .arg("--no-index") + .arg("--find-links") + .arg(context.workspace_root.join("test/links/")) + .arg(hash_mode), @" + exit_code: 1 (failure) + ----- stderr ----- + Resolved 1 package in [TIME] + × Failed to download `ok==1.0.0` + ╰─▶ Hash mismatch for `ok==1.0.0` - Expected: - sha256:0000000000000000000000000000000000000000000000000000000000000000 + Expected: + sha256:0000000000000000000000000000000000000000000000000000000000000000 - Computed: - sha256:79f0b33e6ce1e09eaa1784c8eee275dfe84d215d9c65c652f07c18e85fdaac5f - "); + Computed: + sha256:79f0b33e6ce1e09eaa1784c8eee275dfe84d215d9c65c652f07c18e85fdaac5f + "); + } + } Ok(()) } From d5c435c15d8f7b421422e83b9e04fc3b3af246c7 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Tue, 8 Sep 2026 14:45:14 +0100 Subject: [PATCH 3/5] Restore existing required-hash pin diagnostics --- crates/uv-resolver/src/error.rs | 2 +- crates/uv-types/src/hash.rs | 2 +- crates/uv/tests/build/build.rs | 2 +- crates/uv/tests/pip/pip_sync.rs | 10 +++++----- crates/uv/tests/pip_install/pip_install.rs | 8 ++++---- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/uv-resolver/src/error.rs b/crates/uv-resolver/src/error.rs index 00290ce3084..111d8dabf5d 100644 --- a/crates/uv-resolver/src/error.rs +++ b/crates/uv-resolver/src/error.rs @@ -118,7 +118,7 @@ pub enum ResolveError { InvalidVersion(#[from] uv_pep440::VersionSpecifierBuildError), #[error( - "In `--require-hashes` mode, all requirements must be pinned and hashed upfront, but found: `{0}`" + "In `--require-hashes` mode, all requirements must be pinned upfront with `==`, but found: `{0}`" )] UnhashedPackage(PackageName), diff --git a/crates/uv-types/src/hash.rs b/crates/uv-types/src/hash.rs index ee287be8d90..ed0ca923f22 100644 --- a/crates/uv-types/src/hash.rs +++ b/crates/uv-types/src/hash.rs @@ -524,7 +524,7 @@ pub enum HashStrategyError { #[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")] ConflictingArchiveUrlHashes(String, HashDigest, HashDigest), #[error( - "In `{1}` mode, registry requirements must be pinned with `==` or `===`, but found: {0}" + "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}" )] UnpinnedRequirement(String, HashCheckingMode), #[error( diff --git a/crates/uv/tests/build/build.rs b/crates/uv/tests/build/build.rs index 15a34aa8c25..93939d3e865 100644 --- a/crates/uv/tests/build/build.rs +++ b/crates/uv/tests/build/build.rs @@ -1442,7 +1442,7 @@ fn build_sha() -> Result<()> { error: Failed to build `[TEMP_DIR]/project` Caused by: Failed to resolve requirements from `build-system.requires` Caused by: No solution found when resolving: `hatchling` - Caused by: In `--require-hashes` mode, all requirements must be pinned and hashed upfront, but found: `hatchling` + Caused by: In `--require-hashes` mode, all requirements must be pinned upfront with `==`, but found: `hatchling` "); project diff --git a/crates/uv/tests/pip/pip_sync.rs b/crates/uv/tests/pip/pip_sync.rs index 25749893b0c..5ce2bccbc87 100644 --- a/crates/uv/tests/pip/pip_sync.rs +++ b/crates/uv/tests/pip/pip_sync.rs @@ -3293,7 +3293,7 @@ fn require_hashes_in_requirements_txt() -> Result<()> { .arg("requirements.txt"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio + error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio " ); @@ -3342,7 +3342,7 @@ fn require_hashes_missing_version() -> Result<()> { .arg("--require-hashes"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio + error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio " ); @@ -3377,7 +3377,7 @@ fn require_hashes_invalid_operator() -> Result<()> { .arg("--require-hashes"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio>4.0.0 + error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio>4.0.0 " ); @@ -4127,7 +4127,7 @@ fn require_hashes_repeated_dependency() -> Result<()> { .arg("--require-hashes"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio + error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio " ); @@ -4141,7 +4141,7 @@ fn require_hashes_repeated_dependency() -> Result<()> { .arg("--require-hashes"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio + error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio " ); diff --git a/crates/uv/tests/pip_install/pip_install.rs b/crates/uv/tests/pip_install/pip_install.rs index 9eedaaff2ff..598e86a8b5d 100644 --- a/crates/uv/tests/pip_install/pip_install.rs +++ b/crates/uv/tests/pip_install/pip_install.rs @@ -1208,7 +1208,7 @@ fn install_require_hashes_in_requirements_txt() -> Result<()> { .arg("--strict"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: iniconfig + error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: iniconfig " ); @@ -8459,7 +8459,7 @@ fn require_hashes_missing_dependency() -> Result<()> { .arg("--require-hashes"), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, all requirements must be pinned and hashed upfront, but found: `markupsafe` + error: In `--require-hashes` mode, all requirements must be pinned upfront with `==`, but found: `markupsafe` " ); @@ -8542,7 +8542,7 @@ fn require_hashes_constraint() -> Result<()> { .arg(constraints_txt.path()), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio + error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio " ); @@ -8754,7 +8754,7 @@ fn require_hashes_override() -> Result<()> { .arg(overrides_txt.path()), @" exit_code: 2 (failure) ----- stderr ----- - error: In `--require-hashes` mode, registry requirements must be pinned with `==` or `===`, but found: anyio + error: In `--require-hashes` mode, all requirements must have their versions pinned with `==`, but found: anyio " ); From e46958f8527b7d504feb362ba3f170753b16fa70 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Tue, 8 Sep 2026 14:46:20 +0100 Subject: [PATCH 4/5] Simplify exact-pin operator match --- crates/uv-types/src/hash.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/crates/uv-types/src/hash.rs b/crates/uv-types/src/hash.rs index ed0ca923f22..8d3c30ee7a4 100644 --- a/crates/uv-types/src/hash.rs +++ b/crates/uv-types/src/hash.rs @@ -407,14 +407,7 @@ impl HashStrategy { // Must be pinned to a specific version. let is_pinned = match specifier.operator() { Operator::Equal | Operator::ExactEqual => true, - Operator::EqualStar - | Operator::NotEqual - | Operator::NotEqualStar - | Operator::TildeEqual - | Operator::LessThan - | Operator::LessThanEqual - | Operator::GreaterThan - | Operator::GreaterThanEqual => false, + _ => false, }; if !is_pinned { return None; From 9d40ad9820446ce043777ce37007afbd9ef4b105 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Tue, 8 Sep 2026 19:06:12 +0100 Subject: [PATCH 5/5] Use matches! for hash pin detection --- crates/uv-types/src/hash.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/uv-types/src/hash.rs b/crates/uv-types/src/hash.rs index 8d3c30ee7a4..9da40c5e027 100644 --- a/crates/uv-types/src/hash.rs +++ b/crates/uv-types/src/hash.rs @@ -405,10 +405,8 @@ impl HashStrategy { }; // Must be pinned to a specific version. - let is_pinned = match specifier.operator() { - Operator::Equal | Operator::ExactEqual => true, - _ => false, - }; + let is_pinned = + matches!(specifier.operator(), Operator::Equal | Operator::ExactEqual); if !is_pinned { return None; }