alpm_files/files/v1.rs
1//! The representation of [alpm-files] files (version 1).
2//!
3//! [alpm-files]: https://alpm.archlinux.page/specifications/alpm-files.5.html
4
5use std::{collections::HashSet, fmt::Display, path::PathBuf, str::FromStr};
6
7use alpm_common::relative_files;
8use fluent_i18n::t;
9use winnow::{
10 ModalResult,
11 Parser,
12 ascii::{line_ending, multispace0, space1, till_line_ending},
13 combinator::{alt, cut_err, eof, fail, not, opt, repeat, terminated},
14 error::{StrContext, StrContextValue},
15};
16
17use crate::{Error, FilesStyle, FilesStyleToString};
18
19/// The raw data section in [alpm-files] data.
20///
21/// [alpm-files]: https://alpm.archlinux.page/specifications/alpm-files.5.html
22#[derive(Debug)]
23pub(crate) struct FilesSection(Vec<PathBuf>);
24
25impl FilesSection {
26 /// The section keyword ("%FILES%").
27 pub(crate) const SECTION_KEYWORD: &str = "%FILES%";
28
29 /// Recognizes a [`PathBuf`] in a single line.
30 ///
31 /// # Note
32 ///
33 /// This parser only consumes till the end of a line and attempts to parse a [`PathBuf`] from
34 /// it. Trailing line endings and EOF are handled.
35 ///
36 /// # Errors
37 ///
38 /// Returns an error if a [`PathBuf`] cannot be created from the line, or something other than a
39 /// line ending or EOF is encountered afterwards.
40 fn parse_path(input: &mut &str) -> ModalResult<PathBuf> {
41 // Parse until the end of the line and attempt conversion to PathBuf.
42 // Make sure that the string is not empty!
43 alt((
44 (space1, line_ending)
45 .take()
46 .and_then(cut_err(fail))
47 .context(StrContext::Expected(StrContextValue::Description(
48 "relative path not consisting of whitespaces and/or tabs",
49 ))),
50 till_line_ending,
51 ))
52 .verify(|s: &str| !s.is_empty())
53 .context(StrContext::Label("relative path"))
54 .parse_to()
55 .parse_next(input)
56 }
57
58 /// Recognizes [alpm-files] data in a string slice.
59 ///
60 /// # Errors
61 ///
62 /// Returns an error, if
63 ///
64 /// - `input` is not empty and the first line does not contain the required section header
65 /// "%FILES%",
66 /// - or there are lines following the section header, but they cannot be parsed as a [`Vec`] of
67 /// [`PathBuf`].
68 ///
69 /// [alpm-files]: https://alpm.archlinux.page/specifications/alpm-files.5.html
70 pub(crate) fn parser(input: &mut &str) -> ModalResult<Self> {
71 // Return early if the input is empty.
72 // This may be the case in an alpm-db-files file if a package contains no files.
73 if input.is_empty() {
74 return Ok(Self(Vec::new()));
75 }
76
77 // Consume the required section header "%FILES%".
78 // Optionally consume one following line ending.
79 cut_err(terminated(Self::SECTION_KEYWORD, alt((line_ending, eof))))
80 .context(StrContext::Label("alpm-files section header"))
81 .context(StrContext::Expected(StrContextValue::Description(
82 Self::SECTION_KEYWORD,
83 )))
84 .parse_next(input)?;
85
86 // Return early if there is only the section header.
87 // This may be the case in an alpm-repo-files file if a package contains no files.
88 if input.is_empty() {
89 return Ok(Self(Vec::new()));
90 }
91
92 // Consider all following lines as paths.
93 // Optionally consume one following line ending.
94 let paths: Vec<PathBuf> =
95 repeat(0.., terminated(Self::parse_path, alt((line_ending, eof)))).parse_next(input)?;
96
97 // Consume any trailing whitespaces or new lines.
98 multispace0.parse_next(input)?;
99
100 // Fail if there are any further non-whitespace characters.
101 let _opt: Option<&str> =
102 opt(not(eof)
103 .take()
104 .and_then(cut_err(fail).context(StrContext::Expected(
105 StrContextValue::Description("no further path after newline"),
106 ))))
107 .parse_next(input)?;
108
109 Ok(Self(paths))
110 }
111
112 /// Returns the paths.
113 pub fn paths(self) -> Vec<PathBuf> {
114 self.0
115 }
116}
117
118/// A collection of paths that are invalid in the context of a [`FilesV1`].
119///
120/// A [`FilesV1`] must not contain duplicate paths or (non top-level) paths that do not have a
121/// parent in the same set of paths.
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub(crate) struct FilesV1PathErrors {
124 pub(crate) absolute: HashSet<PathBuf>,
125 pub(crate) without_parent: HashSet<PathBuf>,
126 pub(crate) duplicate: HashSet<PathBuf>,
127}
128
129impl FilesV1PathErrors {
130 /// Creates a new [`FilesV1PathErrors`].
131 pub(crate) fn new() -> Self {
132 Self {
133 absolute: HashSet::new(),
134 without_parent: HashSet::new(),
135 duplicate: HashSet::new(),
136 }
137 }
138
139 /// Adds a new absolute path.
140 pub(crate) fn add_absolute(&mut self, path: PathBuf) -> bool {
141 self.absolute.insert(path)
142 }
143
144 /// Adds a new (non top-level) path that does not have a parent.
145 pub(crate) fn add_without_parent(&mut self, path: PathBuf) -> bool {
146 self.without_parent.insert(path)
147 }
148
149 /// Adds a new duplicate path.
150 pub(crate) fn add_duplicate(&mut self, path: PathBuf) -> bool {
151 self.duplicate.insert(path)
152 }
153
154 /// Fails if `self` tracks any invalid paths.
155 pub(crate) fn fail(&self) -> Result<(), Error> {
156 if !(self.absolute.is_empty()
157 && self.without_parent.is_empty()
158 && self.duplicate.is_empty())
159 {
160 Err(Error::InvalidFilesPaths {
161 message: self.to_string(),
162 })
163 } else {
164 Ok(())
165 }
166 }
167}
168
169impl Display for FilesV1PathErrors {
170 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 fn write_invalid_set(
172 f: &mut std::fmt::Formatter<'_>,
173 message: String,
174 set: &HashSet<PathBuf>,
175 ) -> std::fmt::Result {
176 if !set.is_empty() {
177 writeln!(f, "{message}:")?;
178 let mut set = set.iter().collect::<Vec<_>>();
179 set.sort();
180 for path in set.iter() {
181 writeln!(f, "{}", path.as_path().display())?;
182 }
183 }
184 Ok(())
185 }
186
187 write_invalid_set(f, t!("filesv1-path-errors-absolute-paths"), &self.absolute)?;
188 write_invalid_set(
189 f,
190 t!("filesv1-path-errors-paths-without-a-parent"),
191 &self.without_parent,
192 )?;
193 write_invalid_set(
194 f,
195 t!("filesv1-path-errors-duplicate-paths"),
196 &self.duplicate,
197 )?;
198
199 Ok(())
200 }
201}
202
203/// The representation of [alpm-files] data (version 1).
204///
205/// [alpm-files]: https://alpm.archlinux.page/specifications/alpm-files.5.html
206#[derive(Clone, Debug)]
207#[cfg_attr(feature = "serde", derive(serde::Serialize))]
208pub struct FilesV1(Vec<PathBuf>);
209
210impl AsRef<[PathBuf]> for FilesV1 {
211 /// Returns a reference to the inner [`Vec`] of [`PathBuf`]s.
212 fn as_ref(&self) -> &[PathBuf] {
213 &self.0
214 }
215}
216
217impl FilesStyleToString for FilesV1 {
218 /// Returns the [`String`] representation of the [`FilesV1`].
219 ///
220 /// The formatting of the returned string depends on the provided [`FilesStyle`].
221 ///
222 /// # Examples
223 ///
224 /// ```
225 /// use std::path::PathBuf;
226 ///
227 /// use alpm_files::{FilesStyle, FilesStyleToString, FilesV1};
228 ///
229 /// # fn main() -> Result<(), alpm_files::Error> {
230 /// // An empty alpm-db-files.
231 /// let expected = "";
232 /// let files = FilesV1::try_from(Vec::new())?;
233 /// assert_eq!(files.to_string(FilesStyle::Db), expected);
234 ///
235 /// // An alpm-db-files with entries.
236 /// let expected = r#"%FILES%
237 /// usr/
238 /// usr/bin/
239 /// usr/bin/foo
240 ///
241 /// "#;
242 /// let files = FilesV1::try_from(vec![
243 /// PathBuf::from("usr/"),
244 /// PathBuf::from("usr/bin/"),
245 /// PathBuf::from("usr/bin/foo"),
246 /// ])?;
247 /// assert_eq!(files.to_string(FilesStyle::Db), expected);
248 ///
249 /// // An empty alpm-repo-files.
250 /// let expected = "%FILES%\n";
251 /// let files = FilesV1::try_from(Vec::new())?;
252 /// assert_eq!(files.to_string(FilesStyle::Repo), expected);
253 ///
254 /// // An alpm-repo-files with entries.
255 /// let expected = r#"%FILES%
256 /// usr/
257 /// usr/bin/
258 /// usr/bin/foo
259 /// "#;
260 /// let files = FilesV1::try_from(vec![
261 /// PathBuf::from("usr/"),
262 /// PathBuf::from("usr/bin/"),
263 /// PathBuf::from("usr/bin/foo"),
264 /// ])?;
265 /// assert_eq!(files.to_string(FilesStyle::Repo), expected);
266 /// # Ok(())
267 /// # }
268 /// ```
269 fn to_string(&self, style: FilesStyle) -> String {
270 let mut output = String::new();
271
272 // Return empty string if no paths are tracked and the targeted file format requires no
273 // section header.
274 if self.0.is_empty() && matches!(style, FilesStyle::Db) {
275 return output;
276 }
277
278 output.push_str(FilesSection::SECTION_KEYWORD);
279 output.push('\n');
280
281 if self.0.is_empty() && matches!(style, FilesStyle::Repo) {
282 return output;
283 }
284
285 for path in self.0.iter() {
286 output.push_str(&format!("{}", path.to_string_lossy()));
287 output.push('\n');
288 }
289
290 // The alpm-db-files style adds a trailing newline.
291 if matches!(style, FilesStyle::Db) {
292 output.push('\n');
293 }
294
295 output
296 }
297}
298
299impl FromStr for FilesV1 {
300 type Err = Error;
301
302 /// Creates a new [`FilesV1`] from a string slice.
303 ///
304 /// # Note
305 ///
306 /// Delegates to the [`TryFrom`] [`Vec`] of [`PathBuf`] implementation, after the string slice
307 /// has been parsed as a [`Vec`] of [`PathBuf`].
308 ///
309 /// # Errors
310 ///
311 /// Returns an error, if
312 ///
313 /// - `value` is not empty and the first line does not contain the section header ("%FILES%"),
314 /// - there are lines following the section header, but they cannot be parsed as a [`Vec`] of
315 /// [`PathBuf`],
316 /// - or [`Self::try_from`] [`Vec`] of [`PathBuf`] fails.
317 ///
318 /// # Examples
319 ///
320 /// ```
321 /// use std::{path::PathBuf, str::FromStr};
322 ///
323 /// use alpm_files::FilesV1;
324 /// use winnow::Parser;
325 ///
326 /// # fn main() -> Result<(), alpm_files::Error> {
327 /// # let expected: Vec<PathBuf> = Vec::new();
328 /// // No files according to alpm-db-files.
329 /// let data = "";
330 /// let files = FilesV1::from_str(data)?;
331 /// # assert_eq!(files.as_ref(), expected);
332 ///
333 /// // No files according to alpm-repo-files.
334 /// let data = "%FILES%";
335 /// let files = FilesV1::from_str(data)?;
336 /// # assert_eq!(files.as_ref(), expected);
337 /// let data = "%FILES%\n";
338 /// let files = FilesV1::from_str(data)?;
339 /// # assert_eq!(files.as_ref(), expected);
340 ///
341 /// # let expected: Vec<PathBuf> = vec![
342 /// # PathBuf::from("usr/"),
343 /// # PathBuf::from("usr/bin/"),
344 /// # PathBuf::from("usr/bin/foo"),
345 /// # ];
346 /// // Files according to alpm-repo-files.
347 /// let data = r#"%FILES%
348 /// usr/
349 /// usr/bin/
350 /// usr/bin/foo"#;
351 /// let files = FilesV1::from_str(data)?;
352 /// # assert_eq!(files.as_ref(), expected);
353 ///
354 /// // Files according to alpm-db-files.
355 /// let data = r#"%FILES%
356 /// usr/
357 /// usr/bin/
358 /// usr/bin/foo
359 /// "#;
360 /// let files = FilesV1::from_str(data)?;
361 /// # assert_eq!(files.as_ref(), expected.as_slice());
362 /// # Ok(())
363 /// # }
364 /// ```
365 fn from_str(s: &str) -> Result<Self, Self::Err> {
366 let files_section = FilesSection::parser.parse(s)?;
367 FilesV1::try_from(files_section.paths())
368 }
369}
370
371impl TryFrom<PathBuf> for FilesV1 {
372 type Error = Error;
373
374 /// Creates a new [`FilesV1`] from all files and directories in a directory.
375 ///
376 /// # Note
377 ///
378 /// Delegates to [`alpm_common::relative_files`] to get a sorted list of all files and
379 /// directories in the directory `value` (relative to `value`).
380 /// Afterwards, tries to construct a [`FilesV1`] from this list.
381 ///
382 /// # Errors
383 ///
384 /// Returns an error if
385 ///
386 /// - [`alpm_common::relative_files`] fails,
387 /// - or [`TryFrom`] [`Vec`] of [`PathBuf`] for [`FilesV1`] fails.
388 ///
389 /// # Examples
390 ///
391 /// ```
392 /// use std::{
393 /// fs::{File, create_dir_all},
394 /// path::PathBuf,
395 /// };
396 ///
397 /// use alpm_files::FilesV1;
398 /// use tempfile::tempdir;
399 ///
400 /// # fn main() -> testresult::TestResult {
401 /// let temp_dir = tempdir()?;
402 /// let path = temp_dir.path();
403 /// create_dir_all(path.join("usr/bin/"))?;
404 /// File::create(path.join("usr/bin/foo"))?;
405 ///
406 /// let files = FilesV1::try_from(path.to_path_buf())?;
407 /// assert_eq!(
408 /// files.as_ref(),
409 /// vec![
410 /// PathBuf::from("usr/"),
411 /// PathBuf::from("usr/bin/"),
412 /// PathBuf::from("usr/bin/foo")
413 /// ]
414 /// );
415 /// # Ok(())
416 /// # }
417 /// ```
418 fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
419 FilesV1::try_from(relative_files(value, &[])?)
420 }
421}
422
423impl TryFrom<Vec<PathBuf>> for FilesV1 {
424 type Error = Error;
425
426 /// Creates a new [`FilesV1`] from a [`Vec`] of [`PathBuf`].
427 ///
428 /// The provided `value` is sorted and checked for non top-level paths without a parent, as well
429 /// as any duplicate paths.
430 ///
431 /// # Errors
432 ///
433 /// Returns an error if
434 ///
435 /// - `value` contains absolute paths,
436 /// - `value` contains (non top-level) paths without a parent directory present in `value`,
437 /// - or `value` contains duplicate paths.
438 ///
439 /// # Examples
440 ///
441 /// ```
442 /// use std::path::PathBuf;
443 ///
444 /// use alpm_files::FilesV1;
445 ///
446 /// # fn main() -> Result<(), alpm_files::Error> {
447 /// let paths: Vec<PathBuf> = vec![
448 /// PathBuf::from("usr/"),
449 /// PathBuf::from("usr/bin/"),
450 /// PathBuf::from("usr/bin/foo"),
451 /// ];
452 /// let files = FilesV1::try_from(paths)?;
453 ///
454 /// // Absolute paths are not allowed.
455 /// let paths: Vec<PathBuf> = vec![
456 /// PathBuf::from("/usr/"),
457 /// PathBuf::from("/usr/bin/"),
458 /// PathBuf::from("/usr/bin/foo"),
459 /// ];
460 /// assert!(FilesV1::try_from(paths).is_err());
461 ///
462 /// // Every path (excluding top-level paths) must have a parent.
463 /// let paths: Vec<PathBuf> = vec![PathBuf::from("usr/bin/"), PathBuf::from("usr/bin/foo")];
464 /// assert!(FilesV1::try_from(paths).is_err());
465 ///
466 /// // Every path must be unique.
467 /// let paths: Vec<PathBuf> = vec![
468 /// PathBuf::from("usr/"),
469 /// PathBuf::from("usr/"),
470 /// PathBuf::from("usr/bin/"),
471 /// PathBuf::from("usr/bin/foo"),
472 /// ];
473 /// assert!(FilesV1::try_from(paths).is_err());
474 /// # Ok(())
475 /// # }
476 /// ```
477 fn try_from(value: Vec<PathBuf>) -> Result<Self, Self::Error> {
478 let mut paths = value;
479 paths.sort_unstable();
480
481 let mut errors = FilesV1PathErrors::new();
482 let mut path_set = HashSet::new();
483 let empty_parent = PathBuf::from("");
484 let root_parent = PathBuf::from("/");
485
486 for path in paths.iter() {
487 let path = path.as_path();
488
489 // Add absolute paths as errors.
490 if path.is_absolute() {
491 errors.add_absolute(path.to_path_buf());
492 }
493
494 // Add non top-level, relative paths without a parent as errors.
495 if let Some(parent) = path.parent() {
496 if parent != empty_parent && parent != root_parent && !path_set.contains(parent) {
497 errors.add_without_parent(path.to_path_buf());
498 }
499 }
500
501 // Add duplicates as errors.
502 if !path_set.insert(path) {
503 errors.add_duplicate(path.to_path_buf());
504 }
505 }
506
507 errors.fail()?;
508
509 Ok(Self(paths))
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use std::fs::{File, create_dir_all};
516
517 use rstest::rstest;
518 use tempfile::tempdir;
519 use testresult::TestResult;
520
521 use super::*;
522
523 /// Ensures that a [`FilesV1`] can be successfully created from a directory.
524 #[test]
525 fn filesv1_try_from_pathbuf_succeeds() -> TestResult {
526 let temp_dir = tempdir()?;
527 let path = temp_dir.path();
528 create_dir_all(path.join("usr/bin/"))?;
529 File::create(path.join("usr/bin/foo"))?;
530
531 let files = FilesV1::try_from(path.to_path_buf())?;
532
533 assert_eq!(
534 files.as_ref(),
535 vec![
536 PathBuf::from("usr/"),
537 PathBuf::from("usr/bin/"),
538 PathBuf::from("usr/bin/foo")
539 ]
540 );
541
542 Ok(())
543 }
544
545 #[rstest]
546 #[case::dirs_and_files(vec![PathBuf::from("usr/"), PathBuf::from("usr/bin/"), PathBuf::from("usr/bin/foo")], 3)]
547 #[case::empty(Vec::new(), 0)]
548 fn filesv1_try_from_pathbufs_succeeds(
549 #[case] paths: Vec<PathBuf>,
550 #[case] len: usize,
551 ) -> TestResult {
552 let files = FilesV1::try_from(paths)?;
553
554 assert_eq!(files.as_ref().len(), len);
555
556 Ok(())
557 }
558
559 #[rstest]
560 #[case::absolute_paths(
561 vec![
562 PathBuf::from("/usr/"), PathBuf::from("/usr/bin/"), PathBuf::from("/usr/bin/foo")
563 ],
564 FilesV1PathErrors{
565 absolute: HashSet::from_iter([
566 PathBuf::from("/usr/"),
567 PathBuf::from("/usr/bin/"),
568 PathBuf::from("/usr/bin/foo"),
569 ]),
570 without_parent: HashSet::new(),
571 duplicate: HashSet::new(),
572 }
573 )]
574 #[case::without_parents(
575 vec![PathBuf::from("usr/bin/"), PathBuf::from("usr/bin/foo")],
576 FilesV1PathErrors{
577 absolute: HashSet::new(),
578 without_parent: HashSet::from_iter([
579 PathBuf::from("usr/bin/"),
580 ]),
581 duplicate: HashSet::new(),
582 }
583 )]
584 #[case::duplicates(
585 vec![PathBuf::from("usr/"), PathBuf::from("usr/")],
586 FilesV1PathErrors{
587 absolute: HashSet::new(),
588 without_parent: HashSet::new(),
589 duplicate: HashSet::from_iter([
590 PathBuf::from("usr/"),
591 ]),
592 }
593 )]
594 fn filesv1_try_from_pathbufs_fails(
595 #[case] paths: Vec<PathBuf>,
596 #[case] expected_errors: FilesV1PathErrors,
597 ) -> TestResult {
598 let result = FilesV1::try_from(paths);
599 let errors = match result {
600 Ok(files) => return Err(format!("Should have failed with an Error::InvalidFilesPaths, but succeeded to create a FilesV1: {files:?}").into()),
601 Err(Error::InvalidFilesPaths { message }) => message,
602 Err(error) => return Err(format!("Expected an Error::InvalidFilesPaths, but got: {error}").into()),
603 };
604
605 eprintln!("{errors}");
606 assert_eq!(errors, expected_errors.to_string());
607
608 Ok(())
609 }
610}