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::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    /// Return the hash collection policy.
67    pub fn collection(&self) -> HashCollection {
68        self.collection
69    }
70
71    /// Return the hash verification policy.
72    pub fn verification(&self) -> &HashVerification {
73        &self.verification
74    }
75
76    /// Return the [`ArchiveHashPolicy`] for the given distribution.
77    pub fn archive_policy<T: DistributionMetadata>(
78        &self,
79        distribution: &T,
80    ) -> ArchiveHashPolicy<'_> {
81        self.archive_policy_for_id(|| distribution.version_id())
82    }
83
84    /// Return the [`MetadataHashPolicy`] for retrieving the given distribution's metadata.
85    pub fn metadata_policy<T: DistributionMetadata>(
86        &self,
87        distribution: &T,
88    ) -> MetadataHashPolicy<'_> {
89        MetadataHashPolicy {
90            collection: self.collection,
91            validation: self.validation_for_id(|| distribution.version_id()),
92        }
93    }
94
95    /// Return the [`ArchiveHashPolicy`] for the given registry-based package.
96    pub fn archive_policy_for_package(
97        &self,
98        name: &PackageName,
99        version: &Version,
100    ) -> ArchiveHashPolicy<'_> {
101        self.archive_policy_for_id(|| VersionId::from_registry(name.clone(), version.clone()))
102    }
103
104    /// Return the [`ArchiveHashPolicy`] for the given direct URL package.
105    ///
106    /// A direct URL identifies a single concrete artifact, so every provided digest must match.
107    pub fn archive_policy_for_url(&self, url: &DisplaySafeUrl) -> ArchiveHashPolicy<'_> {
108        self.archive_policy_for_id(|| VersionId::from_url(url))
109    }
110
111    /// Return the [`MetadataHashPolicy`] for a URL whose package name is not yet known.
112    pub fn metadata_policy_for_url(&self, url: &DisplaySafeUrl) -> MetadataHashPolicy<'_> {
113        MetadataHashPolicy {
114            collection: self.collection,
115            validation: self.validation_for_id(|| VersionId::from_url(url)),
116        }
117    }
118
119    /// Return the archive hash policy for a distribution identity.
120    fn archive_policy_for_id(&self, id: impl FnOnce() -> VersionId) -> ArchiveHashPolicy<'_> {
121        let validation = self.validation_for_id(id);
122        match validation {
123            HashValidation::None => match self.collection {
124                HashCollection::None => ArchiveHashPolicy::None,
125                HashCollection::Url | HashCollection::All => ArchiveHashPolicy::Generate,
126            },
127            HashValidation::Any(_) | HashValidation::All(_) => validation.into(),
128        }
129    }
130
131    /// Construct an identity only when verification requires a lookup.
132    fn validation_for_id(&self, id: impl FnOnce() -> VersionId) -> HashValidation<'_> {
133        match &self.verification {
134            HashVerification::IfPresent(hashes) => {
135                let id = id();
136                if let Some(hashes) = hashes.get(&id) {
137                    return hash_validation(&id, hashes);
138                }
139                // `==1.0.0` can also select `1.0.0+local`. If the local version has no hash
140                // of its own, check it against the hash for `1.0.0`.
141                if let VersionId::NameVersion(name, version) = &id
142                    && version.is_local()
143                    && let Some(hashes) = hashes.get(&VersionId::from_registry(
144                        name.clone(),
145                        version.clone().without_local(),
146                    ))
147                {
148                    return HashValidation::Any(hashes);
149                }
150            }
151            HashVerification::Required(hashes) => {
152                let id = id();
153                return hash_validation(
154                    &id,
155                    hashes.get(&id).map(Vec::as_slice).unwrap_or_default(),
156                );
157            }
158            HashVerification::None => {}
159        }
160        HashValidation::None
161    }
162
163    /// Returns `true` if the given registry-based package is allowed.
164    pub fn allows_package(&self, name: &PackageName, version: &Version) -> bool {
165        match &self.verification {
166            HashVerification::Required(hashes) => {
167                hashes.contains_key(&VersionId::from_registry(name.clone(), version.clone()))
168            }
169            HashVerification::None | HashVerification::IfPresent(_) => true,
170        }
171    }
172
173    /// Returns `true` if the given direct URL package is allowed.
174    pub fn allows_url(&self, url: &DisplaySafeUrl) -> bool {
175        match &self.verification {
176            HashVerification::Required(hashes) => hashes.contains_key(&VersionId::from_url(url)),
177            HashVerification::None | HashVerification::IfPresent(_) => true,
178        }
179    }
180
181    /// Return a [`HashStrategy`] augmented with archive URL hashes discovered in additional
182    /// requirements after the initial command-line parse.
183    pub fn augment_with_requirements<'a>(
184        mut self,
185        requirements: impl Iterator<Item = &'a Requirement>,
186    ) -> Result<Self, HashStrategyError> {
187        match &mut self.verification {
188            HashVerification::None => {}
189            HashVerification::IfPresent(existing) | HashVerification::Required(existing) => {
190                if let Some(hashes) = Self::augment_hashes(existing, requirements)? {
191                    *existing = Arc::new(hashes);
192                }
193            }
194        }
195        Ok(self)
196    }
197
198    /// Return a [`HashStrategy`] augmented with archive URL hashes discovered in distribution
199    /// metadata.
200    ///
201    /// Required-hash verification is intentionally a closed set. In that mode, distribution
202    /// untrusted metadata cannot authorize a requirement that was absent from the input hash set.
203    /// Explicit requirements, such as `build-system.requires` and user-provided metadata, can still
204    /// contribute hashes via [`Self::augment_with_requirements`].
205    pub fn augment_with_metadata_requirements<'a>(
206        self,
207        requirements: impl Iterator<Item = &'a Requirement>,
208    ) -> Result<Self, HashStrategyError> {
209        if matches!(&self.verification, HashVerification::Required(_)) {
210            return Ok(self);
211        }
212        self.augment_with_requirements(requirements)
213    }
214
215    /// Read the required hashes from a set of [`UnresolvedRequirement`] entries.
216    ///
217    /// When the environment is not given, this treats all marker expressions
218    /// that reference the environment as true. In other words, it does
219    /// environment independent expression evaluation. (Which in turn devolves
220    /// to "only evaluate marker expressions that reference an extra name.")
221    pub fn from_requirements<'a>(
222        requirements: impl Iterator<Item = (&'a UnresolvedRequirement, &'a [String])>,
223        constraints: impl Iterator<Item = (&'a Requirement, &'a [String])>,
224        marker_env: Option<&ResolverMarkerEnvironment>,
225        mode: HashCheckingMode,
226    ) -> Result<Self, HashStrategyError> {
227        let mut constraint_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
228
229        // First, index the constraints by name.
230        for (requirement, digests) in constraints {
231            if !requirement
232                .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
233            {
234                continue;
235            }
236
237            // Every constraint must be a pinned version.
238            let Some(id) = Self::pin(requirement) else {
239                if mode.is_require() {
240                    return Err(HashStrategyError::UnpinnedRequirement(
241                        requirement.to_string(),
242                        mode,
243                    ));
244                }
245                continue;
246            };
247
248            // Parse the hashes provided directly on the requirement, then merge in any hashes from
249            // the URL fragment.
250            let mut digests = digests
251                .iter()
252                .map(|digest| HashDigest::from_str(digest))
253                .collect::<Result<Vec<_>, _>>()?;
254            if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
255                merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
256            }
257
258            if mode.is_require() {
259                digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
260            }
261
262            if digests.is_empty() {
263                continue;
264            }
265
266            merge_hashes(&mut constraint_hashes, id, digests, requirement)?;
267        }
268
269        // For each requirement, map from hash identity to allowed hashes.
270        let mut requirement_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
271        for (requirement, digests) in requirements {
272            if !requirement
273                .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
274            {
275                continue;
276            }
277
278            // Every requirement must be either a pinned version or a direct URL.
279            let id = match &requirement {
280                UnresolvedRequirement::Named(requirement) => {
281                    if let Some(id) = Self::pin(requirement) {
282                        id
283                    } else {
284                        if mode.is_require() {
285                            return Err(HashStrategyError::UnpinnedRequirement(
286                                requirement.to_string(),
287                                mode,
288                            ));
289                        }
290                        continue;
291                    }
292                }
293                UnresolvedRequirement::Unnamed(requirement) => {
294                    // Direct URLs are always allowed.
295                    VersionId::from_parsed_url(requirement.url.parsed_url.clone())
296                }
297            };
298
299            // Parse the hashes provided directly on the requirement, then merge in any hashes from
300            // the URL fragment.
301            let mut digests = digests
302                .iter()
303                .map(|digest| HashDigest::from_str(digest))
304                .collect::<Result<Vec<_>, _>>()?;
305            if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
306                merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
307            }
308
309            let has_md5 = mode.is_require()
310                && digests
311                    .iter()
312                    .any(|digest| digest.algorithm() == HashAlgorithm::Md5);
313            if mode.is_require() {
314                digests.retain(|digest| digest.algorithm() != HashAlgorithm::Md5);
315            }
316
317            let digests = if let Some(constraint) = constraint_hashes.remove(&id) {
318                if digests.is_empty() {
319                    // If there are _only_ hashes on the constraints, use them.
320                    constraint
321                } else if matches!(id, VersionId::ArchiveUrl { .. }) {
322                    let mut merged = digests;
323                    merge_digests(&mut merged, &constraint, requirement)?;
324                    merged
325                } else {
326                    // If there are constraint and requirement hashes, take the intersection.
327                    let intersection: Vec<_> = digests
328                        .into_iter()
329                        .filter(|digest| constraint.contains(digest))
330                        .collect();
331                    if intersection.is_empty() {
332                        return Err(HashStrategyError::NoIntersection(
333                            requirement.to_string(),
334                            mode,
335                        ));
336                    }
337                    intersection
338                }
339            } else {
340                digests
341            };
342
343            // Under `--require-hashes`, every requirement must include a hash.
344            if digests.is_empty() {
345                if mode.is_require() {
346                    if has_md5 {
347                        return Err(HashStrategyError::InsecureHashAlgorithm(
348                            requirement.to_string(),
349                            HashAlgorithm::Md5,
350                            mode,
351                        ));
352                    }
353                    return Err(HashStrategyError::MissingHashes(
354                        requirement.to_string(),
355                        mode,
356                    ));
357                }
358                continue;
359            }
360
361            merge_hashes(&mut requirement_hashes, id, digests, requirement)?;
362        }
363
364        // Merge the hashes, preferring requirements over constraints, since overlapping
365        // requirements were already merged.
366        let hashes: FxHashMap<VersionId, Vec<HashDigest>> = constraint_hashes
367            .into_iter()
368            .chain(requirement_hashes)
369            .collect();
370        match mode {
371            HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
372            HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
373        }
374    }
375
376    /// Read the required hashes from a [`Resolution`].
377    pub fn from_resolution(
378        resolution: &Resolution,
379        mode: HashCheckingMode,
380    ) -> Result<Self, HashStrategyError> {
381        let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
382
383        for (dist, digests) in resolution.hashes() {
384            if digests.is_empty() {
385                // Under `--require-hashes`, every requirement must include a hash.
386                if mode.is_require() {
387                    return Err(HashStrategyError::MissingHashes(
388                        dist.name().to_string(),
389                        mode,
390                    ));
391                }
392                continue;
393            }
394            hashes.insert(dist.version_id(), digests.to_vec());
395        }
396
397        match mode {
398            HashCheckingMode::Verify => Ok(Self::verify(Arc::new(hashes))),
399            HashCheckingMode::Require => Ok(Self::require(Arc::new(hashes))),
400        }
401    }
402
403    /// Augment an existing set of hashes with archive URL hashes discovered in additional
404    /// requirements.
405    ///
406    /// Archive URL requirements are keyed by a [`VersionId`] so that requirements that refer to
407    /// the same underlying archive but differ only in hash fragments are merged onto the same
408    /// digest set.
409    ///
410    /// Returns `Ok(None)` if no new hashes were added or updated.
411    fn augment_hashes<'a>(
412        existing: &FxHashMap<VersionId, Vec<HashDigest>>,
413        requirements: impl Iterator<Item = &'a Requirement>,
414    ) -> Result<Option<FxHashMap<VersionId, Vec<HashDigest>>>, HashStrategyError> {
415        let mut hashes = None;
416
417        for requirement in requirements {
418            let Some((id, digests)) = Self::requirement_hashes(requirement) else {
419                continue;
420            };
421            let current = hashes.as_ref().unwrap_or(existing);
422            let current_digests = current.get(&id);
423            let mut merged = current_digests.cloned().unwrap_or_default();
424            merge_digests(&mut merged, &digests, requirement)?;
425
426            if current_digests.map(Vec::as_slice) == Some(merged.as_slice()) {
427                continue;
428            }
429
430            hashes
431                .get_or_insert_with(|| existing.clone())
432                .insert(id, merged);
433        }
434
435        Ok(hashes)
436    }
437
438    /// Extract the archive URL hash target and digests for a requirement, if any.
439    fn requirement_hashes(requirement: &Requirement) -> Option<(VersionId, Vec<HashDigest>)> {
440        let mut digests = HashDigests::from(requirement.hashes()?).to_vec();
441        if digests.is_empty() {
442            return None;
443        }
444        digests.sort_unstable();
445        let id = Self::pin(requirement)?;
446        Some((id, digests))
447    }
448
449    /// Pin a [`Requirement`] to a [`VersionId`], if possible.
450    fn pin(requirement: &Requirement) -> Option<VersionId> {
451        match &requirement.source {
452            RequirementSource::Registry { specifier, .. } => {
453                // Must be a single specifier.
454                let [specifier] = specifier.as_ref() else {
455                    return None;
456                };
457
458                // Must be pinned to a specific version.
459                let is_pinned =
460                    matches!(specifier.operator(), Operator::Equal | Operator::ExactEqual);
461                if !is_pinned {
462                    return None;
463                }
464
465                Some(VersionId::from_registry(
466                    requirement.name.clone(),
467                    specifier.version().clone(),
468                ))
469            }
470            RequirementSource::Url {
471                location,
472                subdirectory,
473                ..
474            } => Some(VersionId::from_archive(
475                location.clone(),
476                subdirectory.clone().map(Path::into_path_buf),
477            )),
478            RequirementSource::GitDirectory {
479                git, subdirectory, ..
480            } => Some(VersionId::from_git(git, subdirectory.as_deref())),
481            RequirementSource::GitPath {
482                git, install_path, ..
483            } => Some(VersionId::from_git(git, Some(install_path))),
484            RequirementSource::Path { install_path, .. } => {
485                Some(VersionId::from_path(install_path))
486            }
487            RequirementSource::Directory { install_path, .. } => {
488                Some(VersionId::from_directory(install_path))
489            }
490        }
491    }
492}
493
494fn hash_validation<'a>(id: &VersionId, digests: &'a [HashDigest]) -> HashValidation<'a> {
495    match id {
496        VersionId::NameVersion { .. } => HashValidation::Any(digests),
497        VersionId::ArchiveUrl { .. }
498        | VersionId::Git { .. }
499        | VersionId::Path { .. }
500        | VersionId::Directory { .. }
501        | VersionId::Unknown { .. } => HashValidation::All(digests),
502    }
503}
504
505/// Merge repeated hashes for a requirement or constraint into the hash map.
506fn merge_hashes(
507    hashes: &mut FxHashMap<VersionId, Vec<HashDigest>>,
508    id: VersionId,
509    incoming: Vec<HashDigest>,
510    requirement: impl Display,
511) -> Result<(), HashStrategyError> {
512    if incoming.is_empty() {
513        return Ok(());
514    }
515
516    if !matches!(&id, VersionId::ArchiveUrl { .. }) {
517        hashes.insert(id, incoming);
518        return Ok(());
519    }
520
521    if let Some(existing) = hashes.get_mut(&id) {
522        return merge_digests(existing, &incoming, requirement);
523    }
524
525    let mut merged = Vec::new();
526    merge_digests(&mut merged, &incoming, requirement)?;
527    hashes.insert(id, merged);
528    Ok(())
529}
530
531/// Merge `incoming` digests into `existing`.
532///
533/// Exact duplicates are ignored. Digests for different algorithms are accumulated. If the
534/// same algorithm appears with two different values, returns
535/// [`HashStrategyError::ConflictingArchiveUrlHashes`].
536fn merge_digests<'a>(
537    existing: &mut Vec<HashDigest>,
538    incoming: impl IntoIterator<Item = &'a HashDigest>,
539    requirement: impl Display,
540) -> Result<(), HashStrategyError> {
541    for digest in incoming {
542        match existing
543            .iter()
544            .find(|candidate| candidate.algorithm == digest.algorithm)
545        {
546            Some(candidate) if candidate == digest => {}
547            Some(conflict) => {
548                return Err(HashStrategyError::ConflictingArchiveUrlHashes(
549                    requirement.to_string(),
550                    conflict.clone(),
551                    digest.clone(),
552                ));
553            }
554            None => existing.push(digest.clone()),
555        }
556    }
557    existing.sort_unstable();
558
559    Ok(())
560}
561
562#[derive(thiserror::Error, Debug)]
563pub enum HashStrategyError {
564    #[error(transparent)]
565    Hash(#[from] HashError),
566    #[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")]
567    ConflictingArchiveUrlHashes(String, HashDigest, HashDigest),
568    #[error(
569        "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}"
570    )]
571    UnpinnedRequirement(String, HashCheckingMode),
572    #[error(
573        "`{1}` hashes are insecure and cannot be used with `{2}` but no other hashes are available for: {0}"
574    )]
575    InsecureHashAlgorithm(String, HashAlgorithm, HashCheckingMode),
576    #[error("In `{1}` mode, all requirements must have a hash, but none were provided for: {0}")]
577    MissingHashes(String, HashCheckingMode),
578    #[error(
579        "In `{1}` mode, all requirements must have a hash, but there were no overlapping hashes between the requirements and constraints for: {0}"
580    )]
581    NoIntersection(String, HashCheckingMode),
582}
583
584#[cfg(test)]
585mod tests {
586    use std::slice;
587    use std::str::FromStr;
588    use std::sync::Arc;
589
590    use rustc_hash::FxHashMap;
591    use uv_configuration::HashCheckingMode;
592    use uv_distribution_filename::DistExtension;
593    use uv_distribution_types::{
594        ArchiveHashPolicy, HashCollection, HashValidation, MetadataHashPolicy, Requirement,
595        RequirementSource, UnresolvedRequirement, VersionId,
596    };
597    use uv_normalize::PackageName;
598    use uv_pep440::Version;
599    use uv_pypi_types::HashDigest;
600    use uv_redacted::DisplaySafeUrl;
601
602    use super::{HashStrategy, HashVerification};
603
604    fn requirement(url: &str) -> Requirement {
605        Requirement {
606            name: "anyio".parse().unwrap(),
607            extras: Box::default(),
608            groups: Box::default(),
609            marker: "python_version >= '3.8'".parse().unwrap(),
610            source: RequirementSource::Url {
611                location: "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl"
612                    .parse()
613                    .unwrap(),
614                subdirectory: None,
615                ext: DistExtension::Wheel,
616                url: url.parse().unwrap(),
617            },
618            origin: None,
619        }
620    }
621
622    #[test]
623    fn from_requirements_merges_direct_url_hashes_across_fragments() {
624        let first = UnresolvedRequirement::Named(requirement(
625            "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
626        ));
627        let second = UnresolvedRequirement::Named(requirement(
628            "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
629        ));
630
631        let hasher = HashStrategy::from_requirements(
632            [(&first, &[][..]), (&second, &[][..])].into_iter(),
633            std::iter::empty(),
634            None,
635            HashCheckingMode::Require,
636        )
637        .unwrap();
638
639        let mut expected = vec![
640            HashDigest::from_str(
641                "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
642            )
643            .unwrap(),
644            HashDigest::from_str(
645                "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
646            )
647            .unwrap(),
648        ];
649        expected.sort_unstable();
650
651        for requirement in [&first, &second] {
652            let UnresolvedRequirement::Named(requirement) = requirement else {
653                panic!("expected named requirement");
654            };
655            let RequirementSource::Url { url, .. } = &requirement.source else {
656                panic!("expected direct URL requirement");
657            };
658            assert_eq!(
659                hasher.archive_policy_for_url(url),
660                ArchiveHashPolicy::All(expected.as_slice())
661            );
662        }
663    }
664
665    #[test]
666    fn generate_and_verify_validates_known_hashes_and_generates_unknown_hashes()
667    -> Result<(), Box<dyn std::error::Error>> {
668        let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
669        let unknown_url: DisplaySafeUrl = "https://example.com/anyio-4.1.0.tar.gz".parse()?;
670        let name: PackageName = "anyio".parse()?;
671        let version: Version = "4.0.0".parse()?;
672        let unknown_version: Version = "4.1.0".parse()?;
673        let digest = HashDigest::from_str(
674            "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
675        )?;
676        let hashes = FxHashMap::from_iter([
677            (VersionId::from_url(&url), vec![digest.clone()]),
678            (
679                VersionId::from_registry(name.clone(), version.clone()),
680                vec![digest.clone()],
681            ),
682        ]);
683        let strategy = HashStrategy::collect(HashCollection::All)
684            .with_verification(HashVerification::IfPresent(Arc::new(hashes)));
685
686        assert_eq!(
687            strategy.archive_policy_for_url(&url),
688            ArchiveHashPolicy::All(slice::from_ref(&digest))
689        );
690        for fragment in [
691            "#subdirectory=.",
692            "#subdirectory=./",
693            "#subdirectory=",
694            "#subdirectory=nested/..",
695        ] {
696            let root_url = format!("{url}{fragment}").parse()?;
697            assert_eq!(
698                strategy.archive_policy_for_url(&root_url),
699                ArchiveHashPolicy::All(slice::from_ref(&digest))
700            );
701        }
702        assert_eq!(
703            strategy.archive_policy_for_url(&unknown_url),
704            ArchiveHashPolicy::Generate
705        );
706        assert_eq!(
707            strategy.metadata_policy_for_url(&unknown_url),
708            MetadataHashPolicy {
709                collection: HashCollection::All,
710                validation: HashValidation::None,
711            }
712        );
713        assert_eq!(
714            strategy.metadata_policy_for_url(&url),
715            MetadataHashPolicy {
716                collection: HashCollection::All,
717                validation: HashValidation::All(slice::from_ref(&digest)),
718            }
719        );
720        assert_eq!(
721            strategy.archive_policy_for_package(&name, &version),
722            ArchiveHashPolicy::Any(slice::from_ref(&digest))
723        );
724        assert_eq!(
725            strategy.archive_policy_for_package(&name, &unknown_version),
726            ArchiveHashPolicy::Generate
727        );
728
729        Ok(())
730    }
731
732    #[test]
733    fn required_hashes_take_precedence_over_collection() -> Result<(), Box<dyn std::error::Error>> {
734        let url: DisplaySafeUrl = "https://example.com/anyio-4.0.0.tar.gz".parse()?;
735        let name: PackageName = "anyio".parse()?;
736        let version: Version = "4.0.0".parse()?;
737        let strategy = HashStrategy::collect(HashCollection::All)
738            .with_verification(HashVerification::Required(Arc::default()));
739
740        assert_eq!(
741            strategy.archive_policy_for_url(&url),
742            ArchiveHashPolicy::All(&[])
743        );
744        assert_eq!(
745            strategy.metadata_policy_for_url(&url),
746            MetadataHashPolicy {
747                collection: HashCollection::All,
748                validation: HashValidation::All(&[]),
749            }
750        );
751        assert_eq!(
752            strategy.archive_policy_for_package(&name, &version),
753            ArchiveHashPolicy::Any(&[])
754        );
755        assert!(!strategy.allows_url(&url));
756        assert!(!strategy.allows_package(&name, &version));
757        Ok(())
758    }
759}