1use std::fmt::Display;
2use std::path::Path;
3use std::str::FromStr;
4use std::sync::Arc;
5
6use rustc_hash::FxHashMap;
7
8use uv_configuration::HashCheckingMode;
9use uv_distribution_types::{
10 DistributionMetadata, HashGeneration, HashPolicy, Name, Requirement, RequirementSource,
11 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 generation: Option<HashGeneration>,
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 generate(generation: HashGeneration) -> Self {
43 Self {
44 generation: Some(generation),
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 generation(&self) -> Option<HashGeneration> {
68 self.generation
69 }
70
71 pub fn verification(&self) -> &HashVerification {
73 &self.verification
74 }
75
76 pub fn get<T: DistributionMetadata>(&self, distribution: &T) -> HashPolicy<'_> {
78 self.get_id(|| distribution.version_id())
79 }
80
81 pub fn get_package(&self, name: &PackageName, version: &Version) -> HashPolicy<'_> {
83 self.get_id(|| VersionId::from_registry(name.clone(), version.clone()))
84 }
85
86 pub fn get_url(&self, url: &DisplaySafeUrl) -> HashPolicy<'_> {
90 self.get_id(|| VersionId::from_url(url))
91 }
92
93 fn get_id(&self, id: impl FnOnce() -> VersionId) -> HashPolicy<'_> {
95 match &self.verification {
96 HashVerification::IfPresent(hashes) => {
97 let id = id();
98 if let Some(hashes) = hashes.get(&id) {
99 return hash_policy(&id, hashes);
100 }
101 if let VersionId::NameVersion(name, version) = &id
104 && version.is_local()
105 && let Some(hashes) = hashes.get(&VersionId::from_registry(
106 name.clone(),
107 version.clone().without_local(),
108 ))
109 {
110 return HashPolicy::Any(hashes);
111 }
112 }
113 HashVerification::Required(hashes) => {
114 let id = id();
115 return hash_policy(&id, hashes.get(&id).map(Vec::as_slice).unwrap_or_default());
116 }
117 HashVerification::None => {}
118 }
119 self.generation
120 .map_or(HashPolicy::None, HashPolicy::Generate)
121 }
122
123 pub fn allows_package(&self, name: &PackageName, version: &Version) -> bool {
125 match &self.verification {
126 HashVerification::Required(hashes) => {
127 hashes.contains_key(&VersionId::from_registry(name.clone(), version.clone()))
128 }
129 HashVerification::None | HashVerification::IfPresent(_) => true,
130 }
131 }
132
133 pub fn allows_url(&self, url: &DisplaySafeUrl) -> bool {
135 match &self.verification {
136 HashVerification::Required(hashes) => hashes.contains_key(&VersionId::from_url(url)),
137 HashVerification::None | HashVerification::IfPresent(_) => true,
138 }
139 }
140
141 pub fn augment_with_requirements<'a>(
144 mut self,
145 requirements: impl Iterator<Item = &'a Requirement>,
146 ) -> Result<Self, HashStrategyError> {
147 match &mut self.verification {
148 HashVerification::None => {}
149 HashVerification::IfPresent(existing) | HashVerification::Required(existing) => {
150 if let Some(hashes) = Self::augment_hashes(existing, requirements)? {
151 *existing = Arc::new(hashes);
152 }
153 }
154 }
155 Ok(self)
156 }
157
158 pub fn augment_with_metadata_requirements<'a>(
166 self,
167 requirements: impl Iterator<Item = &'a Requirement>,
168 ) -> Result<Self, HashStrategyError> {
169 if matches!(&self.verification, HashVerification::Required(_)) {
170 return Ok(self);
171 }
172 self.augment_with_requirements(requirements)
173 }
174
175 pub fn from_requirements<'a>(
182 requirements: impl Iterator<Item = (&'a UnresolvedRequirement, &'a [String])>,
183 constraints: impl Iterator<Item = (&'a Requirement, &'a [String])>,
184 marker_env: Option<&ResolverMarkerEnvironment>,
185 mode: HashCheckingMode,
186 ) -> Result<Self, HashStrategyError> {
187 let mut constraint_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
188
189 for (requirement, digests) in constraints {
191 if !requirement
192 .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
193 {
194 continue;
195 }
196
197 let Some(id) = Self::pin(requirement) else {
199 if mode.is_require() {
200 return Err(HashStrategyError::UnpinnedRequirement(
201 requirement.to_string(),
202 mode,
203 ));
204 }
205 continue;
206 };
207
208 let mut digests = digests
211 .iter()
212 .map(|digest| HashDigest::from_str(digest))
213 .collect::<Result<Vec<_>, _>>()?;
214 if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
215 merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
216 }
217
218 if mode.is_require() {
219 digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
220 }
221
222 if digests.is_empty() {
223 continue;
224 }
225
226 merge_hashes(&mut constraint_hashes, id, digests, requirement)?;
227 }
228
229 let mut requirement_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
231 for (requirement, digests) in requirements {
232 if !requirement
233 .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
234 {
235 continue;
236 }
237
238 let id = match &requirement {
240 UnresolvedRequirement::Named(requirement) => {
241 if let Some(id) = Self::pin(requirement) {
242 id
243 } else {
244 if mode.is_require() {
245 return Err(HashStrategyError::UnpinnedRequirement(
246 requirement.to_string(),
247 mode,
248 ));
249 }
250 continue;
251 }
252 }
253 UnresolvedRequirement::Unnamed(requirement) => {
254 VersionId::from_parsed_url(requirement.url.parsed_url.clone())
256 }
257 };
258
259 let mut digests = digests
262 .iter()
263 .map(|digest| HashDigest::from_str(digest))
264 .collect::<Result<Vec<_>, _>>()?;
265 if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
266 merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
267 }
268
269 let has_md5 = mode.is_require()
270 && digests
271 .iter()
272 .any(|digest| digest.algorithm() == HashAlgorithm::Md5);
273 if mode.is_require() {
274 digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
275 }
276
277 let digests = if let Some(constraint) = constraint_hashes.remove(&id) {
278 if digests.is_empty() {
279 constraint
281 } else if matches!(id, VersionId::ArchiveUrl { .. }) {
282 let mut merged = digests;
283 merge_digests(&mut merged, &constraint, requirement)?;
284 merged
285 } else {
286 let intersection: Vec<_> = digests
288 .into_iter()
289 .filter(|digest| constraint.contains(digest))
290 .collect();
291 if intersection.is_empty() {
292 return Err(HashStrategyError::NoIntersection(
293 requirement.to_string(),
294 mode,
295 ));
296 }
297 intersection
298 }
299 } else {
300 digests
301 };
302
303 if digests.is_empty() {
305 if mode.is_require() {
306 if has_md5 {
307 return Err(HashStrategyError::InsecureHashAlgorithm(
308 requirement.to_string(),
309 HashAlgorithm::Md5,
310 mode,
311 ));
312 }
313 return Err(HashStrategyError::MissingHashes(
314 requirement.to_string(),
315 mode,
316 ));
317 }
318 continue;
319 }
320
321 merge_hashes(&mut requirement_hashes, id, digests, requirement)?;
322 }
323
324 let hashes: FxHashMap<VersionId, Vec<HashDigest>> = constraint_hashes
327 .into_iter()
328 .chain(requirement_hashes)
329 .collect();
330 match mode {
331 HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
332 HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
333 }
334 }
335
336 pub fn from_resolution(
338 resolution: &Resolution,
339 mode: HashCheckingMode,
340 ) -> Result<Self, HashStrategyError> {
341 let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
342
343 for (dist, digests) in resolution.hashes() {
344 if digests.is_empty() {
345 if mode.is_require() {
347 return Err(HashStrategyError::MissingHashes(
348 dist.name().to_string(),
349 mode,
350 ));
351 }
352 continue;
353 }
354 hashes.insert(dist.version_id(), digests.to_vec());
355 }
356
357 match mode {
358 HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
359 HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
360 }
361 }
362
363 fn augment_hashes<'a>(
372 existing: &FxHashMap<VersionId, Vec<HashDigest>>,
373 requirements: impl Iterator<Item = &'a Requirement>,
374 ) -> Result<Option<FxHashMap<VersionId, Vec<HashDigest>>>, HashStrategyError> {
375 let mut hashes = None;
376
377 for requirement in requirements {
378 let Some((id, digests)) = Self::requirement_hashes(requirement) else {
379 continue;
380 };
381 let current = hashes.as_ref().unwrap_or(existing);
382 let current_digests = current.get(&id);
383 let mut merged = current_digests.cloned().unwrap_or_default();
384 merge_digests(&mut merged, &digests, requirement)?;
385
386 if current_digests.map(Vec::as_slice) == Some(merged.as_slice()) {
387 continue;
388 }
389
390 hashes
391 .get_or_insert_with(|| existing.clone())
392 .insert(id, merged);
393 }
394
395 Ok(hashes)
396 }
397
398 fn requirement_hashes(requirement: &Requirement) -> Option<(VersionId, Vec<HashDigest>)> {
400 let mut digests = HashDigests::from(requirement.hashes()?).to_vec();
401 if digests.is_empty() {
402 return None;
403 }
404 digests.sort_unstable();
405 let id = Self::pin(requirement)?;
406 Some((id, digests))
407 }
408
409 fn pin(requirement: &Requirement) -> Option<VersionId> {
411 match &requirement.source {
412 RequirementSource::Registry { specifier, .. } => {
413 let [specifier] = specifier.as_ref() else {
415 return None;
416 };
417
418 let is_pinned =
420 matches!(specifier.operator(), Operator::Equal | Operator::ExactEqual);
421 if !is_pinned {
422 return None;
423 }
424
425 Some(VersionId::from_registry(
426 requirement.name.clone(),
427 specifier.version().clone(),
428 ))
429 }
430 RequirementSource::Url {
431 location,
432 subdirectory,
433 ..
434 } => Some(VersionId::from_archive(
435 location.clone(),
436 subdirectory.clone().map(Path::into_path_buf),
437 )),
438 RequirementSource::GitDirectory {
439 git, subdirectory, ..
440 } => Some(VersionId::from_git(git, subdirectory.as_deref())),
441 RequirementSource::GitPath {
442 git, install_path, ..
443 } => Some(VersionId::from_git(git, Some(install_path))),
444 RequirementSource::Path { install_path, .. } => {
445 Some(VersionId::from_path(install_path))
446 }
447 RequirementSource::Directory { install_path, .. } => {
448 Some(VersionId::from_directory(install_path))
449 }
450 }
451 }
452}
453
454fn hash_policy<'a>(id: &VersionId, digests: &'a [HashDigest]) -> HashPolicy<'a> {
455 match id {
456 VersionId::NameVersion { .. } => HashPolicy::Any(digests),
457 VersionId::ArchiveUrl { .. }
458 | VersionId::Git { .. }
459 | VersionId::Path { .. }
460 | VersionId::Directory { .. }
461 | VersionId::Unknown { .. } => HashPolicy::All(digests),
462 }
463}
464
465fn merge_hashes(
467 hashes: &mut FxHashMap<VersionId, Vec<HashDigest>>,
468 id: VersionId,
469 incoming: Vec<HashDigest>,
470 requirement: impl Display,
471) -> Result<(), HashStrategyError> {
472 if incoming.is_empty() {
473 return Ok(());
474 }
475
476 if !matches!(&id, VersionId::ArchiveUrl { .. }) {
477 hashes.insert(id, incoming);
478 return Ok(());
479 }
480
481 if let Some(existing) = hashes.get_mut(&id) {
482 return merge_digests(existing, &incoming, requirement);
483 }
484
485 let mut merged = Vec::new();
486 merge_digests(&mut merged, &incoming, requirement)?;
487 hashes.insert(id, merged);
488 Ok(())
489}
490
491fn merge_digests<'a>(
497 existing: &mut Vec<HashDigest>,
498 incoming: impl IntoIterator<Item = &'a HashDigest>,
499 requirement: impl Display,
500) -> Result<(), HashStrategyError> {
501 for digest in incoming {
502 match existing
503 .iter()
504 .find(|candidate| candidate.algorithm == digest.algorithm)
505 {
506 Some(candidate) if candidate == digest => {}
507 Some(conflict) => {
508 return Err(HashStrategyError::ConflictingArchiveUrlHashes(
509 requirement.to_string(),
510 conflict.clone(),
511 digest.clone(),
512 ));
513 }
514 None => existing.push(digest.clone()),
515 }
516 }
517 existing.sort_unstable();
518
519 Ok(())
520}
521
522#[derive(thiserror::Error, Debug)]
523pub enum HashStrategyError {
524 #[error(transparent)]
525 Hash(#[from] HashError),
526 #[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")]
527 ConflictingArchiveUrlHashes(String, HashDigest, HashDigest),
528 #[error(
529 "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}"
530 )]
531 UnpinnedRequirement(String, HashCheckingMode),
532 #[error(
533 "`{1}` hashes are insecure and cannot be used with `{2}` but no other hashes are available for: {0}"
534 )]
535 InsecureHashAlgorithm(String, HashAlgorithm, HashCheckingMode),
536 #[error("In `{1}` mode, all requirements must have a hash, but none were provided for: {0}")]
537 MissingHashes(String, HashCheckingMode),
538 #[error(
539 "In `{1}` mode, all requirements must have a hash, but there were no overlapping hashes between the requirements and constraints for: {0}"
540 )]
541 NoIntersection(String, HashCheckingMode),
542}
543
544#[cfg(test)]
545mod tests {
546 use std::slice;
547 use std::str::FromStr;
548 use std::sync::Arc;
549
550 use rustc_hash::FxHashMap;
551 use uv_configuration::HashCheckingMode;
552 use uv_distribution_filename::DistExtension;
553 use uv_distribution_types::{
554 HashGeneration, HashPolicy, Requirement, RequirementSource, UnresolvedRequirement,
555 VersionId,
556 };
557 use uv_normalize::PackageName;
558 use uv_pep440::Version;
559 use uv_pypi_types::HashDigest;
560 use uv_redacted::DisplaySafeUrl;
561
562 use super::{HashStrategy, HashVerification};
563
564 fn requirement(url: &str) -> Requirement {
565 Requirement {
566 name: "anyio".parse().unwrap(),
567 extras: Box::default(),
568 groups: Box::default(),
569 marker: "python_version >= '3.8'".parse().unwrap(),
570 source: RequirementSource::Url {
571 location: "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl"
572 .parse()
573 .unwrap(),
574 subdirectory: None,
575 ext: DistExtension::Wheel,
576 url: url.parse().unwrap(),
577 },
578 origin: None,
579 }
580 }
581
582 #[test]
583 fn from_requirements_merges_direct_url_hashes_across_fragments() {
584 let first = UnresolvedRequirement::Named(requirement(
585 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
586 ));
587 let second = UnresolvedRequirement::Named(requirement(
588 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
589 ));
590
591 let hasher = HashStrategy::from_requirements(
592 [(&first, &[][..]), (&second, &[][..])].into_iter(),
593 std::iter::empty(),
594 None,
595 HashCheckingMode::Require,
596 )
597 .unwrap();
598
599 let mut expected = vec![
600 HashDigest::from_str(
601 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
602 )
603 .unwrap(),
604 HashDigest::from_str(
605 "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
606 )
607 .unwrap(),
608 ];
609 expected.sort_unstable();
610
611 for requirement in [&first, &second] {
612 let UnresolvedRequirement::Named(requirement) = requirement else {
613 panic!("expected named requirement");
614 };
615 let RequirementSource::Url { url, .. } = &requirement.source else {
616 panic!("expected direct URL requirement");
617 };
618 assert_eq!(hasher.get_url(url), HashPolicy::All(expected.as_slice()));
619 }
620 }
621
622 #[test]
623 fn generate_and_verify_validates_known_hashes_and_generates_unknown_hashes()
624 -> Result<(), Box<dyn std::error::Error>> {
625 let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
626 let unknown_url: DisplaySafeUrl = "https://example.com/anyio-4.1.0.tar.gz".parse()?;
627 let name: PackageName = "anyio".parse()?;
628 let version: Version = "4.0.0".parse()?;
629 let unknown_version: Version = "4.1.0".parse()?;
630 let digest = HashDigest::from_str(
631 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
632 )?;
633 let hashes = FxHashMap::from_iter([
634 (VersionId::from_url(&url), vec![digest.clone()]),
635 (
636 VersionId::from_registry(name.clone(), version.clone()),
637 vec![digest.clone()],
638 ),
639 ]);
640 let strategy = HashStrategy::generate(HashGeneration::All)
641 .with_verification(HashVerification::IfPresent(Arc::new(hashes)));
642
643 assert_eq!(
644 strategy.get_url(&url),
645 HashPolicy::All(slice::from_ref(&digest))
646 );
647 for fragment in [
648 "#subdirectory=.",
649 "#subdirectory=./",
650 "#subdirectory=",
651 "#subdirectory=nested/..",
652 ] {
653 let root_url = format!("{url}{fragment}").parse()?;
654 assert_eq!(
655 strategy.get_url(&root_url),
656 HashPolicy::All(slice::from_ref(&digest))
657 );
658 }
659 assert_eq!(
660 strategy.get_url(&unknown_url),
661 HashPolicy::Generate(HashGeneration::All)
662 );
663 assert_eq!(
664 strategy.get_package(&name, &version),
665 HashPolicy::Any(slice::from_ref(&digest))
666 );
667 assert_eq!(
668 strategy.get_package(&name, &unknown_version),
669 HashPolicy::Generate(HashGeneration::All)
670 );
671
672 Ok(())
673 }
674
675 #[test]
676 fn required_hashes_take_precedence_over_generation() -> Result<(), Box<dyn std::error::Error>> {
677 let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
678 let name: PackageName = "anyio".parse()?;
679 let version: Version = "4.0.0".parse()?;
680 let strategy = HashStrategy::generate(HashGeneration::All)
681 .with_verification(HashVerification::Required(Arc::default()));
682
683 assert_eq!(strategy.get_url(&url), HashPolicy::All(&[]));
684 assert_eq!(strategy.get_package(&name, &version), HashPolicy::Any(&[]));
685 assert!(!strategy.allows_url(&url));
686 assert!(!strategy.allows_package(&name, &version));
687 Ok(())
688 }
689}