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}