Skip to main content

uv_distribution_types/
file.rs

1use std::borrow::Cow;
2use std::fmt::{self, Display, Formatter};
3use std::str::FromStr;
4use std::sync::Arc;
5
6use jiff::Timestamp;
7use serde::{Deserialize, Serialize};
8
9use uv_pep440::{VersionSpecifiers, VersionSpecifiersParseError};
10use uv_pep508::split_scheme;
11use uv_pypi_types::{CoreMetadata, HashDigests, Yanked};
12use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
13use uv_small_str::SmallString;
14
15/// Error converting [`uv_pypi_types::PypiFile`] to [`distribution_type::File`].
16#[derive(Debug, thiserror::Error)]
17pub enum FileConversionError {
18    #[error("Failed to parse `requires-python`: `{0}`")]
19    RequiresPython(String, #[source] VersionSpecifiersParseError),
20    #[error("Failed to parse URL: {0}")]
21    Url(String, #[source] url::ParseError),
22    #[error("Failed to parse filename from URL: {0}")]
23    MissingPathSegments(String),
24    #[error(transparent)]
25    Utf8(#[from] std::str::Utf8Error),
26}
27
28/// Internal analog to [`uv_pypi_types::PypiFile`].
29#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
30#[rkyv(derive(Debug))]
31pub struct File {
32    pub dist_info_metadata: bool,
33    pub filename: SmallString,
34    pub hashes: HashDigests,
35    pub requires_python: Option<Arc<VersionSpecifiers>>,
36    pub size: Option<u64>,
37    // N.B. We don't use a Jiff timestamp here because it's a little
38    // annoying to do so with rkyv. Since we only use this field for doing
39    // comparisons in testing, we just store it as a UTC timestamp in
40    // milliseconds.
41    pub upload_time_utc_ms: Option<i64>,
42    pub url: FileLocation,
43    pub yanked: Option<Box<Yanked>>,
44    pub zstd: Option<Box<Zstd>>,
45}
46
47impl File {
48    /// `TryFrom` instead of `From` to filter out files with invalid requires python version specifiers
49    pub fn try_from_pypi(
50        file: uv_pypi_types::PypiFile,
51        base: &SmallString,
52    ) -> Result<Self, FileConversionError> {
53        Ok(Self {
54            dist_info_metadata: file
55                .core_metadata
56                .as_ref()
57                .is_some_and(CoreMetadata::is_available),
58            filename: file.filename,
59            hashes: HashDigests::from(file.hashes),
60            requires_python: file
61                .requires_python
62                .transpose()
63                .map_err(|err| FileConversionError::RequiresPython(err.line().clone(), err))?,
64            size: file.size,
65            upload_time_utc_ms: file.upload_time.map(Timestamp::as_millisecond),
66            url: FileLocation::new(file.url, base),
67            yanked: file.yanked,
68            zstd: None,
69        })
70    }
71
72    pub fn try_from_pyx(
73        file: uv_pypi_types::PyxFile,
74        base: &SmallString,
75    ) -> Result<Self, FileConversionError> {
76        let filename = if let Some(filename) = file.filename {
77            filename
78        } else {
79            // Remove any query parameters or fragments from the URL to get the filename.
80            let base_url = file
81                .url
82                .as_ref()
83                .split_once('?')
84                .or_else(|| file.url.as_ref().split_once('#'))
85                .map(|(path, _)| path)
86                .unwrap_or(file.url.as_ref());
87
88            // Take the last segment, stripping any query or fragment.
89            let last = base_url
90                .split('/')
91                .next_back()
92                .ok_or_else(|| FileConversionError::MissingPathSegments(file.url.to_string()))?;
93
94            // Decode the filename, which may be percent-encoded.
95            let filename = percent_encoding::percent_decode_str(last).decode_utf8()?;
96
97            SmallString::from(filename)
98        };
99        Ok(Self {
100            filename,
101            dist_info_metadata: file
102                .core_metadata
103                .as_ref()
104                .is_some_and(CoreMetadata::is_available),
105            hashes: HashDigests::from(file.hashes),
106            requires_python: file
107                .requires_python
108                .transpose()
109                .map_err(|err| FileConversionError::RequiresPython(err.line().clone(), err))?,
110            size: file.size,
111            upload_time_utc_ms: file.upload_time.map(Timestamp::as_millisecond),
112            url: FileLocation::new(file.url, base),
113            yanked: file.yanked,
114            zstd: file
115                .zstd
116                .map(|zstd| Zstd {
117                    hashes: HashDigests::from(zstd.hashes),
118                    size: zstd.size,
119                })
120                .map(Box::new),
121        })
122    }
123}
124
125/// While a registry file is generally a remote URL, it can also be a file if it comes from a directory flat indexes.
126#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
127#[rkyv(derive(Debug))]
128pub enum FileLocation {
129    /// URL relative to the base URL.
130    RelativeUrl(SmallString, SmallString),
131    /// Absolute URL.
132    AbsoluteUrl(UrlString),
133}
134
135impl FileLocation {
136    /// Parse a relative or absolute URL on a page with a base URL.
137    ///
138    /// This follows the HTML semantics where a link on a page is resolved relative to the URL of
139    /// that page.
140    pub fn new(url: SmallString, base: &SmallString) -> Self {
141        match split_scheme(&url) {
142            Some(..) => Self::AbsoluteUrl(UrlString::new(url)),
143            None => Self::RelativeUrl(base.clone(), url),
144        }
145    }
146
147    /// Returns the final raw URL path component after removing any query or fragment.
148    ///
149    /// The filename is not percent-decoded.
150    pub fn raw_filename(&self) -> &str {
151        let path = match self {
152            Self::RelativeUrl(_, path) => path.as_ref(),
153            Self::AbsoluteUrl(url) => url.as_ref(),
154        };
155        let path = path.split_once(['?', '#']).map_or(path, |(path, _)| path);
156        path.rsplit_once('/').map_or(path, |(_, filename)| filename)
157    }
158
159    /// Convert this location to a URL.
160    ///
161    /// A relative URL has its base joined to the path. An absolute URL is
162    /// parsed as-is. And a path location is turned into a URL via the `file`
163    /// protocol.
164    ///
165    /// # Errors
166    ///
167    /// This returns an error if any of the URL parsing fails, or if, for
168    /// example, the location is a path and the path isn't valid UTF-8.
169    /// (Because URLs must be valid UTF-8.)
170    pub fn to_url(&self) -> Result<DisplaySafeUrl, ToUrlError> {
171        match self {
172            Self::RelativeUrl(base, path) => {
173                let base_url =
174                    DisplaySafeUrl::parse(base).map_err(|err| ToUrlError::InvalidBase {
175                        base: base.to_string(),
176                        err,
177                    })?;
178                let joined = base_url.join(path).map_err(|err| ToUrlError::InvalidJoin {
179                    base: base.to_string(),
180                    path: path.to_string(),
181                    err,
182                })?;
183                Ok(joined)
184            }
185            Self::AbsoluteUrl(absolute) => absolute.to_url(),
186        }
187    }
188}
189
190impl Display for FileLocation {
191    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
192        match self {
193            Self::RelativeUrl(_base, url) => Display::fmt(&url, f),
194            Self::AbsoluteUrl(url) => Display::fmt(&url.0, f),
195        }
196    }
197}
198
199/// A [`Url`] represented as a `String`.
200///
201/// This type is not guaranteed to be a valid URL, and may error on conversion.
202#[derive(
203    Debug,
204    Clone,
205    PartialEq,
206    Eq,
207    PartialOrd,
208    Ord,
209    Hash,
210    Serialize,
211    Deserialize,
212    rkyv::Archive,
213    rkyv::Deserialize,
214    rkyv::Serialize,
215)]
216#[serde(transparent)]
217#[rkyv(derive(Debug))]
218pub struct UrlString(SmallString);
219
220impl UrlString {
221    /// Create a new [`UrlString`] from a [`String`].
222    fn new(url: SmallString) -> Self {
223        Self(url)
224    }
225
226    /// Converts a [`UrlString`] to a [`DisplaySafeUrl`].
227    pub fn to_url(&self) -> Result<DisplaySafeUrl, ToUrlError> {
228        DisplaySafeUrl::from_str(&self.0).map_err(|err| ToUrlError::InvalidAbsolute {
229            absolute: self.0.to_string(),
230            err,
231        })
232    }
233
234    /// Return the [`UrlString`] with any query parameters and fragments removed.
235    pub fn base_str(&self) -> &str {
236        self.as_ref()
237            .split_once('?')
238            .or_else(|| self.as_ref().split_once('#'))
239            .map(|(path, _)| path)
240            .unwrap_or(self.as_ref())
241    }
242
243    /// Return the [`UrlString`] (as a [`Cow`]) with any fragments removed.
244    #[must_use]
245    pub fn without_fragment(&self) -> Cow<'_, Self> {
246        self.as_ref()
247            .split_once('#')
248            .map(|(path, _)| Cow::Owned(Self(SmallString::from(path))))
249            .unwrap_or(Cow::Borrowed(self))
250    }
251}
252
253impl AsRef<str> for UrlString {
254    fn as_ref(&self) -> &str {
255        &self.0
256    }
257}
258
259impl From<DisplaySafeUrl> for UrlString {
260    fn from(value: DisplaySafeUrl) -> Self {
261        Self(value.as_str().into())
262    }
263}
264
265impl From<&DisplaySafeUrl> for UrlString {
266    fn from(value: &DisplaySafeUrl) -> Self {
267        Self(value.as_str().into())
268    }
269}
270
271impl Display for UrlString {
272    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
273        fmt::Display::fmt(&self.0, f)
274    }
275}
276
277/// An error that occurs when a [`FileLocation`] is not a valid URL.
278#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
279pub enum ToUrlError {
280    /// An error that occurs when the base URL in [`FileLocation::Relative`]
281    /// could not be parsed as a valid URL.
282    #[error("Could not parse base URL `{base}` as a valid URL")]
283    InvalidBase {
284        /// The base URL that could not be parsed as a valid URL.
285        base: String,
286        /// The underlying URL parse error.
287        #[source]
288        err: DisplaySafeUrlError,
289    },
290    /// An error that occurs when the base URL could not be joined with
291    /// the relative path in a [`FileLocation::Relative`].
292    #[error("Could not join base URL `{base}` to relative path `{path}`")]
293    InvalidJoin {
294        /// The base URL that could not be parsed as a valid URL.
295        base: String,
296        /// The relative path segment.
297        path: String,
298        /// The underlying URL parse error.
299        #[source]
300        err: DisplaySafeUrlError,
301    },
302    /// An error that occurs when the absolute URL in [`FileLocation::Absolute`]
303    /// could not be parsed as a valid URL.
304    #[error("Could not parse absolute URL `{absolute}` as a valid URL")]
305    InvalidAbsolute {
306        /// The absolute URL that could not be parsed as a valid URL.
307        absolute: String,
308        /// The underlying URL parse error.
309        #[source]
310        err: DisplaySafeUrlError,
311    },
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
315pub struct Zstd {
316    pub hashes: HashDigests,
317    pub size: Option<u64>,
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn raw_filename() {
326        let base = SmallString::from("https://example.com/simple/");
327
328        let location = FileLocation::new(
329            SmallString::from("files/example%20pkg.whl?download=1#fragment"),
330            &base,
331        );
332        assert_eq!(location.raw_filename(), "example%20pkg.whl");
333
334        let location = FileLocation::new(
335            SmallString::from("https://files.example.com/example.whl#sha256=digest"),
336            &base,
337        );
338        assert_eq!(location.raw_filename(), "example.whl");
339    }
340
341    #[test]
342    fn base_str() {
343        let url = UrlString("https://example.com/path?query#fragment".into());
344        assert_eq!(url.base_str(), "https://example.com/path");
345
346        let url = UrlString("https://example.com/path#fragment".into());
347        assert_eq!(url.base_str(), "https://example.com/path");
348
349        let url = UrlString("https://example.com/path".into());
350        assert_eq!(url.base_str(), "https://example.com/path");
351    }
352
353    #[test]
354    fn without_fragment() {
355        // Borrows a URL without a fragment
356        let url = UrlString("https://example.com/path".into());
357        assert_eq!(&*url.without_fragment(), &url);
358        assert!(matches!(url.without_fragment(), Cow::Borrowed(_)));
359
360        // Removes the fragment if present on the URL
361        let url = UrlString("https://example.com/path?query#fragment".into());
362        assert_eq!(
363            &*url.without_fragment(),
364            &UrlString("https://example.com/path?query".into())
365        );
366        assert!(matches!(url.without_fragment(), Cow::Owned(_)));
367    }
368}