Skip to main content

alpm_types/
database.rs

1//! Components for various types of databases.
2//!
3//! This module encompasses types for the use in [alpm-db] and [alpm-repo-db].
4//!
5//! [alpm-db]: https://alpm.archlinux.page/specifications/alpm-db.7.html
6//! [alpm-repo-db]: https://alpm.archlinux.page/specifications/alpm-repo-db.7.html
7
8use std::{
9    fmt::{Display, Formatter},
10    fs::symlink_metadata,
11    path::{Path, PathBuf},
12    str::FromStr,
13};
14
15use alpm_parsers::prelude::*;
16use fluent_i18n::t;
17#[cfg(feature = "serde")]
18use serde::{Deserialize, Serialize};
19use winnow::{
20    Parser,
21    combinator::{fail, peek, repeat_till},
22    error::ErrMode,
23    stream::Stream,
24    token::any,
25};
26
27use crate::{FullVersion, Name};
28
29/// The error that may occur when handling [alpm-db] or [alpm-repo-db] databases.
30///
31/// [alpm-db]: https://alpm.archlinux.page/specifications/alpm-db.7.html
32/// [alpm-repo-db]: https://alpm.archlinux.page/specifications/alpm-repo-db.7.html
33#[derive(Debug, thiserror::Error)]
34pub enum Error {
35    /// An [alpm-db] entry is not a directory.
36    ///
37    /// [alpm-db]: https://alpm.archlinux.page/specifications/alpm-db.7.html
38    #[error("{msg}", msg = t!("error-database-entry-is-not-a-directory", { "path" => .path }))]
39    IsNotADirectory {
40        /// The path of the entry, that is not a directory.
41        path: PathBuf,
42    },
43
44    /// An [alpm-db] entry has no filename.
45    ///
46    /// [alpm-db]: https://alpm.archlinux.page/specifications/alpm-db.7.html
47    #[error("{msg}", msg = t!("error-database-entry-has-no-filename", { "path" => .path }))]
48    HasNoFilename {
49        /// The path, that has no filename.
50        path: PathBuf,
51    },
52
53    /// An [alpm-db] entry contains non-UTF-8 characters.
54    ///
55    /// [alpm-db]: https://alpm.archlinux.page/specifications/alpm-db.7.html
56    #[error("{msg}", msg = t!("error-database-entry-has-non-utf8-chars", { "path" => .path }))]
57    ContainsNonUtf8Chars {
58        /// The path, that has no filename.
59        path: PathBuf,
60    },
61}
62
63/// The name of a directory that stores the metadata of an installed package.
64///
65/// This combines an [alpm-package-name] and a **full** [alpm-package-version].
66///
67/// [alpm-package-name]: https://alpm.archlinux.page/specifications/alpm-package-name.7.html
68/// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
69#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
70#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
71pub struct EntryName {
72    /// The package name.
73    ///
74    /// See
75    /// [alpm-package-name](https://alpm.archlinux.page/specifications/alpm-package-name.7.html).
76    pub name: Name,
77
78    /// The full package version.
79    ///
80    /// See
81    /// [alpm-package-version](https://alpm.archlinux.page/specifications/alpm-package-version.7.html).
82    pub version: FullVersion,
83}
84
85impl EntryName {
86    /// Creates a new [`EntryName`].
87    pub fn new(name: Name, version: FullVersion) -> Self {
88        Self { name, version }
89    }
90
91    /// Returns a reference to the [`Name`].
92    pub fn name(&self) -> &Name {
93        &self.name
94    }
95
96    /// Returns a reference to the [`FullVersion`].
97    pub fn version(&self) -> &FullVersion {
98        &self.version
99    }
100
101    /// Returns the entry name as [`PathBuf`].
102    pub fn to_path_buf(&self) -> PathBuf {
103        PathBuf::from(self.to_string())
104    }
105}
106
107impl Display for EntryName {
108    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
109        write!(f, "{}-{}", self.name, self.version)
110    }
111}
112
113impl FromStr for EntryName {
114    type Err = crate::Error;
115
116    /// Parses a [`EntryName`] from a string slice.
117    ///
118    /// Delegates to [`EntryName::parser_until`].
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if [`EntryName::parser_until`] fails.
123    fn from_str(s: &str) -> Result<Self, Self::Err> {
124        Ok(Self::parser_until_eof.parse(Input::new(s))?)
125    }
126}
127
128impl ParserUntil for EntryName {
129    /// Recognizes an [`EntryName`] in a string slice before a `delimiter`.
130    ///
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if
135    ///
136    /// - the [`Name`] component can not be recognized,
137    /// - or the [`FullVersion`] component can not be recognized,
138    ///
139    /// # Examples
140    ///
141    /// ```
142    /// use alpm_parsers::prelude::*;
143    /// use alpm_types::EntryName;
144    /// use winnow::Parser;
145    ///
146    /// # fn main() -> testresult::TestResult {
147    /// let entry_name = "example-package-1:1.0.0-1";
148    /// assert_eq!(
149    ///     entry_name,
150    ///     EntryName::parser_until_eof
151    ///         .parse(Input::new(entry_name))?
152    ///         .to_string()
153    /// );
154    /// # Ok(())
155    /// # }
156    /// ```
157    // TODO(cleanup): Investigate the arithmetic_side_effects
158    #[expect(clippy::arithmetic_side_effects)]
159    fn parser_until<'a, P>(delimiter: P) -> impl Parser<Input<'a>, Self, ErrMode<ParseStack<'a>>>
160    where
161        P: Parser<Input<'a>, &'a str, ErrMode<ParseStack<'a>>>,
162    {
163        // Define the actual parser closure.
164        // The delimiter is moved into the closure and borrowed via `by_ref()` on each call.
165        let mut delimiter_parser = delimiter;
166        let parser = move |input: &mut Input<'a>| -> PResult<'a, Self> {
167            // Detect the amount of dashes in input and subsequently in the Name component.
168            //
169            // Note: This is a necessary step because dashes are used as delimiters between the
170            // components of the entry name and the Name component (an alpm-package-name) can
171            // contain dashes, too.
172            // We know that the minimum amount of dashes in a valid entry name of an alpm-db is
173            // two (one dash between the Name and FullVersion component and one dash in the
174            // FullVersion component). We rely on this fact to determine the amount of
175            // dashes in the Name component and thereby the cut-off point between the
176            // Name and the FullVersion component.
177            let checkpoint = input.checkpoint();
178            let dashes: usize =
179                repeat_till::<_, _, (), _, _, _, _>(0.., any, peek(delimiter_parser.by_ref()))
180                    .take()
181                    .map(|s| {
182                        s.chars().fold(0, |acc, char| {
183                            if char == '-' {
184                                return acc + 1;
185                            }
186                            acc
187                        })
188                    })
189                    .parse_next(input)?;
190            input.reset(&checkpoint);
191
192            if dashes < 2 {
193                return fail
194                    .label("alpm-db entry name")
195                    .description(concat!(
196                        "Expected a package name, followed by an alpm-package-version (full or full with epoch).",
197                        "\nAll components must be delimited with a dash ('-')."
198                    ))
199                    .parse_next(input);
200            }
201
202            // The (zero or more) dashes in the Name component.
203            let dashes_till_version = dashes.saturating_sub(1);
204
205            // Advance the parser to the dash just behind the Name component, based on the amount of
206            // dashes in the Name, e.g.:
207            // "example-package-1:1.0.0-1" -> "-1:1.0.0-1"
208            let name =
209                Name::parse_name_followed_by_version(dashes_till_version).parse_next(input)?;
210
211            // Consume leading dash in front of FullVersion, e.g.:
212            // "-1:1.0.0-1" -> "1:1.0.0-1"
213            "-".parse_next(input)?;
214
215            // Parse the FullVersion component.
216            // Advance the parser to beyond the FullVersion component (which contains one dash),
217            // e.g.: "1:1.0.0-1" -> ""
218            let version: FullVersion =
219                FullVersion::parser_until(delimiter_parser.by_ref()).parse_next(input)?;
220
221            Ok(Self { name, version })
222        };
223
224        parser.layer("alpm database entry")
225    }
226}
227
228impl TryFrom<&Path> for EntryName {
229    type Error = crate::Error;
230
231    /// Creates an [`EntryName`] from the file name of a [`Path`].
232    ///
233    /// # Note
234    ///
235    /// The last component of the path is used as the entry name.
236    /// This function relies on metadata and file type checks on the file system.
237    ///
238    /// # Errors
239    ///
240    /// Returns an error if:
241    ///
242    /// - the metadata of the directory cannot be read,
243    /// - the path is not a directory,
244    /// - the path does not have a file name,
245    /// - the file name cannot be converted to a string,
246    /// - or the file name cannot be parsed as a valid entry name (see [`FromStr`] implementation).
247    fn try_from(value: &Path) -> Result<Self, Self::Error> {
248        let metadata = symlink_metadata(value).map_err(|source| crate::Error::IoPath {
249            path: value.to_path_buf(),
250            context: t!("error-io-path-reading-metadata-of-directory"),
251            source,
252        })?;
253
254        if !metadata.file_type().is_dir() {
255            return Err(Error::IsNotADirectory {
256                path: value.to_path_buf(),
257            }
258            .into());
259        }
260
261        let Some(file_name) = value.file_name() else {
262            return Err(Error::HasNoFilename {
263                path: value.to_path_buf(),
264            }
265            .into());
266        };
267
268        let Some(file_name_str) = file_name.to_str() else {
269            return Err(Error::ContainsNonUtf8Chars {
270                path: value.to_path_buf(),
271            }
272            .into());
273        };
274
275        Self::from_str(file_name_str)
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use std::{fs::create_dir_all, os::unix::fs::symlink, str::FromStr};
282
283    use rstest::rstest;
284    use tempfile::tempdir;
285    use testresult::TestResult;
286
287    use super::*;
288
289    /// Ensures, that [`EntryName::from_str`] succeeds on valid input.
290    #[rstest]
291    #[case::no_dash_name_full_version("example-1.0.0-1", "example", "1.0.0-1")]
292    #[case::no_dash_name_full_version_with_epoch("example-1:1.0.0-1", "example", "1:1.0.0-1")]
293    #[case::multi_dash_name_full_version("example-foo-1.0.0-1", "example-foo", "1.0.0-1")]
294    #[case::multi_dash_name_full_version_with_epoch(
295        "example-foo-1:1.0.0-1",
296        "example-foo",
297        "1:1.0.0-1"
298    )]
299    fn entry_name_from_str_succeeds(
300        #[case] input: &str,
301        #[case] name: &str,
302        #[case] version: &str,
303    ) -> TestResult {
304        let entry_name = EntryName::from_str(input)?;
305        assert_eq!(entry_name.name(), &Name::from_str(name)?);
306        assert_eq!(entry_name.version(), &FullVersion::from_str(version)?);
307        assert_eq!(entry_name.to_path_buf(), PathBuf::from(input));
308
309        Ok(())
310    }
311
312    /// Ensures, that [`EntryName::from_str`] fails on input not containing a [`FullVersion`].
313    #[rstest]
314    #[case::version("example-1.0.0")]
315    #[case::version_with_epoch("example-1:1.0.0")]
316    fn entry_name_from_str_fails_on_non_full_version(#[case] input: &str) -> TestResult {
317        let Err(crate::Error::ParseError(_)) = EntryName::from_str(input) else {
318            panic!("Expected to fail with a Error::ParseError");
319        };
320
321        Ok(())
322    }
323
324    /// Ensures, that [`EntryName::try_from`] succeeds for valid directories.
325    #[test]
326    fn entry_name_try_from_path_succeeds() -> TestResult {
327        let tempdir = tempdir()?;
328        let entry_name_dir = tempdir.path().join("example-1.0.0-1");
329        create_dir_all(&entry_name_dir)?;
330
331        let entry_name = EntryName::try_from(entry_name_dir.as_path())?;
332
333        assert_eq!(
334            entry_name,
335            EntryName::new("example".parse()?, "1.0.0-1".parse()?)
336        );
337
338        Ok(())
339    }
340
341    /// Ensures, that [`EntryName::try_from`] fails on non-existent directories.
342    #[test]
343    fn entry_name_try_from_path_fails_on_nonexistent_path() -> TestResult {
344        let tempdir = tempdir()?;
345        let entry_name_dir = tempdir.path().join("example-1.0.0-1");
346
347        let Err(crate::Error::IoPath { .. }) = EntryName::try_from(entry_name_dir.as_path()) else {
348            panic!("Expected to fail with Error::IoPath");
349        };
350
351        Ok(())
352    }
353
354    /// Ensures, that [`EntryName::try_from`] fails on the path being a symlink.
355    #[test]
356    fn entry_name_try_from_path_fails_on_symlink() -> TestResult {
357        let tempdir = tempdir()?;
358        let target_dir = tempdir.path().join("malicious");
359        let entry_name_dir = tempdir.path().join("example-1.0.0-1");
360        create_dir_all(&target_dir)?;
361        symlink(&target_dir, &entry_name_dir)?;
362
363        let Err(crate::Error::Database(Error::IsNotADirectory { .. })) =
364            EntryName::try_from(entry_name_dir.as_path())
365        else {
366            panic!("Expected to fail with Error::Database(Error::IsNotADirectory)");
367        };
368
369        Ok(())
370    }
371
372    /// Ensures, that [`EntryName::try_from`] fails on the path terminating with `..`.
373    #[test]
374    fn entry_name_try_from_path_fails_on_no_filename() -> TestResult {
375        let tempdir = tempdir()?;
376        let entry_name_dir = tempdir.path().join("example-1.0.0-1").join("..");
377        create_dir_all(&entry_name_dir)?;
378
379        let Err(crate::Error::Database(Error::HasNoFilename { .. })) =
380            EntryName::try_from(entry_name_dir.as_path())
381        else {
382            panic!("Expected to fail with Error::Database(Error::HasNoFilename)");
383        };
384
385        Ok(())
386    }
387}