Skip to main content

uv_types/
hash.rs

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::{Constraints, HashCheckingMode};
9use uv_distribution_types::{
10    ArchiveHashPolicy, DistributionMetadata, HashCollection, HashValidation, MetadataHashPolicy,
11    Name, Requirement, RequirementSource, 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/// Hash collection and verification policies for a resolution.
19///
20/// Verification takes precedence for distributions with trusted hashes. The collection policy
21/// applies to the remaining distributions.
22#[derive(Debug, Default, Clone)]
23pub struct HashStrategy {
24    collection: HashCollection,
25    verification: HashVerification,
26}
27
28/// The trusted hashes to enforce when retrieving distributions.
29#[derive(Debug, Default, Clone)]
30pub enum HashVerification {
31    /// Hashes do not need to be validated.
32    #[default]
33    None,
34    /// Validate known hashes, without requiring hashes for other distributions.
35    IfPresent(Arc<FxHashMap<VersionId, Vec<HashDigest>>>),
36    /// Every distribution must have a matching trusted hash.
37    Required(Arc<FxHashMap<VersionId, Vec<HashDigest>>>),
38}
39
40impl HashStrategy {
41    /// Collect declared hashes for resolution, computing missing hashes according to the policy.
42    pub fn collect(collection: HashCollection) -> Self {
43        Self {
44            collection,
45            ..Self::default()
46        }
47    }
48
49    /// Validate hashes when present.
50    pub fn verify(hashes: Arc<FxHashMap<VersionId, Vec<HashDigest>>>) -> Self {
51        Self::default().with_verification(HashVerification::IfPresent(hashes))
52    }
53
54    /// Require a matching trusted hash for every distribution.
55    fn require(hashes: Arc<FxHashMap<VersionId, Vec<HashDigest>>>) -> Self {
56        Self::default().with_verification(HashVerification::Required(hashes))
57    }
58
59    /// Set verification independently of hash collection.
60    #[must_use]
61    pub fn with_verification(mut self, verification: HashVerification) -> Self {
62        self.verification = verification;
63        self
64    }
65
66    /// Apply constraint hashes using the same rules as [`Self::from_requirements`].
67    ///
68    /// Preserve hash collection and require hashes if either strategy requires them. Constraints
69    /// for identities absent from this strategy remain available for newly resolved dependencies.
70    pub fn with_constraint_hashes(mut self, constraints: &Self) -> Result<Self, HashStrategyError> {
71        let (requirement_hashes, mode) = match &self.verification {
72            HashVerification::None => {
73                self.verification = constraints.verification.clone();
74                return Ok(self);
75            }
76            HashVerification::IfPresent(hashes) => {
77                let mode = match &constraints.verification {
78                    HashVerification::Required(_) => HashCheckingMode::Require,
79                    HashVerification::None | HashVerification::IfPresent(_) => {
80                        HashCheckingMode::Verify
81                    }
82                };
83                (hashes, mode)
84            }
85            HashVerification::Required(hashes) => (hashes, HashCheckingMode::Require),
86        };
87
88        let mut constraints = constraints.clone();
89        let constraint_hashes = match &mut constraints.verification {
90            HashVerification::None => return Ok(self),
91            HashVerification::IfPresent(hashes) | HashVerification::Required(hashes) => {
92                Arc::make_mut(hashes)
93            }
94        };
95        if mode.is_require() {
96            constraint_hashes.retain(|_, digests| {
97                digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
98                !digests.is_empty()
99            });
100        }
101        let mut hashes = constraint_hashes.clone();
102        for (id, digests) in requirement_hashes.iter() {
103            let mut digests = digests.clone();
104            if mode.is_require() {
105                digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
106            }
107            let digests = if let Some(constraint) = constraints.hashes_for_id(id) {
108                combine_constraint_hashes(id, digests, constraint, id, mode)?
109            } else {
110                digests
111            };
112            if !digests.is_empty() {
113                hashes.insert(id.clone(), digests);
114            }
115        }
116        self.verification = match mode {
117            HashCheckingMode::Verify => HashVerification::IfPresent(Arc::new(hashes)),
118            HashCheckingMode::Require => HashVerification::Required(Arc::new(hashes)),
119        };
120        Ok(self)
121    }
122
123    /// Return the hash collection policy.
124    pub fn collection(&self) -> HashCollection {
125        self.collection
126    }
127
128    /// Return the hash verification policy.
129    pub fn verification(&self) -> &HashVerification {
130        &self.verification
131    }
132
133    /// Return the [`ArchiveHashPolicy`] for the given distribution.
134    pub fn archive_policy<T: DistributionMetadata>(
135        &self,
136        distribution: &T,
137    ) -> ArchiveHashPolicy<'_> {
138        self.archive_policy_for_id(|| distribution.version_id())
139    }
140
141    /// Return the [`MetadataHashPolicy`] for retrieving the given distribution's metadata.
142    pub fn metadata_policy<T: DistributionMetadata>(
143        &self,
144        distribution: &T,
145    ) -> MetadataHashPolicy<'_> {
146        MetadataHashPolicy {
147            collection: self.collection,
148            validation: self.validation_for_id(|| distribution.version_id()),
149        }
150    }
151
152    /// Return the [`ArchiveHashPolicy`] for the given registry-based package.
153    pub fn archive_policy_for_package(
154        &self,
155        name: &PackageName,
156        version: &Version,
157    ) -> ArchiveHashPolicy<'_> {
158        self.archive_policy_for_id(|| VersionId::from_registry(name.clone(), version.clone()))
159    }
160
161    /// Return the [`ArchiveHashPolicy`] for the given direct URL package.
162    ///
163    /// A direct URL identifies a single concrete artifact, so every provided digest must match.
164    pub fn archive_policy_for_url(&self, url: &DisplaySafeUrl) -> ArchiveHashPolicy<'_> {
165        self.archive_policy_for_id(|| VersionId::from_url(url))
166    }
167
168    /// Return the [`MetadataHashPolicy`] for a URL whose package name is not yet known.
169    pub fn metadata_policy_for_url(&self, url: &DisplaySafeUrl) -> MetadataHashPolicy<'_> {
170        MetadataHashPolicy {
171            collection: self.collection,
172            validation: self.validation_for_id(|| VersionId::from_url(url)),
173        }
174    }
175
176    /// Return the archive hash policy for a distribution identity.
177    fn archive_policy_for_id(&self, id: impl FnOnce() -> VersionId) -> ArchiveHashPolicy<'_> {
178        let validation = self.validation_for_id(id);
179        match validation {
180            HashValidation::None => match self.collection {
181                HashCollection::None => ArchiveHashPolicy::None,
182                HashCollection::Url | HashCollection::All => ArchiveHashPolicy::Generate,
183            },
184            HashValidation::Any(_) | HashValidation::All(_) => validation.into(),
185        }
186    }
187
188    /// Construct an identity only when verification requires a lookup.
189    fn validation_for_id(&self, id: impl FnOnce() -> VersionId) -> HashValidation<'_> {
190        match &self.verification {
191            HashVerification::IfPresent(_) => {
192                let id = id();
193                if let Some(hashes) = self.hashes_for_id(&id) {
194                    return hash_validation(&id, hashes);
195                }
196            }
197            HashVerification::Required(hashes) => {
198                let id = id();
199                return hash_validation(
200                    &id,
201                    hashes.get(&id).map(Vec::as_slice).unwrap_or_default(),
202                );
203            }
204            HashVerification::None => {}
205        }
206        HashValidation::None
207    }
208
209    /// Look up supplied hashes, including public-version pins in verification mode.
210    fn hashes_for_id(&self, id: &VersionId) -> Option<&[HashDigest]> {
211        match &self.verification {
212            HashVerification::None => None,
213            HashVerification::IfPresent(hashes) => hashes
214                .get(id)
215                .or_else(|| {
216                    // `==1.0.0` can also select `1.0.0+local`. If the local version has no hash
217                    // of its own, check it against the hash for `1.0.0`.
218                    if let VersionId::NameVersion(name, version) = id
219                        && version.is_local()
220                    {
221                        hashes.get(&VersionId::from_registry(
222                            name.clone(),
223                            version.clone().without_local(),
224                        ))
225                    } else {
226                        None
227                    }
228                })
229                .map(Vec::as_slice),
230            HashVerification::Required(hashes) => hashes.get(id).map(Vec::as_slice),
231        }
232    }
233
234    /// Returns `true` if the given registry-based package is allowed.
235    pub fn allows_package(&self, name: &PackageName, version: &Version) -> bool {
236        match &self.verification {
237            HashVerification::Required(hashes) => {
238                hashes.contains_key(&VersionId::from_registry(name.clone(), version.clone()))
239            }
240            HashVerification::None | HashVerification::IfPresent(_) => true,
241        }
242    }
243
244    /// Returns `true` if the given direct URL package is allowed.
245    pub fn allows_url(&self, url: &DisplaySafeUrl) -> bool {
246        match &self.verification {
247            HashVerification::Required(hashes) => hashes.contains_key(&VersionId::from_url(url)),
248            HashVerification::None | HashVerification::IfPresent(_) => true,
249        }
250    }
251
252    /// Return a [`HashStrategy`] augmented with archive URL hashes discovered in additional
253    /// requirements after the initial command-line parse.
254    pub fn augment_with_requirements<'a>(
255        mut self,
256        requirements: impl Iterator<Item = &'a Requirement>,
257    ) -> Result<Self, HashStrategyError> {
258        match &mut self.verification {
259            HashVerification::None => {}
260            HashVerification::IfPresent(existing) | HashVerification::Required(existing) => {
261                if let Some(hashes) = Self::augment_hashes(existing, requirements)? {
262                    *existing = Arc::new(hashes);
263                }
264            }
265        }
266        Ok(self)
267    }
268
269    /// Return a [`HashStrategy`] augmented with archive URL hashes discovered in distribution
270    /// metadata.
271    ///
272    /// Required-hash verification is intentionally a closed set. In that mode, distribution
273    /// untrusted metadata cannot authorize a requirement that was absent from the input hash set.
274    /// Explicit requirements, such as `build-system.requires` and user-provided metadata, can still
275    /// contribute hashes via [`Self::augment_with_requirements`].
276    pub fn augment_with_metadata_requirements<'a>(
277        self,
278        requirements: impl Iterator<Item = &'a Requirement>,
279    ) -> Result<Self, HashStrategyError> {
280        if matches!(&self.verification, HashVerification::Required(_)) {
281            return Ok(self);
282        }
283        self.augment_with_requirements(requirements)
284    }
285
286    /// Collect hashes from [`UnresolvedRequirement`] entries and constraints.
287    ///
288    /// For duplicate registry pins and local files, the last nonempty hash list wins. Remote
289    /// archive URLs combine hashes across algorithms and reject conflicting digests for the same
290    /// algorithm. Registry pins accept any allowed digest; direct references must match all
291    /// supplied digests. When requirements and constraints both supply hashes, registry pins
292    /// permit only shared hashes; direct references must match hashes from both sources.
293    ///
294    /// When the environment is not given, this treats all marker expressions
295    /// that reference the environment as true. In other words, it does
296    /// environment independent expression evaluation. (Which in turn devolves
297    /// to "only evaluate marker expressions that reference an extra name.")
298    pub fn from_requirements<'a>(
299        requirements: impl Iterator<Item = (&'a UnresolvedRequirement, &'a [String])>,
300        constraints: impl Iterator<Item = (&'a Requirement, &'a [String])>,
301        marker_env: Option<&ResolverMarkerEnvironment>,
302        mode: HashCheckingMode,
303    ) -> Result<Self, HashStrategyError> {
304        let mut constraint_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
305
306        // First, index the constraints by name.
307        for (requirement, digests) in constraints {
308            if !requirement
309                .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
310            {
311                continue;
312            }
313
314            // Every constraint must be a pinned version.
315            let Some(id) = Self::pin(requirement) else {
316                if mode.is_require() {
317                    return Err(HashStrategyError::UnpinnedRequirement(
318                        requirement.to_string(),
319                        mode,
320                    ));
321                }
322                continue;
323            };
324
325            // Parse the hashes provided directly on the requirement, then merge in any hashes from
326            // the URL fragment.
327            let mut digests = digests
328                .iter()
329                .map(|digest| HashDigest::from_str(digest))
330                .collect::<Result<Vec<_>, _>>()?;
331            if let Some(fragment_hashes) = requirement.hashes()? {
332                let fragment_hashes = HashDigests::from(fragment_hashes);
333                merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
334            }
335
336            if mode.is_require() {
337                digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
338            }
339
340            if digests.is_empty() {
341                continue;
342            }
343
344            merge_hashes(&mut constraint_hashes, id, digests, requirement)?;
345        }
346
347        // For each requirement, map from hash identity to allowed hashes.
348        let mut requirement_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
349        for (requirement, digests) in requirements {
350            if !requirement
351                .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
352            {
353                continue;
354            }
355
356            // Every requirement must be either a pinned version or a direct URL.
357            let id = match &requirement {
358                UnresolvedRequirement::Named(requirement) => {
359                    if let Some(id) = Self::pin(requirement) {
360                        id
361                    } else {
362                        if mode.is_require() {
363                            return Err(HashStrategyError::UnpinnedRequirement(
364                                requirement.to_string(),
365                                mode,
366                            ));
367                        }
368                        continue;
369                    }
370                }
371                UnresolvedRequirement::Unnamed(requirement) => {
372                    // Direct URLs are always allowed.
373                    VersionId::from_parsed_url(requirement.url.parsed_url.clone())
374                }
375            };
376
377            // Parse the hashes provided directly on the requirement, then merge in any hashes from
378            // the URL fragment.
379            let mut digests = digests
380                .iter()
381                .map(|digest| HashDigest::from_str(digest))
382                .collect::<Result<Vec<_>, _>>()?;
383            if let Some(fragment_hashes) = requirement.hashes()? {
384                let fragment_hashes = HashDigests::from(fragment_hashes);
385                merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
386            }
387
388            let has_md5 = mode.is_require()
389                && digests
390                    .iter()
391                    .any(|digest| digest.algorithm() == HashAlgorithm::Md5);
392            if mode.is_require() {
393                digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
394            }
395
396            let digests = if let Some(constraint) = constraint_hashes.get(&id) {
397                // A hashless duplicate must not replace earlier requirement hashes with the
398                // constraint's hashes.
399                if digests.is_empty() && requirement_hashes.contains_key(&id) {
400                    continue;
401                }
402                combine_constraint_hashes(&id, digests, constraint, requirement, mode)?
403            } else {
404                digests
405            };
406
407            // Under `--require-hashes`, every requirement needs a hash from the requirement or a
408            // constraint.
409            if digests.is_empty() {
410                if mode.is_require() {
411                    if has_md5 {
412                        return Err(HashStrategyError::InsecureHashAlgorithm(
413                            requirement.to_string(),
414                            HashAlgorithm::Md5,
415                            mode,
416                        ));
417                    }
418                    return Err(HashStrategyError::MissingHashes(
419                        requirement.to_string(),
420                        mode,
421                    ));
422                }
423                continue;
424            }
425
426            merge_hashes(&mut requirement_hashes, id, digests, requirement)?;
427        }
428
429        // Requirements take precedence because each matching constraint is already applied.
430        let hashes: FxHashMap<VersionId, Vec<HashDigest>> = constraint_hashes
431            .into_iter()
432            .chain(requirement_hashes)
433            .collect();
434        match mode {
435            HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
436            HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
437        }
438    }
439
440    /// Collect hashes from [`Constraints`] using the same handling as regular constraints in
441    /// [`Self::from_requirements`], preserving declaration order.
442    pub fn from_constraints(
443        constraints: &Constraints,
444        marker_env: Option<&ResolverMarkerEnvironment>,
445        mode: HashCheckingMode,
446    ) -> Result<Self, HashStrategyError> {
447        Self::from_requirements(
448            std::iter::empty(),
449            constraints
450                .specifications()
451                .map(|entry| (&entry.requirement, entry.hashes.as_slice())),
452            marker_env,
453            mode,
454        )
455    }
456
457    /// Read the required hashes from a [`Resolution`].
458    pub fn from_resolution(
459        resolution: &Resolution,
460        mode: HashCheckingMode,
461    ) -> Result<Self, HashStrategyError> {
462        let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
463
464        for (dist, digests) in resolution.hashes() {
465            if digests.is_empty() {
466                // Under `--require-hashes`, every requirement must include a hash.
467                if mode.is_require() {
468                    return Err(HashStrategyError::MissingHashes(
469                        dist.name().to_string(),
470                        mode,
471                    ));
472                }
473                continue;
474            }
475            hashes.insert(dist.version_id(), digests.to_vec());
476        }
477
478        match mode {
479            HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
480            HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
481        }
482    }
483
484    /// Augment an existing set of hashes with archive URL hashes discovered in additional
485    /// requirements.
486    ///
487    /// Archive URL requirements are keyed by a [`VersionId`] so that requirements that refer to
488    /// the same underlying archive but differ only in hash fragments are merged onto the same
489    /// digest set.
490    ///
491    /// Returns `Ok(None)` if no new hashes were added or updated.
492    fn augment_hashes<'a>(
493        existing: &FxHashMap<VersionId, Vec<HashDigest>>,
494        requirements: impl Iterator<Item = &'a Requirement>,
495    ) -> Result<Option<FxHashMap<VersionId, Vec<HashDigest>>>, HashStrategyError> {
496        let mut hashes = None;
497
498        for requirement in requirements {
499            let Some((id, digests)) = Self::requirement_hashes(requirement)? else {
500                continue;
501            };
502            let current = hashes.as_ref().unwrap_or(existing);
503            let current_digests = current.get(&id);
504            let mut merged = current_digests.cloned().unwrap_or_default();
505            merge_digests(&mut merged, &digests, requirement)?;
506
507            if current_digests.map(Vec::as_slice) == Some(merged.as_slice()) {
508                continue;
509            }
510
511            hashes
512                .get_or_insert_with(|| existing.clone())
513                .insert(id, merged);
514        }
515
516        Ok(hashes)
517    }
518
519    /// Extract the archive URL hash target and digests for a requirement, if any.
520    fn requirement_hashes(
521        requirement: &Requirement,
522    ) -> Result<Option<(VersionId, Vec<HashDigest>)>, HashStrategyError> {
523        let Some(hashes) = requirement.hashes()? else {
524            return Ok(None);
525        };
526        let mut digests = HashDigests::from(hashes).to_vec();
527        if digests.is_empty() {
528            return Ok(None);
529        }
530        digests.sort_unstable();
531        let Some(id) = Self::pin(requirement) else {
532            return Ok(None);
533        };
534        Ok(Some((id, digests)))
535    }
536
537    /// Pin a [`Requirement`] to a [`VersionId`], if possible.
538    fn pin(requirement: &Requirement) -> Option<VersionId> {
539        match &requirement.source {
540            RequirementSource::Registry { specifier, .. } => {
541                // Must be a single specifier.
542                let [specifier] = specifier.as_ref() else {
543                    return None;
544                };
545
546                // Must be pinned to a specific version.
547                let is_pinned =
548                    matches!(specifier.operator(), Operator::Equal | Operator::ExactEqual);
549                if !is_pinned {
550                    return None;
551                }
552
553                Some(VersionId::from_registry(
554                    requirement.name.clone(),
555                    specifier.version().clone(),
556                ))
557            }
558            RequirementSource::Url {
559                location,
560                subdirectory,
561                ..
562            } => Some(VersionId::from_archive(
563                location.clone(),
564                subdirectory.clone().map(Path::into_path_buf),
565            )),
566            RequirementSource::GitDirectory {
567                git, subdirectory, ..
568            } => Some(VersionId::from_git(git, subdirectory.as_deref())),
569            RequirementSource::GitPath {
570                git, install_path, ..
571            } => Some(VersionId::from_git(git, Some(install_path))),
572            RequirementSource::Path { install_path, .. } => {
573                Some(VersionId::from_path(install_path))
574            }
575            RequirementSource::Directory { install_path, .. } => {
576                Some(VersionId::from_directory(install_path))
577            }
578        }
579    }
580}
581
582fn hash_validation<'a>(id: &VersionId, digests: &'a [HashDigest]) -> HashValidation<'a> {
583    match id {
584        VersionId::NameVersion { .. } => HashValidation::Any(digests),
585        VersionId::ArchiveUrl { .. }
586        | VersionId::Git { .. }
587        | VersionId::Path { .. }
588        | VersionId::Directory { .. }
589        | VersionId::Unknown { .. } => HashValidation::All(digests),
590    }
591}
592
593/// Combine hashes for a requirement and an applicable constraint.
594fn combine_constraint_hashes(
595    id: &VersionId,
596    mut digests: Vec<HashDigest>,
597    constraint: &[HashDigest],
598    requirement: impl Display,
599    mode: HashCheckingMode,
600) -> Result<Vec<HashDigest>, HashStrategyError> {
601    if digests.is_empty() {
602        // If there are _only_ hashes on the constraints, use them.
603        return Ok(constraint.to_vec());
604    }
605    match id {
606        VersionId::NameVersion(..) => {
607            // If there are constraint and requirement hashes, take the intersection.
608            digests.retain(|digest| constraint.contains(digest));
609            if digests.is_empty() {
610                return Err(HashStrategyError::NoIntersection(
611                    requirement.to_string(),
612                    mode,
613                ));
614            }
615        }
616        VersionId::ArchiveUrl { .. }
617        | VersionId::Git { .. }
618        | VersionId::Path(..)
619        | VersionId::Directory(..)
620        | VersionId::Unknown(..) => {
621            merge_digests(&mut digests, constraint, requirement)?;
622        }
623    }
624    Ok(digests)
625}
626
627/// Merge repeated hashes for a requirement or constraint into the hash map.
628fn merge_hashes(
629    hashes: &mut FxHashMap<VersionId, Vec<HashDigest>>,
630    id: VersionId,
631    incoming: Vec<HashDigest>,
632    requirement: impl Display,
633) -> Result<(), HashStrategyError> {
634    if incoming.is_empty() {
635        return Ok(());
636    }
637
638    if !matches!(&id, VersionId::ArchiveUrl { .. }) {
639        hashes.insert(id, incoming);
640        return Ok(());
641    }
642
643    if let Some(existing) = hashes.get_mut(&id) {
644        return merge_digests(existing, &incoming, requirement);
645    }
646
647    let mut merged = Vec::new();
648    merge_digests(&mut merged, &incoming, requirement)?;
649    hashes.insert(id, merged);
650    Ok(())
651}
652
653/// Merge `incoming` digests into `existing`.
654///
655/// Exact duplicates are ignored. Digests for different algorithms are accumulated. If the
656/// same algorithm appears with two different values, returns
657/// [`HashStrategyError::ConflictingArchiveUrlHashes`].
658fn merge_digests<'a>(
659    existing: &mut Vec<HashDigest>,
660    incoming: impl IntoIterator<Item = &'a HashDigest>,
661    requirement: impl Display,
662) -> Result<(), HashStrategyError> {
663    for digest in incoming {
664        match existing
665            .iter()
666            .find(|candidate| candidate.algorithm() == digest.algorithm())
667        {
668            Some(candidate) if candidate == digest => {}
669            Some(conflict) => {
670                return Err(HashStrategyError::ConflictingArchiveUrlHashes(
671                    requirement.to_string(),
672                    conflict.clone(),
673                    digest.clone(),
674                ));
675            }
676            None => existing.push(digest.clone()),
677        }
678    }
679    existing.sort_unstable();
680
681    Ok(())
682}
683
684#[derive(thiserror::Error, Debug)]
685pub enum HashStrategyError {
686    #[error(transparent)]
687    Hash(#[from] HashError),
688    #[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")]
689    ConflictingArchiveUrlHashes(String, HashDigest, HashDigest),
690    #[error(
691        "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}"
692    )]
693    UnpinnedRequirement(String, HashCheckingMode),
694    #[error(
695        "`{1}` hashes are insecure and cannot be used with `{2}` but no other hashes are available for: {0}"
696    )]
697    InsecureHashAlgorithm(String, HashAlgorithm, HashCheckingMode),
698    #[error("In `{1}` mode, all requirements must have a hash, but none were provided for: {0}")]
699    MissingHashes(String, HashCheckingMode),
700    #[error(
701        "In `{1}` mode, all requirements must have a hash, but there were no overlapping hashes between the requirements and constraints for: {0}"
702    )]
703    NoIntersection(String, HashCheckingMode),
704}
705
706#[cfg(test)]
707mod tests {
708    use std::path::Path;
709    use std::slice;
710    use std::str::FromStr;
711    use std::sync::Arc;
712
713    use rustc_hash::FxHashMap;
714    use uv_configuration::HashCheckingMode;
715    use uv_distribution_filename::DistExtension;
716    use uv_distribution_types::{
717        ArchiveHashPolicy, HashCollection, HashValidation, MetadataHashPolicy, Requirement,
718        RequirementScope, RequirementSource, UnresolvedRequirement, VersionId,
719    };
720    use uv_normalize::PackageName;
721    use uv_pep440::Version;
722    use uv_pypi_types::HashDigest;
723    use uv_redacted::DisplaySafeUrl;
724
725    use super::{HashStrategy, HashVerification};
726
727    fn requirement(url: &str) -> Requirement {
728        Requirement {
729            name: "anyio".parse().unwrap(),
730            extras: Box::default(),
731            groups: Box::default(),
732            marker: "python_version >= '3.8'".parse().unwrap(),
733            source: RequirementSource::Url {
734                location: "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl"
735                    .parse()
736                    .unwrap(),
737                subdirectory: None,
738                ext: DistExtension::Wheel,
739                url: url.parse().unwrap(),
740            },
741            scope: RequirementScope::Global,
742            origin: None,
743        }
744    }
745
746    #[test]
747    fn from_requirements_merges_direct_url_hashes_across_fragments() {
748        let first = UnresolvedRequirement::Named(requirement(
749            "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=CFDB2B588B9FC25EDE96D8DB56ED50848B0B649DCA3DD1DF0B11F683BB9E0B5F",
750        ));
751        let second = UnresolvedRequirement::Named(requirement(
752            "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
753        ));
754
755        let hasher = HashStrategy::from_requirements(
756            [(&first, &[][..]), (&second, &[][..])].into_iter(),
757            std::iter::empty(),
758            None,
759            HashCheckingMode::Require,
760        )
761        .unwrap();
762
763        let mut expected = vec![
764            HashDigest::from_str(
765                "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
766            )
767            .unwrap(),
768            HashDigest::from_str(
769                "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
770            )
771            .unwrap(),
772        ];
773        expected.sort_unstable();
774
775        for requirement in [&first, &second] {
776            let UnresolvedRequirement::Named(requirement) = requirement else {
777                panic!("expected named requirement");
778            };
779            let RequirementSource::Url { url, .. } = &requirement.source else {
780                panic!("expected direct URL requirement");
781            };
782            assert_eq!(
783                hasher.archive_policy_for_url(url),
784                ArchiveHashPolicy::All(expected.as_slice())
785            );
786        }
787    }
788
789    #[test]
790    fn generate_and_verify_validates_known_hashes_and_generates_unknown_hashes()
791    -> Result<(), Box<dyn std::error::Error>> {
792        let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
793        let unknown_url: DisplaySafeUrl = "https://example.com/anyio-4.1.0.tar.gz".parse()?;
794        let name: PackageName = "anyio".parse()?;
795        let version: Version = "4.0.0".parse()?;
796        let unknown_version: Version = "4.1.0".parse()?;
797        let digest = HashDigest::from_str(
798            "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
799        )?;
800        let hashes = FxHashMap::from_iter([
801            (VersionId::from_url(&url), vec![digest.clone()]),
802            (
803                VersionId::from_registry(name.clone(), version.clone()),
804                vec![digest.clone()],
805            ),
806        ]);
807        let strategy = HashStrategy::collect(HashCollection::All)
808            .with_verification(HashVerification::IfPresent(Arc::new(hashes)));
809
810        assert_eq!(
811            strategy.archive_policy_for_url(&url),
812            ArchiveHashPolicy::All(slice::from_ref(&digest))
813        );
814        for fragment in [
815            "#subdirectory=.",
816            "#subdirectory=./",
817            "#subdirectory=",
818            "#subdirectory=nested/..",
819        ] {
820            let root_url = format!("{url}{fragment}").parse()?;
821            assert_eq!(
822                strategy.archive_policy_for_url(&root_url),
823                ArchiveHashPolicy::All(slice::from_ref(&digest))
824            );
825        }
826        assert_eq!(
827            strategy.archive_policy_for_url(&unknown_url),
828            ArchiveHashPolicy::Generate
829        );
830        assert_eq!(
831            strategy.metadata_policy_for_url(&unknown_url),
832            MetadataHashPolicy {
833                collection: HashCollection::All,
834                validation: HashValidation::None,
835            }
836        );
837        assert_eq!(
838            strategy.metadata_policy_for_url(&url),
839            MetadataHashPolicy {
840                collection: HashCollection::All,
841                validation: HashValidation::All(slice::from_ref(&digest)),
842            }
843        );
844        assert_eq!(
845            strategy.archive_policy_for_package(&name, &version),
846            ArchiveHashPolicy::Any(slice::from_ref(&digest))
847        );
848        assert_eq!(
849            strategy.archive_policy_for_package(&name, &unknown_version),
850            ArchiveHashPolicy::Generate
851        );
852
853        Ok(())
854    }
855
856    #[test]
857    fn required_hashes_take_precedence_over_collection() -> Result<(), Box<dyn std::error::Error>> {
858        let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
859        let name: PackageName = "anyio".parse()?;
860        let version: Version = "4.0.0".parse()?;
861        let strategy = HashStrategy::collect(HashCollection::All)
862            .with_verification(HashVerification::Required(Arc::default()));
863
864        assert_eq!(
865            strategy.archive_policy_for_url(&url),
866            ArchiveHashPolicy::All(&[])
867        );
868        assert_eq!(
869            strategy.metadata_policy_for_url(&url),
870            MetadataHashPolicy {
871                collection: HashCollection::All,
872                validation: HashValidation::All(&[]),
873            }
874        );
875        assert_eq!(
876            strategy.archive_policy_for_package(&name, &version),
877            ArchiveHashPolicy::Any(&[])
878        );
879        assert!(!strategy.allows_url(&url));
880        assert!(!strategy.allows_package(&name, &version));
881
882        let digest = HashDigest::from_str(
883            "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
884        )?;
885        let constraints = HashStrategy::verify(Arc::new(FxHashMap::from_iter([
886            (VersionId::from_url(&url), vec![digest.clone()]),
887            (
888                VersionId::from_registry(name.clone(), version.clone()),
889                vec![HashDigest::from_str(
890                    "md5:420d85e19168705cdf0223621b18831a",
891                )?],
892            ),
893        ])));
894        for strategy in [
895            strategy.clone().with_constraint_hashes(&constraints)?,
896            constraints.with_constraint_hashes(&strategy)?,
897        ] {
898            assert_eq!(
899                strategy.archive_policy_for_url(&url),
900                ArchiveHashPolicy::All(slice::from_ref(&digest))
901            );
902            assert_eq!(
903                strategy.archive_policy_for_package(&name, &version),
904                ArchiveHashPolicy::Any(&[])
905            );
906            assert!(!strategy.allows_package(&name, &version));
907        }
908        Ok(())
909    }
910
911    #[test]
912    fn constraint_hashes_combine_with_locked_hashes() -> Result<(), Box<dyn std::error::Error>> {
913        let name: PackageName = "anyio".parse()?;
914        let version: Version = "4.0.0".parse()?;
915        let local_version: Version = "4.0.0+local".parse()?;
916        let digest = HashDigest::from_str(
917            "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
918        )?;
919        let other = HashDigest::from_str(
920            "sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a",
921        )?;
922        let sha512 = HashDigest::from_str(
923            "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
924        )?;
925        let registry = VersionId::from_registry(name.clone(), version);
926        let local_registry = VersionId::from_registry(name, local_version);
927        let archive = VersionId::from_url(&"https://example.com/anyio-4.0.0.tar.gz".parse()?);
928        let path = VersionId::from_path(Path::new("anyio-4.0.0.tar.gz"));
929        for (id, constraint_id, constraint, expected) in [
930            (
931                registry.clone(),
932                registry,
933                digest.clone(),
934                vec![digest.clone()],
935            ),
936            (
937                local_registry,
938                VersionId::from_registry("anyio".parse()?, "4.0.0".parse()?),
939                digest.clone(),
940                vec![digest.clone()],
941            ),
942            (path.clone(), path, digest.clone(), vec![digest.clone()]),
943            (
944                archive.clone(),
945                archive,
946                sha512.clone(),
947                vec![digest.clone(), sha512],
948            ),
949        ] {
950            let locked = HashStrategy::collect(HashCollection::All).with_verification(
951                HashVerification::IfPresent(Arc::new(FxHashMap::from_iter([(
952                    id.clone(),
953                    vec![digest.clone()],
954                )]))),
955            );
956            let constraints = HashStrategy::verify(Arc::new(FxHashMap::from_iter([(
957                constraint_id.clone(),
958                vec![constraint],
959            )])));
960            let combined = locked.clone().with_constraint_hashes(&constraints)?;
961            assert_eq!(combined.collection(), HashCollection::All);
962            assert_eq!(combined.hashes_for_id(&id), Some(expected.as_slice()));
963
964            let conflicting = HashStrategy::verify(Arc::new(FxHashMap::from_iter([(
965                constraint_id,
966                vec![other.clone()],
967            )])));
968            assert!(locked.with_constraint_hashes(&conflicting).is_err());
969        }
970        Ok(())
971    }
972}