alpm_files/files/
v1.rs

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