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::{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 digests.is_empty() {
188 continue;
189 }
190
191 merge_hashes(&mut constraint_hashes, id, digests, requirement)?;
192 }
193
194 let mut requirement_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
196 for (requirement, digests) in requirements {
197 if !requirement
198 .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
199 {
200 continue;
201 }
202
203 let id = match &requirement {
205 UnresolvedRequirement::Named(requirement) => {
206 if let Some(id) = Self::pin(requirement) {
207 id
208 } else {
209 if mode.is_require() {
210 return Err(HashStrategyError::UnpinnedRequirement(
211 requirement.to_string(),
212 mode,
213 ));
214 }
215 continue;
216 }
217 }
218 UnresolvedRequirement::Unnamed(requirement) => {
219 VersionId::from_parsed_url(requirement.url.parsed_url.clone())
221 }
222 };
223
224 let mut digests = digests
227 .iter()
228 .map(|digest| HashDigest::from_str(digest))
229 .collect::<Result<Vec<_>, _>>()?;
230 if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
231 merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
232 }
233
234 let digests = if let Some(constraint) = constraint_hashes.remove(&id) {
235 if digests.is_empty() {
236 constraint
238 } else if matches!(id, VersionId::ArchiveUrl { .. }) {
239 let mut merged = digests;
240 merge_digests(&mut merged, &constraint, requirement)?;
241 merged
242 } else {
243 let intersection: Vec<_> = digests
245 .into_iter()
246 .filter(|digest| constraint.contains(digest))
247 .collect();
248 if intersection.is_empty() {
249 return Err(HashStrategyError::NoIntersection(
250 requirement.to_string(),
251 mode,
252 ));
253 }
254 intersection
255 }
256 } else {
257 digests
258 };
259
260 if digests.is_empty() {
262 if mode.is_require() {
263 return Err(HashStrategyError::MissingHashes(
264 requirement.to_string(),
265 mode,
266 ));
267 }
268 continue;
269 }
270
271 merge_hashes(&mut requirement_hashes, id, digests, requirement)?;
272 }
273
274 let hashes: FxHashMap<VersionId, Vec<HashDigest>> = constraint_hashes
277 .into_iter()
278 .chain(requirement_hashes)
279 .collect();
280 match mode {
281 HashCheckingMode::Verify => Ok(Self::Verify(Arc::new(hashes))),
282 HashCheckingMode::Require => Ok(Self::Require(Arc::new(hashes))),
283 }
284 }
285
286 pub fn from_resolution(
288 resolution: &Resolution,
289 mode: HashCheckingMode,
290 ) -> Result<Self, HashStrategyError> {
291 let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
292
293 for (dist, digests) in resolution.hashes() {
294 if digests.is_empty() {
295 if mode.is_require() {
297 return Err(HashStrategyError::MissingHashes(
298 dist.name().to_string(),
299 mode,
300 ));
301 }
302 continue;
303 }
304 hashes.insert(dist.version_id(), digests.to_vec());
305 }
306
307 match mode {
308 HashCheckingMode::Verify => Ok(Self::Verify(Arc::new(hashes))),
309 HashCheckingMode::Require => Ok(Self::Require(Arc::new(hashes))),
310 }
311 }
312
313 fn augment_hashes<'a>(
322 existing: &FxHashMap<VersionId, Vec<HashDigest>>,
323 requirements: impl Iterator<Item = &'a Requirement>,
324 ) -> Result<Option<FxHashMap<VersionId, Vec<HashDigest>>>, HashStrategyError> {
325 let mut hashes = None;
326
327 for requirement in requirements {
328 let Some((id, digests)) = Self::requirement_hashes(requirement) else {
329 continue;
330 };
331 let current = hashes.as_ref().unwrap_or(existing);
332 let current_digests = current.get(&id);
333 let mut merged = current_digests.cloned().unwrap_or_default();
334 merge_digests(&mut merged, &digests, requirement)?;
335
336 if current_digests.map(Vec::as_slice) == Some(merged.as_slice()) {
337 continue;
338 }
339
340 hashes
341 .get_or_insert_with(|| existing.clone())
342 .insert(id, merged);
343 }
344
345 Ok(hashes)
346 }
347
348 fn requirement_hashes(requirement: &Requirement) -> Option<(VersionId, Vec<HashDigest>)> {
350 let mut digests = HashDigests::from(requirement.hashes()?).to_vec();
351 if digests.is_empty() {
352 return None;
353 }
354 digests.sort_unstable();
355 let id = Self::pin(requirement)?;
356 Some((id, digests))
357 }
358
359 fn pin(requirement: &Requirement) -> Option<VersionId> {
361 match &requirement.source {
362 RequirementSource::Registry { specifier, .. } => {
363 let [specifier] = specifier.as_ref() else {
365 return None;
366 };
367
368 if *specifier.operator() != uv_pep440::Operator::Equal {
370 return None;
371 }
372
373 Some(VersionId::from_registry(
374 requirement.name.clone(),
375 specifier.version().clone(),
376 ))
377 }
378 RequirementSource::Url {
379 location,
380 subdirectory,
381 ..
382 } => Some(VersionId::from_archive(
383 location.clone(),
384 subdirectory.clone().map(Path::into_path_buf),
385 )),
386 RequirementSource::GitDirectory {
387 git, subdirectory, ..
388 } => Some(VersionId::from_git(git, subdirectory.as_deref())),
389 RequirementSource::GitPath {
390 git, install_path, ..
391 } => Some(VersionId::from_git(git, Some(install_path))),
392 RequirementSource::Path { install_path, .. } => {
393 Some(VersionId::from_path(install_path))
394 }
395 RequirementSource::Directory { install_path, .. } => {
396 Some(VersionId::from_directory(install_path))
397 }
398 }
399 }
400}
401
402fn hash_policy<'a>(id: &VersionId, digests: &'a [HashDigest]) -> HashPolicy<'a> {
403 match id {
404 VersionId::NameVersion { .. } => HashPolicy::Any(digests),
405 VersionId::ArchiveUrl { .. }
406 | VersionId::Git { .. }
407 | VersionId::Path { .. }
408 | VersionId::Directory { .. }
409 | VersionId::Unknown { .. } => HashPolicy::All(digests),
410 }
411}
412
413fn merge_hashes(
415 hashes: &mut FxHashMap<VersionId, Vec<HashDigest>>,
416 id: VersionId,
417 incoming: Vec<HashDigest>,
418 requirement: impl Display,
419) -> Result<(), HashStrategyError> {
420 if incoming.is_empty() {
421 return Ok(());
422 }
423
424 if !matches!(&id, VersionId::ArchiveUrl { .. }) {
425 hashes.insert(id, incoming);
426 return Ok(());
427 }
428
429 if let Some(existing) = hashes.get_mut(&id) {
430 return merge_digests(existing, &incoming, requirement);
431 }
432
433 let mut merged = Vec::new();
434 merge_digests(&mut merged, &incoming, requirement)?;
435 hashes.insert(id, merged);
436 Ok(())
437}
438
439fn merge_digests<'a>(
445 existing: &mut Vec<HashDigest>,
446 incoming: impl IntoIterator<Item = &'a HashDigest>,
447 requirement: impl Display,
448) -> Result<(), HashStrategyError> {
449 for digest in incoming {
450 match existing
451 .iter()
452 .find(|candidate| candidate.algorithm == digest.algorithm)
453 {
454 Some(candidate) if candidate == digest => {}
455 Some(conflict) => {
456 return Err(HashStrategyError::ConflictingArchiveUrlHashes(
457 requirement.to_string(),
458 conflict.clone(),
459 digest.clone(),
460 ));
461 }
462 None => existing.push(digest.clone()),
463 }
464 }
465 existing.sort_unstable();
466
467 Ok(())
468}
469
470#[derive(thiserror::Error, Debug)]
471pub enum HashStrategyError {
472 #[error(transparent)]
473 Hash(#[from] HashError),
474 #[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")]
475 ConflictingArchiveUrlHashes(String, HashDigest, HashDigest),
476 #[error(
477 "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}"
478 )]
479 UnpinnedRequirement(String, HashCheckingMode),
480 #[error("In `{1}` mode, all requirements must have a hash, but none were provided for: {0}")]
481 MissingHashes(String, HashCheckingMode),
482 #[error(
483 "In `{1}` mode, all requirements must have a hash, but there were no overlapping hashes between the requirements and constraints for: {0}"
484 )]
485 NoIntersection(String, HashCheckingMode),
486}
487
488#[cfg(test)]
489mod tests {
490 use std::str::FromStr;
491 use uv_configuration::HashCheckingMode;
492 use uv_distribution_filename::DistExtension;
493 use uv_distribution_types::{
494 HashPolicy, Requirement, RequirementSource, UnresolvedRequirement,
495 };
496 use uv_pypi_types::HashDigest;
497
498 use super::HashStrategy;
499
500 fn requirement(url: &str) -> Requirement {
501 Requirement {
502 name: "anyio".parse().unwrap(),
503 extras: Box::default(),
504 groups: Box::default(),
505 marker: "python_version >= '3.8'".parse().unwrap(),
506 source: RequirementSource::Url {
507 location: "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl"
508 .parse()
509 .unwrap(),
510 subdirectory: None,
511 ext: DistExtension::Wheel,
512 url: url.parse().unwrap(),
513 },
514 origin: None,
515 }
516 }
517
518 #[test]
519 fn from_requirements_merges_direct_url_hashes_across_fragments() {
520 let first = UnresolvedRequirement::Named(requirement(
521 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
522 ));
523 let second = UnresolvedRequirement::Named(requirement(
524 "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
525 ));
526
527 let hasher = HashStrategy::from_requirements(
528 [(&first, &[][..]), (&second, &[][..])].into_iter(),
529 std::iter::empty(),
530 None,
531 HashCheckingMode::Require,
532 )
533 .unwrap();
534
535 let mut expected = vec![
536 HashDigest::from_str(
537 "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
538 )
539 .unwrap(),
540 HashDigest::from_str(
541 "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
542 )
543 .unwrap(),
544 ];
545 expected.sort_unstable();
546
547 for requirement in [&first, &second] {
548 let UnresolvedRequirement::Named(requirement) = requirement else {
549 panic!("expected named requirement");
550 };
551 let RequirementSource::Url { url, .. } = &requirement.source else {
552 panic!("expected direct URL requirement");
553 };
554 assert_eq!(hasher.get_url(url), HashPolicy::All(expected.as_slice()));
555 }
556 }
557}