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}