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::{Constraints, 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 with_constraint_hashes(mut self, constraints: &Self) -> Result<Self, HashStrategyError> {
71 let (requirement_hashes, mode) = match &self.verification {
72 HashVerification::None => {
73 self.verification = constraints.verification.clone();
74 return Ok(self);
75 }
76 HashVerification::IfPresent(hashes) => {
77 let mode = match &constraints.verification {
78 HashVerification::Required(_) => HashCheckingMode::Require,
79 HashVerification::None | HashVerification::IfPresent(_) => {
80 HashCheckingMode::Verify
81 }
82 };
83 (hashes, mode)
84 }
85 HashVerification::Required(hashes) => (hashes, HashCheckingMode::Require),
86 };
87
88 let mut constraints = constraints.clone();
89 let constraint_hashes = match &mut constraints.verification {
90 HashVerification::None => return Ok(self),
91 HashVerification::IfPresent(hashes) | HashVerification::Required(hashes) => {
92 Arc::make_mut(hashes)
93 }
94 };
95 if mode.is_require() {
96 constraint_hashes.retain(|_, digests| {
97 digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
98 !digests.is_empty()
99 });
100 }
101 let mut hashes = constraint_hashes.clone();
102 for (id, digests) in requirement_hashes.iter() {
103 let mut digests = digests.clone();
104 if mode.is_require() {
105 digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
106 }
107 let digests = if let Some(constraint) = constraints.hashes_for_id(id) {
108 combine_constraint_hashes(id, digests, constraint, id, mode)?
109 } else {
110 digests
111 };
112 if !digests.is_empty() {
113 hashes.insert(id.clone(), digests);
114 }
115 }
116 self.verification = match mode {
117 HashCheckingMode::Verify => HashVerification::IfPresent(Arc::new(hashes)),
118 HashCheckingMode::Require => HashVerification::Required(Arc::new(hashes)),
119 };
120 Ok(self)
121 }
122
123 pub fn collection(&self) -> HashCollection {
125 self.collection
126 }
127
128 pub fn verification(&self) -> &HashVerification {
130 &self.verification
131 }
132
133 pub fn archive_policy<T: DistributionMetadata>(
135 &self,
136 distribution: &T,
137 ) -> ArchiveHashPolicy<'_> {
138 self.archive_policy_for_id(|| distribution.version_id())
139 }
140
141 pub fn metadata_policy<T: DistributionMetadata>(
143 &self,
144 distribution: &T,
145 ) -> MetadataHashPolicy<'_> {
146 MetadataHashPolicy {
147 collection: self.collection,
148 validation: self.validation_for_id(|| distribution.version_id()),
149 }
150 }
151
152 pub fn archive_policy_for_package(
154 &self,
155 name: &PackageName,
156 version: &Version,
157 ) -> ArchiveHashPolicy<'_> {
158 self.archive_policy_for_id(|| VersionId::from_registry(name.clone(), version.clone()))
159 }
160
161 pub fn archive_policy_for_url(&self, url: &DisplaySafeUrl) -> ArchiveHashPolicy<'_> {
165 self.archive_policy_for_id(|| VersionId::from_url(url))
166 }
167
168 pub fn metadata_policy_for_url(&self, url: &DisplaySafeUrl) -> MetadataHashPolicy<'_> {
170 MetadataHashPolicy {
171 collection: self.collection,
172 validation: self.validation_for_id(|| VersionId::from_url(url)),
173 }
174 }
175
176 fn archive_policy_for_id(&self, id: impl FnOnce() -> VersionId) -> ArchiveHashPolicy<'_> {
178 let validation = self.validation_for_id(id);
179 match validation {
180 HashValidation::None => match self.collection {
181 HashCollection::None => ArchiveHashPolicy::None,
182 HashCollection::Url | HashCollection::All => ArchiveHashPolicy::Generate,
183 },
184 HashValidation::Any(_) | HashValidation::All(_) => validation.into(),
185 }
186 }
187
188 fn validation_for_id(&self, id: impl FnOnce() -> VersionId) -> HashValidation<'_> {
190 match &self.verification {
191 HashVerification::IfPresent(_) => {
192 let id = id();
193 if let Some(hashes) = self.hashes_for_id(&id) {
194 return hash_validation(&id, hashes);
195 }
196 }
197 HashVerification::Required(hashes) => {
198 let id = id();
199 return hash_validation(
200 &id,
201 hashes.get(&id).map(Vec::as_slice).unwrap_or_default(),
202 );
203 }
204 HashVerification::None => {}
205 }
206 HashValidation::None
207 }
208
209 fn hashes_for_id(&self, id: &VersionId) -> Option<&[HashDigest]> {
211 match &self.verification {
212 HashVerification::None => None,
213 HashVerification::IfPresent(hashes) => hashes
214 .get(id)
215 .or_else(|| {
216 if let VersionId::NameVersion(name, version) = id
219 && version.is_local()
220 {
221 hashes.get(&VersionId::from_registry(
222 name.clone(),
223 version.clone().without_local(),
224 ))
225 } else {
226 None
227 }
228 })
229 .map(Vec::as_slice),
230 HashVerification::Required(hashes) => hashes.get(id).map(Vec::as_slice),
231 }
232 }
233
234 pub fn allows_package(&self, name: &PackageName, version: &Version) -> bool {
236 match &self.verification {
237 HashVerification::Required(hashes) => {
238 hashes.contains_key(&VersionId::from_registry(name.clone(), version.clone()))
239 }
240 HashVerification::None | HashVerification::IfPresent(_) => true,
241 }
242 }
243
244 pub fn allows_url(&self, url: &DisplaySafeUrl) -> bool {
246 match &self.verification {
247 HashVerification::Required(hashes) => hashes.contains_key(&VersionId::from_url(url)),
248 HashVerification::None | HashVerification::IfPresent(_) => true,
249 }
250 }
251
252 pub fn augment_with_requirements<'a>(
255 mut self,
256 requirements: impl Iterator<Item = &'a Requirement>,
257 ) -> Result<Self, HashStrategyError> {
258 match &mut self.verification {
259 HashVerification::None => {}
260 HashVerification::IfPresent(existing) | HashVerification::Required(existing) => {
261 if let Some(hashes) = Self::augment_hashes(existing, requirements)? {
262 *existing = Arc::new(hashes);
263 }
264 }
265 }
266 Ok(self)
267 }
268
269 pub fn augment_with_metadata_requirements<'a>(
277 self,
278 requirements: impl Iterator<Item = &'a Requirement>,
279 ) -> Result<Self, HashStrategyError> {
280 if matches!(&self.verification, HashVerification::Required(_)) {
281 return Ok(self);
282 }
283 self.augment_with_requirements(requirements)
284 }
285
286 pub fn from_requirements<'a>(
299 requirements: impl Iterator<Item = (&'a UnresolvedRequirement, &'a [String])>,
300 constraints: impl Iterator<Item = (&'a Requirement, &'a [String])>,
301 marker_env: Option<&ResolverMarkerEnvironment>,
302 mode: HashCheckingMode,
303 ) -> Result<Self, HashStrategyError> {
304 let mut constraint_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
305
306 for (requirement, digests) in constraints {
308 if !requirement
309 .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
310 {
311 continue;
312 }
313
314 let Some(id) = Self::pin(requirement) else {
316 if mode.is_require() {
317 return Err(HashStrategyError::UnpinnedRequirement(
318 requirement.to_string(),
319 mode,
320 ));
321 }
322 continue;
323 };
324
325 let mut digests = digests
328 .iter()
329 .map(|digest| HashDigest::from_str(digest))
330 .collect::<Result<Vec<_>, _>>()?;
331 if let Some(fragment_hashes) = requirement.hashes()? {
332 let fragment_hashes = HashDigests::from(fragment_hashes);
333 merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
334 }
335
336 if mode.is_require() {
337 digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
338 }
339
340 if digests.is_empty() {
341 continue;
342 }
343
344 merge_hashes(&mut constraint_hashes, id, digests, requirement)?;
345 }
346
347 let mut requirement_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
349 for (requirement, digests) in requirements {
350 if !requirement
351 .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
352 {
353 continue;
354 }
355
356 let id = match &requirement {
358 UnresolvedRequirement::Named(requirement) => {
359 if let Some(id) = Self::pin(requirement) {
360 id
361 } else {
362 if mode.is_require() {
363 return Err(HashStrategyError::UnpinnedRequirement(
364 requirement.to_string(),
365 mode,
366 ));
367 }
368 continue;
369 }
370 }
371 UnresolvedRequirement::Unnamed(requirement) => {
372 VersionId::from_parsed_url(requirement.url.parsed_url.clone())
374 }
375 };
376
377 let mut digests = digests
380 .iter()
381 .map(|digest| HashDigest::from_str(digest))
382 .collect::<Result<Vec<_>, _>>()?;
383 if let Some(fragment_hashes) = requirement.hashes()? {
384 let fragment_hashes = HashDigests::from(fragment_hashes);
385 merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
386 }
387
388 let has_md5 = mode.is_require()
389 && digests
390 .iter()
391 .any(|digest| digest.algorithm() == HashAlgorithm::Md5);
392 if mode.is_require() {
393 digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
394 }
395
396 let digests = if let Some(constraint) = constraint_hashes.remove(&id) {
397 combine_constraint_hashes(&id, digests, &constraint, requirement, mode)?
398 } else {
399 digests
400 };
401
402 if digests.is_empty() {
404 if mode.is_require() {
405 if has_md5 {
406 return Err(HashStrategyError::InsecureHashAlgorithm(
407 requirement.to_string(),
408 HashAlgorithm::Md5,
409 mode,
410 ));
411 }
412 return Err(HashStrategyError::MissingHashes(
413 requirement.to_string(),
414 mode,
415 ));
416 }
417 continue;
418 }
419
420 merge_hashes(&mut requirement_hashes, id, digests, requirement)?;
421 }
422
423 let hashes: FxHashMap<VersionId, Vec<HashDigest>> = constraint_hashes
426 .into_iter()
427 .chain(requirement_hashes)
428 .collect();
429 match mode {
430 HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
431 HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
432 }
433 }
434
435 pub fn from_constraints(
438 constraints: &Constraints,
439 marker_env: Option<&ResolverMarkerEnvironment>,
440 mode: HashCheckingMode,
441 ) -> Result<Self, HashStrategyError> {
442 Self::from_requirements(
443 std::iter::empty(),
444 constraints
445 .specifications()
446 .map(|entry| (&entry.requirement, entry.hashes.as_slice())),
447 marker_env,
448 mode,
449 )
450 }
451
452 pub fn from_resolution(
454 resolution: &Resolution,
455 mode: HashCheckingMode,
456 ) -> Result<Self, HashStrategyError> {
457 let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
458
459 for (dist, digests) in resolution.hashes() {
460 if digests.is_empty() {
461 if mode.is_require() {
463 return Err(HashStrategyError::MissingHashes(
464 dist.name().to_string(),
465 mode,
466 ));
467 }
468 continue;
469 }
470 hashes.insert(dist.version_id(), digests.to_vec());
471 }
472
473 match mode {
474 HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
475 HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
476 }
477 }
478
479 fn augment_hashes<'a>(
488 existing: &FxHashMap<VersionId, Vec<HashDigest>>,
489 requirements: impl Iterator<Item = &'a Requirement>,
490 ) -> Result<Option<FxHashMap<VersionId, Vec<HashDigest>>>, HashStrategyError> {
491 let mut hashes = None;
492
493 for requirement in requirements {
494 let Some((id, digests)) = Self::requirement_hashes(requirement)? else {
495 continue;
496 };
497 let current = hashes.as_ref().unwrap_or(existing);
498 let current_digests = current.get(&id);
499 let mut merged = current_digests.cloned().unwrap_or_default();
500 merge_digests(&mut merged, &digests, requirement)?;
501
502 if current_digests.map(Vec::as_slice) == Some(merged.as_slice()) {
503 continue;
504 }
505
506 hashes
507 .get_or_insert_with(|| existing.clone())
508 .insert(id, merged);
509 }
510
511 Ok(hashes)
512 }
513
514 fn requirement_hashes(
516 requirement: &Requirement,
517 ) -> Result<Option<(VersionId, Vec<HashDigest>)>, HashStrategyError> {
518 let Some(hashes) = requirement.hashes()? else {
519 return Ok(None);
520 };
521 let mut digests = HashDigests::from(hashes).to_vec();
522 if digests.is_empty() {
523 return Ok(None);
524 }
525 digests.sort_unstable();
526 let Some(id) = Self::pin(requirement) else {
527 return Ok(None);
528 };
529 Ok(Some((id, digests)))
530 }
531
532 fn pin(requirement: &Requirement) -> Option<VersionId> {
534 match &requirement.source {
535 RequirementSource::Registry { specifier, .. } => {
536 let [specifier] = specifier.as_ref() else {
538 return None;
539 };
540
541 let is_pinned =
543 matches!(specifier.operator(), Operator::Equal | Operator::ExactEqual);
544 if !is_pinned {
545 return None;
546 }
547
548 Some(VersionId::from_registry(
549 requirement.name.clone(),
550 specifier.version().clone(),
551 ))
552 }
553 RequirementSource::Url {
554 location,
555 subdirectory,
556 ..
557 } => Some(VersionId::from_archive(
558 location.clone(),
559 subdirectory.clone().map(Path::into_path_buf),
560 )),
561 RequirementSource::GitDirectory {
562 git, subdirectory, ..
563 } => Some(VersionId::from_git(git, subdirectory.as_deref())),
564 RequirementSource::GitPath {
565 git, install_path, ..
566 } => Some(VersionId::from_git(git, Some(install_path))),
567 RequirementSource::Path { install_path, .. } => {
568 Some(VersionId::from_path(install_path))
569 }
570 RequirementSource::Directory { install_path, .. } => {
571 Some(VersionId::from_directory(install_path))
572 }
573 }
574 }
575}
576
577fn hash_validation<'a>(id: &VersionId, digests: &'a [HashDigest]) -> HashValidation<'a> {
578 match id {
579 VersionId::NameVersion { .. } => HashValidation::Any(digests),
580 VersionId::ArchiveUrl { .. }
581 | VersionId::Git { .. }
582 | VersionId::Path { .. }
583 | VersionId::Directory { .. }
584 | VersionId::Unknown { .. } => HashValidation::All(digests),
585 }
586}
587
588fn combine_constraint_hashes(
590 id: &VersionId,
591 mut digests: Vec<HashDigest>,
592 constraint: &[HashDigest],
593 requirement: impl Display,
594 mode: HashCheckingMode,
595) -> Result<Vec<HashDigest>, HashStrategyError> {
596 if digests.is_empty() {
597 return Ok(constraint.to_vec());
599 }
600 match id {
601 VersionId::ArchiveUrl { .. } => {
602 merge_digests(&mut digests, constraint, requirement)?;
603 }
604 VersionId::NameVersion(..)
605 | VersionId::Git { .. }
606 | VersionId::Path(..)
607 | VersionId::Directory(..)
608 | VersionId::Unknown(..) => {
609 digests.retain(|digest| constraint.contains(digest));
611 if digests.is_empty() {
612 return Err(HashStrategyError::NoIntersection(
613 requirement.to_string(),
614 mode,
615 ));
616 }
617 }
618 }
619 Ok(digests)
620}
621
622fn merge_hashes(
624 hashes: &mut FxHashMap<VersionId, Vec<HashDigest>>,
625 id: VersionId,
626 incoming: Vec<HashDigest>,
627 requirement: impl Display,
628) -> Result<(), HashStrategyError> {
629 if incoming.is_empty() {
630 return Ok(());
631 }
632
633 if !matches!(&id, VersionId::ArchiveUrl { .. }) {
634 hashes.insert(id, incoming);
635 return Ok(());
636 }
637
638 if let Some(existing) = hashes.get_mut(&id) {
639 return merge_digests(existing, &incoming, requirement);
640 }
641
642 let mut merged = Vec::new();
643 merge_digests(&mut merged, &incoming, requirement)?;
644 hashes.insert(id, merged);
645 Ok(())
646}
647
648fn merge_digests<'a>(
654 existing: &mut Vec<HashDigest>,
655 incoming: impl IntoIterator<Item = &'a HashDigest>,
656 requirement: impl Display,
657) -> Result<(), HashStrategyError> {
658 for digest in incoming {
659 match existing
660 .iter()
661 .find(|candidate| candidate.algorithm() == digest.algorithm())
662 {
663 Some(candidate) if candidate == digest => {}
664 Some(conflict) => {
665 return Err(HashStrategyError::ConflictingArchiveUrlHashes(
666 requirement.to_string(),
667 conflict.clone(),
668 digest.clone(),
669 ));
670 }
671 None => existing.push(digest.clone()),
672 }
673 }
674 existing.sort_unstable();
675
676 Ok(())
677}
678
679#[derive(thiserror::Error, Debug)]
680pub enum HashStrategyError {
681 #[error(transparent)]
682 Hash(#[from] HashError),
683 #[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")]
684 ConflictingArchiveUrlHashes(String, HashDigest, HashDigest),
685 #[error(
686 "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}"
687 )]
688 UnpinnedRequirement(String, HashCheckingMode),
689 #[error(
690 "`{1}` hashes are insecure and cannot be used with `{2}` but no other hashes are available for: {0}"
691 )]
692 InsecureHashAlgorithm(String, HashAlgorithm, HashCheckingMode),
693 #[error("In `{1}` mode, all requirements must have a hash, but none were provided for: {0}")]
694 MissingHashes(String, HashCheckingMode),
695 #[error(
696 "In `{1}` mode, all requirements must have a hash, but there were no overlapping hashes between the requirements and constraints for: {0}"
697 )]
698 NoIntersection(String, HashCheckingMode),
699}
700
701#[cfg(test)]
702mod tests {
703 use std::path::Path;
704 use std::slice;
705 use std::str::FromStr;
706 use std::sync::Arc;
707
708 use rustc_hash::FxHashMap;
709 use uv_configuration::HashCheckingMode;
710 use uv_distribution_filename::DistExtension;
711 use uv_distribution_types::{
712 ArchiveHashPolicy, HashCollection, HashValidation, MetadataHashPolicy, Requirement,
713 RequirementScope, RequirementSource, UnresolvedRequirement, VersionId,
714 };
715 use uv_normalize::PackageName;
716 use uv_pep440::Version;
717 use uv_pypi_types::HashDigest;
718 use uv_redacted::DisplaySafeUrl;
719
720 use super::{HashStrategy, HashVerification};
721
722 fn requirement(url: &str) -> Requirement {
723 Requirement {
724 name: "anyio".parse().unwrap(),
725 extras: Box::default(),
726 groups: Box::default(),
727 marker: "python_version >= '3.8'".parse().unwrap(),
728 source: RequirementSource::Url {
729 location: "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl"
730 .parse()
731 .unwrap(),
732 subdirectory: None,
733 ext: DistExtension::Wheel,
734 url: url.parse().unwrap(),
735 },
736 scope: RequirementScope::Global,
737 origin: None,
738 }
739 }
740
741 #[test]
742 fn from_requirements_merges_direct_url_hashes_across_fragments() {
743 let first = UnresolvedRequirement::Named(requirement(
744 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=CFDB2B588B9FC25EDE96D8DB56ED50848B0B649DCA3DD1DF0B11F683BB9E0B5F",
745 ));
746 let second = UnresolvedRequirement::Named(requirement(
747 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
748 ));
749
750 let hasher = HashStrategy::from_requirements(
751 [(&first, &[][..]), (&second, &[][..])].into_iter(),
752 std::iter::empty(),
753 None,
754 HashCheckingMode::Require,
755 )
756 .unwrap();
757
758 let mut expected = vec![
759 HashDigest::from_str(
760 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
761 )
762 .unwrap(),
763 HashDigest::from_str(
764 "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
765 )
766 .unwrap(),
767 ];
768 expected.sort_unstable();
769
770 for requirement in [&first, &second] {
771 let UnresolvedRequirement::Named(requirement) = requirement else {
772 panic!("expected named requirement");
773 };
774 let RequirementSource::Url { url, .. } = &requirement.source else {
775 panic!("expected direct URL requirement");
776 };
777 assert_eq!(
778 hasher.archive_policy_for_url(url),
779 ArchiveHashPolicy::All(expected.as_slice())
780 );
781 }
782 }
783
784 #[test]
785 fn generate_and_verify_validates_known_hashes_and_generates_unknown_hashes()
786 -> Result<(), Box<dyn std::error::Error>> {
787 let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
788 let unknown_url: DisplaySafeUrl = "https://example.com/anyio-4.1.0.tar.gz".parse()?;
789 let name: PackageName = "anyio".parse()?;
790 let version: Version = "4.0.0".parse()?;
791 let unknown_version: Version = "4.1.0".parse()?;
792 let digest = HashDigest::from_str(
793 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
794 )?;
795 let hashes = FxHashMap::from_iter([
796 (VersionId::from_url(&url), vec![digest.clone()]),
797 (
798 VersionId::from_registry(name.clone(), version.clone()),
799 vec![digest.clone()],
800 ),
801 ]);
802 let strategy = HashStrategy::collect(HashCollection::All)
803 .with_verification(HashVerification::IfPresent(Arc::new(hashes)));
804
805 assert_eq!(
806 strategy.archive_policy_for_url(&url),
807 ArchiveHashPolicy::All(slice::from_ref(&digest))
808 );
809 for fragment in [
810 "#subdirectory=.",
811 "#subdirectory=./",
812 "#subdirectory=",
813 "#subdirectory=nested/..",
814 ] {
815 let root_url = format!("{url}{fragment}").parse()?;
816 assert_eq!(
817 strategy.archive_policy_for_url(&root_url),
818 ArchiveHashPolicy::All(slice::from_ref(&digest))
819 );
820 }
821 assert_eq!(
822 strategy.archive_policy_for_url(&unknown_url),
823 ArchiveHashPolicy::Generate
824 );
825 assert_eq!(
826 strategy.metadata_policy_for_url(&unknown_url),
827 MetadataHashPolicy {
828 collection: HashCollection::All,
829 validation: HashValidation::None,
830 }
831 );
832 assert_eq!(
833 strategy.metadata_policy_for_url(&url),
834 MetadataHashPolicy {
835 collection: HashCollection::All,
836 validation: HashValidation::All(slice::from_ref(&digest)),
837 }
838 );
839 assert_eq!(
840 strategy.archive_policy_for_package(&name, &version),
841 ArchiveHashPolicy::Any(slice::from_ref(&digest))
842 );
843 assert_eq!(
844 strategy.archive_policy_for_package(&name, &unknown_version),
845 ArchiveHashPolicy::Generate
846 );
847
848 Ok(())
849 }
850
851 #[test]
852 fn required_hashes_take_precedence_over_collection() -> Result<(), Box<dyn std::error::Error>> {
853 let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
854 let name: PackageName = "anyio".parse()?;
855 let version: Version = "4.0.0".parse()?;
856 let strategy = HashStrategy::collect(HashCollection::All)
857 .with_verification(HashVerification::Required(Arc::default()));
858
859 assert_eq!(
860 strategy.archive_policy_for_url(&url),
861 ArchiveHashPolicy::All(&[])
862 );
863 assert_eq!(
864 strategy.metadata_policy_for_url(&url),
865 MetadataHashPolicy {
866 collection: HashCollection::All,
867 validation: HashValidation::All(&[]),
868 }
869 );
870 assert_eq!(
871 strategy.archive_policy_for_package(&name, &version),
872 ArchiveHashPolicy::Any(&[])
873 );
874 assert!(!strategy.allows_url(&url));
875 assert!(!strategy.allows_package(&name, &version));
876
877 let digest = HashDigest::from_str(
878 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
879 )?;
880 let constraints = HashStrategy::verify(Arc::new(FxHashMap::from_iter([
881 (VersionId::from_url(&url), vec![digest.clone()]),
882 (
883 VersionId::from_registry(name.clone(), version.clone()),
884 vec![HashDigest::from_str(
885 "md5:420d85e19168705cdf0223621b18831a",
886 )?],
887 ),
888 ])));
889 for strategy in [
890 strategy.clone().with_constraint_hashes(&constraints)?,
891 constraints.with_constraint_hashes(&strategy)?,
892 ] {
893 assert_eq!(
894 strategy.archive_policy_for_url(&url),
895 ArchiveHashPolicy::All(slice::from_ref(&digest))
896 );
897 assert_eq!(
898 strategy.archive_policy_for_package(&name, &version),
899 ArchiveHashPolicy::Any(&[])
900 );
901 assert!(!strategy.allows_package(&name, &version));
902 }
903 Ok(())
904 }
905
906 #[test]
907 fn constraint_hashes_combine_with_locked_hashes() -> Result<(), Box<dyn std::error::Error>> {
908 let name: PackageName = "anyio".parse()?;
909 let version: Version = "4.0.0".parse()?;
910 let local_version: Version = "4.0.0+local".parse()?;
911 let digest = HashDigest::from_str(
912 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
913 )?;
914 let other = HashDigest::from_str(
915 "sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a",
916 )?;
917 let sha512 = HashDigest::from_str(
918 "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
919 )?;
920 let registry = VersionId::from_registry(name.clone(), version);
921 let local_registry = VersionId::from_registry(name, local_version);
922 let archive = VersionId::from_url(&"https://example.com/anyio-4.0.0.tar.gz".parse()?);
923 let path = VersionId::from_path(Path::new("anyio-4.0.0.tar.gz"));
924 for (id, constraint_id, constraint, expected) in [
925 (
926 registry.clone(),
927 registry,
928 digest.clone(),
929 vec![digest.clone()],
930 ),
931 (
932 local_registry,
933 VersionId::from_registry("anyio".parse()?, "4.0.0".parse()?),
934 digest.clone(),
935 vec![digest.clone()],
936 ),
937 (path.clone(), path, digest.clone(), vec![digest.clone()]),
938 (
939 archive.clone(),
940 archive,
941 sha512.clone(),
942 vec![digest.clone(), sha512],
943 ),
944 ] {
945 let locked = HashStrategy::collect(HashCollection::All).with_verification(
946 HashVerification::IfPresent(Arc::new(FxHashMap::from_iter([(
947 id.clone(),
948 vec![digest.clone()],
949 )]))),
950 );
951 let constraints = HashStrategy::verify(Arc::new(FxHashMap::from_iter([(
952 constraint_id.clone(),
953 vec![constraint],
954 )])));
955 let combined = locked.clone().with_constraint_hashes(&constraints)?;
956 assert_eq!(combined.collection(), HashCollection::All);
957 assert_eq!(combined.hashes_for_id(&id), Some(expected.as_slice()));
958
959 let conflicting = HashStrategy::verify(Arc::new(FxHashMap::from_iter([(
960 constraint_id,
961 vec![other.clone()],
962 )])));
963 assert!(locked.with_constraint_hashes(&conflicting).is_err());
964 }
965 Ok(())
966 }
967}