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