1use std::fmt::Display;
2use std::path::Path;
3use std::str::FromStr;
4use std::sync::Arc;
5
6use rustc_hash::FxHashMap;
7
8use uv_configuration::HashCheckingMode;
9use uv_distribution_types::{
10 DistributionMetadata, HashGeneration, HashPolicy, Name, Requirement, RequirementSource,
11 Resolution, UnresolvedRequirement, VersionId,
12};
13use uv_normalize::PackageName;
14use uv_pep440::Version;
15use uv_pypi_types::{HashAlgorithm, HashDigest, HashDigests, HashError, ResolverMarkerEnvironment};
16use uv_redacted::DisplaySafeUrl;
17
18#[derive(Debug, Default, Clone)]
23pub struct HashStrategy {
24 generation: Option<HashGeneration>,
25 verification: HashVerification,
26}
27
28#[derive(Debug, Default, Clone)]
30pub enum HashVerification {
31 #[default]
33 None,
34 IfPresent(Arc<FxHashMap<VersionId, Vec<HashDigest>>>),
36 Required(Arc<FxHashMap<VersionId, Vec<HashDigest>>>),
38}
39
40impl HashStrategy {
41 pub fn generate(generation: HashGeneration) -> Self {
43 Self {
44 generation: Some(generation),
45 ..Self::default()
46 }
47 }
48
49 pub fn verify(hashes: Arc<FxHashMap<VersionId, Vec<HashDigest>>>) -> Self {
51 Self::default().with_verification(HashVerification::IfPresent(hashes))
52 }
53
54 fn require(hashes: Arc<FxHashMap<VersionId, Vec<HashDigest>>>) -> Self {
56 Self::default().with_verification(HashVerification::Required(hashes))
57 }
58
59 #[must_use]
61 fn with_verification(mut self, verification: HashVerification) -> Self {
62 self.verification = verification;
63 self
64 }
65
66 pub fn generation(&self) -> Option<HashGeneration> {
68 self.generation
69 }
70
71 pub fn verification(&self) -> &HashVerification {
73 &self.verification
74 }
75
76 pub fn get<T: DistributionMetadata>(&self, distribution: &T) -> HashPolicy<'_> {
78 self.get_id(|| distribution.version_id())
79 }
80
81 pub fn get_package(&self, name: &PackageName, version: &Version) -> HashPolicy<'_> {
83 self.get_id(|| VersionId::from_registry(name.clone(), version.clone()))
84 }
85
86 pub fn get_url(&self, url: &DisplaySafeUrl) -> HashPolicy<'_> {
90 self.get_id(|| VersionId::from_url(url))
91 }
92
93 fn get_id(&self, id: impl FnOnce() -> VersionId) -> HashPolicy<'_> {
95 match &self.verification {
96 HashVerification::IfPresent(hashes) => {
97 let id = id();
98 if let Some(hashes) = hashes.get(&id) {
99 return hash_policy(&id, hashes);
100 }
101 }
102 HashVerification::Required(hashes) => {
103 let id = id();
104 return hash_policy(&id, hashes.get(&id).map(Vec::as_slice).unwrap_or_default());
105 }
106 HashVerification::None => {}
107 }
108 self.generation
109 .map_or(HashPolicy::None, HashPolicy::Generate)
110 }
111
112 pub fn allows_package(&self, name: &PackageName, version: &Version) -> bool {
114 match &self.verification {
115 HashVerification::Required(hashes) => {
116 hashes.contains_key(&VersionId::from_registry(name.clone(), version.clone()))
117 }
118 HashVerification::None | HashVerification::IfPresent(_) => true,
119 }
120 }
121
122 pub fn allows_url(&self, url: &DisplaySafeUrl) -> bool {
124 match &self.verification {
125 HashVerification::Required(hashes) => hashes.contains_key(&VersionId::from_url(url)),
126 HashVerification::None | HashVerification::IfPresent(_) => true,
127 }
128 }
129
130 pub fn augment_with_requirements<'a>(
133 mut self,
134 requirements: impl Iterator<Item = &'a Requirement>,
135 ) -> Result<Self, HashStrategyError> {
136 match &mut self.verification {
137 HashVerification::None => {}
138 HashVerification::IfPresent(existing) | HashVerification::Required(existing) => {
139 if let Some(hashes) = Self::augment_hashes(existing, requirements)? {
140 *existing = Arc::new(hashes);
141 }
142 }
143 }
144 Ok(self)
145 }
146
147 pub fn from_requirements<'a>(
154 requirements: impl Iterator<Item = (&'a UnresolvedRequirement, &'a [String])>,
155 constraints: impl Iterator<Item = (&'a Requirement, &'a [String])>,
156 marker_env: Option<&ResolverMarkerEnvironment>,
157 mode: HashCheckingMode,
158 ) -> Result<Self, HashStrategyError> {
159 let mut constraint_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
160
161 for (requirement, digests) in constraints {
163 if !requirement
164 .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
165 {
166 continue;
167 }
168
169 let Some(id) = Self::pin(requirement) else {
171 if mode.is_require() {
172 return Err(HashStrategyError::UnpinnedRequirement(
173 requirement.to_string(),
174 mode,
175 ));
176 }
177 continue;
178 };
179
180 let mut digests = digests
183 .iter()
184 .map(|digest| HashDigest::from_str(digest))
185 .collect::<Result<Vec<_>, _>>()?;
186 if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
187 merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
188 }
189
190 if mode.is_require() {
191 digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
192 }
193
194 if digests.is_empty() {
195 continue;
196 }
197
198 merge_hashes(&mut constraint_hashes, id, digests, requirement)?;
199 }
200
201 let mut requirement_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
203 for (requirement, digests) in requirements {
204 if !requirement
205 .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
206 {
207 continue;
208 }
209
210 let id = match &requirement {
212 UnresolvedRequirement::Named(requirement) => {
213 if let Some(id) = Self::pin(requirement) {
214 id
215 } else {
216 if mode.is_require() {
217 return Err(HashStrategyError::UnpinnedRequirement(
218 requirement.to_string(),
219 mode,
220 ));
221 }
222 continue;
223 }
224 }
225 UnresolvedRequirement::Unnamed(requirement) => {
226 VersionId::from_parsed_url(requirement.url.parsed_url.clone())
228 }
229 };
230
231 let mut digests = digests
234 .iter()
235 .map(|digest| HashDigest::from_str(digest))
236 .collect::<Result<Vec<_>, _>>()?;
237 if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
238 merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
239 }
240
241 let has_md5 = mode.is_require()
242 && digests
243 .iter()
244 .any(|digest| digest.algorithm() == HashAlgorithm::Md5);
245 if mode.is_require() {
246 digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
247 }
248
249 let digests = if let Some(constraint) = constraint_hashes.remove(&id) {
250 if digests.is_empty() {
251 constraint
253 } else if matches!(id, VersionId::ArchiveUrl { .. }) {
254 let mut merged = digests;
255 merge_digests(&mut merged, &constraint, requirement)?;
256 merged
257 } else {
258 let intersection: Vec<_> = digests
260 .into_iter()
261 .filter(|digest| constraint.contains(digest))
262 .collect();
263 if intersection.is_empty() {
264 return Err(HashStrategyError::NoIntersection(
265 requirement.to_string(),
266 mode,
267 ));
268 }
269 intersection
270 }
271 } else {
272 digests
273 };
274
275 if digests.is_empty() {
277 if mode.is_require() {
278 if has_md5 {
279 return Err(HashStrategyError::InsecureHashAlgorithm(
280 requirement.to_string(),
281 HashAlgorithm::Md5,
282 mode,
283 ));
284 }
285 return Err(HashStrategyError::MissingHashes(
286 requirement.to_string(),
287 mode,
288 ));
289 }
290 continue;
291 }
292
293 merge_hashes(&mut requirement_hashes, id, digests, requirement)?;
294 }
295
296 let hashes: FxHashMap<VersionId, Vec<HashDigest>> = constraint_hashes
299 .into_iter()
300 .chain(requirement_hashes)
301 .collect();
302 match mode {
303 HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
304 HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
305 }
306 }
307
308 pub fn from_resolution(
310 resolution: &Resolution,
311 mode: HashCheckingMode,
312 ) -> Result<Self, HashStrategyError> {
313 let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
314
315 for (dist, digests) in resolution.hashes() {
316 if digests.is_empty() {
317 if mode.is_require() {
319 return Err(HashStrategyError::MissingHashes(
320 dist.name().to_string(),
321 mode,
322 ));
323 }
324 continue;
325 }
326 hashes.insert(dist.version_id(), digests.to_vec());
327 }
328
329 match mode {
330 HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
331 HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
332 }
333 }
334
335 fn augment_hashes<'a>(
344 existing: &FxHashMap<VersionId, Vec<HashDigest>>,
345 requirements: impl Iterator<Item = &'a Requirement>,
346 ) -> Result<Option<FxHashMap<VersionId, Vec<HashDigest>>>, HashStrategyError> {
347 let mut hashes = None;
348
349 for requirement in requirements {
350 let Some((id, digests)) = Self::requirement_hashes(requirement) else {
351 continue;
352 };
353 let current = hashes.as_ref().unwrap_or(existing);
354 let current_digests = current.get(&id);
355 let mut merged = current_digests.cloned().unwrap_or_default();
356 merge_digests(&mut merged, &digests, requirement)?;
357
358 if current_digests.map(Vec::as_slice) == Some(merged.as_slice()) {
359 continue;
360 }
361
362 hashes
363 .get_or_insert_with(|| existing.clone())
364 .insert(id, merged);
365 }
366
367 Ok(hashes)
368 }
369
370 fn requirement_hashes(requirement: &Requirement) -> Option<(VersionId, Vec<HashDigest>)> {
372 let mut digests = HashDigests::from(requirement.hashes()?).to_vec();
373 if digests.is_empty() {
374 return None;
375 }
376 digests.sort_unstable();
377 let id = Self::pin(requirement)?;
378 Some((id, digests))
379 }
380
381 fn pin(requirement: &Requirement) -> Option<VersionId> {
383 match &requirement.source {
384 RequirementSource::Registry { specifier, .. } => {
385 let [specifier] = specifier.as_ref() else {
387 return None;
388 };
389
390 if *specifier.operator() != uv_pep440::Operator::Equal {
392 return None;
393 }
394
395 Some(VersionId::from_registry(
396 requirement.name.clone(),
397 specifier.version().clone(),
398 ))
399 }
400 RequirementSource::Url {
401 location,
402 subdirectory,
403 ..
404 } => Some(VersionId::from_archive(
405 location.clone(),
406 subdirectory.clone().map(Path::into_path_buf),
407 )),
408 RequirementSource::GitDirectory {
409 git, subdirectory, ..
410 } => Some(VersionId::from_git(git, subdirectory.as_deref())),
411 RequirementSource::GitPath {
412 git, install_path, ..
413 } => Some(VersionId::from_git(git, Some(install_path))),
414 RequirementSource::Path { install_path, .. } => {
415 Some(VersionId::from_path(install_path))
416 }
417 RequirementSource::Directory { install_path, .. } => {
418 Some(VersionId::from_directory(install_path))
419 }
420 }
421 }
422}
423
424fn hash_policy<'a>(id: &VersionId, digests: &'a [HashDigest]) -> HashPolicy<'a> {
425 match id {
426 VersionId::NameVersion { .. } => HashPolicy::Any(digests),
427 VersionId::ArchiveUrl { .. }
428 | VersionId::Git { .. }
429 | VersionId::Path { .. }
430 | VersionId::Directory { .. }
431 | VersionId::Unknown { .. } => HashPolicy::All(digests),
432 }
433}
434
435fn merge_hashes(
437 hashes: &mut FxHashMap<VersionId, Vec<HashDigest>>,
438 id: VersionId,
439 incoming: Vec<HashDigest>,
440 requirement: impl Display,
441) -> Result<(), HashStrategyError> {
442 if incoming.is_empty() {
443 return Ok(());
444 }
445
446 if !matches!(&id, VersionId::ArchiveUrl { .. }) {
447 hashes.insert(id, incoming);
448 return Ok(());
449 }
450
451 if let Some(existing) = hashes.get_mut(&id) {
452 return merge_digests(existing, &incoming, requirement);
453 }
454
455 let mut merged = Vec::new();
456 merge_digests(&mut merged, &incoming, requirement)?;
457 hashes.insert(id, merged);
458 Ok(())
459}
460
461fn merge_digests<'a>(
467 existing: &mut Vec<HashDigest>,
468 incoming: impl IntoIterator<Item = &'a HashDigest>,
469 requirement: impl Display,
470) -> Result<(), HashStrategyError> {
471 for digest in incoming {
472 match existing
473 .iter()
474 .find(|candidate| candidate.algorithm == digest.algorithm)
475 {
476 Some(candidate) if candidate == digest => {}
477 Some(conflict) => {
478 return Err(HashStrategyError::ConflictingArchiveUrlHashes(
479 requirement.to_string(),
480 conflict.clone(),
481 digest.clone(),
482 ));
483 }
484 None => existing.push(digest.clone()),
485 }
486 }
487 existing.sort_unstable();
488
489 Ok(())
490}
491
492#[derive(thiserror::Error, Debug)]
493pub enum HashStrategyError {
494 #[error(transparent)]
495 Hash(#[from] HashError),
496 #[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")]
497 ConflictingArchiveUrlHashes(String, HashDigest, HashDigest),
498 #[error(
499 "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}"
500 )]
501 UnpinnedRequirement(String, HashCheckingMode),
502 #[error(
503 "`{1}` hashes are insecure and cannot be used with `{2}` but no other hashes are available for: {0}"
504 )]
505 InsecureHashAlgorithm(String, HashAlgorithm, HashCheckingMode),
506 #[error("In `{1}` mode, all requirements must have a hash, but none were provided for: {0}")]
507 MissingHashes(String, HashCheckingMode),
508 #[error(
509 "In `{1}` mode, all requirements must have a hash, but there were no overlapping hashes between the requirements and constraints for: {0}"
510 )]
511 NoIntersection(String, HashCheckingMode),
512}
513
514#[cfg(test)]
515mod tests {
516 use std::slice;
517 use std::str::FromStr;
518 use std::sync::Arc;
519
520 use rustc_hash::FxHashMap;
521 use uv_configuration::HashCheckingMode;
522 use uv_distribution_filename::DistExtension;
523 use uv_distribution_types::{
524 HashGeneration, HashPolicy, Requirement, RequirementSource, UnresolvedRequirement,
525 VersionId,
526 };
527 use uv_normalize::PackageName;
528 use uv_pep440::Version;
529 use uv_pypi_types::HashDigest;
530 use uv_redacted::DisplaySafeUrl;
531
532 use super::{HashStrategy, HashVerification};
533
534 fn requirement(url: &str) -> Requirement {
535 Requirement {
536 name: "anyio".parse().unwrap(),
537 extras: Box::default(),
538 groups: Box::default(),
539 marker: "python_version >= '3.8'".parse().unwrap(),
540 source: RequirementSource::Url {
541 location: "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl"
542 .parse()
543 .unwrap(),
544 subdirectory: None,
545 ext: DistExtension::Wheel,
546 url: url.parse().unwrap(),
547 },
548 origin: None,
549 }
550 }
551
552 #[test]
553 fn from_requirements_merges_direct_url_hashes_across_fragments() {
554 let first = UnresolvedRequirement::Named(requirement(
555 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
556 ));
557 let second = UnresolvedRequirement::Named(requirement(
558 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
559 ));
560
561 let hasher = HashStrategy::from_requirements(
562 [(&first, &[][..]), (&second, &[][..])].into_iter(),
563 std::iter::empty(),
564 None,
565 HashCheckingMode::Require,
566 )
567 .unwrap();
568
569 let mut expected = vec![
570 HashDigest::from_str(
571 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
572 )
573 .unwrap(),
574 HashDigest::from_str(
575 "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
576 )
577 .unwrap(),
578 ];
579 expected.sort_unstable();
580
581 for requirement in [&first, &second] {
582 let UnresolvedRequirement::Named(requirement) = requirement else {
583 panic!("expected named requirement");
584 };
585 let RequirementSource::Url { url, .. } = &requirement.source else {
586 panic!("expected direct URL requirement");
587 };
588 assert_eq!(hasher.get_url(url), HashPolicy::All(expected.as_slice()));
589 }
590 }
591
592 #[test]
593 fn generate_and_verify_validates_known_hashes_and_generates_unknown_hashes()
594 -> Result<(), Box<dyn std::error::Error>> {
595 let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
596 let unknown_url: DisplaySafeUrl = "https://example.com/anyio-4.1.0.tar.gz".parse()?;
597 let name: PackageName = "anyio".parse()?;
598 let version: Version = "4.0.0".parse()?;
599 let unknown_version: Version = "4.1.0".parse()?;
600 let digest = HashDigest::from_str(
601 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
602 )?;
603 let hashes = FxHashMap::from_iter([
604 (VersionId::from_url(&url), vec![digest.clone()]),
605 (
606 VersionId::from_registry(name.clone(), version.clone()),
607 vec![digest.clone()],
608 ),
609 ]);
610 let strategy = HashStrategy::generate(HashGeneration::All)
611 .with_verification(HashVerification::IfPresent(Arc::new(hashes)));
612
613 assert_eq!(
614 strategy.get_url(&url),
615 HashPolicy::All(slice::from_ref(&digest))
616 );
617 assert_eq!(
618 strategy.get_url(&unknown_url),
619 HashPolicy::Generate(HashGeneration::All)
620 );
621 assert_eq!(
622 strategy.get_package(&name, &version),
623 HashPolicy::Any(slice::from_ref(&digest))
624 );
625 assert_eq!(
626 strategy.get_package(&name, &unknown_version),
627 HashPolicy::Generate(HashGeneration::All)
628 );
629
630 Ok(())
631 }
632
633 #[test]
634 fn required_hashes_take_precedence_over_generation() -> Result<(), Box<dyn std::error::Error>> {
635 let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
636 let name: PackageName = "anyio".parse()?;
637 let version: Version = "4.0.0".parse()?;
638 let strategy = HashStrategy::generate(HashGeneration::All)
639 .with_verification(HashVerification::Required(Arc::default()));
640
641 assert_eq!(strategy.get_url(&url), HashPolicy::All(&[]));
642 assert_eq!(strategy.get_package(&name, &version), HashPolicy::Any(&[]));
643 assert!(!strategy.allows_url(&url));
644 assert!(!strategy.allows_package(&name, &version));
645 Ok(())
646 }
647}