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 ArchiveHashPolicy, DistributionMetadata, HashCollection, HashValidation, MetadataHashPolicy,
11 Name, Requirement, RequirementSource, Resolution, UnresolvedRequirement, VersionId,
12};
13use uv_normalize::PackageName;
14use uv_pep440::{Operator, Version};
15use uv_pypi_types::{HashAlgorithm, HashDigest, HashDigests, HashError, ResolverMarkerEnvironment};
16use uv_redacted::DisplaySafeUrl;
17
18#[derive(Debug, Default, Clone)]
23pub struct HashStrategy {
24 collection: HashCollection,
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 collect(collection: HashCollection) -> Self {
43 Self {
44 collection,
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 pub fn with_verification(mut self, verification: HashVerification) -> Self {
62 self.verification = verification;
63 self
64 }
65
66 pub fn collection(&self) -> HashCollection {
68 self.collection
69 }
70
71 pub fn verification(&self) -> &HashVerification {
73 &self.verification
74 }
75
76 pub fn archive_policy<T: DistributionMetadata>(
78 &self,
79 distribution: &T,
80 ) -> ArchiveHashPolicy<'_> {
81 self.archive_policy_for_id(|| distribution.version_id())
82 }
83
84 pub fn metadata_policy<T: DistributionMetadata>(
86 &self,
87 distribution: &T,
88 ) -> MetadataHashPolicy<'_> {
89 MetadataHashPolicy {
90 collection: self.collection,
91 validation: self.validation_for_id(|| distribution.version_id()),
92 }
93 }
94
95 pub fn archive_policy_for_package(
97 &self,
98 name: &PackageName,
99 version: &Version,
100 ) -> ArchiveHashPolicy<'_> {
101 self.archive_policy_for_id(|| VersionId::from_registry(name.clone(), version.clone()))
102 }
103
104 pub fn archive_policy_for_url(&self, url: &DisplaySafeUrl) -> ArchiveHashPolicy<'_> {
108 self.archive_policy_for_id(|| VersionId::from_url(url))
109 }
110
111 pub fn metadata_policy_for_url(&self, url: &DisplaySafeUrl) -> MetadataHashPolicy<'_> {
113 MetadataHashPolicy {
114 collection: self.collection,
115 validation: self.validation_for_id(|| VersionId::from_url(url)),
116 }
117 }
118
119 fn archive_policy_for_id(&self, id: impl FnOnce() -> VersionId) -> ArchiveHashPolicy<'_> {
121 let validation = self.validation_for_id(id);
122 match validation {
123 HashValidation::None => match self.collection {
124 HashCollection::None => ArchiveHashPolicy::None,
125 HashCollection::Url | HashCollection::All => ArchiveHashPolicy::Generate,
126 },
127 HashValidation::Any(_) | HashValidation::All(_) => validation.into(),
128 }
129 }
130
131 fn validation_for_id(&self, id: impl FnOnce() -> VersionId) -> HashValidation<'_> {
133 match &self.verification {
134 HashVerification::IfPresent(hashes) => {
135 let id = id();
136 if let Some(hashes) = hashes.get(&id) {
137 return hash_validation(&id, hashes);
138 }
139 if let VersionId::NameVersion(name, version) = &id
142 && version.is_local()
143 && let Some(hashes) = hashes.get(&VersionId::from_registry(
144 name.clone(),
145 version.clone().without_local(),
146 ))
147 {
148 return HashValidation::Any(hashes);
149 }
150 }
151 HashVerification::Required(hashes) => {
152 let id = id();
153 return hash_validation(
154 &id,
155 hashes.get(&id).map(Vec::as_slice).unwrap_or_default(),
156 );
157 }
158 HashVerification::None => {}
159 }
160 HashValidation::None
161 }
162
163 pub fn allows_package(&self, name: &PackageName, version: &Version) -> bool {
165 match &self.verification {
166 HashVerification::Required(hashes) => {
167 hashes.contains_key(&VersionId::from_registry(name.clone(), version.clone()))
168 }
169 HashVerification::None | HashVerification::IfPresent(_) => true,
170 }
171 }
172
173 pub fn allows_url(&self, url: &DisplaySafeUrl) -> bool {
175 match &self.verification {
176 HashVerification::Required(hashes) => hashes.contains_key(&VersionId::from_url(url)),
177 HashVerification::None | HashVerification::IfPresent(_) => true,
178 }
179 }
180
181 pub fn augment_with_requirements<'a>(
184 mut self,
185 requirements: impl Iterator<Item = &'a Requirement>,
186 ) -> Result<Self, HashStrategyError> {
187 match &mut self.verification {
188 HashVerification::None => {}
189 HashVerification::IfPresent(existing) | HashVerification::Required(existing) => {
190 if let Some(hashes) = Self::augment_hashes(existing, requirements)? {
191 *existing = Arc::new(hashes);
192 }
193 }
194 }
195 Ok(self)
196 }
197
198 pub fn augment_with_metadata_requirements<'a>(
206 self,
207 requirements: impl Iterator<Item = &'a Requirement>,
208 ) -> Result<Self, HashStrategyError> {
209 if matches!(&self.verification, HashVerification::Required(_)) {
210 return Ok(self);
211 }
212 self.augment_with_requirements(requirements)
213 }
214
215 pub fn from_requirements<'a>(
222 requirements: impl Iterator<Item = (&'a UnresolvedRequirement, &'a [String])>,
223 constraints: impl Iterator<Item = (&'a Requirement, &'a [String])>,
224 marker_env: Option<&ResolverMarkerEnvironment>,
225 mode: HashCheckingMode,
226 ) -> Result<Self, HashStrategyError> {
227 let mut constraint_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
228
229 for (requirement, digests) in constraints {
231 if !requirement
232 .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
233 {
234 continue;
235 }
236
237 let Some(id) = Self::pin(requirement) else {
239 if mode.is_require() {
240 return Err(HashStrategyError::UnpinnedRequirement(
241 requirement.to_string(),
242 mode,
243 ));
244 }
245 continue;
246 };
247
248 let mut digests = digests
251 .iter()
252 .map(|digest| HashDigest::from_str(digest))
253 .collect::<Result<Vec<_>, _>>()?;
254 if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
255 merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
256 }
257
258 if mode.is_require() {
259 digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
260 }
261
262 if digests.is_empty() {
263 continue;
264 }
265
266 merge_hashes(&mut constraint_hashes, id, digests, requirement)?;
267 }
268
269 let mut requirement_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
271 for (requirement, digests) in requirements {
272 if !requirement
273 .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
274 {
275 continue;
276 }
277
278 let id = match &requirement {
280 UnresolvedRequirement::Named(requirement) => {
281 if let Some(id) = Self::pin(requirement) {
282 id
283 } else {
284 if mode.is_require() {
285 return Err(HashStrategyError::UnpinnedRequirement(
286 requirement.to_string(),
287 mode,
288 ));
289 }
290 continue;
291 }
292 }
293 UnresolvedRequirement::Unnamed(requirement) => {
294 VersionId::from_parsed_url(requirement.url.parsed_url.clone())
296 }
297 };
298
299 let mut digests = digests
302 .iter()
303 .map(|digest| HashDigest::from_str(digest))
304 .collect::<Result<Vec<_>, _>>()?;
305 if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
306 merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
307 }
308
309 let has_md5 = mode.is_require()
310 && digests
311 .iter()
312 .any(|digest| digest.algorithm() == HashAlgorithm::Md5);
313 if mode.is_require() {
314 digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
315 }
316
317 let digests = if let Some(constraint) = constraint_hashes.remove(&id) {
318 if digests.is_empty() {
319 constraint
321 } else if matches!(id, VersionId::ArchiveUrl { .. }) {
322 let mut merged = digests;
323 merge_digests(&mut merged, &constraint, requirement)?;
324 merged
325 } else {
326 let intersection: Vec<_> = digests
328 .into_iter()
329 .filter(|digest| constraint.contains(digest))
330 .collect();
331 if intersection.is_empty() {
332 return Err(HashStrategyError::NoIntersection(
333 requirement.to_string(),
334 mode,
335 ));
336 }
337 intersection
338 }
339 } else {
340 digests
341 };
342
343 if digests.is_empty() {
345 if mode.is_require() {
346 if has_md5 {
347 return Err(HashStrategyError::InsecureHashAlgorithm(
348 requirement.to_string(),
349 HashAlgorithm::Md5,
350 mode,
351 ));
352 }
353 return Err(HashStrategyError::MissingHashes(
354 requirement.to_string(),
355 mode,
356 ));
357 }
358 continue;
359 }
360
361 merge_hashes(&mut requirement_hashes, id, digests, requirement)?;
362 }
363
364 let hashes: FxHashMap<VersionId, Vec<HashDigest>> = constraint_hashes
367 .into_iter()
368 .chain(requirement_hashes)
369 .collect();
370 match mode {
371 HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
372 HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
373 }
374 }
375
376 pub fn from_resolution(
378 resolution: &Resolution,
379 mode: HashCheckingMode,
380 ) -> Result<Self, HashStrategyError> {
381 let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
382
383 for (dist, digests) in resolution.hashes() {
384 if digests.is_empty() {
385 if mode.is_require() {
387 return Err(HashStrategyError::MissingHashes(
388 dist.name().to_string(),
389 mode,
390 ));
391 }
392 continue;
393 }
394 hashes.insert(dist.version_id(), digests.to_vec());
395 }
396
397 match mode {
398 HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
399 HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
400 }
401 }
402
403 fn augment_hashes<'a>(
412 existing: &FxHashMap<VersionId, Vec<HashDigest>>,
413 requirements: impl Iterator<Item = &'a Requirement>,
414 ) -> Result<Option<FxHashMap<VersionId, Vec<HashDigest>>>, HashStrategyError> {
415 let mut hashes = None;
416
417 for requirement in requirements {
418 let Some((id, digests)) = Self::requirement_hashes(requirement) else {
419 continue;
420 };
421 let current = hashes.as_ref().unwrap_or(existing);
422 let current_digests = current.get(&id);
423 let mut merged = current_digests.cloned().unwrap_or_default();
424 merge_digests(&mut merged, &digests, requirement)?;
425
426 if current_digests.map(Vec::as_slice) == Some(merged.as_slice()) {
427 continue;
428 }
429
430 hashes
431 .get_or_insert_with(|| existing.clone())
432 .insert(id, merged);
433 }
434
435 Ok(hashes)
436 }
437
438 fn requirement_hashes(requirement: &Requirement) -> Option<(VersionId, Vec<HashDigest>)> {
440 let mut digests = HashDigests::from(requirement.hashes()?).to_vec();
441 if digests.is_empty() {
442 return None;
443 }
444 digests.sort_unstable();
445 let id = Self::pin(requirement)?;
446 Some((id, digests))
447 }
448
449 fn pin(requirement: &Requirement) -> Option<VersionId> {
451 match &requirement.source {
452 RequirementSource::Registry { specifier, .. } => {
453 let [specifier] = specifier.as_ref() else {
455 return None;
456 };
457
458 let is_pinned =
460 matches!(specifier.operator(), Operator::Equal | Operator::ExactEqual);
461 if !is_pinned {
462 return None;
463 }
464
465 Some(VersionId::from_registry(
466 requirement.name.clone(),
467 specifier.version().clone(),
468 ))
469 }
470 RequirementSource::Url {
471 location,
472 subdirectory,
473 ..
474 } => Some(VersionId::from_archive(
475 location.clone(),
476 subdirectory.clone().map(Path::into_path_buf),
477 )),
478 RequirementSource::GitDirectory {
479 git, subdirectory, ..
480 } => Some(VersionId::from_git(git, subdirectory.as_deref())),
481 RequirementSource::GitPath {
482 git, install_path, ..
483 } => Some(VersionId::from_git(git, Some(install_path))),
484 RequirementSource::Path { install_path, .. } => {
485 Some(VersionId::from_path(install_path))
486 }
487 RequirementSource::Directory { install_path, .. } => {
488 Some(VersionId::from_directory(install_path))
489 }
490 }
491 }
492}
493
494fn hash_validation<'a>(id: &VersionId, digests: &'a [HashDigest]) -> HashValidation<'a> {
495 match id {
496 VersionId::NameVersion { .. } => HashValidation::Any(digests),
497 VersionId::ArchiveUrl { .. }
498 | VersionId::Git { .. }
499 | VersionId::Path { .. }
500 | VersionId::Directory { .. }
501 | VersionId::Unknown { .. } => HashValidation::All(digests),
502 }
503}
504
505fn merge_hashes(
507 hashes: &mut FxHashMap<VersionId, Vec<HashDigest>>,
508 id: VersionId,
509 incoming: Vec<HashDigest>,
510 requirement: impl Display,
511) -> Result<(), HashStrategyError> {
512 if incoming.is_empty() {
513 return Ok(());
514 }
515
516 if !matches!(&id, VersionId::ArchiveUrl { .. }) {
517 hashes.insert(id, incoming);
518 return Ok(());
519 }
520
521 if let Some(existing) = hashes.get_mut(&id) {
522 return merge_digests(existing, &incoming, requirement);
523 }
524
525 let mut merged = Vec::new();
526 merge_digests(&mut merged, &incoming, requirement)?;
527 hashes.insert(id, merged);
528 Ok(())
529}
530
531fn merge_digests<'a>(
537 existing: &mut Vec<HashDigest>,
538 incoming: impl IntoIterator<Item = &'a HashDigest>,
539 requirement: impl Display,
540) -> Result<(), HashStrategyError> {
541 for digest in incoming {
542 match existing
543 .iter()
544 .find(|candidate| candidate.algorithm == digest.algorithm)
545 {
546 Some(candidate) if candidate == digest => {}
547 Some(conflict) => {
548 return Err(HashStrategyError::ConflictingArchiveUrlHashes(
549 requirement.to_string(),
550 conflict.clone(),
551 digest.clone(),
552 ));
553 }
554 None => existing.push(digest.clone()),
555 }
556 }
557 existing.sort_unstable();
558
559 Ok(())
560}
561
562#[derive(thiserror::Error, Debug)]
563pub enum HashStrategyError {
564 #[error(transparent)]
565 Hash(#[from] HashError),
566 #[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")]
567 ConflictingArchiveUrlHashes(String, HashDigest, HashDigest),
568 #[error(
569 "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}"
570 )]
571 UnpinnedRequirement(String, HashCheckingMode),
572 #[error(
573 "`{1}` hashes are insecure and cannot be used with `{2}` but no other hashes are available for: {0}"
574 )]
575 InsecureHashAlgorithm(String, HashAlgorithm, HashCheckingMode),
576 #[error("In `{1}` mode, all requirements must have a hash, but none were provided for: {0}")]
577 MissingHashes(String, HashCheckingMode),
578 #[error(
579 "In `{1}` mode, all requirements must have a hash, but there were no overlapping hashes between the requirements and constraints for: {0}"
580 )]
581 NoIntersection(String, HashCheckingMode),
582}
583
584#[cfg(test)]
585mod tests {
586 use std::slice;
587 use std::str::FromStr;
588 use std::sync::Arc;
589
590 use rustc_hash::FxHashMap;
591 use uv_configuration::HashCheckingMode;
592 use uv_distribution_filename::DistExtension;
593 use uv_distribution_types::{
594 ArchiveHashPolicy, HashCollection, HashValidation, MetadataHashPolicy, Requirement,
595 RequirementSource, UnresolvedRequirement, VersionId,
596 };
597 use uv_normalize::PackageName;
598 use uv_pep440::Version;
599 use uv_pypi_types::HashDigest;
600 use uv_redacted::DisplaySafeUrl;
601
602 use super::{HashStrategy, HashVerification};
603
604 fn requirement(url: &str) -> Requirement {
605 Requirement {
606 name: "anyio".parse().unwrap(),
607 extras: Box::default(),
608 groups: Box::default(),
609 marker: "python_version >= '3.8'".parse().unwrap(),
610 source: RequirementSource::Url {
611 location: "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl"
612 .parse()
613 .unwrap(),
614 subdirectory: None,
615 ext: DistExtension::Wheel,
616 url: url.parse().unwrap(),
617 },
618 origin: None,
619 }
620 }
621
622 #[test]
623 fn from_requirements_merges_direct_url_hashes_across_fragments() {
624 let first = UnresolvedRequirement::Named(requirement(
625 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
626 ));
627 let second = UnresolvedRequirement::Named(requirement(
628 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
629 ));
630
631 let hasher = HashStrategy::from_requirements(
632 [(&first, &[][..]), (&second, &[][..])].into_iter(),
633 std::iter::empty(),
634 None,
635 HashCheckingMode::Require,
636 )
637 .unwrap();
638
639 let mut expected = vec![
640 HashDigest::from_str(
641 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
642 )
643 .unwrap(),
644 HashDigest::from_str(
645 "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
646 )
647 .unwrap(),
648 ];
649 expected.sort_unstable();
650
651 for requirement in [&first, &second] {
652 let UnresolvedRequirement::Named(requirement) = requirement else {
653 panic!("expected named requirement");
654 };
655 let RequirementSource::Url { url, .. } = &requirement.source else {
656 panic!("expected direct URL requirement");
657 };
658 assert_eq!(
659 hasher.archive_policy_for_url(url),
660 ArchiveHashPolicy::All(expected.as_slice())
661 );
662 }
663 }
664
665 #[test]
666 fn generate_and_verify_validates_known_hashes_and_generates_unknown_hashes()
667 -> Result<(), Box<dyn std::error::Error>> {
668 let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
669 let unknown_url: DisplaySafeUrl = "https://example.com/anyio-4.1.0.tar.gz".parse()?;
670 let name: PackageName = "anyio".parse()?;
671 let version: Version = "4.0.0".parse()?;
672 let unknown_version: Version = "4.1.0".parse()?;
673 let digest = HashDigest::from_str(
674 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
675 )?;
676 let hashes = FxHashMap::from_iter([
677 (VersionId::from_url(&url), vec![digest.clone()]),
678 (
679 VersionId::from_registry(name.clone(), version.clone()),
680 vec![digest.clone()],
681 ),
682 ]);
683 let strategy = HashStrategy::collect(HashCollection::All)
684 .with_verification(HashVerification::IfPresent(Arc::new(hashes)));
685
686 assert_eq!(
687 strategy.archive_policy_for_url(&url),
688 ArchiveHashPolicy::All(slice::from_ref(&digest))
689 );
690 for fragment in [
691 "#subdirectory=.",
692 "#subdirectory=./",
693 "#subdirectory=",
694 "#subdirectory=nested/..",
695 ] {
696 let root_url = format!("{url}{fragment}").parse()?;
697 assert_eq!(
698 strategy.archive_policy_for_url(&root_url),
699 ArchiveHashPolicy::All(slice::from_ref(&digest))
700 );
701 }
702 assert_eq!(
703 strategy.archive_policy_for_url(&unknown_url),
704 ArchiveHashPolicy::Generate
705 );
706 assert_eq!(
707 strategy.metadata_policy_for_url(&unknown_url),
708 MetadataHashPolicy {
709 collection: HashCollection::All,
710 validation: HashValidation::None,
711 }
712 );
713 assert_eq!(
714 strategy.metadata_policy_for_url(&url),
715 MetadataHashPolicy {
716 collection: HashCollection::All,
717 validation: HashValidation::All(slice::from_ref(&digest)),
718 }
719 );
720 assert_eq!(
721 strategy.archive_policy_for_package(&name, &version),
722 ArchiveHashPolicy::Any(slice::from_ref(&digest))
723 );
724 assert_eq!(
725 strategy.archive_policy_for_package(&name, &unknown_version),
726 ArchiveHashPolicy::Generate
727 );
728
729 Ok(())
730 }
731
732 #[test]
733 fn required_hashes_take_precedence_over_collection() -> Result<(), Box<dyn std::error::Error>> {
734 let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
735 let name: PackageName = "anyio".parse()?;
736 let version: Version = "4.0.0".parse()?;
737 let strategy = HashStrategy::collect(HashCollection::All)
738 .with_verification(HashVerification::Required(Arc::default()));
739
740 assert_eq!(
741 strategy.archive_policy_for_url(&url),
742 ArchiveHashPolicy::All(&[])
743 );
744 assert_eq!(
745 strategy.metadata_policy_for_url(&url),
746 MetadataHashPolicy {
747 collection: HashCollection::All,
748 validation: HashValidation::All(&[]),
749 }
750 );
751 assert_eq!(
752 strategy.archive_policy_for_package(&name, &version),
753 ArchiveHashPolicy::Any(&[])
754 );
755 assert!(!strategy.allows_url(&url));
756 assert!(!strategy.allows_package(&name, &version));
757 Ok(())
758 }
759}