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