Skip to main content

alpm_db/files/
v1.rs

1//! The representation of [alpm-db-files] files (version 1).
2//!
3//! [alpm-db-files]: https://alpm.archlinux.page/specifications/alpm-db-files.5.html
4
5use std::{collections::HashSet, fmt::Display, path::PathBuf, str::FromStr};
6
7use alpm_common::relative_files;
8use alpm_parsers::traits::AlpmParser;
9use alpm_types::{Md5Checksum, RelativeFilePath, RelativePath};
10use fluent_i18n::t;
11use winnow::{
12    ModalResult,
13    Parser,
14    ascii::{line_ending, multispace0, space1, till_line_ending},
15    combinator::{alt, cut_err, eof, fail, not, opt, repeat, separated_pair, terminated},
16    error::{StrContext, StrContextValue},
17    token::take_while,
18};
19
20use crate::files::Error;
21
22/// The raw data section in [alpm-db-files] data.
23///
24/// [alpm-db-files]: https://alpm.archlinux.page/specifications/alpm-db-files.5.html
25#[derive(Debug)]
26pub(crate) struct FilesSection(Vec<RelativePath>);
27
28impl FilesSection {
29    /// The section keyword ("%FILES%").
30    pub(crate) const SECTION_KEYWORD: &str = "%FILES%";
31
32    /// Recognizes a [`RelativePath`] in a single line.
33    ///
34    /// # Note
35    ///
36    /// This parser only consumes till the end of a line and attempts to parse a [`RelativePath`]
37    /// from it. Trailing line endings and EOF are handled.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error if a [`RelativePath`] cannot be created from the line, or something other
42    /// than a line ending or EOF is encountered afterwards.
43    fn parse_path(input: &mut &str) -> ModalResult<RelativePath> {
44        // Parse until the end of the line and attempt conversion to RelativePath.
45        // Make sure that the string is not empty!
46        alt((
47            (space1, line_ending)
48                .take()
49                .and_then(cut_err(fail))
50                .context(StrContext::Expected(StrContextValue::Description(
51                    "relative path not consisting of whitespaces and/or tabs",
52                ))),
53            till_line_ending,
54        ))
55        .verify(|s: &str| !s.is_empty())
56        .context(StrContext::Label("relative path"))
57        .parse_to()
58        .parse_next(input)
59    }
60
61    /// Recognizes [alpm-db-files] data in a string slice.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error, if
66    ///
67    /// - `input` is not empty and the first line does not contain the required section header
68    ///   "%FILES%",
69    /// - or there are lines following the section header, but they cannot be parsed as a [`Vec`] of
70    ///   [`RelativePath`].
71    ///
72    /// [alpm-db-files]: https://alpm.archlinux.page/specifications/alpm-db-files.5.html
73    pub(crate) fn parser(input: &mut &str) -> ModalResult<Self> {
74        // Return early if the input is empty.
75        // This may be the case in an alpm-db-files file if a package contains no files.
76        if input.is_empty() {
77            return Ok(Self(Vec::new()));
78        }
79
80        // Consume the required section header "%FILES%".
81        // Optionally consume one following line ending.
82        cut_err(terminated(Self::SECTION_KEYWORD, alt((line_ending, eof))))
83            .context(StrContext::Label("alpm-db-files section header"))
84            .context(StrContext::Expected(StrContextValue::Description(
85                Self::SECTION_KEYWORD,
86            )))
87            .parse_next(input)?;
88
89        // Return early if there is only the section header.
90        if input.is_empty() {
91            return Ok(Self(Vec::new()));
92        }
93
94        // Consider all following lines as paths.
95        // Optionally consume one following line ending.
96        let paths: Vec<RelativePath> =
97            repeat(0.., terminated(Self::parse_path, alt((line_ending, eof)))).parse_next(input)?;
98
99        // Consume any trailing whitespaces or new lines.
100        multispace0.parse_next(input)?;
101
102        // If a BACKUP section follows, leave the rest of the input to that parser.
103        if input.is_empty() || input.starts_with(BackupSection::SECTION_KEYWORD) {
104            return Ok(Self(paths));
105        }
106
107        // Fail if there are any further non-whitespace characters.
108        let _opt: Option<&str> =
109            opt(not(eof)
110                .take()
111                .and_then(cut_err(fail).context(StrContext::Expected(
112                    StrContextValue::Description("no further path after newline"),
113                ))))
114            .parse_next(input)?;
115
116        Ok(Self(paths))
117    }
118
119    /// Returns the paths.
120    pub fn paths(self) -> Vec<PathBuf> {
121        self.0.into_iter().map(RelativePath::into_inner).collect()
122    }
123}
124
125/// A path that should be tracked for backup together with its checksum.
126#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
127pub struct BackupEntry {
128    /// The path to the file that is backed up.
129    pub path: RelativeFilePath,
130    /// The MD5 checksum of the backed up file as stored in the package.
131    pub md5: Md5Checksum,
132}
133
134impl BackupEntry {
135    /// Recognizes a single backup entry.
136    ///
137    /// Each entry consists of a relative path, a tab, and a 32 character hexadecimal MD5 digest.
138    ///
139    /// # Note
140    ///
141    /// As a special edge case, the parser does not fail if it encounters the keyword `(null)`
142    /// instead of an MD-5 hash digest. The `(null)` keyword may be present in [alpm-db-files]
143    /// files, due to how [pacman] handles package metadata with invalid `backup` entries.
144    /// Specifically, if a package is created from a [PKGBUILD] that tracks files in its `backup`
145    /// array, which are not in the package, then pacman creates an invalid `%BACKUP%` entry upon
146    /// installation of the package, instead of skipping the invalid entries.
147    ///
148    /// [PKGBUILD]: https://man.archlinux.org/man/PKGBUILD.5
149    /// [alpm-db-files]: https://alpm.archlinux.page/specifications/alpm-db-files.5.html
150    /// [pacman]: https://man.archlinux.org/man/pacman.8
151    pub(crate) fn parser(input: &mut &str) -> ModalResult<Option<Self>> {
152        let mut line = till_line_ending.parse_next(input)?;
153        separated_pair(
154            take_while(1.., |c: char| c != '\t' && c != '\n' && c != '\r')
155                .verify(|s: &str| !s.chars().all(|c| c.is_whitespace()))
156                .context(StrContext::Label("relative path"))
157                .parse_to(),
158            '\t',
159            alt((
160                // Some alpm-db-files metadata may contain "(null)" instead of a hash digest for a
161                // backup entry. This happens if a file that is not contained in a
162                // package is added to the package's PKGBUILD and pacman adds an (unused) backup
163                // entry for it nonetheless.
164                "(null)".value(None),
165                Md5Checksum::parser.map(Some),
166            )),
167        )
168        .map(|(path, md5)| md5.map(|md5| BackupEntry { path, md5 }))
169        .parse_next(&mut line)
170    }
171}
172
173/// The raw backup section in [alpm-db-files] data.
174///
175/// [alpm-db-files]: https://alpm.archlinux.page/specifications/alpm-db-files.5.html
176#[derive(Debug)]
177pub(crate) struct BackupSection(Vec<BackupEntry>);
178
179impl BackupSection {
180    /// The section keyword ("%BACKUP%").
181    pub(crate) const SECTION_KEYWORD: &str = "%BACKUP%";
182
183    /// Recognizes the optional `%BACKUP%` section.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if the section header is missing or malformed, or if any entry cannot be
188    /// parsed.
189    pub(crate) fn parser(input: &mut &str) -> ModalResult<Self> {
190        cut_err(terminated(Self::SECTION_KEYWORD, alt((line_ending, eof))))
191            .context(StrContext::Label("alpm-db-files backup section header"))
192            .context(StrContext::Expected(StrContextValue::Description(
193                Self::SECTION_KEYWORD,
194            )))
195            .parse_next(input)?;
196
197        if input.is_empty() {
198            return Ok(Self(Vec::new()));
199        }
200
201        let entries: Vec<BackupEntry> = repeat(
202            0..,
203            terminated(BackupEntry::parser, alt((line_ending, eof))),
204        )
205        .map(|entries: Vec<Option<BackupEntry>>| entries.into_iter().flatten().collect::<Vec<_>>())
206        .parse_next(input)?;
207
208        // Consume any trailing whitespaces or new lines.
209        multispace0.parse_next(input)?;
210
211        // Fail if there are any further non-whitespace characters.
212        let _opt: Option<&str> =
213            opt(not(eof)
214                .take()
215                .and_then(cut_err(fail).context(StrContext::Expected(
216                    StrContextValue::Description("no further backup entry after newline"),
217                ))))
218            .parse_next(input)?;
219
220        Ok(Self(entries))
221    }
222
223    /// Returns the parsed entries.
224    pub fn entries(self) -> Vec<BackupEntry> {
225        self.0
226    }
227}
228
229/// A collection of paths that are invalid in the context of a [`DbFilesV1`].
230///
231/// A [`DbFilesV1`] must not contain duplicate paths or (non top-level) paths that do not have a
232/// parent in the same set of paths.
233#[derive(Clone, Debug, Eq, PartialEq)]
234pub(crate) struct FilesV1PathErrors {
235    pub(crate) absolute: HashSet<PathBuf>,
236    pub(crate) without_parent: HashSet<PathBuf>,
237    pub(crate) duplicate: HashSet<PathBuf>,
238}
239
240impl FilesV1PathErrors {
241    /// Creates a new [`FilesV1PathErrors`].
242    pub(crate) fn new() -> Self {
243        Self {
244            absolute: HashSet::new(),
245            without_parent: HashSet::new(),
246            duplicate: HashSet::new(),
247        }
248    }
249
250    /// Adds a new absolute path.
251    pub(crate) fn add_absolute(&mut self, path: PathBuf) -> bool {
252        self.absolute.insert(path)
253    }
254
255    /// Adds a new (non top-level) path that does not have a parent.
256    pub(crate) fn add_without_parent(&mut self, path: PathBuf) -> bool {
257        self.without_parent.insert(path)
258    }
259
260    /// Adds a new duplicate path.
261    pub(crate) fn add_duplicate(&mut self, path: PathBuf) -> bool {
262        self.duplicate.insert(path)
263    }
264
265    /// Fails if `self` tracks any invalid paths.
266    pub(crate) fn fail(&self) -> Result<(), Error> {
267        if !(self.absolute.is_empty()
268            && self.without_parent.is_empty()
269            && self.duplicate.is_empty())
270        {
271            Err(Error::InvalidFilesPaths {
272                message: self.to_string(),
273            })
274        } else {
275            Ok(())
276        }
277    }
278}
279
280impl Display for FilesV1PathErrors {
281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282        fn write_invalid_set(
283            f: &mut std::fmt::Formatter<'_>,
284            message: String,
285            set: &HashSet<PathBuf>,
286        ) -> std::fmt::Result {
287            if !set.is_empty() {
288                writeln!(f, "{message}:")?;
289                let mut set = set.iter().collect::<Vec<_>>();
290                set.sort();
291                for path in set.iter() {
292                    writeln!(f, "{}", path.as_path().display())?;
293                }
294            }
295            Ok(())
296        }
297
298        write_invalid_set(f, t!("filesv1-path-errors-absolute-paths"), &self.absolute)?;
299        write_invalid_set(
300            f,
301            t!("filesv1-path-errors-paths-without-a-parent"),
302            &self.without_parent,
303        )?;
304        write_invalid_set(
305            f,
306            t!("filesv1-path-errors-duplicate-paths"),
307            &self.duplicate,
308        )?;
309
310        Ok(())
311    }
312}
313
314/// A collection of invalid backup entries for a [`DbFilesV1`].
315///
316/// A [`DbFilesV1`] must not contain duplicate backup paths or backup paths that are not listed in
317/// the `%FILES%` section.
318#[derive(Clone, Debug, Eq, PartialEq)]
319pub(crate) struct BackupV1Errors {
320    pub(crate) not_in_files: HashSet<RelativeFilePath>,
321    pub(crate) duplicate: HashSet<RelativeFilePath>,
322}
323
324impl BackupV1Errors {
325    /// Creates a new [`BackupV1Errors`].
326    pub(crate) fn new() -> Self {
327        Self {
328            not_in_files: HashSet::new(),
329            duplicate: HashSet::new(),
330        }
331    }
332
333    /// Adds a new path that is not tracked by the `%FILES%` section.
334    pub(crate) fn add_not_in_files(&mut self, path: RelativeFilePath) -> bool {
335        self.not_in_files.insert(path)
336    }
337
338    /// Adds a new duplicate path.
339    pub(crate) fn add_duplicate(&mut self, path: RelativeFilePath) -> bool {
340        self.duplicate.insert(path)
341    }
342
343    /// Fails if `self` tracks any invalid backup entries.
344    pub(crate) fn fail(&self) -> Result<(), Error> {
345        if !(self.not_in_files.is_empty() && self.duplicate.is_empty()) {
346            Err(Error::InvalidBackupEntries {
347                message: self.to_string(),
348            })
349        } else {
350            Ok(())
351        }
352    }
353}
354
355impl Display for BackupV1Errors {
356    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        fn write_invalid_set(
358            f: &mut std::fmt::Formatter<'_>,
359            message: String,
360            set: &HashSet<RelativeFilePath>,
361        ) -> std::fmt::Result {
362            if !set.is_empty() {
363                writeln!(f, "{message}:")?;
364                let mut set = set.iter().collect::<Vec<_>>();
365                set.sort_by(|a, b| a.inner().cmp(b.inner()));
366                for path in set.iter() {
367                    writeln!(f, "{path}")?;
368                }
369            }
370            Ok(())
371        }
372
373        write_invalid_set(
374            f,
375            t!("backupv1-errors-not-in-files-section"),
376            &self.not_in_files,
377        )?;
378        write_invalid_set(f, t!("backupv1-errors-duplicate-paths"), &self.duplicate)?;
379
380        Ok(())
381    }
382}
383
384/// The representation of [alpm-db-files] data (version 1).
385///
386/// [alpm-db-files]: https://alpm.archlinux.page/specifications/alpm-db-files.5.html
387#[derive(Clone, Debug, serde::Serialize)]
388pub struct DbFilesV1 {
389    files: Vec<PathBuf>,
390    #[serde(default)]
391    #[serde(skip_serializing_if = "Vec::is_empty")]
392    backup: Vec<BackupEntry>,
393}
394
395impl AsRef<[PathBuf]> for DbFilesV1 {
396    /// Returns a reference to the inner [`Vec`] of [`PathBuf`]s.
397    fn as_ref(&self) -> &[PathBuf] {
398        &self.files
399    }
400}
401
402impl DbFilesV1 {
403    /// Returns the backup entries tracked for this file listing.
404    pub fn backups(&self) -> &[BackupEntry] {
405        &self.backup
406    }
407
408    fn try_from_parts(
409        mut paths: Vec<PathBuf>,
410        mut backup: Vec<BackupEntry>,
411    ) -> Result<Self, Error> {
412        paths.sort_unstable();
413
414        let mut errors = FilesV1PathErrors::new();
415        let mut path_set = HashSet::new();
416        let empty_parent = PathBuf::from("");
417        let root_parent = PathBuf::from("/");
418
419        for path in paths.iter() {
420            let path = path.as_path();
421
422            // Add absolute paths as errors.
423            if path.is_absolute() {
424                errors.add_absolute(path.to_path_buf());
425            }
426
427            // Add non top-level, relative paths without a parent as errors.
428            if let Some(parent) = path.parent()
429                && parent != empty_parent
430                && parent != root_parent
431                && !path_set.contains(parent)
432            {
433                errors.add_without_parent(path.to_path_buf());
434            }
435
436            // Add duplicates as errors.
437            if !path_set.insert(path.to_path_buf()) {
438                errors.add_duplicate(path.to_path_buf());
439            }
440        }
441
442        errors.fail()?;
443
444        let mut backup_errors = BackupV1Errors::new();
445        let mut backup_set: HashSet<RelativeFilePath> = HashSet::new();
446
447        for entry in backup.iter() {
448            if !path_set.contains(entry.path.inner()) {
449                backup_errors.add_not_in_files(entry.path.clone());
450            }
451
452            if !backup_set.insert(entry.path.clone()) {
453                backup_errors.add_duplicate(entry.path.clone());
454            }
455        }
456
457        backup_errors.fail()?;
458
459        backup.sort_unstable_by(|a, b| a.path.inner().cmp(b.path.inner()));
460
461        Ok(Self {
462            files: paths,
463            backup,
464        })
465    }
466}
467
468impl Display for DbFilesV1 {
469    /// Returns the [`String`] representation of the [`DbFilesV1`].
470    ///
471    /// # Examples
472    ///
473    /// ```
474    /// use std::path::PathBuf;
475    ///
476    /// use alpm_db::files::DbFilesV1;
477    ///
478    /// # fn main() -> Result<(), alpm_db::files::Error> {
479    /// // An empty alpm-db-files.
480    /// let expected = "";
481    /// let files = DbFilesV1::try_from(Vec::new())?;
482    /// assert_eq!(files.to_string(), expected);
483    ///
484    /// // An alpm-db-files with entries.
485    /// let expected = r#"%FILES%
486    /// usr/
487    /// usr/bin/
488    /// usr/bin/foo
489    ///
490    /// "#;
491    /// let files = DbFilesV1::try_from(vec![
492    ///     PathBuf::from("usr/"),
493    ///     PathBuf::from("usr/bin/"),
494    ///     PathBuf::from("usr/bin/foo"),
495    /// ])?;
496    /// assert_eq!(files.to_string(), expected);
497    /// # Ok(())
498    /// # }
499    /// ```
500    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
501        // Return empty string if no paths or backups exist and no section is required.
502        if self.files.is_empty() && self.backup.is_empty() {
503            return Ok(());
504        }
505
506        // %FILES% section
507        writeln!(f, "{}", FilesSection::SECTION_KEYWORD)?;
508
509        for path in &self.files {
510            writeln!(f, "{}", path.to_string_lossy())?;
511        }
512
513        // The spec requires a *trailing* blank line after %FILES%
514        writeln!(f)?;
515
516        // Optional %BACKUP% section
517        if !self.backup.is_empty() {
518            writeln!(f, "{}", BackupSection::SECTION_KEYWORD)?;
519
520            for entry in &self.backup {
521                writeln!(f, "{}\t{}", entry.path, entry.md5)?;
522            }
523        }
524
525        Ok(())
526    }
527}
528
529impl FromStr for DbFilesV1 {
530    type Err = Error;
531
532    /// Creates a new [`DbFilesV1`] from a string slice.
533    ///
534    /// # Note
535    ///
536    /// Delegates to the [`TryFrom`] [`Vec`] of [`PathBuf`] implementation, after the string slice
537    /// has been parsed as a [`Vec`] of [`PathBuf`].
538    ///
539    /// # Errors
540    ///
541    /// Returns an error, if
542    ///
543    /// - `value` is not empty and the first line does not contain the section header ("%FILES%"),
544    /// - there are lines following the section header, but they cannot be parsed as a [`Vec`] of
545    ///   [`PathBuf`],
546    /// - or [`Self::try_from`] [`Vec`] of [`PathBuf`] fails.
547    ///
548    /// # Examples
549    ///
550    /// ```
551    /// use std::{path::PathBuf, str::FromStr};
552    ///
553    /// use alpm_db::files::DbFilesV1;
554    /// use winnow::Parser;
555    ///
556    /// # fn main() -> Result<(), alpm_db::files::Error> {
557    /// # let expected: Vec<PathBuf> = Vec::new();
558    /// // No files according to alpm-db-files.
559    /// let data = "";
560    /// let files = DbFilesV1::from_str(data)?;
561    /// # assert_eq!(files.as_ref(), expected);
562    ///
563    /// // No files according to alpm-db-files.
564    /// let data = "%FILES%";
565    /// let files = DbFilesV1::from_str(data)?;
566    /// # assert_eq!(files.as_ref(), expected);
567    /// let data = "%FILES%\n";
568    /// let files = DbFilesV1::from_str(data)?;
569    /// # assert_eq!(files.as_ref(), expected);
570    ///
571    /// # let expected: Vec<PathBuf> = vec![
572    /// #     PathBuf::from("usr/"),
573    /// #     PathBuf::from("usr/bin/"),
574    /// #     PathBuf::from("usr/bin/foo"),
575    /// # ];
576    /// // DbFiles according to alpm-db-files.
577    /// let data = r#"%FILES%
578    /// usr/
579    /// usr/bin/
580    /// usr/bin/foo"#;
581    /// let files = DbFilesV1::from_str(data)?;
582    /// # assert_eq!(files.as_ref(), expected);
583    ///
584    /// // DbFiles according to alpm-db-files.
585    /// let data = r#"%FILES%
586    /// usr/
587    /// usr/bin/
588    /// usr/bin/foo
589    /// "#;
590    /// let files = DbFilesV1::from_str(data)?;
591    /// # assert_eq!(files.as_ref(), expected.as_slice());
592    /// # Ok(())
593    /// # }
594    /// ```
595    fn from_str(s: &str) -> Result<Self, Self::Err> {
596        let (files_section, backup_section) =
597            (|input: &mut &str| -> ModalResult<(FilesSection, BackupSection)> {
598                let files_section = FilesSection::parser.parse_next(input)?;
599                let backup_section = if input.is_empty() {
600                    BackupSection(Vec::new())
601                } else {
602                    BackupSection::parser.parse_next(input)?
603                };
604                Ok((files_section, backup_section))
605            })
606            .parse(s)?;
607
608        DbFilesV1::try_from_parts(files_section.paths(), backup_section.entries())
609    }
610}
611
612impl TryFrom<PathBuf> for DbFilesV1 {
613    type Error = Error;
614
615    /// Creates a new [`DbFilesV1`] from all files and directories in a directory.
616    ///
617    /// # Note
618    ///
619    /// Delegates to [`alpm_common::relative_files`] to get a sorted list of all files and
620    /// directories in the directory `value` (relative to `value`).
621    /// Afterwards, tries to construct a [`DbFilesV1`] from this list.
622    ///
623    /// # Errors
624    ///
625    /// Returns an error if
626    ///
627    /// - [`alpm_common::relative_files`] fails,
628    /// - or [`TryFrom`] [`Vec`] of [`PathBuf`] for [`DbFilesV1`] fails.
629    ///
630    /// # Examples
631    ///
632    /// ```
633    /// use std::{
634    ///     fs::{File, create_dir_all},
635    ///     path::PathBuf,
636    /// };
637    ///
638    /// use alpm_db::files::DbFilesV1;
639    /// use tempfile::tempdir;
640    ///
641    /// # fn main() -> testresult::TestResult {
642    /// let temp_dir = tempdir()?;
643    /// let path = temp_dir.path();
644    /// create_dir_all(path.join("usr/bin/"))?;
645    /// File::create(path.join("usr/bin/foo"))?;
646    ///
647    /// let files = DbFilesV1::try_from(path.to_path_buf())?;
648    /// assert_eq!(
649    ///     files.as_ref(),
650    ///     vec![
651    ///         PathBuf::from("usr/"),
652    ///         PathBuf::from("usr/bin/"),
653    ///         PathBuf::from("usr/bin/foo")
654    ///     ]
655    /// );
656    /// # Ok(())
657    /// # }
658    /// ```
659    fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
660        DbFilesV1::try_from_parts(relative_files(value, &[])?, Vec::new())
661    }
662}
663
664impl TryFrom<Vec<PathBuf>> for DbFilesV1 {
665    type Error = Error;
666
667    /// Creates a new [`DbFilesV1`] from a [`Vec`] of [`PathBuf`].
668    ///
669    /// The provided `value` is sorted and checked for non top-level paths without a parent, as well
670    /// as any duplicate paths.
671    ///
672    /// # Errors
673    ///
674    /// Returns an error if
675    ///
676    /// - `value` contains absolute paths,
677    /// - `value` contains (non top-level) paths without a parent directory present in `value`,
678    /// - or `value` contains duplicate paths.
679    ///
680    /// # Examples
681    ///
682    /// ```
683    /// use std::path::PathBuf;
684    ///
685    /// use alpm_db::files::DbFilesV1;
686    ///
687    /// # fn main() -> Result<(), alpm_db::files::Error> {
688    /// let paths: Vec<PathBuf> = vec![
689    ///     PathBuf::from("usr/"),
690    ///     PathBuf::from("usr/bin/"),
691    ///     PathBuf::from("usr/bin/foo"),
692    /// ];
693    /// let files = DbFilesV1::try_from(paths)?;
694    ///
695    /// // Absolute paths are not allowed.
696    /// let paths: Vec<PathBuf> = vec![
697    ///     PathBuf::from("/usr/"),
698    ///     PathBuf::from("/usr/bin/"),
699    ///     PathBuf::from("/usr/bin/foo"),
700    /// ];
701    /// assert!(DbFilesV1::try_from(paths).is_err());
702    ///
703    /// // Every path (excluding top-level paths) must have a parent.
704    /// let paths: Vec<PathBuf> = vec![PathBuf::from("usr/bin/"), PathBuf::from("usr/bin/foo")];
705    /// assert!(DbFilesV1::try_from(paths).is_err());
706    ///
707    /// // Every path must be unique.
708    /// let paths: Vec<PathBuf> = vec![
709    ///     PathBuf::from("usr/"),
710    ///     PathBuf::from("usr/"),
711    ///     PathBuf::from("usr/bin/"),
712    ///     PathBuf::from("usr/bin/foo"),
713    /// ];
714    /// assert!(DbFilesV1::try_from(paths).is_err());
715    /// # Ok(())
716    /// # }
717    /// ```
718    fn try_from(value: Vec<PathBuf>) -> Result<Self, Self::Error> {
719        DbFilesV1::try_from_parts(value, Vec::new())
720    }
721}
722
723impl TryFrom<(Vec<PathBuf>, Vec<BackupEntry>)> for DbFilesV1 {
724    type Error = Error;
725
726    /// Creates a new [`DbFilesV1`] from a [`Vec`] of [`PathBuf`] and backup entries.
727    fn try_from(value: (Vec<PathBuf>, Vec<BackupEntry>)) -> Result<Self, Self::Error> {
728        let (paths, backup) = value;
729        DbFilesV1::try_from_parts(paths, backup)
730    }
731}
732
733#[cfg(test)]
734mod tests {
735    use std::{
736        fs::{File, create_dir_all},
737        str::FromStr,
738    };
739
740    use alpm_types::{Md5Checksum, RelativeFilePath};
741    use rstest::rstest;
742    use tempfile::tempdir;
743    use testresult::TestResult;
744
745    use super::*;
746
747    /// Ensures that a [`DbFilesV1`] can be successfully created from a directory.
748    #[test]
749    fn filesv1_try_from_pathbuf_succeeds() -> TestResult {
750        let temp_dir = tempdir()?;
751        let path = temp_dir.path();
752        create_dir_all(path.join("usr/bin/"))?;
753        File::create(path.join("usr/bin/foo"))?;
754
755        let files = DbFilesV1::try_from(path.to_path_buf())?;
756
757        assert_eq!(
758            files.as_ref(),
759            vec![
760                PathBuf::from("usr/"),
761                PathBuf::from("usr/bin/"),
762                PathBuf::from("usr/bin/foo")
763            ]
764        );
765
766        Ok(())
767    }
768
769    #[rstest]
770    #[case::dirs_and_files(vec![PathBuf::from("usr/"), PathBuf::from("usr/bin/"), PathBuf::from("usr/bin/foo")], 3)]
771    #[case::empty(Vec::new(), 0)]
772    fn filesv1_try_from_pathbufs_succeeds(
773        #[case] paths: Vec<PathBuf>,
774        #[case] len: usize,
775    ) -> TestResult {
776        let files = DbFilesV1::try_from(paths)?;
777
778        assert_eq!(files.as_ref().len(), len);
779
780        Ok(())
781    }
782
783    #[rstest]
784    #[case::absolute_paths(
785        vec![
786            PathBuf::from("/usr/"), PathBuf::from("/usr/bin/"), PathBuf::from("/usr/bin/foo")
787        ],
788        FilesV1PathErrors{
789            absolute: HashSet::from_iter([
790                PathBuf::from("/usr/"),
791                PathBuf::from("/usr/bin/"),
792                PathBuf::from("/usr/bin/foo"),
793            ]),
794            without_parent: HashSet::new(),
795            duplicate: HashSet::new(),
796        }
797    )]
798    #[case::without_parents(
799        vec![PathBuf::from("usr/bin/"), PathBuf::from("usr/bin/foo")],
800        FilesV1PathErrors{
801            absolute: HashSet::new(),
802            without_parent: HashSet::from_iter([
803                PathBuf::from("usr/bin/"),
804            ]),
805            duplicate: HashSet::new(),
806        }
807    )]
808    #[case::duplicates(
809        vec![PathBuf::from("usr/"), PathBuf::from("usr/")],
810        FilesV1PathErrors{
811            absolute: HashSet::new(),
812            without_parent: HashSet::new(),
813            duplicate: HashSet::from_iter([
814                PathBuf::from("usr/"),
815            ]),
816        }
817    )]
818    fn filesv1_try_from_pathbufs_fails(
819        #[case] paths: Vec<PathBuf>,
820        #[case] expected_errors: FilesV1PathErrors,
821    ) -> TestResult {
822        let result = DbFilesV1::try_from(paths);
823        let errors = match result {
824            Ok(files) => panic!(
825                "Should have failed with an Error::InvalidFilesPaths, but succeeded to create a DbFilesV1: {files:?}"
826            ),
827            Err(Error::InvalidFilesPaths { message }) => message,
828            Err(error) => panic!("Expected an Error::InvalidFilesPaths, but got: {error}"),
829        };
830
831        eprintln!("{errors}");
832        assert_eq!(errors, expected_errors.to_string());
833
834        Ok(())
835    }
836
837    #[test]
838    fn filesv1_try_from_paths_and_backups_succeeds() -> TestResult {
839        let paths = vec![
840            PathBuf::from("usr/"),
841            PathBuf::from("usr/bin/"),
842            PathBuf::from("usr/bin/foo"),
843        ];
844        let backup = vec![BackupEntry {
845            path: RelativeFilePath::from_str("usr/bin/foo")?,
846            md5: Md5Checksum::from_str("d41d8cd98f00b204e9800998ecf8427e")?,
847        }];
848
849        let files = DbFilesV1::try_from((paths, backup))?;
850
851        assert_eq!(files.backups().len(), 1);
852
853        Ok(())
854    }
855
856    #[rstest]
857    #[case::backup_not_in_files(
858        vec![PathBuf::from("usr/")],
859        vec![BackupEntry {
860            path: RelativeFilePath::from_str("usr/bin/foo").unwrap(),
861            md5: Md5Checksum::from_str("d41d8cd98f00b204e9800998ecf8427e").unwrap(),
862        }],
863        BackupV1Errors{
864            not_in_files: HashSet::from_iter([RelativeFilePath::from_str("usr/bin/foo").unwrap()]),
865            duplicate: HashSet::new(),
866        }
867    )]
868    #[case::duplicate_backup_entries(
869        vec![
870            PathBuf::from("usr/"),
871            PathBuf::from("usr/bin/"),
872            PathBuf::from("usr/bin/foo")
873        ],
874        vec![
875            BackupEntry {
876                path: RelativeFilePath::from_str("usr/bin/foo").unwrap(),
877                md5: Md5Checksum::from_str("d41d8cd98f00b204e9800998ecf8427e").unwrap(),
878            },
879            BackupEntry {
880                path: RelativeFilePath::from_str("usr/bin/foo").unwrap(),
881                md5: Md5Checksum::from_str("d41d8cd98f00b204e9800998ecf8427e").unwrap(),
882            }
883        ],
884        BackupV1Errors{
885            not_in_files: HashSet::new(),
886            duplicate: HashSet::from_iter([RelativeFilePath::from_str("usr/bin/foo").unwrap()]),
887        }
888    )]
889    fn filesv1_try_from_paths_and_backups_fails(
890        #[case] paths: Vec<PathBuf>,
891        #[case] backup: Vec<BackupEntry>,
892        #[case] expected_errors: BackupV1Errors,
893    ) -> TestResult {
894        let result = DbFilesV1::try_from((paths, backup));
895        let errors = match result {
896            Ok(files) => panic!(
897                "Should have failed with an Error::InvalidBackupEntries, but succeeded to create a DbFilesV1: {files:?}"
898            ),
899            Err(Error::InvalidBackupEntries { message }) => message,
900            Err(error) => panic!("Expected an Error::InvalidBackupEntries, but got: {error}"),
901        };
902
903        eprintln!("{errors}");
904        assert_eq!(errors, expected_errors.to_string());
905
906        Ok(())
907    }
908
909    #[test]
910    fn filesv1_from_str_rejects_absolute_paths() -> TestResult {
911        let data = "%FILES%\n/usr/bin/foo\n";
912
913        match DbFilesV1::from_str(data) {
914            Err(Error::ParseError(_)) => Ok(()),
915            Err(error) => panic!("expected ParseError, got {error}"),
916            Ok(files) => panic!("expected parse failure, got {files:?}"),
917        }
918    }
919
920    #[test]
921    fn filesv1_from_str_skips_null_backup_entries() -> TestResult {
922        let data = r#"%FILES%
923etc/
924etc/foo/
925etc/foo/foo.conf
926
927%BACKUP%
928etc/foo/foo.conf	d41d8cd98f00b204e9800998ecf8427e
929etc/foo/bar.conf	(null)
930"#;
931
932        let files = DbFilesV1::from_str(data)?;
933
934        assert_eq!(
935            files.backups(),
936            &[BackupEntry {
937                path: RelativeFilePath::from_str("etc/foo/foo.conf")?,
938                md5: Md5Checksum::from_str("d41d8cd98f00b204e9800998ecf8427e")?
939            }]
940        );
941
942        Ok(())
943    }
944}