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