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