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