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}