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