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