uv_types/
hash.rs

1use std::str::FromStr;
2use std::sync::Arc;
3
4use rustc_hash::FxHashMap;
5
6use uv_configuration::HashCheckingMode;
7use uv_distribution_types::{
8    DistributionMetadata, HashGeneration, HashPolicy, Name, Requirement, RequirementSource,
9    Resolution, UnresolvedRequirement, VersionId,
10};
11use uv_normalize::PackageName;
12use uv_pep440::Version;
13use uv_pypi_types::{HashDigest, HashDigests, HashError, ResolverMarkerEnvironment};
14use uv_redacted::DisplaySafeUrl;
15
16#[derive(Debug, Default, Clone)]
17pub enum HashStrategy {
18    /// No hash policy is specified.
19    #[default]
20    None,
21    /// Hashes should be generated (specifically, a SHA-256 hash), but not validated.
22    Generate(HashGeneration),
23    /// Hashes should be validated, if present, but ignored if absent.
24    ///
25    /// If necessary, hashes should be generated to ensure that the archive is valid.
26    Verify(Arc<FxHashMap<VersionId, Vec<HashDigest>>>),
27    /// Hashes should be validated against a pre-defined list of hashes.
28    ///
29    /// If necessary, hashes should be generated to ensure that the archive is valid.
30    Require(Arc<FxHashMap<VersionId, Vec<HashDigest>>>),
31}
32
33impl HashStrategy {
34    /// Return the [`HashPolicy`] for the given distribution.
35    pub fn get<T: DistributionMetadata>(&self, distribution: &T) -> HashPolicy<'_> {
36        match self {
37            Self::None => HashPolicy::None,
38            Self::Generate(mode) => HashPolicy::Generate(*mode),
39            Self::Verify(hashes) => {
40                if let Some(hashes) = hashes.get(&distribution.version_id()) {
41                    HashPolicy::Validate(hashes.as_slice())
42                } else {
43                    HashPolicy::None
44                }
45            }
46            Self::Require(hashes) => HashPolicy::Validate(
47                hashes
48                    .get(&distribution.version_id())
49                    .map(Vec::as_slice)
50                    .unwrap_or_default(),
51            ),
52        }
53    }
54
55    /// Return the [`HashPolicy`] for the given registry-based package.
56    pub fn get_package(&self, name: &PackageName, version: &Version) -> HashPolicy<'_> {
57        match self {
58            Self::None => HashPolicy::None,
59            Self::Generate(mode) => HashPolicy::Generate(*mode),
60            Self::Verify(hashes) => {
61                if let Some(hashes) =
62                    hashes.get(&VersionId::from_registry(name.clone(), version.clone()))
63                {
64                    HashPolicy::Validate(hashes.as_slice())
65                } else {
66                    HashPolicy::None
67                }
68            }
69            Self::Require(hashes) => HashPolicy::Validate(
70                hashes
71                    .get(&VersionId::from_registry(name.clone(), version.clone()))
72                    .map(Vec::as_slice)
73                    .unwrap_or_default(),
74            ),
75        }
76    }
77
78    /// Return the [`HashPolicy`] for the given direct URL package.
79    pub fn get_url(&self, url: &DisplaySafeUrl) -> HashPolicy<'_> {
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(&VersionId::from_url(url)) {
85                    HashPolicy::Validate(hashes.as_slice())
86                } else {
87                    HashPolicy::None
88                }
89            }
90            Self::Require(hashes) => HashPolicy::Validate(
91                hashes
92                    .get(&VersionId::from_url(url))
93                    .map(Vec::as_slice)
94                    .unwrap_or_default(),
95            ),
96        }
97    }
98
99    /// Returns `true` if the given registry-based package is allowed.
100    pub fn allows_package(&self, name: &PackageName, version: &Version) -> bool {
101        match self {
102            Self::None => true,
103            Self::Generate(_) => true,
104            Self::Verify(_) => true,
105            Self::Require(hashes) => {
106                hashes.contains_key(&VersionId::from_registry(name.clone(), version.clone()))
107            }
108        }
109    }
110
111    /// Returns `true` if the given direct URL package is allowed.
112    pub fn allows_url(&self, url: &DisplaySafeUrl) -> bool {
113        match self {
114            Self::None => true,
115            Self::Generate(_) => true,
116            Self::Verify(_) => true,
117            Self::Require(hashes) => hashes.contains_key(&VersionId::from_url(url)),
118        }
119    }
120
121    /// Generate the required hashes from a set of [`UnresolvedRequirement`] entries.
122    ///
123    /// When the environment is not given, this treats all marker expressions
124    /// that reference the environment as true. In other words, it does
125    /// environment independent expression evaluation. (Which in turn devolves
126    /// to "only evaluate marker expressions that reference an extra name.")
127    pub fn from_requirements<'a>(
128        requirements: impl Iterator<Item = (&'a UnresolvedRequirement, &'a [String])>,
129        constraints: impl Iterator<Item = (&'a Requirement, &'a [String])>,
130        marker_env: Option<&ResolverMarkerEnvironment>,
131        mode: HashCheckingMode,
132    ) -> Result<Self, HashStrategyError> {
133        let mut constraint_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
134
135        // First, index the constraints by name.
136        for (requirement, digests) in constraints {
137            if !requirement
138                .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
139            {
140                continue;
141            }
142
143            // Every constraint must be a pinned version.
144            let Some(id) = Self::pin(requirement) else {
145                if mode.is_require() {
146                    return Err(HashStrategyError::UnpinnedRequirement(
147                        requirement.to_string(),
148                        mode,
149                    ));
150                }
151                continue;
152            };
153
154            let digests = if digests.is_empty() {
155                // If there are no hashes, and the distribution is URL-based, attempt to extract
156                // it from the fragment.
157                requirement
158                    .hashes()
159                    .map(HashDigests::from)
160                    .map(|hashes| hashes.to_vec())
161                    .unwrap_or_default()
162            } else {
163                // Parse the hashes.
164                digests
165                    .iter()
166                    .map(|digest| HashDigest::from_str(digest))
167                    .collect::<Result<Vec<_>, _>>()?
168            };
169
170            if digests.is_empty() {
171                continue;
172            }
173
174            constraint_hashes.insert(id, digests);
175        }
176
177        // For each requirement, map from name to allowed hashes. We use the last entry for each
178        // package.
179        let mut requirement_hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
180        for (requirement, digests) in requirements {
181            if !requirement
182                .evaluate_markers(marker_env.map(ResolverMarkerEnvironment::markers), &[])
183            {
184                continue;
185            }
186
187            // Every requirement must be either a pinned version or a direct URL.
188            let id = match &requirement {
189                UnresolvedRequirement::Named(requirement) => {
190                    if let Some(id) = Self::pin(requirement) {
191                        id
192                    } else {
193                        if mode.is_require() {
194                            return Err(HashStrategyError::UnpinnedRequirement(
195                                requirement.to_string(),
196                                mode,
197                            ));
198                        }
199                        continue;
200                    }
201                }
202                UnresolvedRequirement::Unnamed(requirement) => {
203                    // Direct URLs are always allowed.
204                    VersionId::from_url(&requirement.url.verbatim)
205                }
206            };
207
208            let digests = if digests.is_empty() {
209                // If there are no hashes, and the distribution is URL-based, attempt to extract
210                // it from the fragment.
211                requirement
212                    .hashes()
213                    .map(HashDigests::from)
214                    .map(|hashes| hashes.to_vec())
215                    .unwrap_or_default()
216            } else {
217                // Parse the hashes.
218                digests
219                    .iter()
220                    .map(|digest| HashDigest::from_str(digest))
221                    .collect::<Result<Vec<_>, _>>()?
222            };
223
224            let digests = if let Some(constraint) = constraint_hashes.remove(&id) {
225                if digests.is_empty() {
226                    // If there are _only_ hashes on the constraints, use them.
227                    constraint
228                } else {
229                    // If there are constraint and requirement hashes, take the intersection.
230                    let intersection: Vec<_> = digests
231                        .into_iter()
232                        .filter(|digest| constraint.contains(digest))
233                        .collect();
234                    if intersection.is_empty() {
235                        return Err(HashStrategyError::NoIntersection(
236                            requirement.to_string(),
237                            mode,
238                        ));
239                    }
240                    intersection
241                }
242            } else {
243                digests
244            };
245
246            // Under `--require-hashes`, every requirement must include a hash.
247            if digests.is_empty() {
248                if mode.is_require() {
249                    return Err(HashStrategyError::MissingHashes(
250                        requirement.to_string(),
251                        mode,
252                    ));
253                }
254                continue;
255            }
256
257            requirement_hashes.insert(id, digests);
258        }
259
260        // Merge the hashes, preferring requirements over constraints, since overlapping
261        // requirements were already merged.
262        let hashes: FxHashMap<VersionId, Vec<HashDigest>> = constraint_hashes
263            .into_iter()
264            .chain(requirement_hashes)
265            .collect();
266        match mode {
267            HashCheckingMode::Verify => Ok(Self::Verify(Arc::new(hashes))),
268            HashCheckingMode::Require => Ok(Self::Require(Arc::new(hashes))),
269        }
270    }
271
272    /// Generate the required hashes from a [`Resolution`].
273    pub fn from_resolution(
274        resolution: &Resolution,
275        mode: HashCheckingMode,
276    ) -> Result<Self, HashStrategyError> {
277        let mut hashes = FxHashMap::<VersionId, Vec<HashDigest>>::default();
278
279        for (dist, digests) in resolution.hashes() {
280            if digests.is_empty() {
281                // Under `--require-hashes`, every requirement must include a hash.
282                if mode.is_require() {
283                    return Err(HashStrategyError::MissingHashes(
284                        dist.name().to_string(),
285                        mode,
286                    ));
287                }
288                continue;
289            }
290            hashes.insert(dist.version_id(), digests.to_vec());
291        }
292
293        match mode {
294            HashCheckingMode::Verify => Ok(Self::Verify(Arc::new(hashes))),
295            HashCheckingMode::Require => Ok(Self::Require(Arc::new(hashes))),
296        }
297    }
298
299    /// Pin a [`Requirement`] to a [`PackageId`], if possible.
300    fn pin(requirement: &Requirement) -> Option<VersionId> {
301        match &requirement.source {
302            RequirementSource::Registry { specifier, .. } => {
303                // Must be a single specifier.
304                let [specifier] = specifier.as_ref() else {
305                    return None;
306                };
307
308                // Must be pinned to a specific version.
309                if *specifier.operator() != uv_pep440::Operator::Equal {
310                    return None;
311                }
312
313                Some(VersionId::from_registry(
314                    requirement.name.clone(),
315                    specifier.version().clone(),
316                ))
317            }
318            RequirementSource::Url { url, .. }
319            | RequirementSource::Git { url, .. }
320            | RequirementSource::Path { url, .. }
321            | RequirementSource::Directory { url, .. } => Some(VersionId::from_url(url)),
322        }
323    }
324}
325
326#[derive(thiserror::Error, Debug)]
327pub enum HashStrategyError {
328    #[error(transparent)]
329    Hash(#[from] HashError),
330    #[error(
331        "In `{1}` mode, all requirements must have their versions pinned with `==`, but found: {0}"
332    )]
333    UnpinnedRequirement(String, HashCheckingMode),
334    #[error("In `{1}` mode, all requirements must have a hash, but none were provided for: {0}")]
335    MissingHashes(String, HashCheckingMode),
336    #[error(
337        "In `{1}` mode, all requirements must have a hash, but there were no overlapping hashes between the requirements and constraints for: {0}"
338    )]
339    NoIntersection(String, HashCheckingMode),
340}