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 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 origin: None,
737 }
738 }
739
740 #[test]
741 fn from_requirements_merges_direct_url_hashes_across_fragments() {
742 let first = UnresolvedRequirement::Named(requirement(
743 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=CFDB2B588B9FC25EDE96D8DB56ED50848B0B649DCA3DD1DF0B11F683BB9E0B5F",
744 ));
745 let second = UnresolvedRequirement::Named(requirement(
746 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
747 ));
748
749 let hasher = HashStrategy::from_requirements(
750 [(&first, &[][..]), (&second, &[][..])].into_iter(),
751 std::iter::empty(),
752 None,
753 HashCheckingMode::Require,
754 )
755 .unwrap();
756
757 let mut expected = vec![
758 HashDigest::from_str(
759 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
760 )
761 .unwrap(),
762 HashDigest::from_str(
763 "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
764 )
765 .unwrap(),
766 ];
767 expected.sort_unstable();
768
769 for requirement in [&first, &second] {
770 let UnresolvedRequirement::Named(requirement) = requirement else {
771 panic!("expected named requirement");
772 };
773 let RequirementSource::Url { url, .. } = &requirement.source else {
774 panic!("expected direct URL requirement");
775 };
776 assert_eq!(
777 hasher.archive_policy_for_url(url),
778 ArchiveHashPolicy::All(expected.as_slice())
779 );
780 }
781 }
782
783 #[test]
784 fn generate_and_verify_validates_known_hashes_and_generates_unknown_hashes()
785 -> Result<(), Box<dyn std::error::Error>> {
786 let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
787 let unknown_url: DisplaySafeUrl = "https://example.com/anyio-4.1.0.tar.gz".parse()?;
788 let name: PackageName = "anyio".parse()?;
789 let version: Version = "4.0.0".parse()?;
790 let unknown_version: Version = "4.1.0".parse()?;
791 let digest = HashDigest::from_str(
792 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
793 )?;
794 let hashes = FxHashMap::from_iter([
795 (VersionId::from_url(&url), vec![digest.clone()]),
796 (
797 VersionId::from_registry(name.clone(), version.clone()),
798 vec![digest.clone()],
799 ),
800 ]);
801 let strategy = HashStrategy::collect(HashCollection::All)
802 .with_verification(HashVerification::IfPresent(Arc::new(hashes)));
803
804 assert_eq!(
805 strategy.archive_policy_for_url(&url),
806 ArchiveHashPolicy::All(slice::from_ref(&digest))
807 );
808 for fragment in [
809 "#subdirectory=.",
810 "#subdirectory=./",
811 "#subdirectory=",
812 "#subdirectory=nested/..",
813 ] {
814 let root_url = format!("{url}{fragment}").parse()?;
815 assert_eq!(
816 strategy.archive_policy_for_url(&root_url),
817 ArchiveHashPolicy::All(slice::from_ref(&digest))
818 );
819 }
820 assert_eq!(
821 strategy.archive_policy_for_url(&unknown_url),
822 ArchiveHashPolicy::Generate
823 );
824 assert_eq!(
825 strategy.metadata_policy_for_url(&unknown_url),
826 MetadataHashPolicy {
827 collection: HashCollection::All,
828 validation: HashValidation::None,
829 }
830 );
831 assert_eq!(
832 strategy.metadata_policy_for_url(&url),
833 MetadataHashPolicy {
834 collection: HashCollection::All,
835 validation: HashValidation::All(slice::from_ref(&digest)),
836 }
837 );
838 assert_eq!(
839 strategy.archive_policy_for_package(&name, &version),
840 ArchiveHashPolicy::Any(slice::from_ref(&digest))
841 );
842 assert_eq!(
843 strategy.archive_policy_for_package(&name, &unknown_version),
844 ArchiveHashPolicy::Generate
845 );
846
847 Ok(())
848 }
849
850 #[test]
851 fn required_hashes_take_precedence_over_collection() -> Result<(), Box<dyn std::error::Error>> {
852 let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
853 let name: PackageName = "anyio".parse()?;
854 let version: Version = "4.0.0".parse()?;
855 let strategy = HashStrategy::collect(HashCollection::All)
856 .with_verification(HashVerification::Required(Arc::default()));
857
858 assert_eq!(
859 strategy.archive_policy_for_url(&url),
860 ArchiveHashPolicy::All(&[])
861 );
862 assert_eq!(
863 strategy.metadata_policy_for_url(&url),
864 MetadataHashPolicy {
865 collection: HashCollection::All,
866 validation: HashValidation::All(&[]),
867 }
868 );
869 assert_eq!(
870 strategy.archive_policy_for_package(&name, &version),
871 ArchiveHashPolicy::Any(&[])
872 );
873 assert!(!strategy.allows_url(&url));
874 assert!(!strategy.allows_package(&name, &version));
875
876 let digest = HashDigest::from_str(
877 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
878 )?;
879 let constraints = HashStrategy::verify(Arc::new(FxHashMap::from_iter([
880 (VersionId::from_url(&url), vec![digest.clone()]),
881 (
882 VersionId::from_registry(name.clone(), version.clone()),
883 vec![HashDigest::from_str(
884 "md5:420d85e19168705cdf0223621b18831a",
885 )?],
886 ),
887 ])));
888 for strategy in [
889 strategy.clone().with_constraint_hashes(&constraints)?,
890 constraints.with_constraint_hashes(&strategy)?,
891 ] {
892 assert_eq!(
893 strategy.archive_policy_for_url(&url),
894 ArchiveHashPolicy::All(slice::from_ref(&digest))
895 );
896 assert_eq!(
897 strategy.archive_policy_for_package(&name, &version),
898 ArchiveHashPolicy::Any(&[])
899 );
900 assert!(!strategy.allows_package(&name, &version));
901 }
902 Ok(())
903 }
904
905 #[test]
906 fn constraint_hashes_combine_with_locked_hashes() -> Result<(), Box<dyn std::error::Error>> {
907 let name: PackageName = "anyio".parse()?;
908 let version: Version = "4.0.0".parse()?;
909 let local_version: Version = "4.0.0+local".parse()?;
910 let digest = HashDigest::from_str(
911 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
912 )?;
913 let other = HashDigest::from_str(
914 "sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a",
915 )?;
916 let sha512 = HashDigest::from_str(
917 "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
918 )?;
919 let registry = VersionId::from_registry(name.clone(), version);
920 let local_registry = VersionId::from_registry(name, local_version);
921 let archive = VersionId::from_url(&"https://example.com/anyio-4.0.0.tar.gz".parse()?);
922 let path = VersionId::from_path(Path::new("anyio-4.0.0.tar.gz"));
923 for (id, constraint_id, constraint, expected) in [
924 (
925 registry.clone(),
926 registry,
927 digest.clone(),
928 vec![digest.clone()],
929 ),
930 (
931 local_registry,
932 VersionId::from_registry("anyio".parse()?, "4.0.0".parse()?),
933 digest.clone(),
934 vec![digest.clone()],
935 ),
936 (path.clone(), path, digest.clone(), vec![digest.clone()]),
937 (
938 archive.clone(),
939 archive,
940 sha512.clone(),
941 vec![digest.clone(), sha512],
942 ),
943 ] {
944 let locked = HashStrategy::collect(HashCollection::All).with_verification(
945 HashVerification::IfPresent(Arc::new(FxHashMap::from_iter([(
946 id.clone(),
947 vec![digest.clone()],
948 )]))),
949 );
950 let constraints = HashStrategy::verify(Arc::new(FxHashMap::from_iter([(
951 constraint_id.clone(),
952 vec![constraint],
953 )])));
954 let combined = locked.clone().with_constraint_hashes(&constraints)?;
955 assert_eq!(combined.collection(), HashCollection::All);
956 assert_eq!(combined.hashes_for_id(&id), Some(expected.as_slice()));
957
958 let conflicting = HashStrategy::verify(Arc::new(FxHashMap::from_iter([(
959 constraint_id,
960 vec![other.clone()],
961 )])));
962 assert!(locked.with_constraint_hashes(&conflicting).is_err());
963 }
964 Ok(())
965 }
966}