diff --git a/crates/uv-requirements/src/source_tree.rs b/crates/uv-requirements/src/source_tree.rs index 9e5266b087059..6c7b4c8d21257 100644 --- a/crates/uv-requirements/src/source_tree.rs +++ b/crates/uv-requirements/src/source_tree.rs @@ -113,7 +113,7 @@ impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> { let hashes = match self.hasher { HashStrategy::None => HashPolicy::None, HashStrategy::Generate => HashPolicy::Generate, - HashStrategy::Validate { .. } => { + HashStrategy::Verify(_) | HashStrategy::Require(_) => { return Err(anyhow::anyhow!( "Hash-checking is not supported for local directories: {}", path.user_display() diff --git a/crates/uv-types/src/hash.rs b/crates/uv-types/src/hash.rs index 2f3229efd15e7..14da8ce9d2d3a 100644 --- a/crates/uv-types/src/hash.rs +++ b/crates/uv-types/src/hash.rs @@ -15,9 +15,14 @@ pub enum HashStrategy { None, /// Hashes should be generated (specifically, a SHA-256 hash), but not validated. Generate, - /// Hashes should be validated against a pre-defined list of hashes. If necessary, hashes should - /// be generated so as to ensure that the archive is valid. - Validate(FxHashMap>), + /// Hashes should be validated, if present, but ignored if absent. + /// + /// If necessary, hashes should be generated to ensure that the archive is valid. + Verify(FxHashMap>), + /// Hashes should be validated against a pre-defined list of hashes. + /// + /// If necessary, hashes should be generated to ensure that the archive is valid. + Require(FxHashMap>), } impl HashStrategy { @@ -26,7 +31,14 @@ impl HashStrategy { match self { Self::None => HashPolicy::None, Self::Generate => HashPolicy::Generate, - Self::Validate(hashes) => HashPolicy::Validate( + Self::Verify(hashes) => { + if let Some(hashes) = hashes.get(&distribution.package_id()) { + HashPolicy::Validate(hashes.as_slice()) + } else { + HashPolicy::None + } + } + Self::Require(hashes) => HashPolicy::Validate( hashes .get(&distribution.package_id()) .map(Vec::as_slice) @@ -40,7 +52,14 @@ impl HashStrategy { match self { Self::None => HashPolicy::None, Self::Generate => HashPolicy::Generate, - Self::Validate(hashes) => HashPolicy::Validate( + Self::Verify(hashes) => { + if let Some(hashes) = hashes.get(&PackageId::from_registry(name.clone())) { + HashPolicy::Validate(hashes.as_slice()) + } else { + HashPolicy::None + } + } + Self::Require(hashes) => HashPolicy::Validate( hashes .get(&PackageId::from_registry(name.clone())) .map(Vec::as_slice) @@ -54,7 +73,14 @@ impl HashStrategy { match self { Self::None => HashPolicy::None, Self::Generate => HashPolicy::Generate, - Self::Validate(hashes) => HashPolicy::Validate( + Self::Verify(hashes) => { + if let Some(hashes) = hashes.get(&PackageId::from_url(url)) { + HashPolicy::Validate(hashes.as_slice()) + } else { + HashPolicy::None + } + } + Self::Require(hashes) => HashPolicy::Validate( hashes .get(&PackageId::from_url(url)) .map(Vec::as_slice) @@ -68,7 +94,8 @@ impl HashStrategy { match self { Self::None => true, Self::Generate => true, - Self::Validate(hashes) => hashes.contains_key(&PackageId::from_registry(name.clone())), + Self::Verify(_) => true, + Self::Require(hashes) => hashes.contains_key(&PackageId::from_registry(name.clone())), } } @@ -77,7 +104,8 @@ impl HashStrategy { match self { Self::None => true, Self::Generate => true, - Self::Validate(hashes) => hashes.contains_key(&PackageId::from_url(url)), + Self::Verify(_) => true, + Self::Require(hashes) => hashes.contains_key(&PackageId::from_url(url)), } } @@ -87,7 +115,7 @@ impl HashStrategy { /// that reference the environment as true. In other words, it does /// environment independent expression evaluation. (Which in turn devolves /// to "only evaluate marker expressions that reference an extra name.") - pub fn from_requirements<'a>( + pub fn require<'a>( requirements: impl Iterator, markers: Option<&MarkerEnvironment>, ) -> Result { @@ -103,7 +131,12 @@ impl HashStrategy { // Every requirement must be either a pinned version or a direct URL. let id = match &requirement { UnresolvedRequirement::Named(requirement) => { - uv_requirement_to_package_id(requirement)? + Self::pin(requirement).ok_or_else(|| { + HashStrategyError::UnpinnedRequirement( + requirement.to_string(), + HashCheckingMode::Require, + ) + })? } UnresolvedRequirement::Unnamed(requirement) => { // Direct URLs are always allowed. @@ -113,7 +146,10 @@ impl HashStrategy { // Every requirement must include a hash. if digests.is_empty() { - return Err(HashStrategyError::MissingHashes(requirement.to_string())); + return Err(HashStrategyError::MissingHashes( + requirement.to_string(), + HashCheckingMode::Require, + )); } // Parse the hashes. @@ -125,41 +161,102 @@ impl HashStrategy { hashes.insert(id, digests); } - Ok(Self::Validate(hashes)) + Ok(Self::Require(hashes)) } -} -fn uv_requirement_to_package_id(requirement: &Requirement) -> Result { - Ok(match &requirement.source { - RequirementSource::Registry { specifier, .. } => { - // Must be a single specifier. - let [specifier] = specifier.as_ref() else { - return Err(HashStrategyError::UnpinnedRequirement( - requirement.to_string(), - )); + /// Generate the hashes to verify from a set of [`UnresolvedRequirement`] entries. + pub fn verify<'a>( + requirements: impl Iterator, + markers: Option<&MarkerEnvironment>, + ) -> Result { + let mut hashes = FxHashMap::>::default(); + + // For each requirement, map from name to allowed hashes. We use the last entry for each + // package. + for (requirement, digests) in requirements { + if !requirement.evaluate_markers(markers, &[]) { + continue; + } + + // Hashes are optional in this mode. + if digests.is_empty() { + continue; + } + + // Parse the hashes. + let digests = digests + .iter() + .map(|digest| HashDigest::from_str(digest)) + .collect::, _>>()?; + + // Every requirement must be either a pinned version or a direct URL. + let id = match &requirement { + UnresolvedRequirement::Named(requirement) => { + Self::pin(requirement).ok_or_else(|| { + HashStrategyError::UnpinnedRequirement( + requirement.to_string(), + HashCheckingMode::Verify, + ) + })? + } + UnresolvedRequirement::Unnamed(requirement) => { + // Direct URLs are always allowed. + PackageId::from_url(&requirement.url.verbatim) + } }; - // Must be pinned to a specific version. - if *specifier.operator() != pep440_rs::Operator::Equal { - return Err(HashStrategyError::UnpinnedRequirement( - requirement.to_string(), - )); + hashes.insert(id, digests); + } + + Ok(Self::Verify(hashes)) + } + + /// Pin a [`Requirement`] to a [`PackageId`], if possible. + fn pin(requirement: &Requirement) -> Option { + match &requirement.source { + RequirementSource::Registry { specifier, .. } => { + // Must be a single specifier. + let [specifier] = specifier.as_ref() else { + return None; + }; + + // Must be pinned to a specific version. + if *specifier.operator() != pep440_rs::Operator::Equal { + return None; + } + + Some(PackageId::from_registry(requirement.name.clone())) } + RequirementSource::Url { url, .. } + | RequirementSource::Git { url, .. } + | RequirementSource::Path { url, .. } => Some(PackageId::from_url(url)), + } + } +} - PackageId::from_registry(requirement.name.clone()) +#[derive(Debug, Copy, Clone)] +pub enum HashCheckingMode { + Require, + Verify, +} + +impl std::fmt::Display for HashCheckingMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Require => write!(f, "--require-hashes"), + Self::Verify => write!(f, "--verify-hashes"), } - RequirementSource::Url { url, .. } - | RequirementSource::Git { url, .. } - | RequirementSource::Path { url, .. } => PackageId::from_url(url), - }) + } } #[derive(thiserror::Error, Debug)] pub enum HashStrategyError { #[error(transparent)] Hash(#[from] HashError), - #[error("In `--require-hashes` mode, all requirement must have their versions pinned with `==`, but found: {0}")] - UnpinnedRequirement(String), - #[error("In `--require-hashes` mode, all requirement must have a hash, but none were provided for: {0}")] - MissingHashes(String), + #[error( + "In `{1}` mode, all requirement must have their versions pinned with `==`, but found: {0}" + )] + UnpinnedRequirement(String, HashCheckingMode), + #[error("In `{1}` mode, all requirement must have a hash, but none were provided for: {0}")] + MissingHashes(String, HashCheckingMode), } diff --git a/crates/uv-workspace/src/combine.rs b/crates/uv-workspace/src/combine.rs index e9d56eafffd03..6c349bde0299d 100644 --- a/crates/uv-workspace/src/combine.rs +++ b/crates/uv-workspace/src/combine.rs @@ -114,6 +114,7 @@ impl Combine for PipOptions { link_mode: self.link_mode.combine(other.link_mode), compile_bytecode: self.compile_bytecode.combine(other.compile_bytecode), require_hashes: self.require_hashes.combine(other.require_hashes), + verify_hashes: self.verify_hashes.combine(other.verify_hashes), concurrent_downloads: self .concurrent_downloads .combine(other.concurrent_downloads), diff --git a/crates/uv-workspace/src/settings.rs b/crates/uv-workspace/src/settings.rs index 2f83e5e05e094..050c8ce2c7513 100644 --- a/crates/uv-workspace/src/settings.rs +++ b/crates/uv-workspace/src/settings.rs @@ -94,6 +94,7 @@ pub struct PipOptions { pub link_mode: Option, pub compile_bytecode: Option, pub require_hashes: Option, + pub verify_hashes: Option, pub concurrent_downloads: Option, pub concurrent_builds: Option, pub concurrent_installs: Option, diff --git a/crates/uv/src/cli.rs b/crates/uv/src/cli.rs index ba857bc73c77c..f993db2164fe0 100644 --- a/crates/uv/src/cli.rs +++ b/crates/uv/src/cli.rs @@ -663,13 +663,33 @@ pub(crate) struct PipSyncArgs { /// - Editable installs are not supported. /// - Local dependencies are not supported, unless they point to a specific wheel (`.whl`) or /// source archive (`.zip`, `.tar.gz`), as opposed to a directory. - #[arg(long, env = "UV_REQUIRE_HASHES", - value_parser = clap::builder::BoolishValueParser::new(), overrides_with("no_require_hashes"))] + #[arg( + long, + env = "UV_REQUIRE_HASHES", + value_parser = clap::builder::BoolishValueParser::new(), + overrides_with("no_require_hashes"), + )] pub(crate) require_hashes: bool, #[arg(long, overrides_with("require_hashes"), hide = true)] pub(crate) no_require_hashes: bool, + /// Validate any hashes provided in the requirements file. + /// + /// Unlike `--require-hashes`, `--verify-hashes` does not require that all requirements have + /// hashes; instead, it will limit itself to verifying the hashes of those requirements that do + /// include them. + #[arg( + long, + env = "UV_VERIFY_HASHES", + value_parser = clap::builder::BoolishValueParser::new(), + overrides_with("no_verify_hashes"), + )] + pub(crate) verify_hashes: bool, + + #[arg(long, overrides_with("verify_hashes"), hide = true)] + pub(crate) no_verify_hashes: bool, + /// Attempt to use `keyring` for authentication for index URLs. /// /// Function's similar to `pip`'s `--keyring-provider subprocess` argument, @@ -1026,6 +1046,22 @@ pub(crate) struct PipInstallArgs { #[arg(long, overrides_with("require_hashes"), hide = true)] pub(crate) no_require_hashes: bool, + /// Validate any hashes provided in the requirements file. + /// + /// Unlike `--require-hashes`, `--verify-hashes` does not require that all requirements have + /// hashes; instead, it will limit itself to verifying the hashes of those requirements that do + /// include them. + #[arg( + long, + env = "UV_VERIFY_HASHES", + value_parser = clap::builder::BoolishValueParser::new(), + overrides_with("no_verify_hashes"), + )] + pub(crate) verify_hashes: bool, + + #[arg(long, overrides_with("verify_hashes"), hide = true)] + pub(crate) no_verify_hashes: bool, + /// Attempt to use `keyring` for authentication for index URLs. /// /// Due to not having Python imports, only `--keyring-provider subprocess` argument is currently diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index 056ef22c87081..83691307cfd29 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -54,6 +54,7 @@ pub(crate) async fn pip_install( link_mode: LinkMode, compile: bool, require_hashes: bool, + verify_hashes: bool, setup_py: SetupPyStrategy, connectivity: Connectivity, config_settings: &ConfigSettings, @@ -247,7 +248,15 @@ pub(crate) async fn pip_install( // Collect the set of required hashes. let hasher = if require_hashes { - HashStrategy::from_requirements( + HashStrategy::require( + requirements + .iter() + .chain(overrides.iter()) + .map(|entry| (&entry.requirement, entry.hashes.as_slice())), + Some(&markers), + )? + } else if verify_hashes { + HashStrategy::verify( requirements .iter() .chain(overrides.iter()) diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index ad117ef42ae09..678366e0ec5b9 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -43,6 +43,7 @@ pub(crate) async fn pip_sync( link_mode: LinkMode, compile: bool, require_hashes: bool, + verify_hashes: bool, index_locations: IndexLocations, index_strategy: IndexStrategy, keyring_provider: KeyringProviderType, @@ -198,9 +199,18 @@ pub(crate) async fn pip_sync( // Collect the set of required hashes. let hasher = if require_hashes { - HashStrategy::from_requirements( + HashStrategy::require( requirements .iter() + .chain(overrides.iter()) + .map(|entry| (&entry.requirement, entry.hashes.as_slice())), + Some(&markers), + )? + } else if verify_hashes { + HashStrategy::verify( + requirements + .iter() + .chain(overrides.iter()) .map(|entry| (&entry.requirement, entry.hashes.as_slice())), Some(&markers), )? diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 3ffa7193a2ab8..dc3b4789fd1c5 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -279,6 +279,7 @@ async fn run() -> Result { args.shared.link_mode, args.shared.compile_bytecode, args.shared.require_hashes, + args.shared.verify_hashes, args.shared.index_locations, args.shared.index_strategy, args.shared.keyring_provider, @@ -358,6 +359,7 @@ async fn run() -> Result { args.shared.link_mode, args.shared.compile_bytecode, args.shared.require_hashes, + args.shared.verify_hashes, args.shared.setup_py, globals.connectivity, &args.shared.config_setting, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 23aaf7f10338b..9757770ca747f 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -513,6 +513,8 @@ impl PipSyncSettings { index_strategy, require_hashes, no_require_hashes, + verify_hashes, + no_verify_hashes, keyring_provider, python, system, @@ -584,6 +586,7 @@ impl PipSyncSettings { link_mode, compile_bytecode: flag(compile_bytecode, no_compile_bytecode), require_hashes: flag(require_hashes, no_require_hashes), + verify_hashes: flag(verify_hashes, no_verify_hashes), concurrent_builds: env(env::CONCURRENT_BUILDS), concurrent_downloads: env(env::CONCURRENT_DOWNLOADS), concurrent_installs: env(env::CONCURRENT_INSTALLS), @@ -646,6 +649,8 @@ impl PipInstallSettings { index_strategy, require_hashes, no_require_hashes, + verify_hashes, + no_verify_hashes, keyring_provider, python, system, @@ -746,6 +751,7 @@ impl PipInstallSettings { link_mode, compile_bytecode: flag(compile_bytecode, no_compile_bytecode), require_hashes: flag(require_hashes, no_require_hashes), + verify_hashes: flag(verify_hashes, no_verify_hashes), concurrent_builds: env(env::CONCURRENT_BUILDS), concurrent_downloads: env(env::CONCURRENT_DOWNLOADS), concurrent_installs: env(env::CONCURRENT_INSTALLS), @@ -1083,6 +1089,7 @@ pub(crate) struct PipSharedSettings { pub(crate) link_mode: LinkMode, pub(crate) compile_bytecode: bool, pub(crate) require_hashes: bool, + pub(crate) verify_hashes: bool, pub(crate) concurrency: Concurrency, } @@ -1130,6 +1137,7 @@ impl PipSharedSettings { link_mode, compile_bytecode, require_hashes, + verify_hashes, concurrent_builds, concurrent_downloads, concurrent_installs, @@ -1230,6 +1238,10 @@ impl PipSharedSettings { .require_hashes .combine(require_hashes) .unwrap_or_default(), + verify_hashes: args + .verify_hashes + .combine(verify_hashes) + .unwrap_or_default(), python: args.python.combine(python), system: args.system.combine(system).unwrap_or_default(), break_system_packages: args diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index fcaa0f472b3ff..79442cab30a63 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -4736,9 +4736,19 @@ fn require_hashes_mismatch() -> Result<()> { // Write to a requirements file. let requirements_txt = context.temp_dir.child("requirements.txt"); - requirements_txt.write_str( - "anyio==4.0.0 --hash=sha256:afdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f", - )?; + requirements_txt.write_str(indoc::indoc! {r" + anyio==4.0.0 \ + --hash=sha256:afdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f \ + --hash=sha256:a7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + # via anyio + sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via anyio + "})?; // Raise an error. uv_snapshot!(context.install() @@ -4750,7 +4760,17 @@ fn require_hashes_mismatch() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: In `--require-hashes` mode, all requirements must be pinned upfront with `==`, but found: `idna` + Resolved 3 packages in [TIME] + error: Failed to download distributions + Caused by: Failed to fetch wheel: anyio==4.0.0 + Caused by: Hash mismatch for `anyio==4.0.0` + + Expected: + sha256:afdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f + sha256:a7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a + + Computed: + sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f "### ); @@ -5013,6 +5033,206 @@ fn require_hashes_override() -> Result<()> { Ok(()) } +/// Provide valid hashes for all dependencies with `--require-hashes`. +#[test] +fn verify_hashes() -> Result<()> { + let context = TestContext::new("3.12"); + + // Write to a requirements file. + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str(indoc::indoc! {r" + anyio==4.0.0 \ + --hash=sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f \ + --hash=sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + # via anyio + sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via anyio + "})?; + + uv_snapshot!(context.install() + .arg("-r") + .arg("requirements.txt") + .arg("--verify-hashes"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Downloaded 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.0.0 + + idna==3.6 + + sniffio==1.3.1 + "### + ); + + Ok(()) +} + +/// Omit a pinned version with `--verify-hashes`. +#[test] +fn verify_hashes_missing_version() -> Result<()> { + let context = TestContext::new("3.12"); + + // Write to a requirements file. + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str(indoc::indoc! {r" + anyio \ + --hash=sha256:afdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f \ + --hash=sha256:a7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + # via anyio + sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via anyio + "})?; + + // Raise an error. + uv_snapshot!(context.install() + .arg("-r") + .arg("requirements.txt") + .arg("--verify-hashes"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: In `--verify-hashes` mode, all requirement must have their versions pinned with `==`, but found: anyio + "### + ); + + Ok(()) +} + +/// Provide the wrong hash with `--verify-hashes`. +#[test] +fn verify_hashes_mismatch() -> Result<()> { + let context = TestContext::new("3.12"); + + // Write to a requirements file. + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str(indoc::indoc! {r" + anyio==4.0.0 \ + --hash=sha256:afdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f \ + --hash=sha256:a7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + # via anyio + sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via anyio + "})?; + + // Raise an error. + uv_snapshot!(context.install() + .arg("-r") + .arg("requirements.txt") + .arg("--verify-hashes"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + error: Failed to download distributions + Caused by: Failed to fetch wheel: anyio==4.0.0 + Caused by: Hash mismatch for `anyio==4.0.0` + + Expected: + sha256:afdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f + sha256:a7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a + + Computed: + sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f + "### + ); + + Ok(()) +} + +/// Omit a transitive dependency in `--verify-hashes`. This is allowed. +#[test] +fn verify_hashes_omit_dependency() -> Result<()> { + let context = TestContext::new("3.12"); + + // Write to a requirements file. + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str( + "anyio==4.0.0 --hash=sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f", + )?; + + // Install without error when `--require-hashes` is omitted. + uv_snapshot!(context.install() + .arg("-r") + .arg("requirements.txt") + .arg("--verify-hashes"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Downloaded 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.0.0 + + idna==3.6 + + sniffio==1.3.1 + "### + ); + + Ok(()) +} + +/// We allow `--verify-hashes` for editable dependencies. +#[test] +fn verify_hashes_editable() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str(&indoc::formatdoc! {r" + -e file://{workspace_root}/scripts/packages/black_editable[d] + ", + workspace_root = context.workspace_root.simplified_display(), + })?; + + // Install the editable packages. + uv_snapshot!(context.filters(), context.install() + .arg("-r") + .arg(requirements_txt.path()) + .arg("--verify-hashes"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 8 packages in [TIME] + Downloaded 8 packages in [TIME] + Installed 8 packages in [TIME] + + aiohttp==3.9.3 + + aiosignal==1.3.1 + + attrs==23.2.0 + + black==0.1.0 (from file://[WORKSPACE]/scripts/packages/black_editable) + + frozenlist==1.4.1 + + idna==3.6 + + multidict==6.0.5 + + yarl==1.9.4 + "### + ); + + Ok(()) +} + #[test] fn tool_uv_sources() -> Result<()> { let context = TestContext::new("3.12");