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)]
19pub enum HashStrategy {
20 #[default]
22 None,
23 Generate(HashGeneration),
25 Verify(Arc<FxHashMap<VersionId, Vec<HashDigest>>>),
29 Require(Arc<FxHashMap<VersionId, Vec<HashDigest>>>),
33}
34
35impl HashStrategy {
36 pub fn get<T: DistributionMetadata>(&self, distribution: &T) -> HashPolicy<'_> {
38 match self {
39 Self::None => HashPolicy::None,
40 Self::Generate(mode) => HashPolicy::Generate(*mode),
41 Self::Verify(hashes) => {
42 let id = distribution.version_id();
43 if let Some(hashes) = hashes.get(&id) {
44 hash_policy(&id, hashes.as_slice())
45 } else {
46 HashPolicy::None
47 }
48 }
49 Self::Require(hashes) => {
50 let id = distribution.version_id();
51 hash_policy(&id, hashes.get(&id).map(Vec::as_slice).unwrap_or_default())
52 }
53 }
54 }
55
56 pub fn get_package(&self, name: &PackageName, version: &Version) -> HashPolicy<'_> {
58 let id = VersionId::from_registry(name.clone(), version.clone());
59 match self {
60 Self::None => HashPolicy::None,
61 Self::Generate(mode) => HashPolicy::Generate(*mode),
62 Self::Verify(hashes) => {
63 if let Some(hashes) = hashes.get(&id) {
64 HashPolicy::Any(hashes.as_slice())
65 } else {
66 HashPolicy::None
67 }
68 }
69 Self::Require(hashes) => {
70 HashPolicy::Any(hashes.get(&id).map(Vec::as_slice).unwrap_or_default())
71 }
72 }
73 }
74
75 pub fn get_url(&self, url: &DisplaySafeUrl) -> HashPolicy<'_> {
79 let id = VersionId::from_url(url);
80 match self {
81 Self::None => HashPolicy::None,
82 Self::Generate(mode) => HashPolicy::Generate(*mode),
83 Self::Verify(hashes) => {
84 if let Some(hashes) = hashes.get(&id) {
85 HashPolicy::All(hashes.as_slice())
86 } else {
87 HashPolicy::None
88 }
89 }
90 Self::Require(hashes) => {
91 HashPolicy::All(hashes.get(&id).map(Vec::as_slice).unwrap_or_default())
92 }
93 }
94 }
95
96 pub fn allows_package(&self, name: &PackageName, version: &Version) -> bool {
98 match self {
99 Self::None => true,
100 Self::Generate(_) => true,
101 Self::Verify(_) => true,
102 Self::Require(hashes) => {
103 hashes.contains_key(&VersionId::from_registry(name.clone(), version.clone()))
104 }
105 }
106 }
107
108 pub fn allows_url(&self, url: &DisplaySafeUrl) -> bool {
110 match self {
111 Self::None => true,
112 Self::Generate(_) => true,
113 Self::Verify(_) => true,
114 Self::Require(hashes) => hashes.contains_key(&VersionId::from_url(url)),
115 }
116 }
117
118 pub fn augment_with_requirements<'a>(
121 self,
122 requirements: impl Iterator<Item = &'a Requirement>,
123 ) -> Result<Self, HashStrategyError> {
124 Ok(match self {
125 Self::None => Self::None,
126 Self::Generate(mode) => Self::Generate(mode),
127 Self::Verify(existing) => {
128 if let Some(hashes) = Self::augment_hashes(existing.as_ref(), requirements)? {
129 Self::Verify(Arc::new(hashes))
130 } else {
131 Self::Verify(existing)
132 }
133 }
134 Self::Require(existing) => {
135 if let Some(hashes) = Self::augment_hashes(existing.as_ref(), requirements)? {
136 Self::Require(Arc::new(hashes))
137 } else {
138 Self::Require(existing)
139 }
140 }
141 })
142 }
143
144 pub fn from_requirements<'a>(
151 requirements: impl Iterator<Item = (&'a UnresolvedRequirement, &'a [String])>,
152 constraints: impl Iterator<Item = (&'a Requirement, &'a [String])>,
153 marker_env: Option<&ResolverMarkerEnvironment>,
154 mode: HashCheckingMode,
155 ) -> Result<Self, HashStrategyError> {
156 let mut constraint_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
157
158 for (requirement, digests) in constraints {
160 if !requirement
161 .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
162 {
163 continue;
164 }
165
166 let Some(id) = Self::pin(requirement) else {
168 if mode.is_require() {
169 return Err(HashStrategyError::UnpinnedRequirement(
170 requirement.to_string(),
171 mode,
172 ));
173 }
174 continue;
175 };
176
177 let mut digests = digests
180 .iter()
181 .map(|digest| HashDigest::from_str(digest))
182 .collect::<Result<Vec<_>, _>>()?;
183 if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
184 merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
185 }
186
187 if mode.is_require() {
188 digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
189 }
190
191 if digests.is_empty() {
192 continue;
193 }
194
195 merge_hashes(&mut constraint_hashes, id, digests, requirement)?;
196 }
197
198 let mut requirement_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
200 for (requirement, digests) in requirements {
201 if !requirement
202 .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
203 {
204 continue;
205 }
206
207 let id = match &requirement {
209 UnresolvedRequirement::Named(requirement) => {
210 if let Some(id) = Self::pin(requirement) {
211 id
212 } else {
213 if mode.is_require() {
214 return Err(HashStrategyError::UnpinnedRequirement(
215 requirement.to_string(),
216 mode,
217 ));
218 }
219 continue;
220 }
221 }
222 UnresolvedRequirement::Unnamed(requirement) => {
223 VersionId::from_parsed_url(requirement.url.parsed_url.clone())
225 }
226 };
227
228 let mut digests = digests
231 .iter()
232 .map(|digest| HashDigest::from_str(digest))
233 .collect::<Result<Vec<_>, _>>()?;
234 if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
235 merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
236 }
237
238 let has_md5 = mode.is_require()
239 && digests
240 .iter()
241 .any(|digest| digest.algorithm() == HashAlgorithm::Md5);
242 if mode.is_require() {
243 digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
244 }
245
246 let digests = if let Some(constraint) = constraint_hashes.remove(&id) {
247 if digests.is_empty() {
248 constraint
250 } else if matches!(id, VersionId::ArchiveUrl { .. }) {
251 let mut merged = digests;
252 merge_digests(&mut merged, &constraint, requirement)?;
253 merged
254 } else {
255 let intersection: Vec<_> = digests
257 .into_iter()
258 .filter(|digest| constraint.contains(digest))
259 .collect();
260 if intersection.is_empty() {
261 return Err(HashStrategyError::NoIntersection(
262 requirement.to_string(),
263 mode,
264 ));
265 }
266 intersection
267 }
268 } else {
269 digests
270 };
271
272 if digests.is_empty() {
274 if mode.is_require() {
275 if has_md5 {
276 return Err(HashStrategyError::InsecureHashAlgorithm(
277 requirement.to_string(),
278 HashAlgorithm::Md5,
279 mode,
280 ));
281 }
282 return Err(HashStrategyError::MissingHashes(
283 requirement.to_string(),
284 mode,
285 ));
286 }
287 continue;
288 }
289
290 merge_hashes(&mut requirement_hashes, id, digests, requirement)?;
291 }
292
293 let hashes: FxHashMap<VersionId, Vec<HashDigest>> = constraint_hashes
296 .into_iter()
297 .chain(requirement_hashes)
298 .collect();
299 match mode {
300 HashCheckingMode::Verify => Ok(Self::Verify(Arc::new(hashes))),
301 HashCheckingMode::Require => Ok(Self::Require(Arc::new(hashes))),
302 }
303 }
304
305 pub fn from_resolution(
307 resolution: &Resolution,
308 mode: HashCheckingMode,
309 ) -> Result<Self, HashStrategyError> {
310 let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
311
312 for (dist, digests) in resolution.hashes() {
313 if digests.is_empty() {
314 if mode.is_require() {
316 return Err(HashStrategyError::MissingHashes(
317 dist.name().to_string(),
318 mode,
319 ));
320 }
321 continue;
322 }
323 hashes.insert(dist.version_id(), digests.to_vec());
324 }
325
326 match mode {
327 HashCheckingMode::Verify => Ok(Self::Verify(Arc::new(hashes))),
328 HashCheckingMode::Require => Ok(Self::Require(Arc::new(hashes))),
329 }
330 }
331
332 fn augment_hashes<'a>(
341 existing: &FxHashMap<VersionId, Vec<HashDigest>>,
342 requirements: impl Iterator<Item = &'a Requirement>,
343 ) -> Result<Option<FxHashMap<VersionId, Vec<HashDigest>>>, HashStrategyError> {
344 let mut hashes = None;
345
346 for requirement in requirements {
347 let Some((id, digests)) = Self::requirement_hashes(requirement) else {
348 continue;
349 };
350 let current = hashes.as_ref().unwrap_or(existing);
351 let current_digests = current.get(&id);
352 let mut merged = current_digests.cloned().unwrap_or_default();
353 merge_digests(&mut merged, &digests, requirement)?;
354
355 if current_digests.map(Vec::as_slice) == Some(merged.as_slice()) {
356 continue;
357 }
358
359 hashes
360 .get_or_insert_with(|| existing.clone())
361 .insert(id, merged);
362 }
363
364 Ok(hashes)
365 }
366
367 fn requirement_hashes(requirement: &Requirement) -> Option<(VersionId, Vec<HashDigest>)> {
369 let mut digests = HashDigests::from(requirement.hashes()?).to_vec();
370 if digests.is_empty() {
371 return None;
372 }
373 digests.sort_unstable();
374 let id = Self::pin(requirement)?;
375 Some((id, digests))
376 }
377
378 fn pin(requirement: &Requirement) -> Option<VersionId> {
380 match &requirement.source {
381 RequirementSource::Registry { specifier, .. } => {
382 let [specifier] = specifier.as_ref() else {
384 return None;
385 };
386
387 if *specifier.operator() != uv_pep440::Operator::Equal {
389 return None;
390 }
391
392 Some(VersionId::from_registry(
393 requirement.name.clone(),
394 specifier.version().clone(),
395 ))
396 }
397 RequirementSource::Url {
398 location,
399 subdirectory,
400 ..
401 } => Some(VersionId::from_archive(
402 location.clone(),
403 subdirectory.clone().map(Path::into_path_buf),
404 )),
405 RequirementSource::GitDirectory {
406 git, subdirectory, ..
407 } => Some(VersionId::from_git(git, subdirectory.as_deref())),
408 RequirementSource::GitPath {
409 git, install_path, ..
410 } => Some(VersionId::from_git(git, Some(install_path))),
411 RequirementSource::Path { install_path, .. } => {
412 Some(VersionId::from_path(install_path))
413 }
414 RequirementSource::Directory { install_path, .. } => {
415 Some(VersionId::from_directory(install_path))
416 }
417 }
418 }
419}
420
421fn hash_policy<'a>(id: &VersionId, digests: &'a [HashDigest]) -> HashPolicy<'a> {
422 match id {
423 VersionId::NameVersion { .. } => HashPolicy::Any(digests),
424 VersionId::ArchiveUrl { .. }
425 | VersionId::Git { .. }
426 | VersionId::Path { .. }
427 | VersionId::Directory { .. }
428 | VersionId::Unknown { .. } => HashPolicy::All(digests),
429 }
430}
431
432fn merge_hashes(
434 hashes: &mut FxHashMap<VersionId, Vec<HashDigest>>,
435 id: VersionId,
436 incoming: Vec<HashDigest>,
437 requirement: impl Display,
438) -> Result<(), HashStrategyError> {
439 if incoming.is_empty() {
440 return Ok(());
441 }
442
443 if !matches!(&id, VersionId::ArchiveUrl { .. }) {
444 hashes.insert(id, incoming);
445 return Ok(());
446 }
447
448 if let Some(existing) = hashes.get_mut(&id) {
449 return merge_digests(existing, &incoming, requirement);
450 }
451
452 let mut merged = Vec::new();
453 merge_digests(&mut merged, &incoming, requirement)?;
454 hashes.insert(id, merged);
455 Ok(())
456}
457
458fn merge_digests<'a>(
464 existing: &mut Vec<HashDigest>,
465 incoming: impl IntoIterator<Item = &'a HashDigest>,
466 requirement: impl Display,
467) -> Result<(), HashStrategyError> {
468 for digest in incoming {
469 match existing
470 .iter()
471 .find(|candidate| candidate.algorithm == digest.algorithm)
472 {
473 Some(candidate) if candidate == digest => {}
474 Some(conflict) => {
475 return Err(HashStrategyError::ConflictingArchiveUrlHashes(
476 requirement.to_string(),
477 conflict.clone(),
478 digest.clone(),
479 ));
480 }
481 None => existing.push(digest.clone()),
482 }
483 }
484 existing.sort_unstable();
485
486 Ok(())
487}
488
489#[derive(thiserror::Error, Debug)]
490pub enum HashStrategyError {
491 #[error(transparent)]
492 Hash(#[from] HashError),
493 #[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")]
494 ConflictingArchiveUrlHashes(String, HashDigest, HashDigest),
495 #[error(
496 "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}"
497 )]
498 UnpinnedRequirement(String, HashCheckingMode),
499 #[error(
500 "`{1}` hashes are insecure and cannot be used with `{2}` but no other hashes are available for: {0}"
501 )]
502 InsecureHashAlgorithm(String, HashAlgorithm, HashCheckingMode),
503 #[error("In `{1}` mode, all requirements must have a hash, but none were provided for: {0}")]
504 MissingHashes(String, HashCheckingMode),
505 #[error(
506 "In `{1}` mode, all requirements must have a hash, but there were no overlapping hashes between the requirements and constraints for: {0}"
507 )]
508 NoIntersection(String, HashCheckingMode),
509}
510
511#[cfg(test)]
512mod tests {
513 use std::str::FromStr;
514 use uv_configuration::HashCheckingMode;
515 use uv_distribution_filename::DistExtension;
516 use uv_distribution_types::{
517 HashPolicy, Requirement, RequirementSource, UnresolvedRequirement,
518 };
519 use uv_pypi_types::HashDigest;
520
521 use super::HashStrategy;
522
523 fn requirement(url: &str) -> Requirement {
524 Requirement {
525 name: "anyio".parse().unwrap(),
526 extras: Box::default(),
527 groups: Box::default(),
528 marker: "python_version >= '3.8'".parse().unwrap(),
529 source: RequirementSource::Url {
530 location: "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl"
531 .parse()
532 .unwrap(),
533 subdirectory: None,
534 ext: DistExtension::Wheel,
535 url: url.parse().unwrap(),
536 },
537 origin: None,
538 }
539 }
540
541 #[test]
542 fn from_requirements_merges_direct_url_hashes_across_fragments() {
543 let first = UnresolvedRequirement::Named(requirement(
544 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
545 ));
546 let second = UnresolvedRequirement::Named(requirement(
547 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
548 ));
549
550 let hasher = HashStrategy::from_requirements(
551 [(&first, &[][..]), (&second, &[][..])].into_iter(),
552 std::iter::empty(),
553 None,
554 HashCheckingMode::Require,
555 )
556 .unwrap();
557
558 let mut expected = vec![
559 HashDigest::from_str(
560 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
561 )
562 .unwrap(),
563 HashDigest::from_str(
564 "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
565 )
566 .unwrap(),
567 ];
568 expected.sort_unstable();
569
570 for requirement in [&first, &second] {
571 let UnresolvedRequirement::Named(requirement) = requirement else {
572 panic!("expected named requirement");
573 };
574 let RequirementSource::Url { url, .. } = &requirement.source else {
575 panic!("expected direct URL requirement");
576 };
577 assert_eq!(hasher.get_url(url), HashPolicy::All(expected.as_slice()));
578 }
579 }
580}