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