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::{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 digests.is_empty() {
188                continue;
189            }
190
191            merge_hashes(&mut constraint_hashes, id, digests, requirement)?;
192        }
193
194        // For each requirement, map from hash identity to allowed hashes.
195        let mut requirement_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
196        for (requirement, digests) in requirements {
197            if !requirement
198                .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
199            {
200                continue;
201            }
202
203            // Every requirement must be either a pinned version or a direct URL.
204            let id = match &requirement {
205                UnresolvedRequirement::Named(requirement) => {
206                    if let Some(id) = Self::pin(requirement) {
207                        id
208                    } else {
209                        if mode.is_require() {
210                            return Err(HashStrategyError::UnpinnedRequirement(
211                                requirement.to_string(),
212                                mode,
213                            ));
214                        }
215                        continue;
216                    }
217                }
218                UnresolvedRequirement::Unnamed(requirement) => {
219                    // Direct URLs are always allowed.
220                    VersionId::from_parsed_url(requirement.url.parsed_url.clone())
221                }
222            };
223
224            // Parse the hashes provided directly on the requirement, then merge in any hashes from
225            // the URL fragment.
226            let mut digests = digests
227                .iter()
228                .map(|digest| HashDigest::from_str(digest))
229                .collect::<Result<Vec<_>, _>>()?;
230            if let Some(fragment_hashes) = requirement.hashes().map(HashDigests::from) {
231                merge_digests(&mut digests, fragment_hashes.iter(), requirement)?;
232            }
233
234            let digests = if let Some(constraint) = constraint_hashes.remove(&id) {
235                if digests.is_empty() {
236                    // If there are _only_ hashes on the constraints, use them.
237                    constraint
238                } else if matches!(id, VersionId::ArchiveUrl { .. }) {
239                    let mut merged = digests;
240                    merge_digests(&mut merged, &constraint, requirement)?;
241                    merged
242                } else {
243                    // If there are constraint and requirement hashes, take the intersection.
244                    let intersection: Vec<_> = digests
245                        .into_iter()
246                        .filter(|digest| constraint.contains(digest))
247                        .collect();
248                    if intersection.is_empty() {
249                        return Err(HashStrategyError::NoIntersection(
250                            requirement.to_string(),
251                            mode,
252                        ));
253                    }
254                    intersection
255                }
256            } else {
257                digests
258            };
259
260            // Under `--require-hashes`, every requirement must include a hash.
261            if digests.is_empty() {
262                if mode.is_require() {
263                    return Err(HashStrategyError::MissingHashes(
264                        requirement.to_string(),
265                        mode,
266                    ));
267                }
268                continue;
269            }
270
271            merge_hashes(&mut requirement_hashes, id, digests, requirement)?;
272        }
273
274        // Merge the hashes, preferring requirements over constraints, since overlapping
275        // requirements were already merged.
276        let hashes: FxHashMap<VersionId, Vec<HashDigest>> = constraint_hashes
277            .into_iter()
278            .chain(requirement_hashes)
279            .collect();
280        match mode {
281            HashCheckingMode::Verify => Ok(Self::Verify(Arc::new(hashes))),
282            HashCheckingMode::Require => Ok(Self::Require(Arc::new(hashes))),
283        }
284    }
285
286    /// Generate the required hashes from a [`Resolution`].
287    pub fn from_resolution(
288        resolution: &Resolution,
289        mode: HashCheckingMode,
290    ) -> Result<Self, HashStrategyError> {
291        let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
292
293        for (dist, digests) in resolution.hashes() {
294            if digests.is_empty() {
295                // Under `--require-hashes`, every requirement must include a hash.
296                if mode.is_require() {
297                    return Err(HashStrategyError::MissingHashes(
298                        dist.name().to_string(),
299                        mode,
300                    ));
301                }
302                continue;
303            }
304            hashes.insert(dist.version_id(), digests.to_vec());
305        }
306
307        match mode {
308            HashCheckingMode::Verify => Ok(Self::Verify(Arc::new(hashes))),
309            HashCheckingMode::Require => Ok(Self::Require(Arc::new(hashes))),
310        }
311    }
312
313    /// Augment an existing set of hashes with archive URL hashes discovered in additional
314    /// requirements.
315    ///
316    /// Archive URL requirements are keyed by a [`VersionId`] so that requirements that refer to
317    /// the same underlying archive but differ only in hash fragments are merged onto the same
318    /// digest set.
319    ///
320    /// Returns `Ok(None)` if no new hashes were added or updated.
321    fn augment_hashes<'a>(
322        existing: &FxHashMap<VersionId, Vec<HashDigest>>,
323        requirements: impl Iterator<Item = &'a Requirement>,
324    ) -> Result<Option<FxHashMap<VersionId, Vec<HashDigest>>>, HashStrategyError> {
325        let mut hashes = None;
326
327        for requirement in requirements {
328            let Some((id, digests)) = Self::requirement_hashes(requirement) else {
329                continue;
330            };
331            let current = hashes.as_ref().unwrap_or(existing);
332            let current_digests = current.get(&id);
333            let mut merged = current_digests.cloned().unwrap_or_default();
334            merge_digests(&mut merged, &digests, requirement)?;
335
336            if current_digests.map(Vec::as_slice) == Some(merged.as_slice()) {
337                continue;
338            }
339
340            hashes
341                .get_or_insert_with(|| existing.clone())
342                .insert(id, merged);
343        }
344
345        Ok(hashes)
346    }
347
348    /// Extract the archive URL hash target and digests for a requirement, if any.
349    fn requirement_hashes(requirement: &Requirement) -> Option<(VersionId, Vec<HashDigest>)> {
350        let mut digests = HashDigests::from(requirement.hashes()?).to_vec();
351        if digests.is_empty() {
352            return None;
353        }
354        digests.sort_unstable();
355        let id = Self::pin(requirement)?;
356        Some((id, digests))
357    }
358
359    /// Pin a [`Requirement`] to a [`VersionId`], if possible.
360    fn pin(requirement: &Requirement) -> Option<VersionId> {
361        match &requirement.source {
362            RequirementSource::Registry { specifier, .. } => {
363                // Must be a single specifier.
364                let [specifier] = specifier.as_ref() else {
365                    return None;
366                };
367
368                // Must be pinned to a specific version.
369                if *specifier.operator() != uv_pep440::Operator::Equal {
370                    return None;
371                }
372
373                Some(VersionId::from_registry(
374                    requirement.name.clone(),
375                    specifier.version().clone(),
376                ))
377            }
378            RequirementSource::Url {
379                location,
380                subdirectory,
381                ..
382            } => Some(VersionId::from_archive(
383                location.clone(),
384                subdirectory.clone().map(Path::into_path_buf),
385            )),
386            RequirementSource::GitDirectory {
387                git, subdirectory, ..
388            } => Some(VersionId::from_git(git, subdirectory.as_deref())),
389            RequirementSource::GitPath {
390                git, install_path, ..
391            } => Some(VersionId::from_git(git, Some(install_path))),
392            RequirementSource::Path { install_path, .. } => {
393                Some(VersionId::from_path(install_path))
394            }
395            RequirementSource::Directory { install_path, .. } => {
396                Some(VersionId::from_directory(install_path))
397            }
398        }
399    }
400}
401
402fn hash_policy<'a>(id: &VersionId, digests: &'a [HashDigest]) -> HashPolicy<'a> {
403    match id {
404        VersionId::NameVersion { .. } => HashPolicy::Any(digests),
405        VersionId::ArchiveUrl { .. }
406        | VersionId::Git { .. }
407        | VersionId::Path { .. }
408        | VersionId::Directory { .. }
409        | VersionId::Unknown { .. } => HashPolicy::All(digests),
410    }
411}
412
413/// Merge repeated hashes for a requirement or constraint into the hash map.
414fn merge_hashes(
415    hashes: &mut FxHashMap<VersionId, Vec<HashDigest>>,
416    id: VersionId,
417    incoming: Vec<HashDigest>,
418    requirement: impl Display,
419) -> Result<(), HashStrategyError> {
420    if incoming.is_empty() {
421        return Ok(());
422    }
423
424    if !matches!(&id, VersionId::ArchiveUrl { .. }) {
425        hashes.insert(id, incoming);
426        return Ok(());
427    }
428
429    if let Some(existing) = hashes.get_mut(&id) {
430        return merge_digests(existing, &incoming, requirement);
431    }
432
433    let mut merged = Vec::new();
434    merge_digests(&mut merged, &incoming, requirement)?;
435    hashes.insert(id, merged);
436    Ok(())
437}
438
439/// Merge `incoming` digests into `existing`.
440///
441/// Exact duplicates are ignored. Digests for different algorithms are accumulated. If the
442/// same algorithm appears with two different values, returns
443/// [`HashStrategyError::ConflictingArchiveUrlHashes`].
444fn merge_digests<'a>(
445    existing: &mut Vec<HashDigest>,
446    incoming: impl IntoIterator<Item = &'a HashDigest>,
447    requirement: impl Display,
448) -> Result<(), HashStrategyError> {
449    for digest in incoming {
450        match existing
451            .iter()
452            .find(|candidate| candidate.algorithm == digest.algorithm)
453        {
454            Some(candidate) if candidate == digest => {}
455            Some(conflict) => {
456                return Err(HashStrategyError::ConflictingArchiveUrlHashes(
457                    requirement.to_string(),
458                    conflict.clone(),
459                    digest.clone(),
460                ));
461            }
462            None => existing.push(digest.clone()),
463        }
464    }
465    existing.sort_unstable();
466
467    Ok(())
468}
469
470#[derive(thiserror::Error, Debug)]
471pub enum HashStrategyError {
472    #[error(transparent)]
473    Hash(#[from] HashError),
474    #[error("Conflicting archive URL hashes for `{0}`: `{1}` conflicts with `{2}`")]
475    ConflictingArchiveUrlHashes(String, HashDigest, HashDigest),
476    #[error(
477        "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}"
478    )]
479    UnpinnedRequirement(String, HashCheckingMode),
480    #[error("In `{1}` mode, all requirements must have a hash, but none were provided for: {0}")]
481    MissingHashes(String, HashCheckingMode),
482    #[error(
483        "In `{1}` mode, all requirements must have a hash, but there were no overlapping hashes between the requirements and constraints for: {0}"
484    )]
485    NoIntersection(String, HashCheckingMode),
486}
487
488#[cfg(test)]
489mod tests {
490    use std::str::FromStr;
491    use uv_configuration::HashCheckingMode;
492    use uv_distribution_filename::DistExtension;
493    use uv_distribution_types::{
494        HashPolicy, Requirement, RequirementSource, UnresolvedRequirement,
495    };
496    use uv_pypi_types::HashDigest;
497
498    use super::HashStrategy;
499
500    fn requirement(url: &str) -> Requirement {
501        Requirement {
502            name: "anyio".parse().unwrap(),
503            extras: Box::default(),
504            groups: Box::default(),
505            marker: "python_version >= '3.8'".parse().unwrap(),
506            source: RequirementSource::Url {
507                location: "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl"
508                    .parse()
509                    .unwrap(),
510                subdirectory: None,
511                ext: DistExtension::Wheel,
512                url: url.parse().unwrap(),
513            },
514            origin: None,
515        }
516    }
517
518    #[test]
519    fn from_requirements_merges_direct_url_hashes_across_fragments() {
520        let first = UnresolvedRequirement::Named(requirement(
521            "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
522        ));
523        let second = UnresolvedRequirement::Named(requirement(
524            "https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
525        ));
526
527        let hasher = HashStrategy::from_requirements(
528            [(&first, &[][..]), (&second, &[][..])].into_iter(),
529            std::iter::empty(),
530            None,
531            HashCheckingMode::Require,
532        )
533        .unwrap();
534
535        let mut expected = vec![
536            HashDigest::from_str(
537                "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
538            )
539            .unwrap(),
540            HashDigest::from_str(
541                "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
542            )
543            .unwrap(),
544        ];
545        expected.sort_unstable();
546
547        for requirement in [&first, &second] {
548            let UnresolvedRequirement::Named(requirement) = requirement else {
549                panic!("expected named requirement");
550            };
551            let RequirementSource::Url { url, .. } = &requirement.source else {
552                panic!("expected direct URL requirement");
553            };
554            assert_eq!(hasher.get_url(url), HashPolicy::All(expected.as_slice()));
555        }
556    }
557}