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.get(&id) {
397 if digests.is_empty() && requirement_hashes.contains_key(&id) {
400 continue;
401 }
402 combine_constraint_hashes(&id, digests, constraint, requirement, mode)?
403 } else {
404 digests
405 };
406
407 if digests.is_empty() {
410 if mode.is_require() {
411 if has_md5 {
412 return Err(HashStrategyError::InsecureHashAlgorithm(
413 requirement.to_string(),
414 HashAlgorithm::Md5,
415 mode,
416 ));
417 }
418 return Err(HashStrategyError::MissingHashes(
419 requirement.to_string(),
420 mode,
421 ));
422 }
423 continue;
424 }
425
426 merge_hashes(&mut requirement_hashes, id, digests, requirement)?;
427 }
428
429 let hashes: FxHashMap<VersionId, Vec<HashDigest>> = constraint_hashes
431 .into_iter()
432 .chain(requirement_hashes)
433 .collect();
434 match mode {
435 HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
436 HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
437 }
438 }
439
440 pub fn from_constraints(
443 constraints: &Constraints,
444 marker_env: Option<&ResolverMarkerEnvironment>,
445 mode: HashCheckingMode,
446 ) -> Result<Self, HashStrategyError> {
447 Self::from_requirements(
448 std::iter::empty(),
449 constraints
450 .specifications()
451 .map(|entry| (&entry.requirement, entry.hashes.as_slice())),
452 marker_env,
453 mode,
454 )
455 }
456
457 pub fn from_resolution(
459 resolution: &Resolution,
460 mode: HashCheckingMode,
461 ) -> Result<Self, HashStrategyError> {
462 let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
463
464 for (dist, digests) in resolution.hashes() {
465 if digests.is_empty() {
466 if mode.is_require() {
468 return Err(HashStrategyError::MissingHashes(
469 dist.name().to_string(),
470 mode,
471 ));
472 }
473 continue;
474 }
475 hashes.insert(dist.version_id(), digests.to_vec());
476 }
477
478 match mode {
479 HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
480 HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
481 }
482 }
483
484 fn augment_hashes<'a>(
493 existing: &FxHashMap<VersionId, Vec<HashDigest>>,
494 requirements: impl Iterator<Item = &'a Requirement>,
495 ) -> Result<Option<FxHashMap<VersionId, Vec<HashDigest>>>, HashStrategyError> {
496 let mut hashes = None;
497
498 for requirement in requirements {
499 let Some((id, digests)) = Self::requirement_hashes(requirement)? else {
500 continue;
501 };
502 let current = hashes.as_ref().unwrap_or(existing);
503 let current_digests = current.get(&id);
504 let mut merged = current_digests.cloned().unwrap_or_default();
505 merge_digests(&mut merged, &digests, requirement)?;
506
507 if current_digests.map(Vec::as_slice) == Some(merged.as_slice()) {
508 continue;
509 }
510
511 hashes
512 .get_or_insert_with(|| existing.clone())
513 .insert(id, merged);
514 }
515
516 Ok(hashes)
517 }
518
519 fn requirement_hashes(
521 requirement: &Requirement,
522 ) -> Result<Option<(VersionId, Vec<HashDigest>)>, HashStrategyError> {
523 let Some(hashes) = requirement.hashes()? else {
524 return Ok(None);
525 };
526 let mut digests = HashDigests::from(hashes).to_vec();
527 if digests.is_empty() {
528 return Ok(None);
529 }
530 digests.sort_unstable();
531 let Some(id) = Self::pin(requirement) else {
532 return Ok(None);
533 };
534 Ok(Some((id, digests)))
535 }
536
537 fn pin(requirement: &Requirement) -> Option<VersionId> {
539 match &requirement.source {
540 RequirementSource::Registry { specifier, .. } => {
541 let [specifier] = specifier.as_ref() else {
543 return None;
544 };
545
546 let is_pinned =
548 matches!(specifier.operator(), Operator::Equal | Operator::ExactEqual);
549 if !is_pinned {
550 return None;
551 }
552
553 Some(VersionId::from_registry(
554 requirement.name.clone(),
555 specifier.version().clone(),
556 ))
557 }
558 RequirementSource::Url {
559 location,
560 subdirectory,
561 ..
562 } => Some(VersionId::from_archive(
563 location.clone(),
564 subdirectory.clone().map(Path::into_path_buf),
565 )),
566 RequirementSource::GitDirectory {
567 git, subdirectory, ..
568 } => Some(VersionId::from_git(git, subdirectory.as_deref())),
569 RequirementSource::GitPath {
570 git, install_path, ..
571 } => Some(VersionId::from_git(git, Some(install_path))),
572 RequirementSource::Path { install_path, .. } => {
573 Some(VersionId::from_path(install_path))
574 }
575 RequirementSource::Directory { install_path, .. } => {
576 Some(VersionId::from_directory(install_path))
577 }
578 }
579 }
580}
581
582fn hash_validation<'a>(id: &VersionId, digests: &'a [HashDigest]) -> HashValidation<'a> {
583 match id {
584 VersionId::NameVersion { .. } => HashValidation::Any(digests),
585 VersionId::ArchiveUrl { .. }
586 | VersionId::Git { .. }
587 | VersionId::Path { .. }
588 | VersionId::Directory { .. }
589 | VersionId::Unknown { .. } => HashValidation::All(digests),
590 }
591}
592
593fn combine_constraint_hashes(
595 id: &VersionId,
596 mut digests: Vec<HashDigest>,
597 constraint: &[HashDigest],
598 requirement: impl Display,
599 mode: HashCheckingMode,
600) -> Result<Vec<HashDigest>, HashStrategyError> {
601 if digests.is_empty() {
602 return Ok(constraint.to_vec());
604 }
605 match id {
606 VersionId::NameVersion(..) => {
607 digests.retain(|digest| constraint.contains(digest));
609 if digests.is_empty() {
610 return Err(HashStrategyError::NoIntersection(
611 requirement.to_string(),
612 mode,
613 ));
614 }
615 }
616 VersionId::ArchiveUrl { .. }
617 | VersionId::Git { .. }
618 | VersionId::Path(..)
619 | VersionId::Directory(..)
620 | VersionId::Unknown(..) => {
621 merge_digests(&mut digests, constraint, requirement)?;
622 }
623 }
624 Ok(digests)
625}
626
627fn merge_hashes(
629 hashes: &mut FxHashMap<VersionId, Vec<HashDigest>>,
630 id: VersionId,
631 incoming: Vec<HashDigest>,
632 requirement: impl Display,
633) -> Result<(), HashStrategyError> {
634 if incoming.is_empty() {
635 return Ok(());
636 }
637
638 if !matches!(&id, VersionId::ArchiveUrl { .. }) {
639 hashes.insert(id, incoming);
640 return Ok(());
641 }
642
643 if let Some(existing) = hashes.get_mut(&id) {
644 return merge_digests(existing, &incoming, requirement);
645 }
646
647 let mut merged = Vec::new();
648 merge_digests(&mut merged, &incoming, requirement)?;
649 hashes.insert(id, merged);
650 Ok(())
651}
652
653fn merge_digests<'a>(
659 existing: &mut Vec<HashDigest>,
660 incoming: impl IntoIterator<Item = &'a HashDigest>,
661 requirement: impl Display,
662) -> Result<(), HashStrategyError> {
663 for digest in incoming {
664 match existing
665 .iter()
666 .find(|candidate| candidate.algorithm() == digest.algorithm())
667 {
668 Some(candidate) if candidate == digest => {}
669 Some(conflict) => {
670 return Err(HashStrategyError::ConflictingArchiveUrlHashes(
671 requirement.to_string(),
672 conflict.clone(),
673 digest.clone(),
674 ));
675 }
676 None => existing.push(digest.clone()),
677 }
678 }
679 existing.sort_unstable();
680
681 Ok(())
682}
683
684#[derive(thiserror::Error, Debug)]
685pub enum HashStrategyError {
686 #[error(transparent)]
687 Hash(#[from] HashError),
688 #[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")]
689 ConflictingArchiveUrlHashes(String, HashDigest, HashDigest),
690 #[error(
691 "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}"
692 )]
693 UnpinnedRequirement(String, HashCheckingMode),
694 #[error(
695 "`{1}` hashes are insecure and cannot be used with `{2}` but no other hashes are available for: {0}"
696 )]
697 InsecureHashAlgorithm(String, HashAlgorithm, HashCheckingMode),
698 #[error("In `{1}` mode, all requirements must have a hash, but none were provided for: {0}")]
699 MissingHashes(String, HashCheckingMode),
700 #[error(
701 "In `{1}` mode, all requirements must have a hash, but there were no overlapping hashes between the requirements and constraints for: {0}"
702 )]
703 NoIntersection(String, HashCheckingMode),
704}
705
706#[cfg(test)]
707mod tests {
708 use std::path::Path;
709 use std::slice;
710 use std::str::FromStr;
711 use std::sync::Arc;
712
713 use rustc_hash::FxHashMap;
714 use uv_configuration::HashCheckingMode;
715 use uv_distribution_filename::DistExtension;
716 use uv_distribution_types::{
717 ArchiveHashPolicy, HashCollection, HashValidation, MetadataHashPolicy, Requirement,
718 RequirementScope, RequirementSource, UnresolvedRequirement, VersionId,
719 };
720 use uv_normalize::PackageName;
721 use uv_pep440::Version;
722 use uv_pypi_types::HashDigest;
723 use uv_redacted::DisplaySafeUrl;
724
725 use super::{HashStrategy, HashVerification};
726
727 fn requirement(url: &str) -> Requirement {
728 Requirement {
729 name: "anyio".parse().unwrap(),
730 extras: Box::default(),
731 groups: Box::default(),
732 marker: "python_version >= '3.8'".parse().unwrap(),
733 source: RequirementSource::Url {
734 location: "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl"
735 .parse()
736 .unwrap(),
737 subdirectory: None,
738 ext: DistExtension::Wheel,
739 url: url.parse().unwrap(),
740 },
741 scope: RequirementScope::Global,
742 origin: None,
743 }
744 }
745
746 #[test]
747 fn from_requirements_merges_direct_url_hashes_across_fragments() {
748 let first = UnresolvedRequirement::Named(requirement(
749 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=CFDB2B588B9FC25EDE96D8DB56ED50848B0B649DCA3DD1DF0B11F683BB9E0B5F",
750 ));
751 let second = UnresolvedRequirement::Named(requirement(
752 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
753 ));
754
755 let hasher = HashStrategy::from_requirements(
756 [(&first, &[][..]), (&second, &[][..])].into_iter(),
757 std::iter::empty(),
758 None,
759 HashCheckingMode::Require,
760 )
761 .unwrap();
762
763 let mut expected = vec![
764 HashDigest::from_str(
765 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
766 )
767 .unwrap(),
768 HashDigest::from_str(
769 "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
770 )
771 .unwrap(),
772 ];
773 expected.sort_unstable();
774
775 for requirement in [&first, &second] {
776 let UnresolvedRequirement::Named(requirement) = requirement else {
777 panic!("expected named requirement");
778 };
779 let RequirementSource::Url { url, .. } = &requirement.source else {
780 panic!("expected direct URL requirement");
781 };
782 assert_eq!(
783 hasher.archive_policy_for_url(url),
784 ArchiveHashPolicy::All(expected.as_slice())
785 );
786 }
787 }
788
789 #[test]
790 fn generate_and_verify_validates_known_hashes_and_generates_unknown_hashes()
791 -> Result<(), Box<dyn std::error::Error>> {
792 let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
793 let unknown_url: DisplaySafeUrl = "https://example.com/anyio-4.1.0.tar.gz".parse()?;
794 let name: PackageName = "anyio".parse()?;
795 let version: Version = "4.0.0".parse()?;
796 let unknown_version: Version = "4.1.0".parse()?;
797 let digest = HashDigest::from_str(
798 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
799 )?;
800 let hashes = FxHashMap::from_iter([
801 (VersionId::from_url(&url), vec![digest.clone()]),
802 (
803 VersionId::from_registry(name.clone(), version.clone()),
804 vec![digest.clone()],
805 ),
806 ]);
807 let strategy = HashStrategy::collect(HashCollection::All)
808 .with_verification(HashVerification::IfPresent(Arc::new(hashes)));
809
810 assert_eq!(
811 strategy.archive_policy_for_url(&url),
812 ArchiveHashPolicy::All(slice::from_ref(&digest))
813 );
814 for fragment in [
815 "#subdirectory=.",
816 "#subdirectory=./",
817 "#subdirectory=",
818 "#subdirectory=nested/..",
819 ] {
820 let root_url = format!("{url}{fragment}").parse()?;
821 assert_eq!(
822 strategy.archive_policy_for_url(&root_url),
823 ArchiveHashPolicy::All(slice::from_ref(&digest))
824 );
825 }
826 assert_eq!(
827 strategy.archive_policy_for_url(&unknown_url),
828 ArchiveHashPolicy::Generate
829 );
830 assert_eq!(
831 strategy.metadata_policy_for_url(&unknown_url),
832 MetadataHashPolicy {
833 collection: HashCollection::All,
834 validation: HashValidation::None,
835 }
836 );
837 assert_eq!(
838 strategy.metadata_policy_for_url(&url),
839 MetadataHashPolicy {
840 collection: HashCollection::All,
841 validation: HashValidation::All(slice::from_ref(&digest)),
842 }
843 );
844 assert_eq!(
845 strategy.archive_policy_for_package(&name, &version),
846 ArchiveHashPolicy::Any(slice::from_ref(&digest))
847 );
848 assert_eq!(
849 strategy.archive_policy_for_package(&name, &unknown_version),
850 ArchiveHashPolicy::Generate
851 );
852
853 Ok(())
854 }
855
856 #[test]
857 fn required_hashes_take_precedence_over_collection() -> Result<(), Box<dyn std::error::Error>> {
858 let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
859 let name: PackageName = "anyio".parse()?;
860 let version: Version = "4.0.0".parse()?;
861 let strategy = HashStrategy::collect(HashCollection::All)
862 .with_verification(HashVerification::Required(Arc::default()));
863
864 assert_eq!(
865 strategy.archive_policy_for_url(&url),
866 ArchiveHashPolicy::All(&[])
867 );
868 assert_eq!(
869 strategy.metadata_policy_for_url(&url),
870 MetadataHashPolicy {
871 collection: HashCollection::All,
872 validation: HashValidation::All(&[]),
873 }
874 );
875 assert_eq!(
876 strategy.archive_policy_for_package(&name, &version),
877 ArchiveHashPolicy::Any(&[])
878 );
879 assert!(!strategy.allows_url(&url));
880 assert!(!strategy.allows_package(&name, &version));
881
882 let digest = HashDigest::from_str(
883 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
884 )?;
885 let constraints = HashStrategy::verify(Arc::new(FxHashMap::from_iter([
886 (VersionId::from_url(&url), vec![digest.clone()]),
887 (
888 VersionId::from_registry(name.clone(), version.clone()),
889 vec![HashDigest::from_str(
890 "md5:420d85e19168705cdf0223621b18831a",
891 )?],
892 ),
893 ])));
894 for strategy in [
895 strategy.clone().with_constraint_hashes(&constraints)?,
896 constraints.with_constraint_hashes(&strategy)?,
897 ] {
898 assert_eq!(
899 strategy.archive_policy_for_url(&url),
900 ArchiveHashPolicy::All(slice::from_ref(&digest))
901 );
902 assert_eq!(
903 strategy.archive_policy_for_package(&name, &version),
904 ArchiveHashPolicy::Any(&[])
905 );
906 assert!(!strategy.allows_package(&name, &version));
907 }
908 Ok(())
909 }
910
911 #[test]
912 fn constraint_hashes_combine_with_locked_hashes() -> Result<(), Box<dyn std::error::Error>> {
913 let name: PackageName = "anyio".parse()?;
914 let version: Version = "4.0.0".parse()?;
915 let local_version: Version = "4.0.0+local".parse()?;
916 let digest = HashDigest::from_str(
917 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
918 )?;
919 let other = HashDigest::from_str(
920 "sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a",
921 )?;
922 let sha512 = HashDigest::from_str(
923 "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
924 )?;
925 let registry = VersionId::from_registry(name.clone(), version);
926 let local_registry = VersionId::from_registry(name, local_version);
927 let archive = VersionId::from_url(&"https://example.com/anyio-4.0.0.tar.gz".parse()?);
928 let path = VersionId::from_path(Path::new("anyio-4.0.0.tar.gz"));
929 for (id, constraint_id, constraint, expected) in [
930 (
931 registry.clone(),
932 registry,
933 digest.clone(),
934 vec![digest.clone()],
935 ),
936 (
937 local_registry,
938 VersionId::from_registry("anyio".parse()?, "4.0.0".parse()?),
939 digest.clone(),
940 vec![digest.clone()],
941 ),
942 (path.clone(), path, digest.clone(), vec![digest.clone()]),
943 (
944 archive.clone(),
945 archive,
946 sha512.clone(),
947 vec![digest.clone(), sha512],
948 ),
949 ] {
950 let locked = HashStrategy::collect(HashCollection::All).with_verification(
951 HashVerification::IfPresent(Arc::new(FxHashMap::from_iter([(
952 id.clone(),
953 vec![digest.clone()],
954 )]))),
955 );
956 let constraints = HashStrategy::verify(Arc::new(FxHashMap::from_iter([(
957 constraint_id.clone(),
958 vec![constraint],
959 )])));
960 let combined = locked.clone().with_constraint_hashes(&constraints)?;
961 assert_eq!(combined.collection(), HashCollection::All);
962 assert_eq!(combined.hashes_for_id(&id), Some(expected.as_slice()));
963
964 let conflicting = HashStrategy::verify(Arc::new(FxHashMap::from_iter([(
965 constraint_id,
966 vec![other.clone()],
967 )])));
968 assert!(locked.with_constraint_hashes(&conflicting).is_err());
969 }
970 Ok(())
971 }
972}