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