1use 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#[derive(Debug)]
27pub(crate) struct FilesSection(Vec<RelativePath>);
28
29impl FilesSection {
30 pub(crate) const SECTION_KEYWORD: &str = "%FILES%";
32
33 fn parse_path(input: &mut &str) -> ModalResult<RelativePath> {
45 not(alt(((space0, line_ending).take(), eof))).parse_next(input)?;
47
48 cut_err(
50 till_line_ending
51 .context(StrContext::Label("relative path"))
52 .parse_to(),
53 )
54 .parse_next(input)
55 }
56
57 pub(crate) fn parser(input: &mut &str) -> ModalResult<Self> {
70 if input.is_empty() {
73 return Ok(Self(Vec::new()));
74 }
75
76 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 if input.is_empty() {
87 return Ok(Self(Vec::new()));
88 }
89
90 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 pub fn paths(self) -> Vec<PathBuf> {
100 self.0.into_iter().map(RelativePath::into_inner).collect()
101 }
102}
103
104#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
106pub struct BackupEntry {
107 pub path: RelativeFilePath,
109 pub md5: Md5Checksum,
111}
112
113impl BackupEntry {
114 pub(crate) fn parser(input: &mut &str) -> ModalResult<Option<Self>> {
131 not(alt((eof, (space0, newline).take()))).parse_next(input)?;
133
134 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 "(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#[derive(Debug, Default)]
159pub(crate) struct BackupSection(Vec<BackupEntry>);
160
161impl BackupSection {
162 pub(crate) const SECTION_KEYWORD: &str = "%BACKUP%";
164
165 pub(crate) fn parser(input: &mut &str) -> ModalResult<Self> {
172 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 pub fn entries(self) -> Vec<BackupEntry> {
197 self.0
198 }
199}
200
201#[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 pub(crate) fn new() -> Self {
215 Self {
216 absolute: HashSet::new(),
217 without_parent: HashSet::new(),
218 duplicate: HashSet::new(),
219 }
220 }
221
222 pub(crate) fn add_absolute(&mut self, path: PathBuf) -> bool {
224 self.absolute.insert(path)
225 }
226
227 pub(crate) fn add_without_parent(&mut self, path: PathBuf) -> bool {
229 self.without_parent.insert(path)
230 }
231
232 pub(crate) fn add_duplicate(&mut self, path: PathBuf) -> bool {
234 self.duplicate.insert(path)
235 }
236
237 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#[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 pub(crate) fn new() -> Self {
299 Self {
300 not_in_files: HashSet::new(),
301 duplicate: HashSet::new(),
302 }
303 }
304
305 pub(crate) fn add_not_in_files(&mut self, path: RelativeFilePath) -> bool {
307 self.not_in_files.insert(path)
308 }
309
310 pub(crate) fn add_duplicate(&mut self, path: RelativeFilePath) -> bool {
312 self.duplicate.insert(path)
313 }
314
315 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#[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 fn as_ref(&self) -> &[PathBuf] {
370 &self.files
371 }
372}
373
374impl DbFilesV1 {
375 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 if path.is_absolute() {
396 errors.add_absolute(path.to_path_buf());
397 }
398
399 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 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 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
473 if self.files.is_empty() && self.backup.is_empty() {
475 return Ok(());
476 }
477
478 writeln!(f, "{}", FilesSection::SECTION_KEYWORD)?;
480
481 for path in &self.files {
482 writeln!(f, "{}", path.to_string_lossy())?;
483 }
484
485 writeln!(f)?;
487
488 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 multispace0.parse_next(input)?;
507
508 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 multispace0.parse_next(input)?;
520
521 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 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 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 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 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 #[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}