Skip to main content

alpm_types/
source.rs

1use std::{
2    fmt::{Display, Formatter},
3    path::PathBuf,
4    str::FromStr,
5};
6
7use alpm_parsers::prelude::*;
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10use winnow::{
11    combinator::{alt, peek, repeat_till},
12    error::ErrMode,
13    stream::Stream,
14    token::any,
15};
16
17use crate::{Basename, Error, SourceUrl};
18
19/// Represents the location that a source file should be retrieved from
20///
21/// It can be either a local file (next to the PKGBUILD) or a URL.
22#[derive(Clone, Debug, Eq, PartialEq)]
23#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
24#[cfg_attr(feature = "serde", serde(tag = "type"))]
25pub enum Source {
26    /// A local file source.
27    ///
28    /// The location must be a pure file name, without any path components (`/`).
29    /// Hence, the file must be located directly next to the PKGBUILD.
30    File {
31        /// The optional destination file name.
32        filename: Option<PathBuf>,
33        /// The source file name.
34        location: PathBuf,
35    },
36    /// A URL source.
37    SourceUrl {
38        /// The optional destination file name.
39        filename: Option<PathBuf>,
40        /// The source URL.
41        source_url: SourceUrl,
42    },
43}
44
45impl Source {
46    /// Returns the filename of the source if it is set.
47    pub fn filename(&self) -> Option<&PathBuf> {
48        match self {
49            Self::File { filename, .. } | Self::SourceUrl { filename, .. } => filename.as_ref(),
50        }
51    }
52}
53
54/// For Source, we only define a [`ParserUntil`] trait and not the `AlpmParser` trait, as we
55/// don't provide the [`Url`](url::Url) type parser ourselves. Hence, the indicator for its supposed
56/// "end" must be provided by the caller of the parser.
57impl ParserUntil for Source {
58    fn parser_until<'a, P>(delimiter: P) -> impl Parser<Input<'a>, Self, ErrMode<ParseStack<'a>>>
59    where
60        P: Parser<Input<'a>, &'a str, ErrMode<ParseStack<'a>>>,
61    {
62        // Define the actual parser closure.
63        // The delimiter is moved into the closure and borrowed via `by_ref()` on each call.
64        let mut delimiter_parser = delimiter;
65        let parser = move |input: &mut Input<'a>| -> PResult<'a, Self> {
66            // We have to work with checkpoints here, as we cannot `peek` with a `repeat_till` +
67            // `delimiter_parser`, as that would require the `delimiter_parser` to be borrowed
68            // twice.
69            let checkpoint = input.checkpoint();
70
71            // First up, we handle the case that there's a filename prefix e.g. `filename::...`.
72            // As such, we parse everything until either the expected end or the `::` delimiter.
73            let path: &str = repeat_till::<_, _, (), _, _, _, _>(
74                1..,
75                any,
76                peek(alt(("::", delimiter_parser.by_ref()))),
77            )
78            .label("source url")
79            .expected_text("a filename followed by `::` or a path/url with valid end of input.")
80            .take()
81            .parse_next(input)?;
82
83            let mut filename = None;
84            // We now check, if we hit the `::` delimiter, in which case, the input until here is
85            // treated as a filename.
86            // Otherwise, we reset to the start of the string and will handle the whole expected
87            // input as a URL.
88            let delimiter = alt(("::", peek(delimiter_parser.by_ref()))).parse_next(input)?;
89            if delimiter == "::" {
90                filename = Some(path.into())
91            } else {
92                input.reset(&checkpoint);
93            }
94
95            // Now, take the rest until we hit the delimiter.
96            //
97            // We try to parse the rest as a [`SourceUrl`] first, which internally tries to map the
98            // whole string, or only a substring if a VCS-protocol prefix is detected, to the
99            // `url::Url` type.
100            let location_start = input.checkpoint();
101            let url_result = SourceUrl::parser_until(delimiter_parser.by_ref()).parse_next(input);
102            let source_url = match url_result {
103                Ok(source_url) => Self::SourceUrl {
104                    filename,
105                    source_url,
106                },
107                // If this whole logic fails from an external error, we know that the URL conversion
108                // logic failed. If so, we try to recover from this error by looking at at the
109                // specific URL error and if it's a RelativeUrlWithoutBase, we try
110                // to parse it as a BaseName.
111                Err(ErrMode::Backtrack(error))
112                    if matches!(
113                        error
114                            .external
115                            .as_deref()
116                            .and_then(|e| e.downcast_ref::<Error>()),
117                        Some(Error::InvalidUrl(url::ParseError::RelativeUrlWithoutBase))
118                    ) =>
119                {
120                    input.reset(&location_start);
121
122                    // If the URL conversion failed due to a relative URL without a base, try to
123                    // parse the input as a relative file path.
124                    // If that doesn't succeed either, we report back an error.
125                    let location: Basename = repeat_till::<_, _, (), _, _, _, _>(
126                        0..,
127                        any,
128                        peek(delimiter_parser.by_ref()),
129                    )
130                    .take()
131                    .try_map(|location: &str| {
132                        if location.contains('\0') {
133                            return Err(Error::FileNameContainsInvalidChars(
134                                PathBuf::from(location),
135                                '\0',
136                            ));
137                        }
138                        Basename::from_str(location)
139                    })
140                    .expected_text("a URL or a relative path without separators or a NUL byte.")
141                    .parse_next(input)?;
142
143                    Self::File {
144                        filename,
145                        location: location.as_ref().to_path_buf(),
146                    }
147                }
148                Err(error) => return Err(error),
149            };
150
151            // Now make sure we actually hit the expected delimiter.
152            peek(delimiter_parser.by_ref())
153                .label("source url")
154                .expected_text("end of input.")
155                .parse_next(input)?;
156
157            Ok(source_url)
158        };
159
160        parser.layer("package artifact source")
161    }
162}
163
164impl FromStr for Source {
165    type Err = Error;
166
167    /// Creates a [`Source`] from a string slice.
168    ///
169    /// It is either a filename (in the same directory as the PKGBUILD)
170    /// or a url, optionally prefixed by a destination file name (separated by `::`).
171    ///
172    /// Delegates to [`Source::parser_until`].
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if [`Source::parser_until`] fails.
177    ///
178    /// ## Examples
179    ///
180    /// ```
181    /// use std::{path::Path, str::FromStr};
182    ///
183    /// use alpm_types::Source;
184    /// use url::Url;
185    ///
186    /// # fn main() -> Result<(), alpm_types::Error> {
187    ///
188    /// // Parse from a string that represents a remote file link.
189    /// let source = Source::from_str("foopkg-1.2.3.tar.gz::https://example.com/download")?;
190    /// let Source::SourceUrl {
191    ///     source_url,
192    ///     filename: Some(filename),
193    /// } = source
194    /// else {
195    ///     panic!("The source string is not a URL with a filename override");
196    /// };
197    ///
198    /// assert_eq!(filename, Path::new("foopkg-1.2.3.tar.gz"));
199    /// assert_eq!(
200    ///     AsRef::<Url>::as_ref(&source_url.url).host_str(),
201    ///     Some("example.com")
202    /// );
203    /// assert_eq!(source_url.to_string(), "https://example.com/download");
204    ///
205    /// // Parse from a string that represents a local file.
206    /// let source = Source::from_str("renamed-source.tar.gz::test.tar.gz")?;
207    /// let Source::File {
208    ///     location,
209    ///     filename: Some(filename),
210    /// } = source
211    /// else {
212    ///     panic!("The source string is not a local file with a filename override");
213    /// };
214    /// assert_eq!(location, Path::new("test.tar.gz"));
215    /// assert_eq!(filename, Path::new("renamed-source.tar.gz"));
216    ///
217    /// # Ok(())
218    /// # }
219    /// ```
220    fn from_str(s: &str) -> Result<Self, Self::Err> {
221        Ok(Self::parser_until_eof.parse(Input::new(s))?)
222    }
223}
224
225impl Display for Source {
226    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
227        match self {
228            Self::File { filename, location } => {
229                if let Some(filename) = filename {
230                    write!(f, "{}::{}", filename.display(), location.display())
231                } else {
232                    write!(f, "{}", location.display())
233                }
234            }
235            Self::SourceUrl {
236                filename,
237                source_url,
238            } => {
239                if let Some(filename) = filename {
240                    write!(f, "{}::{}", filename.display(), source_url)
241                } else {
242                    write!(f, "{source_url}")
243                }
244            }
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use insta::assert_snapshot;
252    use rstest::rstest;
253    use testresult::TestResult;
254
255    use super::*;
256    use crate::configure_insta;
257
258    #[rstest]
259    #[case("bikeshed_colour.patch::test", Source::File {
260        filename: Some(PathBuf::from("bikeshed_colour.patch")),
261        location: PathBuf::from("test"),
262    })]
263    #[case("c:foo::test", Source::File {
264        filename: Some(PathBuf::from("c:foo")),
265        location: PathBuf::from("test"),
266    })]
267    #[case("renamed::local", Source::File {
268        filename: Some(PathBuf::from("renamed")),
269        location: PathBuf::from("local"),
270    })]
271    #[case("bikeshed_colour.patch",Source::File {
272        filename: None,
273        location: PathBuf::from("bikeshed_colour.patch"),
274    })]
275    #[case(
276        "foo-1.2.3.tar.gz::https://example.com/download",
277        Source::SourceUrl {
278            filename: Some(PathBuf::from("foo-1.2.3.tar.gz")),
279            source_url: SourceUrl::from_str("https://example.com/download")?,
280        }
281    )]
282    #[case(
283        "my-git-repo::git+https://example.com/project/repo.git?signed#commit=deadbeef",
284        Source::SourceUrl {
285            filename: Some(PathBuf::from("my-git-repo")),
286            source_url: SourceUrl::from_str("git+https://example.com/project/repo.git?signed#commit=deadbeef")?,
287        }
288    )]
289    #[case(
290        "file:///somewhere/else",
291        Source::SourceUrl {
292            filename: None,
293            source_url: SourceUrl::from_str("file:///somewhere/else")?,
294        }
295    )]
296    fn valid_source(#[case] input: &str, #[case] expected: Source) -> TestResult {
297        assert_eq!(
298            Source::from_str(input)?,
299            expected,
300            "Expected valid parsing for Source: {input}"
301        );
302
303        Ok(())
304    }
305
306    #[rstest]
307    #[case("./bikeshed_colour.patch")]
308    #[case("")]
309    #[case("with\0null")]
310    #[case("/absolute/path")]
311    #[case("foo:::/absolute/path")]
312    fn invalid_filename(#[case] input: &str) {
313        let (test_name, _guard) = configure_insta();
314
315        let Err(Error::ParseError(err_msg)) = Source::from_str(input) else {
316            panic!("'{input}' erroneously parsed as a Source")
317        };
318
319        assert_snapshot!(test_name, err_msg.to_string());
320    }
321}