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                if parent != empty_parent && parent != root_parent && !path_set.contains(parent) {
429                    errors.add_without_parent(path.to_path_buf());
430                }
431            }
432
433            // Add duplicates as errors.
434            if !path_set.insert(path.to_path_buf()) {
435                errors.add_duplicate(path.to_path_buf());
436            }
437        }
438
439        errors.fail()?;
440
441        let mut backup_errors = BackupV1Errors::new();
442        let mut backup_set: HashSet<RelativeFilePath> = HashSet::new();
443
444        for entry in backup.iter() {
445            if !path_set.contains(entry.path.inner()) {
446                backup_errors.add_not_in_files(entry.path.clone());
447            }
448
449            if !backup_set.insert(entry.path.clone()) {
450                backup_errors.add_duplicate(entry.path.clone());
451            }
452        }
453
454        backup_errors.fail()?;
455
456        backup.sort_unstable_by(|a, b| a.path.inner().cmp(b.path.inner()));
457
458        Ok(Self {
459            files: paths,
460            backup,
461        })
462    }
463}
464
465impl Display for DbFilesV1 {
466    /// Returns the [`String`] representation of the [`DbFilesV1`].
467    ///
468    /// # Examples
469    ///
470    /// ```
471    /// use std::path::PathBuf;
472    ///
473    /// use alpm_db::files::DbFilesV1;
474    ///
475    /// # fn main() -> Result<(), alpm_db::files::Error> {
476    /// // An empty alpm-db-files.
477    /// let expected = "";
478    /// let files = DbFilesV1::try_from(Vec::new())?;
479    /// assert_eq!(files.to_string(), expected);
480    ///
481    /// // An alpm-db-files with entries.
482    /// let expected = r#"%FILES%
483    /// usr/
484    /// usr/bin/
485    /// usr/bin/foo
486    ///
487    /// "#;
488    /// let files = DbFilesV1::try_from(vec![
489    ///     PathBuf::from("usr/"),
490    ///     PathBuf::from("usr/bin/"),
491    ///     PathBuf::from("usr/bin/foo"),
492    /// ])?;
493    /// assert_eq!(files.to_string(), expected);
494    /// # Ok(())
495    /// # }
496    /// ```
497    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
498        // Return empty string if no paths or backups exist and no section is required.
499        if self.files.is_empty() && self.backup.is_empty() {
500            return Ok(());
501        }
502
503        // %FILES% section
504        writeln!(f, "{}", FilesSection::SECTION_KEYWORD)?;
505
506        for path in &self.files {
507            writeln!(f, "{}", path.to_string_lossy())?;
508        }
509
510        // The spec requires a *trailing* blank line after %FILES%
511        writeln!(f)?;
512
513        // Optional %BACKUP% section
514        if !self.backup.is_empty() {
515            writeln!(f, "{}", BackupSection::SECTION_KEYWORD)?;
516
517            for entry in &self.backup {
518                writeln!(f, "{}\t{}", entry.path, entry.md5)?;
519            }
520        }
521
522        Ok(())
523    }
524}
525
526impl FromStr for DbFilesV1 {
527    type Err = Error;
528
529    /// Creates a new [`DbFilesV1`] from a string slice.
530    ///
531    /// # Note
532    ///
533    /// Delegates to the [`TryFrom`] [`Vec`] of [`PathBuf`] implementation, after the string slice
534    /// has been parsed as a [`Vec`] of [`PathBuf`].
535    ///
536    /// # Errors
537    ///
538    /// Returns an error, if
539    ///
540    /// - `value` is not empty and the first line does not contain the section header ("%FILES%"),
541    /// - there are lines following the section header, but they cannot be parsed as a [`Vec`] of
542    ///   [`PathBuf`],
543    /// - or [`Self::try_from`] [`Vec`] of [`PathBuf`] fails.
544    ///
545    /// # Examples
546    ///
547    /// ```
548    /// use std::{path::PathBuf, str::FromStr};
549    ///
550    /// use alpm_db::files::DbFilesV1;
551    /// use winnow::Parser;
552    ///
553    /// # fn main() -> Result<(), alpm_db::files::Error> {
554    /// # let expected: Vec<PathBuf> = Vec::new();
555    /// // No files according to alpm-db-files.
556    /// let data = "";
557    /// let files = DbFilesV1::from_str(data)?;
558    /// # assert_eq!(files.as_ref(), expected);
559    ///
560    /// // No files according to alpm-db-files.
561    /// let data = "%FILES%";
562    /// let files = DbFilesV1::from_str(data)?;
563    /// # assert_eq!(files.as_ref(), expected);
564    /// let data = "%FILES%\n";
565    /// let files = DbFilesV1::from_str(data)?;
566    /// # assert_eq!(files.as_ref(), expected);
567    ///
568    /// # let expected: Vec<PathBuf> = vec![
569    /// #     PathBuf::from("usr/"),
570    /// #     PathBuf::from("usr/bin/"),
571    /// #     PathBuf::from("usr/bin/foo"),
572    /// # ];
573    /// // DbFiles according to alpm-db-files.
574    /// let data = r#"%FILES%
575    /// usr/
576    /// usr/bin/
577    /// usr/bin/foo"#;
578    /// let files = DbFilesV1::from_str(data)?;
579    /// # assert_eq!(files.as_ref(), expected);
580    ///
581    /// // DbFiles according to alpm-db-files.
582    /// let data = r#"%FILES%
583    /// usr/
584    /// usr/bin/
585    /// usr/bin/foo
586    /// "#;
587    /// let files = DbFilesV1::from_str(data)?;
588    /// # assert_eq!(files.as_ref(), expected.as_slice());
589    /// # Ok(())
590    /// # }
591    /// ```
592    fn from_str(s: &str) -> Result<Self, Self::Err> {
593        let (files_section, backup_section) =
594            (|input: &mut &str| -> ModalResult<(FilesSection, BackupSection)> {
595                let files_section = FilesSection::parser.parse_next(input)?;
596                let backup_section = if input.is_empty() {
597                    BackupSection(Vec::new())
598                } else {
599                    BackupSection::parser.parse_next(input)?
600                };
601                Ok((files_section, backup_section))
602            })
603            .parse(s)?;
604
605        DbFilesV1::try_from_parts(files_section.paths(), backup_section.entries())
606    }
607}
608
609impl TryFrom<PathBuf> for DbFilesV1 {
610    type Error = Error;
611
612    /// Creates a new [`DbFilesV1`] from all files and directories in a directory.
613    ///
614    /// # Note
615    ///
616    /// Delegates to [`alpm_common::relative_files`] to get a sorted list of all files and
617    /// directories in the directory `value` (relative to `value`).
618    /// Afterwards, tries to construct a [`DbFilesV1`] from this list.
619    ///
620    /// # Errors
621    ///
622    /// Returns an error if
623    ///
624    /// - [`alpm_common::relative_files`] fails,
625    /// - or [`TryFrom`] [`Vec`] of [`PathBuf`] for [`DbFilesV1`] fails.
626    ///
627    /// # Examples
628    ///
629    /// ```
630    /// use std::{
631    ///     fs::{File, create_dir_all},
632    ///     path::PathBuf,
633    /// };
634    ///
635    /// use alpm_db::files::DbFilesV1;
636    /// use tempfile::tempdir;
637    ///
638    /// # fn main() -> testresult::TestResult {
639    /// let temp_dir = tempdir()?;
640    /// let path = temp_dir.path();
641    /// create_dir_all(path.join("usr/bin/"))?;
642    /// File::create(path.join("usr/bin/foo"))?;
643    ///
644    /// let files = DbFilesV1::try_from(path.to_path_buf())?;
645    /// assert_eq!(
646    ///     files.as_ref(),
647    ///     vec![
648    ///         PathBuf::from("usr/"),
649    ///         PathBuf::from("usr/bin/"),
650    ///         PathBuf::from("usr/bin/foo")
651    ///     ]
652    /// );
653    /// # Ok(())
654    /// # }
655    /// ```
656    fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
657        DbFilesV1::try_from_parts(relative_files(value, &[])?, Vec::new())
658    }
659}
660
661impl TryFrom<Vec<PathBuf>> for DbFilesV1 {
662    type Error = Error;
663
664    /// Creates a new [`DbFilesV1`] from a [`Vec`] of [`PathBuf`].
665    ///
666    /// The provided `value` is sorted and checked for non top-level paths without a parent, as well
667    /// as any duplicate paths.
668    ///
669    /// # Errors
670    ///
671    /// Returns an error if
672    ///
673    /// - `value` contains absolute paths,
674    /// - `value` contains (non top-level) paths without a parent directory present in `value`,
675    /// - or `value` contains duplicate paths.
676    ///
677    /// # Examples
678    ///
679    /// ```
680    /// use std::path::PathBuf;
681    ///
682    /// use alpm_db::files::DbFilesV1;
683    ///
684    /// # fn main() -> Result<(), alpm_db::files::Error> {
685    /// let paths: Vec<PathBuf> = vec![
686    ///     PathBuf::from("usr/"),
687    ///     PathBuf::from("usr/bin/"),
688    ///     PathBuf::from("usr/bin/foo"),
689    /// ];
690    /// let files = DbFilesV1::try_from(paths)?;
691    ///
692    /// // Absolute paths are not allowed.
693    /// let paths: Vec<PathBuf> = vec![
694    ///     PathBuf::from("/usr/"),
695    ///     PathBuf::from("/usr/bin/"),
696    ///     PathBuf::from("/usr/bin/foo"),
697    /// ];
698    /// assert!(DbFilesV1::try_from(paths).is_err());
699    ///
700    /// // Every path (excluding top-level paths) must have a parent.
701    /// let paths: Vec<PathBuf> = vec![PathBuf::from("usr/bin/"), PathBuf::from("usr/bin/foo")];
702    /// assert!(DbFilesV1::try_from(paths).is_err());
703    ///
704    /// // Every path must be unique.
705    /// let paths: Vec<PathBuf> = vec![
706    ///     PathBuf::from("usr/"),
707    ///     PathBuf::from("usr/"),
708    ///     PathBuf::from("usr/bin/"),
709    ///     PathBuf::from("usr/bin/foo"),
710    /// ];
711    /// assert!(DbFilesV1::try_from(paths).is_err());
712    /// # Ok(())
713    /// # }
714    /// ```
715    fn try_from(value: Vec<PathBuf>) -> Result<Self, Self::Error> {
716        DbFilesV1::try_from_parts(value, Vec::new())
717    }
718}
719
720impl TryFrom<(Vec<PathBuf>, Vec<BackupEntry>)> for DbFilesV1 {
721    type Error = Error;
722
723    /// Creates a new [`DbFilesV1`] from a [`Vec`] of [`PathBuf`] and backup entries.
724    fn try_from(value: (Vec<PathBuf>, Vec<BackupEntry>)) -> Result<Self, Self::Error> {
725        let (paths, backup) = value;
726        DbFilesV1::try_from_parts(paths, backup)
727    }
728}
729
730#[cfg(test)]
731mod tests {
732    use std::{
733        fs::{File, create_dir_all},
734        str::FromStr,
735    };
736
737    use alpm_types::{Md5Checksum, RelativeFilePath};
738    use rstest::rstest;
739    use tempfile::tempdir;
740    use testresult::TestResult;
741
742    use super::*;
743
744    /// Ensures that a [`DbFilesV1`] can be successfully created from a directory.
745    #[test]
746    fn filesv1_try_from_pathbuf_succeeds() -> TestResult {
747        let temp_dir = tempdir()?;
748        let path = temp_dir.path();
749        create_dir_all(path.join("usr/bin/"))?;
750        File::create(path.join("usr/bin/foo"))?;
751
752        let files = DbFilesV1::try_from(path.to_path_buf())?;
753
754        assert_eq!(
755            files.as_ref(),
756            vec![
757                PathBuf::from("usr/"),
758                PathBuf::from("usr/bin/"),
759                PathBuf::from("usr/bin/foo")
760            ]
761        );
762
763        Ok(())
764    }
765
766    #[rstest]
767    #[case::dirs_and_files(vec![PathBuf::from("usr/"), PathBuf::from("usr/bin/"), PathBuf::from("usr/bin/foo")], 3)]
768    #[case::empty(Vec::new(), 0)]
769    fn filesv1_try_from_pathbufs_succeeds(
770        #[case] paths: Vec<PathBuf>,
771        #[case] len: usize,
772    ) -> TestResult {
773        let files = DbFilesV1::try_from(paths)?;
774
775        assert_eq!(files.as_ref().len(), len);
776
777        Ok(())
778    }
779
780    #[rstest]
781    #[case::absolute_paths(
782        vec![
783            PathBuf::from("/usr/"), PathBuf::from("/usr/bin/"), PathBuf::from("/usr/bin/foo")
784        ],
785        FilesV1PathErrors{
786            absolute: HashSet::from_iter([
787                PathBuf::from("/usr/"),
788                PathBuf::from("/usr/bin/"),
789                PathBuf::from("/usr/bin/foo"),
790            ]),
791            without_parent: HashSet::new(),
792            duplicate: HashSet::new(),
793        }
794    )]
795    #[case::without_parents(
796        vec![PathBuf::from("usr/bin/"), PathBuf::from("usr/bin/foo")],
797        FilesV1PathErrors{
798            absolute: HashSet::new(),
799            without_parent: HashSet::from_iter([
800                PathBuf::from("usr/bin/"),
801            ]),
802            duplicate: HashSet::new(),
803        }
804    )]
805    #[case::duplicates(
806        vec![PathBuf::from("usr/"), PathBuf::from("usr/")],
807        FilesV1PathErrors{
808            absolute: HashSet::new(),
809            without_parent: HashSet::new(),
810            duplicate: HashSet::from_iter([
811                PathBuf::from("usr/"),
812            ]),
813        }
814    )]
815    fn filesv1_try_from_pathbufs_fails(
816        #[case] paths: Vec<PathBuf>,
817        #[case] expected_errors: FilesV1PathErrors,
818    ) -> TestResult {
819        let result = DbFilesV1::try_from(paths);
820        let errors = match result {
821            Ok(files) => panic!(
822                "Should have failed with an Error::InvalidFilesPaths, but succeeded to create a DbFilesV1: {files:?}"
823            ),
824            Err(Error::InvalidFilesPaths { message }) => message,
825            Err(error) => panic!("Expected an Error::InvalidFilesPaths, but got: {error}"),
826        };
827
828        eprintln!("{errors}");
829        assert_eq!(errors, expected_errors.to_string());
830
831        Ok(())
832    }
833
834    #[test]
835    fn filesv1_try_from_paths_and_backups_succeeds() -> TestResult {
836        let paths = vec![
837            PathBuf::from("usr/"),
838            PathBuf::from("usr/bin/"),
839            PathBuf::from("usr/bin/foo"),
840        ];
841        let backup = vec![BackupEntry {
842            path: RelativeFilePath::from_str("usr/bin/foo")?,
843            md5: Md5Checksum::from_str("d41d8cd98f00b204e9800998ecf8427e")?,
844        }];
845
846        let files = DbFilesV1::try_from((paths, backup))?;
847
848        assert_eq!(files.backups().len(), 1);
849
850        Ok(())
851    }
852
853    #[rstest]
854    #[case::backup_not_in_files(
855        vec![PathBuf::from("usr/")],
856        vec![BackupEntry {
857            path: RelativeFilePath::from_str("usr/bin/foo").unwrap(),
858            md5: Md5Checksum::from_str("d41d8cd98f00b204e9800998ecf8427e").unwrap(),
859        }],
860        BackupV1Errors{
861            not_in_files: HashSet::from_iter([RelativeFilePath::from_str("usr/bin/foo").unwrap()]),
862            duplicate: HashSet::new(),
863        }
864    )]
865    #[case::duplicate_backup_entries(
866        vec![
867            PathBuf::from("usr/"),
868            PathBuf::from("usr/bin/"),
869            PathBuf::from("usr/bin/foo")
870        ],
871        vec![
872            BackupEntry {
873                path: RelativeFilePath::from_str("usr/bin/foo").unwrap(),
874                md5: Md5Checksum::from_str("d41d8cd98f00b204e9800998ecf8427e").unwrap(),
875            },
876            BackupEntry {
877                path: RelativeFilePath::from_str("usr/bin/foo").unwrap(),
878                md5: Md5Checksum::from_str("d41d8cd98f00b204e9800998ecf8427e").unwrap(),
879            }
880        ],
881        BackupV1Errors{
882            not_in_files: HashSet::new(),
883            duplicate: HashSet::from_iter([RelativeFilePath::from_str("usr/bin/foo").unwrap()]),
884        }
885    )]
886    fn filesv1_try_from_paths_and_backups_fails(
887        #[case] paths: Vec<PathBuf>,
888        #[case] backup: Vec<BackupEntry>,
889        #[case] expected_errors: BackupV1Errors,
890    ) -> TestResult {
891        let result = DbFilesV1::try_from((paths, backup));
892        let errors = match result {
893            Ok(files) => panic!(
894                "Should have failed with an Error::InvalidBackupEntries, but succeeded to create a DbFilesV1: {files:?}"
895            ),
896            Err(Error::InvalidBackupEntries { message }) => message,
897            Err(error) => panic!("Expected an Error::InvalidBackupEntries, but got: {error}"),
898        };
899
900        eprintln!("{errors}");
901        assert_eq!(errors, expected_errors.to_string());
902
903        Ok(())
904    }
905
906    #[test]
907    fn filesv1_from_str_rejects_absolute_paths() -> TestResult {
908        let data = "%FILES%\n/usr/bin/foo\n";
909
910        match DbFilesV1::from_str(data) {
911            Err(Error::ParseError(_)) => Ok(()),
912            Err(error) => panic!("expected ParseError, got {error}"),
913            Ok(files) => panic!("expected parse failure, got {files:?}"),
914        }
915    }
916
917    #[test]
918    fn filesv1_from_str_skips_null_backup_entries() -> TestResult {
919        let data = r#"%FILES%
920etc/
921etc/foo/
922etc/foo/foo.conf
923
924%BACKUP%
925etc/foo/foo.conf	d41d8cd98f00b204e9800998ecf8427e
926etc/foo/bar.conf	(null)
927"#;
928
929        let files = DbFilesV1::from_str(data)?;
930
931        assert_eq!(
932            files.backups(),
933            &[BackupEntry {
934                path: RelativeFilePath::from_str("etc/foo/foo.conf")?,
935                md5: Md5Checksum::from_str("d41d8cd98f00b204e9800998ecf8427e")?
936            }]
937        );
938
939        Ok(())
940    }
941}