Skip to main content

alpm_repo_db/files/
v1.rs

1//! The representation of [alpm-repo-files] files (version 1).
2//!
3//! [alpm-repo-files]: https://alpm.archlinux.page/specifications/alpm-repo-files.5.html
4
5use std::{collections::HashSet, fmt::Display, path::PathBuf, str::FromStr};
6
7use alpm_common::relative_files;
8use alpm_types::RelativePath;
9use fluent_i18n::t;
10use winnow::{
11    ModalResult,
12    Parser,
13    ascii::{line_ending, multispace0, space0, till_line_ending},
14    combinator::{alt, cut_err, eof, not, repeat, terminated},
15    error::{StrContext, StrContextValue},
16};
17
18use crate::files::Error;
19
20/// The raw data section in [alpm-repo-files] data.
21///
22/// [alpm-repo-files]: https://alpm.archlinux.page/specifications/alpm-repo-files.5.html
23#[derive(Debug)]
24pub(crate) struct FilesSection(Vec<RelativePath>);
25
26impl FilesSection {
27    /// The section keyword ("%FILES%").
28    pub(crate) const SECTION_KEYWORD: &str = "%FILES%";
29
30    /// Recognizes a [`RelativePath`] in a single line.
31    ///
32    /// # Note
33    ///
34    /// This parser only consumes till the end of a line and attempts to parse a [`RelativePath`]
35    /// from it. Trailing line endings and EOF are handled.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error if a [`RelativePath`] cannot be created from the line, or something other
40    /// than a line ending or EOF is encountered afterwards.
41    fn parse_path(input: &mut &str) -> ModalResult<RelativePath> {
42        // Make sure that the line is not empty.
43        not(alt(((space0, line_ending).take(), eof))).parse_next(input)?;
44
45        // Parse until the end of the line and attempt conversion to RelativePath.
46        cut_err(
47            till_line_ending
48                .context(StrContext::Label("relative path"))
49                .parse_to(),
50        )
51        .parse_next(input)
52    }
53
54    /// Recognizes [alpm-repo-files] data in a string slice.
55    ///
56    /// # Errors
57    ///
58    /// Returns an error, if
59    ///
60    /// - the first line does not contain the required section header "%FILES%",
61    /// - or there are lines following the section header, but they cannot be parsed as a [`Vec`] of
62    ///   [`RelativePath`].
63    ///
64    /// [alpm-repo-files]: https://alpm.archlinux.page/specifications/alpm-repo-files.5.html
65    pub(crate) fn parser(input: &mut &str) -> ModalResult<Self> {
66        // Consume the required section header "%FILES%".
67        // Optionally consume one following line ending.
68        cut_err(terminated(Self::SECTION_KEYWORD, alt((line_ending, eof))))
69            .context(StrContext::Label("alpm-repo-files section header"))
70            .context(StrContext::Expected(StrContextValue::Description(
71                Self::SECTION_KEYWORD,
72            )))
73            .parse_next(input)?;
74
75        // Return early if there is only the section header.
76        if input.is_empty() {
77            return Ok(Self(Vec::new()));
78        }
79
80        // Consider all following lines as paths.
81        // Optionally consume one following line ending.
82        let paths: Vec<RelativePath> =
83            repeat(0.., terminated(Self::parse_path, alt((line_ending, eof)))).parse_next(input)?;
84
85        // Consume any trailing whitespaces or new lines.
86        multispace0.parse_next(input)?;
87
88        // Fail if there are any further characters.
89        cut_err(eof)
90            .context(StrContext::Expected(StrContextValue::Description(
91                "no further content",
92            )))
93            .parse_next(input)?;
94
95        Ok(Self(paths))
96    }
97
98    /// Returns the paths.
99    pub fn paths(self) -> Vec<PathBuf> {
100        self.0.into_iter().map(RelativePath::into_inner).collect()
101    }
102}
103
104/// A collection of paths that are invalid in the context of a [`RepoFilesV1`].
105///
106/// A [`RepoFilesV1`] must not contain duplicate paths or (non top-level) paths that do not have a
107/// parent in the same set of paths.
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub(crate) struct RepoFilesV1PathErrors {
110    pub(crate) absolute: HashSet<PathBuf>,
111    pub(crate) without_parent: HashSet<PathBuf>,
112    pub(crate) duplicate: HashSet<PathBuf>,
113}
114
115impl RepoFilesV1PathErrors {
116    /// Creates a new [`RepoFilesV1PathErrors`].
117    pub(crate) fn new() -> Self {
118        Self {
119            absolute: HashSet::new(),
120            without_parent: HashSet::new(),
121            duplicate: HashSet::new(),
122        }
123    }
124
125    /// Adds a new absolute path.
126    pub(crate) fn add_absolute(&mut self, path: PathBuf) -> bool {
127        self.absolute.insert(path)
128    }
129
130    /// Adds a new (non top-level) path that does not have a parent.
131    pub(crate) fn add_without_parent(&mut self, path: PathBuf) -> bool {
132        self.without_parent.insert(path)
133    }
134
135    /// Adds a new duplicate path.
136    pub(crate) fn add_duplicate(&mut self, path: PathBuf) -> bool {
137        self.duplicate.insert(path)
138    }
139
140    /// Fails if `self` tracks any invalid paths.
141    pub(crate) fn fail(&self) -> Result<(), Error> {
142        if !(self.absolute.is_empty()
143            && self.without_parent.is_empty()
144            && self.duplicate.is_empty())
145        {
146            Err(Error::InvalidFilesPaths {
147                message: self.to_string(),
148            })
149        } else {
150            Ok(())
151        }
152    }
153}
154
155impl Display for RepoFilesV1PathErrors {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        fn write_invalid_set(
158            f: &mut std::fmt::Formatter<'_>,
159            message: String,
160            set: &HashSet<PathBuf>,
161        ) -> std::fmt::Result {
162            if !set.is_empty() {
163                writeln!(f, "{message}:")?;
164                let mut set = set.iter().collect::<Vec<_>>();
165                set.sort();
166                for path in set.iter() {
167                    writeln!(f, "{}", path.as_path().display())?;
168                }
169            }
170            Ok(())
171        }
172
173        write_invalid_set(f, t!("filesv1-path-errors-absolute-paths"), &self.absolute)?;
174        write_invalid_set(
175            f,
176            t!("filesv1-path-errors-paths-without-a-parent"),
177            &self.without_parent,
178        )?;
179        write_invalid_set(
180            f,
181            t!("filesv1-path-errors-duplicate-paths"),
182            &self.duplicate,
183        )?;
184
185        Ok(())
186    }
187}
188
189/// The representation of [alpm-repo-files] data (version 1).
190///
191/// [alpm-repo-files]: https://alpm.archlinux.page/specifications/alpm-repo-files.5.html
192#[derive(Clone, Debug, serde::Serialize)]
193pub struct RepoFilesV1(Vec<PathBuf>);
194
195impl AsRef<[PathBuf]> for RepoFilesV1 {
196    /// Returns a reference to the inner [`Vec`] of [`PathBuf`]s.
197    fn as_ref(&self) -> &[PathBuf] {
198        &self.0
199    }
200}
201
202impl Display for RepoFilesV1 {
203    /// Returns the [`String`] representation of the [`RepoFilesV1`].
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use std::path::PathBuf;
209    ///
210    /// use alpm_repo_db::files::RepoFilesV1;
211    ///
212    /// # fn main() -> Result<(), alpm_repo_db::files::Error> {
213    /// // An empty alpm-repo-files.
214    /// let expected = "%FILES%\n";
215    /// let files = RepoFilesV1::try_from(Vec::new())?;
216    /// assert_eq!(files.to_string(), expected);
217    ///
218    /// // An alpm-repo-files with entries.
219    /// let expected = r#"%FILES%
220    /// usr/
221    /// usr/bin/
222    /// usr/bin/foo
223    /// "#;
224    /// let files = RepoFilesV1::try_from(vec![
225    ///     PathBuf::from("usr/"),
226    ///     PathBuf::from("usr/bin/"),
227    ///     PathBuf::from("usr/bin/foo"),
228    /// ])?;
229    /// assert_eq!(files.to_string(), expected);
230    /// # Ok(())
231    /// # }
232    /// ```
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        let mut output = String::new();
235
236        output.push_str(FilesSection::SECTION_KEYWORD);
237        output.push('\n');
238
239        for path in self.0.iter() {
240            output.push_str(&format!("{}", path.to_string_lossy()));
241            output.push('\n');
242        }
243
244        write!(f, "{output}")
245    }
246}
247
248impl FromStr for RepoFilesV1 {
249    type Err = Error;
250
251    /// Creates a new [`RepoFilesV1`] from a string slice.
252    ///
253    /// # Note
254    ///
255    /// Delegates to the [`TryFrom`] [`Vec`] of [`PathBuf`] implementation, after the string slice
256    /// has been parsed as a [`Vec`] of [`PathBuf`].
257    ///
258    /// # Errors
259    ///
260    /// Returns an error, if
261    ///
262    /// - the first line does not contain the section header ("%FILES%"),
263    /// - there are lines following the section header, but they cannot be parsed as a [`Vec`] of
264    ///   [`PathBuf`],
265    /// - or [`Self::try_from`] [`Vec`] of [`PathBuf`] fails.
266    ///
267    /// # Examples
268    ///
269    /// ```
270    /// use std::{path::PathBuf, str::FromStr};
271    ///
272    /// use alpm_repo_db::files::RepoFilesV1;
273    ///
274    /// # fn main() -> Result<(), alpm_repo_db::files::Error> {
275    /// // The section header is required; empty input is invalid.
276    /// let data = "";
277    /// assert!(RepoFilesV1::from_str(data).is_err());
278    ///
279    /// # let expected: Vec<PathBuf> = Vec::new();
280    /// // No files according to alpm-repo-files.
281    /// let data = "%FILES%";
282    /// let files = RepoFilesV1::from_str(data)?;
283    /// # assert_eq!(files.as_ref(), expected);
284    /// let data = "%FILES%\n";
285    /// let files = RepoFilesV1::from_str(data)?;
286    /// # assert_eq!(files.as_ref(), expected);
287    ///
288    /// # let expected: Vec<PathBuf> = vec![
289    /// #     PathBuf::from("usr/"),
290    /// #     PathBuf::from("usr/bin/"),
291    /// #     PathBuf::from("usr/bin/foo"),
292    /// # ];
293    /// // Files according to alpm-repo-files.
294    /// let data = r#"%FILES%
295    /// usr/
296    /// usr/bin/
297    /// usr/bin/foo"#;
298    /// let files = RepoFilesV1::from_str(data)?;
299    /// # assert_eq!(files.as_ref(), expected);
300    ///
301    /// // Files according to alpm-repo-files.
302    /// let data = r#"%FILES%
303    /// usr/
304    /// usr/bin/
305    /// usr/bin/foo
306    /// "#;
307    /// let files = RepoFilesV1::from_str(data)?;
308    /// # assert_eq!(files.as_ref(), expected.as_slice());
309    /// # Ok(())
310    /// # }
311    /// ```
312    fn from_str(s: &str) -> Result<Self, Self::Err> {
313        let files_section = FilesSection::parser.parse(s)?;
314        RepoFilesV1::try_from(files_section.paths())
315    }
316}
317
318impl TryFrom<PathBuf> for RepoFilesV1 {
319    type Error = Error;
320
321    /// Creates a new [`RepoFilesV1`] from all files and directories in a directory.
322    ///
323    /// # Note
324    ///
325    /// Delegates to [`alpm_common::relative_files`] to get a sorted list of all files and
326    /// directories in the directory `value` (relative to `value`).
327    /// Afterwards, tries to construct a [`RepoFilesV1`] from this list.
328    ///
329    /// # Errors
330    ///
331    /// Returns an error if
332    ///
333    /// - [`alpm_common::relative_files`] fails,
334    /// - or [`TryFrom`] [`Vec`] of [`PathBuf`] for [`RepoFilesV1`] fails.
335    ///
336    /// # Examples
337    ///
338    /// ```
339    /// use std::{
340    ///     fs::{File, create_dir_all},
341    ///     path::PathBuf,
342    /// };
343    ///
344    /// use alpm_repo_db::files::RepoFilesV1;
345    /// use tempfile::tempdir;
346    ///
347    /// # fn main() -> testresult::TestResult {
348    /// let temp_dir = tempdir()?;
349    /// let path = temp_dir.path();
350    /// create_dir_all(path.join("usr/bin/"))?;
351    /// File::create(path.join("usr/bin/foo"))?;
352    ///
353    /// let files = RepoFilesV1::try_from(path.to_path_buf())?;
354    /// assert_eq!(
355    ///     files.as_ref(),
356    ///     vec![
357    ///         PathBuf::from("usr/"),
358    ///         PathBuf::from("usr/bin/"),
359    ///         PathBuf::from("usr/bin/foo")
360    ///     ]
361    /// );
362    /// # Ok(())
363    /// # }
364    /// ```
365    fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
366        RepoFilesV1::try_from(relative_files(value, &[])?)
367    }
368}
369
370impl TryFrom<Vec<PathBuf>> for RepoFilesV1 {
371    type Error = Error;
372
373    /// Creates a new [`RepoFilesV1`] from a [`Vec`] of [`PathBuf`].
374    ///
375    /// The provided `value` is sorted and checked for non top-level paths without a parent, as well
376    /// as any duplicate paths.
377    ///
378    /// # Errors
379    ///
380    /// Returns an error if
381    ///
382    /// - `value` contains absolute paths,
383    /// - `value` contains (non top-level) paths without a parent directory present in `value`,
384    /// - or `value` contains duplicate paths.
385    ///
386    /// # Examples
387    ///
388    /// ```
389    /// use std::path::PathBuf;
390    ///
391    /// use alpm_repo_db::files::RepoFilesV1;
392    ///
393    /// # fn main() -> Result<(), alpm_repo_db::files::Error> {
394    /// let paths: Vec<PathBuf> = vec![
395    ///     PathBuf::from("usr/"),
396    ///     PathBuf::from("usr/bin/"),
397    ///     PathBuf::from("usr/bin/foo"),
398    /// ];
399    /// let files = RepoFilesV1::try_from(paths)?;
400    ///
401    /// // Absolute paths are not allowed.
402    /// let paths: Vec<PathBuf> = vec![
403    ///     PathBuf::from("/usr/"),
404    ///     PathBuf::from("/usr/bin/"),
405    ///     PathBuf::from("/usr/bin/foo"),
406    /// ];
407    /// assert!(RepoFilesV1::try_from(paths).is_err());
408    ///
409    /// // Every path (excluding top-level paths) must have a parent.
410    /// let paths: Vec<PathBuf> = vec![PathBuf::from("usr/bin/"), PathBuf::from("usr/bin/foo")];
411    /// assert!(RepoFilesV1::try_from(paths).is_err());
412    ///
413    /// // Every path must be unique.
414    /// let paths: Vec<PathBuf> = vec![
415    ///     PathBuf::from("usr/"),
416    ///     PathBuf::from("usr/"),
417    ///     PathBuf::from("usr/bin/"),
418    ///     PathBuf::from("usr/bin/foo"),
419    /// ];
420    /// assert!(RepoFilesV1::try_from(paths).is_err());
421    /// # Ok(())
422    /// # }
423    /// ```
424    fn try_from(value: Vec<PathBuf>) -> Result<Self, Self::Error> {
425        let mut paths = value;
426        paths.sort_unstable();
427
428        let mut errors = RepoFilesV1PathErrors::new();
429        let mut path_set = HashSet::new();
430        let empty_parent = PathBuf::from("");
431        let root_parent = PathBuf::from("/");
432
433        for path in paths.iter() {
434            let path = path.as_path();
435
436            // Add absolute paths as errors.
437            if path.is_absolute() {
438                errors.add_absolute(path.to_path_buf());
439            }
440
441            // Add non top-level, relative paths without a parent as errors.
442            if let Some(parent) = path.parent()
443                && parent != empty_parent
444                && parent != root_parent
445                && !path_set.contains(parent)
446            {
447                errors.add_without_parent(path.to_path_buf());
448            }
449
450            // Add duplicates as errors.
451            if !path_set.insert(path) {
452                errors.add_duplicate(path.to_path_buf());
453            }
454        }
455
456        errors.fail()?;
457
458        Ok(Self(paths))
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use std::{
465        fs::{File, create_dir_all},
466        str::FromStr,
467    };
468
469    use rstest::rstest;
470    use tempfile::tempdir;
471    use testresult::TestResult;
472
473    use super::*;
474
475    /// Ensures that a [`RepoFilesV1`] can be successfully created from a directory.
476    #[test]
477    fn filesv1_try_from_pathbuf_succeeds() -> TestResult {
478        let temp_dir = tempdir()?;
479        let path = temp_dir.path();
480        create_dir_all(path.join("usr/bin/"))?;
481        File::create(path.join("usr/bin/foo"))?;
482
483        let files = RepoFilesV1::try_from(path.to_path_buf())?;
484
485        assert_eq!(
486            files.as_ref(),
487            vec![
488                PathBuf::from("usr/"),
489                PathBuf::from("usr/bin/"),
490                PathBuf::from("usr/bin/foo")
491            ]
492        );
493
494        Ok(())
495    }
496
497    #[rstest]
498    #[case::dirs_and_files(vec![PathBuf::from("usr/"), PathBuf::from("usr/bin/"), PathBuf::from("usr/bin/foo")], 3)]
499    #[case::empty(Vec::new(), 0)]
500    fn filesv1_try_from_pathbufs_succeeds(
501        #[case] paths: Vec<PathBuf>,
502        #[case] len: usize,
503    ) -> TestResult {
504        let files = RepoFilesV1::try_from(paths)?;
505
506        assert_eq!(files.as_ref().len(), len);
507
508        Ok(())
509    }
510
511    /// Ensures that missing section headers result in parse errors.
512    #[test]
513    fn filesv1_from_str_fails_without_header() {
514        let result = RepoFilesV1::from_str("");
515
516        assert!(matches!(result, Err(Error::ParseError(_))));
517    }
518
519    #[rstest]
520    #[case::absolute_paths(
521        vec![
522            PathBuf::from("/usr/"), PathBuf::from("/usr/bin/"), PathBuf::from("/usr/bin/foo")
523        ],
524        RepoFilesV1PathErrors{
525            absolute: HashSet::from_iter([
526                PathBuf::from("/usr/"),
527                PathBuf::from("/usr/bin/"),
528                PathBuf::from("/usr/bin/foo"),
529            ]),
530            without_parent: HashSet::new(),
531            duplicate: HashSet::new(),
532        }
533    )]
534    #[case::without_parents(
535        vec![PathBuf::from("usr/bin/"), PathBuf::from("usr/bin/foo")],
536        RepoFilesV1PathErrors{
537            absolute: HashSet::new(),
538            without_parent: HashSet::from_iter([
539                PathBuf::from("usr/bin/"),
540            ]),
541            duplicate: HashSet::new(),
542        }
543    )]
544    #[case::duplicates(
545        vec![PathBuf::from("usr/"), PathBuf::from("usr/")],
546        RepoFilesV1PathErrors{
547            absolute: HashSet::new(),
548            without_parent: HashSet::new(),
549            duplicate: HashSet::from_iter([
550                PathBuf::from("usr/"),
551            ]),
552        }
553    )]
554    fn filesv1_try_from_pathbufs_fails(
555        #[case] paths: Vec<PathBuf>,
556        #[case] expected_errors: RepoFilesV1PathErrors,
557    ) -> TestResult {
558        let result = RepoFilesV1::try_from(paths);
559        let errors = match result {
560            Ok(files) => panic!(
561                "Should have failed with an Error::InvalidFilesPaths, but succeeded to create a RepoFilesV1: {files:?}"
562            ),
563            Err(Error::InvalidFilesPaths { message }) => message,
564            Err(error) => panic!("Expected an Error::InvalidFilesPaths, but got: {error}"),
565        };
566
567        eprintln!("{errors}");
568        assert_eq!(errors, expected_errors.to_string());
569
570        Ok(())
571    }
572
573    #[test]
574    fn filesv1_from_str_rejects_absolute_paths() -> TestResult {
575        let data = "%FILES%\n/usr/bin/foo\n";
576
577        match RepoFilesV1::from_str(data) {
578            Err(Error::ParseError(_)) => Ok(()),
579            Err(error) => panic!("expected ParseError, got {error}"),
580            Ok(files) => panic!("expected parse failure, got {files:?}"),
581        }
582    }
583}