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