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 constraint hash list wins.
289    /// Remote archive URLs combine hashes across algorithms and reject conflicting digests for
290    /// the same algorithm. Registry pins accept any allowed digest; direct URLs must match all
291    /// supplied digests. When requirements and constraints both supply hashes, only their shared
292    /// hashes are allowed, except for remote archive URLs, whose hashes are combined instead.
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.remove(&id) {
397                combine_constraint_hashes(&id, digests, &constraint, requirement, mode)?
398            } else {
399                digests
400            };
401
402            // Under `--require-hashes`, every requirement must include a hash.
403            if digests.is_empty() {
404                if mode.is_require() {
405                    if has_md5 {
406                        return Err(HashStrategyError::InsecureHashAlgorithm(
407                            requirement.to_string(),
408                            HashAlgorithm::Md5,
409                            mode,
410                        ));
411                    }
412                    return Err(HashStrategyError::MissingHashes(
413                        requirement.to_string(),
414                        mode,
415                    ));
416                }
417                continue;
418            }
419
420            merge_hashes(&mut requirement_hashes, id, digests, requirement)?;
421        }
422
423        // Merge the hashes, preferring requirements over constraints, since overlapping
424        // requirements were already merged.
425        let hashes: FxHashMap<VersionId, Vec<HashDigest>> = constraint_hashes
426            .into_iter()
427            .chain(requirement_hashes)
428            .collect();
429        match mode {
430            HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
431            HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
432        }
433    }
434
435    /// Collect hashes from [`Constraints`] using the same handling as regular constraints in
436    /// [`Self::from_requirements`], preserving declaration order.
437    pub fn from_constraints(
438        constraints: &Constraints,
439        marker_env: Option<&ResolverMarkerEnvironment>,
440        mode: HashCheckingMode,
441    ) -> Result<Self, HashStrategyError> {
442        Self::from_requirements(
443            std::iter::empty(),
444            constraints
445                .specifications()
446                .map(|entry| (&entry.requirement, entry.hashes.as_slice())),
447            marker_env,
448            mode,
449        )
450    }
451
452    /// Read the required hashes from a [`Resolution`].
453    pub fn from_resolution(
454        resolution: &Resolution,
455        mode: HashCheckingMode,
456    ) -> Result<Self, HashStrategyError> {
457        let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
458
459        for (dist, digests) in resolution.hashes() {
460            if digests.is_empty() {
461                // Under `--require-hashes`, every requirement must include a hash.
462                if mode.is_require() {
463                    return Err(HashStrategyError::MissingHashes(
464                        dist.name().to_string(),
465                        mode,
466                    ));
467                }
468                continue;
469            }
470            hashes.insert(dist.version_id(), digests.to_vec());
471        }
472
473        match mode {
474            HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
475            HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
476        }
477    }
478
479    /// Augment an existing set of hashes with archive URL hashes discovered in additional
480    /// requirements.
481    ///
482    /// Archive URL requirements are keyed by a [`VersionId`] so that requirements that refer to
483    /// the same underlying archive but differ only in hash fragments are merged onto the same
484    /// digest set.
485    ///
486    /// Returns `Ok(None)` if no new hashes were added or updated.
487    fn augment_hashes<'a>(
488        existing: &FxHashMap<VersionId, Vec<HashDigest>>,
489        requirements: impl Iterator<Item = &'a Requirement>,
490    ) -> Result<Option<FxHashMap<VersionId, Vec<HashDigest>>>, HashStrategyError> {
491        let mut hashes = None;
492
493        for requirement in requirements {
494            let Some((id, digests)) = Self::requirement_hashes(requirement)? else {
495                continue;
496            };
497            let current = hashes.as_ref().unwrap_or(existing);
498            let current_digests = current.get(&id);
499            let mut merged = current_digests.cloned().unwrap_or_default();
500            merge_digests(&mut merged, &digests, requirement)?;
501
502            if current_digests.map(Vec::as_slice) == Some(merged.as_slice()) {
503                continue;
504            }
505
506            hashes
507                .get_or_insert_with(|| existing.clone())
508                .insert(id, merged);
509        }
510
511        Ok(hashes)
512    }
513
514    /// Extract the archive URL hash target and digests for a requirement, if any.
515    fn requirement_hashes(
516        requirement: &Requirement,
517    ) -> Result<Option<(VersionId, Vec<HashDigest>)>, HashStrategyError> {
518        let Some(hashes) = requirement.hashes()? else {
519            return Ok(None);
520        };
521        let mut digests = HashDigests::from(hashes).to_vec();
522        if digests.is_empty() {
523            return Ok(None);
524        }
525        digests.sort_unstable();
526        let Some(id) = Self::pin(requirement) else {
527            return Ok(None);
528        };
529        Ok(Some((id, digests)))
530    }
531
532    /// Pin a [`Requirement`] to a [`VersionId`], if possible.
533    fn pin(requirement: &Requirement) -> Option<VersionId> {
534        match &requirement.source {
535            RequirementSource::Registry { specifier, .. } => {
536                // Must be a single specifier.
537                let [specifier] = specifier.as_ref() else {
538                    return None;
539                };
540
541                // Must be pinned to a specific version.
542                let is_pinned =
543                    matches!(specifier.operator(), Operator::Equal | Operator::ExactEqual);
544                if !is_pinned {
545                    return None;
546                }
547
548                Some(VersionId::from_registry(
549                    requirement.name.clone(),
550                    specifier.version().clone(),
551                ))
552            }
553            RequirementSource::Url {
554                location,
555                subdirectory,
556                ..
557            } => Some(VersionId::from_archive(
558                location.clone(),
559                subdirectory.clone().map(Path::into_path_buf),
560            )),
561            RequirementSource::GitDirectory {
562                git, subdirectory, ..
563            } => Some(VersionId::from_git(git, subdirectory.as_deref())),
564            RequirementSource::GitPath {
565                git, install_path, ..
566            } => Some(VersionId::from_git(git, Some(install_path))),
567            RequirementSource::Path { install_path, .. } => {
568                Some(VersionId::from_path(install_path))
569            }
570            RequirementSource::Directory { install_path, .. } => {
571                Some(VersionId::from_directory(install_path))
572            }
573        }
574    }
575}
576
577fn hash_validation<'a>(id: &VersionId, digests: &'a [HashDigest]) -> HashValidation<'a> {
578    match id {
579        VersionId::NameVersion { .. } => HashValidation::Any(digests),
580        VersionId::ArchiveUrl { .. }
581        | VersionId::Git { .. }
582        | VersionId::Path { .. }
583        | VersionId::Directory { .. }
584        | VersionId::Unknown { .. } => HashValidation::All(digests),
585    }
586}
587
588/// Combine hashes for a requirement and an applicable constraint.
589fn combine_constraint_hashes(
590    id: &VersionId,
591    mut digests: Vec<HashDigest>,
592    constraint: &[HashDigest],
593    requirement: impl Display,
594    mode: HashCheckingMode,
595) -> Result<Vec<HashDigest>, HashStrategyError> {
596    if digests.is_empty() {
597        // If there are _only_ hashes on the constraints, use them.
598        return Ok(constraint.to_vec());
599    }
600    match id {
601        VersionId::ArchiveUrl { .. } => {
602            merge_digests(&mut digests, constraint, requirement)?;
603        }
604        VersionId::NameVersion(..)
605        | VersionId::Git { .. }
606        | VersionId::Path(..)
607        | VersionId::Directory(..)
608        | VersionId::Unknown(..) => {
609            // If there are constraint and requirement hashes, take the intersection.
610            digests.retain(|digest| constraint.contains(digest));
611            if digests.is_empty() {
612                return Err(HashStrategyError::NoIntersection(
613                    requirement.to_string(),
614                    mode,
615                ));
616            }
617        }
618    }
619    Ok(digests)
620}
621
622/// Merge repeated hashes for a requirement or constraint into the hash map.
623fn merge_hashes(
624    hashes: &mut FxHashMap<VersionId, Vec<HashDigest>>,
625    id: VersionId,
626    incoming: Vec<HashDigest>,
627    requirement: impl Display,
628) -> Result<(), HashStrategyError> {
629    if incoming.is_empty() {
630        return Ok(());
631    }
632
633    if !matches!(&id, VersionId::ArchiveUrl { .. }) {
634        hashes.insert(id, incoming);
635        return Ok(());
636    }
637
638    if let Some(existing) = hashes.get_mut(&id) {
639        return merge_digests(existing, &incoming, requirement);
640    }
641
642    let mut merged = Vec::new();
643    merge_digests(&mut merged, &incoming, requirement)?;
644    hashes.insert(id, merged);
645    Ok(())
646}
647
648/// Merge `incoming` digests into `existing`.
649///
650/// Exact duplicates are ignored. Digests for different algorithms are accumulated. If the
651/// same algorithm appears with two different values, returns
652/// [`HashStrategyError::ConflictingArchiveUrlHashes`].
653fn merge_digests<'a>(
654    existing: &mut Vec<HashDigest>,
655    incoming: impl IntoIterator<Item = &'a HashDigest>,
656    requirement: impl Display,
657) -> Result<(), HashStrategyError> {
658    for digest in incoming {
659        match existing
660            .iter()
661            .find(|candidate| candidate.algorithm() == digest.algorithm())
662        {
663            Some(candidate) if candidate == digest => {}
664            Some(conflict) => {
665                return Err(HashStrategyError::ConflictingArchiveUrlHashes(
666                    requirement.to_string(),
667                    conflict.clone(),
668                    digest.clone(),
669                ));
670            }
671            None => existing.push(digest.clone()),
672        }
673    }
674    existing.sort_unstable();
675
676    Ok(())
677}
678
679#[derive(thiserror::Error, Debug)]
680pub enum HashStrategyError {
681    #[error(transparent)]
682    Hash(#[from] HashError),
683    #[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")]
684    ConflictingArchiveUrlHashes(String, HashDigest, HashDigest),
685    #[error(
686        "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}"
687    )]
688    UnpinnedRequirement(String, HashCheckingMode),
689    #[error(
690        "`{1}` hashes are insecure and cannot be used with `{2}` but no other hashes are available for: {0}"
691    )]
692    InsecureHashAlgorithm(String, HashAlgorithm, HashCheckingMode),
693    #[error("In `{1}` mode, all requirements must have a hash, but none were provided for: {0}")]
694    MissingHashes(String, HashCheckingMode),
695    #[error(
696        "In `{1}` mode, all requirements must have a hash, but there were no overlapping hashes between the requirements and constraints for: {0}"
697    )]
698    NoIntersection(String, HashCheckingMode),
699}
700
701#[cfg(test)]
702mod tests {
703    use std::path::Path;
704    use std::slice;
705    use std::str::FromStr;
706    use std::sync::Arc;
707
708    use rustc_hash::FxHashMap;
709    use uv_configuration::HashCheckingMode;
710    use uv_distribution_filename::DistExtension;
711    use uv_distribution_types::{
712        ArchiveHashPolicy, HashCollection, HashValidation, MetadataHashPolicy, Requirement,
713        RequirementSource, UnresolvedRequirement, VersionId,
714    };
715    use uv_normalize::PackageName;
716    use uv_pep440::Version;
717    use uv_pypi_types::HashDigest;
718    use uv_redacted::DisplaySafeUrl;
719
720    use super::{HashStrategy, HashVerification};
721
722    fn requirement(url: &str) -> Requirement {
723        Requirement {
724            name: "anyio".parse().unwrap(),
725            extras: Box::default(),
726            groups: Box::default(),
727            marker: "python_version >= '3.8'".parse().unwrap(),
728            source: RequirementSource::Url {
729                location: "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl"
730                    .parse()
731                    .unwrap(),
732                subdirectory: None,
733                ext: DistExtension::Wheel,
734                url: url.parse().unwrap(),
735            },
736            origin: None,
737        }
738    }
739
740    #[test]
741    fn from_requirements_merges_direct_url_hashes_across_fragments() {
742        let first = UnresolvedRequirement::Named(requirement(
743            "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=CFDB2B588B9FC25EDE96D8DB56ED50848B0B649DCA3DD1DF0B11F683BB9E0B5F",
744        ));
745        let second = UnresolvedRequirement::Named(requirement(
746            "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
747        ));
748
749        let hasher = HashStrategy::from_requirements(
750            [(&first, &[][..]), (&second, &[][..])].into_iter(),
751            std::iter::empty(),
752            None,
753            HashCheckingMode::Require,
754        )
755        .unwrap();
756
757        let mut expected = vec![
758            HashDigest::from_str(
759                "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
760            )
761            .unwrap(),
762            HashDigest::from_str(
763                "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
764            )
765            .unwrap(),
766        ];
767        expected.sort_unstable();
768
769        for requirement in [&first, &second] {
770            let UnresolvedRequirement::Named(requirement) = requirement else {
771                panic!("expected named requirement");
772            };
773            let RequirementSource::Url { url, .. } = &requirement.source else {
774                panic!("expected direct URL requirement");
775            };
776            assert_eq!(
777                hasher.archive_policy_for_url(url),
778                ArchiveHashPolicy::All(expected.as_slice())
779            );
780        }
781    }
782
783    #[test]
784    fn generate_and_verify_validates_known_hashes_and_generates_unknown_hashes()
785    -> Result<(), Box<dyn std::error::Error>> {
786        let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
787        let unknown_url: DisplaySafeUrl = "https://example.com/anyio-4.1.0.tar.gz".parse()?;
788        let name: PackageName = "anyio".parse()?;
789        let version: Version = "4.0.0".parse()?;
790        let unknown_version: Version = "4.1.0".parse()?;
791        let digest = HashDigest::from_str(
792            "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
793        )?;
794        let hashes = FxHashMap::from_iter([
795            (VersionId::from_url(&url), vec![digest.clone()]),
796            (
797                VersionId::from_registry(name.clone(), version.clone()),
798                vec![digest.clone()],
799            ),
800        ]);
801        let strategy = HashStrategy::collect(HashCollection::All)
802            .with_verification(HashVerification::IfPresent(Arc::new(hashes)));
803
804        assert_eq!(
805            strategy.archive_policy_for_url(&url),
806            ArchiveHashPolicy::All(slice::from_ref(&digest))
807        );
808        for fragment in [
809            "#subdirectory=.",
810            "#subdirectory=./",
811            "#subdirectory=",
812            "#subdirectory=nested/..",
813        ] {
814            let root_url = format!("{url}{fragment}").parse()?;
815            assert_eq!(
816                strategy.archive_policy_for_url(&root_url),
817                ArchiveHashPolicy::All(slice::from_ref(&digest))
818            );
819        }
820        assert_eq!(
821            strategy.archive_policy_for_url(&unknown_url),
822            ArchiveHashPolicy::Generate
823        );
824        assert_eq!(
825            strategy.metadata_policy_for_url(&unknown_url),
826            MetadataHashPolicy {
827                collection: HashCollection::All,
828                validation: HashValidation::None,
829            }
830        );
831        assert_eq!(
832            strategy.metadata_policy_for_url(&url),
833            MetadataHashPolicy {
834                collection: HashCollection::All,
835                validation: HashValidation::All(slice::from_ref(&digest)),
836            }
837        );
838        assert_eq!(
839            strategy.archive_policy_for_package(&name, &version),
840            ArchiveHashPolicy::Any(slice::from_ref(&digest))
841        );
842        assert_eq!(
843            strategy.archive_policy_for_package(&name, &unknown_version),
844            ArchiveHashPolicy::Generate
845        );
846
847        Ok(())
848    }
849
850    #[test]
851    fn required_hashes_take_precedence_over_collection() -> Result<(), Box<dyn std::error::Error>> {
852        let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
853        let name: PackageName = "anyio".parse()?;
854        let version: Version = "4.0.0".parse()?;
855        let strategy = HashStrategy::collect(HashCollection::All)
856            .with_verification(HashVerification::Required(Arc::default()));
857
858        assert_eq!(
859            strategy.archive_policy_for_url(&url),
860            ArchiveHashPolicy::All(&[])
861        );
862        assert_eq!(
863            strategy.metadata_policy_for_url(&url),
864            MetadataHashPolicy {
865                collection: HashCollection::All,
866                validation: HashValidation::All(&[]),
867            }
868        );
869        assert_eq!(
870            strategy.archive_policy_for_package(&name, &version),
871            ArchiveHashPolicy::Any(&[])
872        );
873        assert!(!strategy.allows_url(&url));
874        assert!(!strategy.allows_package(&name, &version));
875
876        let digest = HashDigest::from_str(
877            "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
878        )?;
879        let constraints = HashStrategy::verify(Arc::new(FxHashMap::from_iter([
880            (VersionId::from_url(&url), vec![digest.clone()]),
881            (
882                VersionId::from_registry(name.clone(), version.clone()),
883                vec![HashDigest::from_str(
884                    "md5:420d85e19168705cdf0223621b18831a",
885                )?],
886            ),
887        ])));
888        for strategy in [
889            strategy.clone().with_constraint_hashes(&constraints)?,
890            constraints.with_constraint_hashes(&strategy)?,
891        ] {
892            assert_eq!(
893                strategy.archive_policy_for_url(&url),
894                ArchiveHashPolicy::All(slice::from_ref(&digest))
895            );
896            assert_eq!(
897                strategy.archive_policy_for_package(&name, &version),
898                ArchiveHashPolicy::Any(&[])
899            );
900            assert!(!strategy.allows_package(&name, &version));
901        }
902        Ok(())
903    }
904
905    #[test]
906    fn constraint_hashes_combine_with_locked_hashes() -> Result<(), Box<dyn std::error::Error>> {
907        let name: PackageName = "anyio".parse()?;
908        let version: Version = "4.0.0".parse()?;
909        let local_version: Version = "4.0.0+local".parse()?;
910        let digest = HashDigest::from_str(
911            "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
912        )?;
913        let other = HashDigest::from_str(
914            "sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a",
915        )?;
916        let sha512 = HashDigest::from_str(
917            "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
918        )?;
919        let registry = VersionId::from_registry(name.clone(), version);
920        let local_registry = VersionId::from_registry(name, local_version);
921        let archive = VersionId::from_url(&"https://example.com/anyio-4.0.0.tar.gz".parse()?);
922        let path = VersionId::from_path(Path::new("anyio-4.0.0.tar.gz"));
923        for (id, constraint_id, constraint, expected) in [
924            (
925                registry.clone(),
926                registry,
927                digest.clone(),
928                vec![digest.clone()],
929            ),
930            (
931                local_registry,
932                VersionId::from_registry("anyio".parse()?, "4.0.0".parse()?),
933                digest.clone(),
934                vec![digest.clone()],
935            ),
936            (path.clone(), path, digest.clone(), vec![digest.clone()]),
937            (
938                archive.clone(),
939                archive,
940                sha512.clone(),
941                vec![digest.clone(), sha512],
942            ),
943        ] {
944            let locked = HashStrategy::collect(HashCollection::All).with_verification(
945                HashVerification::IfPresent(Arc::new(FxHashMap::from_iter([(
946                    id.clone(),
947                    vec![digest.clone()],
948                )]))),
949            );
950            let constraints = HashStrategy::verify(Arc::new(FxHashMap::from_iter([(
951                constraint_id.clone(),
952                vec![constraint],
953            )])));
954            let combined = locked.clone().with_constraint_hashes(&constraints)?;
955            assert_eq!(combined.collection(), HashCollection::All);
956            assert_eq!(combined.hashes_for_id(&id), Some(expected.as_slice()));
957
958            let conflicting = HashStrategy::verify(Arc::new(FxHashMap::from_iter([(
959                constraint_id,
960                vec![other.clone()],
961            )])));
962            assert!(locked.with_constraint_hashes(&conflicting).is_err());
963        }
964        Ok(())
965    }
966}