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