Skip to main content

alpm_types/
source.rs

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