Skip to main content

uv_distribution_types/
hash.rs

1use uv_pypi_types::{HashAlgorithm, HashDigest, HashDigests, Hashes};
2use uv_redacted::SafeUrl;
3
4/// Hash generation and validation policy for an archive.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum ArchiveHashPolicy<'a> {
7    /// No hash policy is specified.
8    None,
9    /// Hashes should be generated (specifically, a SHA-256 hash), but not validated.
10    Generate,
11    /// Hashes should be validated against a pre-defined list of hashes, and any matching digest is
12    /// sufficient. If necessary, hashes should be generated so as to ensure that the archive is
13    /// valid.
14    Any(&'a [HashDigest]),
15    /// Hashes should be validated against a pre-defined list of hashes, and every digest must
16    /// match. If necessary, hashes should be generated so as to ensure that the archive is valid.
17    All(&'a [HashDigest]),
18}
19
20impl<'a> ArchiveHashPolicy<'a> {
21    /// Use the hashes advertised for an individual index file when no verification policy is set.
22    ///
23    /// Explicit [`Self::Any`] and [`Self::All`] policies take precedence, including empty policies
24    /// that must reject the distribution. If the index provides no hashes, preserve the policy.
25    #[must_use]
26    pub fn with_index_hashes(self, hashes: &'a [HashDigest]) -> Self {
27        match self {
28            Self::Any(_) | Self::All(_) => self,
29            Self::None | Self::Generate => {
30                if hashes.is_empty() {
31                    self
32                } else {
33                    Self::All(hashes)
34                }
35            }
36        }
37    }
38
39    /// Returns `true` if the hash policy is `None`.
40    pub fn is_none(&self) -> bool {
41        matches!(self, Self::None)
42    }
43
44    /// Returns `true` if the hash policy is `Any` or `All`.
45    pub fn requires_validation(&self) -> bool {
46        matches!(self, Self::Any(_) | Self::All(_))
47    }
48
49    /// Return the algorithms used in the hash policy.
50    pub fn algorithms(&self) -> Vec<HashAlgorithm> {
51        match self {
52            Self::None => vec![],
53            Self::Generate => vec![HashAlgorithm::Sha256],
54            Self::Any(hashes) | Self::All(hashes) => {
55                let mut algorithms = hashes.iter().map(HashDigest::algorithm).collect::<Vec<_>>();
56                algorithms.sort();
57                algorithms.dedup();
58                algorithms
59            }
60        }
61    }
62
63    /// Return the digests used in the hash policy.
64    pub fn digests(&self) -> &[HashDigest] {
65        match self {
66            Self::None => &[],
67            Self::Generate => &[],
68            Self::Any(hashes) | Self::All(hashes) => hashes,
69        }
70    }
71
72    /// Returns `true` if the given hashes satisfy the policy.
73    pub fn matches(&self, hashes: &[HashDigest]) -> bool {
74        match self {
75            Self::None => true,
76            Self::Generate => hashes
77                .iter()
78                .any(|hash| hash.algorithm() == HashAlgorithm::Sha256),
79            Self::Any(required) => {
80                !required.is_empty() && hashes.iter().any(|hash| required.contains(hash))
81            }
82            Self::All(required) => {
83                !required.is_empty() && required.iter().all(|hash| hashes.contains(hash))
84            }
85        }
86    }
87
88    /// Returns `true` if the given hashes include the algorithms required by the policy.
89    fn has_required_algorithms(&self, hashes: &[HashDigest]) -> bool {
90        match self {
91            Self::None => true,
92            Self::Generate => hashes
93                .iter()
94                .any(|hash| hash.algorithm() == HashAlgorithm::Sha256),
95            Self::Any(required) => {
96                !required.is_empty()
97                    && required
98                        .iter()
99                        .map(HashDigest::algorithm)
100                        .any(|algorithm| hashes.iter().any(|hash| hash.algorithm() == algorithm))
101            }
102            Self::All(required) => {
103                !required.is_empty()
104                    && required
105                        .iter()
106                        .map(HashDigest::algorithm)
107                        .all(|algorithm| hashes.iter().any(|hash| hash.algorithm() == algorithm))
108            }
109        }
110    }
111}
112
113/// Archive hashes to collect or validate when fetching distribution metadata.
114///
115/// For example, `uv pip compile --generate-hashes` requests [`HashCollection::All`] with
116/// [`HashValidation::None`]:
117///
118/// - If the index provides a wheel's hash, metadata lookup uses [`ArchiveHashPolicy::None`].
119///   The resolver can record the index-provided hash without hashing the wheel.
120/// - If the index provides no hashes, metadata lookup uses [`ArchiveHashPolicy::Generate`]
121///   to obtain a SHA-256 hash of the wheel.
122///
123/// If source metadata requires a build and `validation` contains expected hashes, the fetcher
124/// instead uses [`ArchiveHashPolicy::Any`] or [`ArchiveHashPolicy::All`] to check the archive
125/// before running the backend. Those expected hashes take precedence over collection.
126#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
127pub struct MetadataHashPolicy<'a> {
128    /// Which missing hashes to collect for the resolution.
129    pub collection: HashCollection,
130    /// Expected hashes to validate before source builds. Wheel validation is deferred to installation.
131    pub validation: HashValidation<'a>,
132}
133
134/// Expected digests to validate against an archive.
135#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
136pub enum HashValidation<'a> {
137    /// No expected digests are specified.
138    #[default]
139    None,
140    /// Require at least one expected digest to match.
141    Any(&'a [HashDigest]),
142    /// Require every expected digest to match. An empty slice rejects all archives.
143    All(&'a [HashDigest]),
144}
145
146impl<'a> From<HashValidation<'a>> for ArchiveHashPolicy<'a> {
147    fn from(validation: HashValidation<'a>) -> Self {
148        match validation {
149            HashValidation::None => Self::None,
150            HashValidation::Any(hashes) => Self::Any(hashes),
151            HashValidation::All(hashes) => Self::All(hashes),
152        }
153    }
154}
155
156/// Which distributions should have hashes collected during resolution.
157///
158/// Reuse declared hashes when available; otherwise, compute a SHA-256 hash from the archive.
159#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
160pub enum HashCollection {
161    /// Do not collect hashes during resolution.
162    #[default]
163    None,
164    /// Supply hashes for non-registry distributions. Registry hashes come from index metadata.
165    Url,
166    /// Also compute missing registry hashes when the index metadata does not provide them.
167    All,
168}
169
170/// Read a URL's declared hash for resolution, excluding MD5, which `--require-hashes` rejects.
171pub fn parse_url_hashes(url: &SafeUrl) -> Option<HashDigests> {
172    let hashes = url
173        .fragment()?
174        .split('&')
175        .find_map(|fragment| Hashes::parse_fragment(fragment).ok())?;
176    let hashes = HashDigests::from(hashes);
177    let contains_md5 = hashes.iter().any(|hash| matches!(hash, HashDigest::Md5(_)));
178    (!contains_md5).then_some(hashes)
179}
180
181pub trait Hashed {
182    /// Return the [`HashDigest`]s for the archive.
183    fn hashes(&self) -> &[HashDigest];
184
185    /// Returns `true` if the archive satisfies the given hash policy.
186    fn satisfies(&self, hashes: ArchiveHashPolicy) -> bool {
187        hashes.matches(self.hashes())
188    }
189
190    /// Returns `true` if the archive includes the algorithms required by the given hash policy.
191    fn has_digests(&self, hashes: ArchiveHashPolicy) -> bool {
192        hashes.has_required_algorithms(self.hashes())
193    }
194}
195
196impl Hashed for Vec<HashDigest> {
197    fn hashes(&self) -> &[HashDigest] {
198        self
199    }
200}
201
202impl Hashed for &[HashDigest] {
203    fn hashes(&self) -> &[HashDigest] {
204        self
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use std::str::FromStr;
211
212    use uv_pypi_types::{HashDigest, HashError};
213
214    use super::ArchiveHashPolicy;
215
216    /// Current call paths reject missing required hashes before reaching this helper, so test
217    /// the defensive empty-policy case directly.
218    #[test]
219    fn index_hashes_preserve_empty_required_policies() -> Result<(), HashError> {
220        let index_hashes = [HashDigest::from_str(
221            "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
222        )?];
223        assert_eq!(
224            ArchiveHashPolicy::Any(&[]).with_index_hashes(&index_hashes),
225            ArchiveHashPolicy::Any(&[])
226        );
227        assert_eq!(
228            ArchiveHashPolicy::All(&[]).with_index_hashes(&index_hashes),
229            ArchiveHashPolicy::All(&[])
230        );
231
232        Ok(())
233    }
234
235    #[test]
236    fn validate_all_requires_every_digest() {
237        let sha256 = HashDigest::from_str(
238            "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
239        )
240        .unwrap();
241        let sha512 = HashDigest::from_str(
242            "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
243        )
244        .unwrap();
245        let wrong_sha512 = HashDigest::from_str(
246            "sha512:e30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
247        )
248        .unwrap();
249
250        let policy = ArchiveHashPolicy::All(&[sha256.clone(), sha512.clone()]);
251        assert!(policy.matches(&[sha256.clone(), sha512]));
252        assert!(!policy.matches(std::slice::from_ref(&sha256)));
253        assert!(!policy.matches(&[sha256, wrong_sha512]));
254    }
255
256    #[test]
257    fn validate_any_requires_one_digest() {
258        let sha256 = HashDigest::from_str(
259            "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
260        )
261        .unwrap();
262        let sha512 = HashDigest::from_str(
263            "sha512:f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
264        )
265        .unwrap();
266        let wrong_sha512 = HashDigest::from_str(
267            "sha512:e30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2",
268        )
269        .unwrap();
270
271        let policy = ArchiveHashPolicy::Any(&[sha256.clone(), sha512]);
272        assert!(policy.matches(&[sha256]));
273        assert!(!policy.matches(&[wrong_sha512]));
274    }
275}