alpm_package/
package.rs

1//! Representation of [alpm-package] files.
2//!
3//! [alpm-package]: https://alpm.archlinux.page/specifications/alpm-package.7.html
4
5use std::{
6    fs::{File, create_dir_all},
7    io::Write,
8    path::{Path, PathBuf},
9    str::FromStr,
10};
11
12use alpm_common::InputPaths;
13use alpm_mtree::Mtree;
14use alpm_types::{MetadataFileName, PackageError, PackageFileName};
15use log::debug;
16use tar::Builder;
17
18use crate::{CompressionEncoder, OutputDir, PackageCreationConfig};
19
20/// An error that can occur when handling [alpm-package] files.
21///
22/// [alpm-package]: https://alpm.archlinux.page/specifications/alpm-package.7.html
23#[derive(Debug, thiserror::Error)]
24pub enum Error {
25    /// An error occurred while adding files from an input directory to a package.
26    #[error("Error while appending file {from_path} to package archive as {to_path}:\n{source}")]
27    AppendFileToArchive {
28        /// The path to the file that is appended to the archive as `to_path`.
29        from_path: PathBuf,
30        /// The path in the archive that `from_path` is appended as.
31        to_path: PathBuf,
32        /// The source error.
33        source: std::io::Error,
34    },
35
36    /// An error occurred while finishing an uncompressed package.
37    #[error("Error while finishing the creation of uncompressed package {package_path}:\n{source}")]
38    FinishArchive {
39        /// The path of the package file that is being written to
40        package_path: PathBuf,
41        /// The source error.
42        source: std::io::Error,
43    },
44}
45
46/// A path that is guaranteed to be an existing absolute directory.
47#[derive(Clone, Debug)]
48pub struct ExistingAbsoluteDir(PathBuf);
49
50impl ExistingAbsoluteDir {
51    /// Creates a new [`ExistingAbsoluteDir`] from `path`.
52    ///
53    /// Creates a directory at `path` if it does not exist yet.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if
58    ///
59    /// - `path` is not absolute,
60    /// - `path` does not exist and cannot be created,
61    /// - the metadata of `path` cannot be retrieved,
62    /// - or `path` is not a directory.
63    pub fn new(path: PathBuf) -> Result<Self, crate::Error> {
64        if !path.is_absolute() {
65            return Err(alpm_common::Error::NonAbsolutePaths {
66                paths: vec![path.clone()],
67            }
68            .into());
69        }
70
71        if !path.exists() {
72            create_dir_all(&path).map_err(|source| crate::Error::IoPath {
73                path: path.clone(),
74                context: "creating absolute directory",
75                source,
76            })?;
77        }
78
79        let metadata = path.metadata().map_err(|source| crate::Error::IoPath {
80            path: path.clone(),
81            context: "retrieving metadata",
82            source,
83        })?;
84
85        if !metadata.is_dir() {
86            return Err(alpm_common::Error::NotADirectory { path: path.clone() }.into());
87        }
88
89        Ok(Self(path))
90    }
91
92    /// Coerces to a Path slice.
93    ///
94    /// Delegates to [`PathBuf::as_path`].
95    pub fn as_path(&self) -> &Path {
96        self.0.as_path()
97    }
98
99    /// Converts a Path to an owned PathBuf.
100    ///
101    /// Delegates to [`Path::to_path_buf`].
102    pub fn to_path_buf(&self) -> PathBuf {
103        self.0.to_path_buf()
104    }
105
106    /// Creates an owned PathBuf with path adjoined to self.
107    ///
108    /// Delegates to [`Path::join`].
109    pub fn join(&self, path: impl AsRef<Path>) -> PathBuf {
110        self.0.join(path)
111    }
112}
113
114impl AsRef<Path> for ExistingAbsoluteDir {
115    fn as_ref(&self) -> &Path {
116        &self.0
117    }
118}
119
120impl From<&OutputDir> for ExistingAbsoluteDir {
121    /// Creates an [`ExistingAbsoluteDir`] from an [`OutputDir`].
122    ///
123    /// As [`OutputDir`] provides a more strict set of requirements, this can be infallible.
124    fn from(value: &OutputDir) -> Self {
125        Self(value.to_path_buf())
126    }
127}
128
129impl TryFrom<&Path> for ExistingAbsoluteDir {
130    type Error = crate::Error;
131
132    /// Creates an [`ExistingAbsoluteDir`] from a [`Path`] reference.
133    ///
134    /// Delegates to [`ExistingAbsoluteDir::new`].
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if [`ExistingAbsoluteDir::new`] fails.
139    fn try_from(value: &Path) -> Result<Self, Self::Error> {
140        Self::new(value.to_path_buf())
141    }
142}
143
144/// Appends relative files from an input directory to a [`Builder`].
145///
146/// Before appending any files, all provided `input_paths` are validated against `mtree` (ALPM-MTREE
147/// data).
148///
149/// # Errors
150///
151/// Returns an error if
152///
153/// - validating any path in `input_paths` using `mtree` fails,
154/// - retrieving files relative to `input_dir` fails,
155/// - or adding one of the relative paths to the `builder` fails.
156fn append_relative_files<W>(
157    mut builder: Builder<W>,
158    mtree: &Mtree,
159    input_paths: &InputPaths,
160) -> Result<Builder<W>, crate::Error>
161where
162    W: Write,
163{
164    // Validate all paths using the ALPM-MTREE data before appending them to the builder.
165    let mtree_path = PathBuf::from(MetadataFileName::Mtree.as_ref());
166    let check_paths = {
167        let all_paths = input_paths.paths();
168        // If there is an ALPM-MTREE file, exclude it from the validation, as the ALPM-MTREE data
169        // does not cover it.
170        if let Some(mtree_position) = all_paths.iter().position(|path| path == &mtree_path) {
171            let before = &all_paths[..mtree_position];
172            let after = if all_paths.len() > mtree_position {
173                &all_paths[mtree_position + 1..]
174            } else {
175                &[]
176            };
177            &[before, after].concat()
178        } else {
179            all_paths
180        }
181    };
182    mtree.validate_paths(&InputPaths::new(input_paths.base_dir(), check_paths)?)?;
183
184    // Append all files/directories to the archive.
185    for relative_file in input_paths.paths() {
186        let from_path = input_paths.base_dir().join(relative_file.as_path());
187        builder
188            .append_path_with_name(from_path.as_path(), relative_file.as_path())
189            .map_err(|source| Error::AppendFileToArchive {
190                from_path,
191                to_path: relative_file.clone(),
192                source,
193            })?
194    }
195
196    Ok(builder)
197}
198
199/// An [alpm-package] file.
200///
201/// Tracks the [`PackageFileName`] of the [alpm-package] as well as its absolute `parent_dir`.
202///
203/// [alpm-package]: https://alpm.archlinux.page/specifications/alpm-package.7.html
204#[derive(Clone, Debug)]
205pub struct Package {
206    file_name: PackageFileName,
207    parent_dir: ExistingAbsoluteDir,
208}
209
210impl Package {
211    /// Creates a new [`Package`].
212    ///
213    /// # Errors
214    ///
215    /// Returns an error if no file exists at the path defined by `parent_dir` and `filename`.
216    pub fn new(
217        file_name: PackageFileName,
218        parent_dir: ExistingAbsoluteDir,
219    ) -> Result<Self, crate::Error> {
220        let file_path = parent_dir.to_path_buf().join(file_name.to_path_buf());
221        if !file_path.exists() {
222            return Err(crate::Error::PathDoesNotExist { path: file_path });
223        }
224        if !file_path.is_file() {
225            return Err(crate::Error::PathIsNotAFile { path: file_path });
226        }
227
228        Ok(Self {
229            file_name,
230            parent_dir,
231        })
232    }
233
234    /// Returns the absolute path of the [`Package`].
235    pub fn to_path_buf(&self) -> PathBuf {
236        self.parent_dir.join(self.file_name.to_path_buf())
237    }
238}
239
240impl TryFrom<&Path> for Package {
241    type Error = crate::Error;
242
243    /// Creates a [`Package`] from a [`Path`] reference.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if
248    ///
249    /// - no file name can be retrieved from `path`,
250    /// - `value` has no parent directory,
251    /// - or [`Package::new`] fails.
252    fn try_from(value: &Path) -> Result<Self, Self::Error> {
253        debug!("Attempt to create a package representation from path {value:?}");
254        let Some(parent_dir) = value.parent() else {
255            return Err(crate::Error::PathHasNoParent {
256                path: value.to_path_buf(),
257            });
258        };
259        let Some(filename) = value.file_name().and_then(|name| name.to_str()) else {
260            return Err(PackageError::InvalidPackageFileNamePath {
261                path: value.to_path_buf(),
262            }
263            .into());
264        };
265
266        Self::new(PackageFileName::from_str(filename)?, parent_dir.try_into()?)
267    }
268}
269
270impl TryFrom<&PackageCreationConfig> for Package {
271    type Error = crate::Error;
272
273    /// Creates a new [`Package`] from a [`PackageCreationConfig`].
274    ///
275    /// Before creating a [`Package`], guarantees the on-disk file consistency with the
276    /// help of available [`Mtree`] data.
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if
281    ///
282    /// - creating a [`PackageFileName`] from `value` fails,
283    /// - creating a [`CompressionEncoder`] fails,
284    /// - creating a compressed or uncompressed package file fails,
285    /// - validating any of the paths using ALPM-MTREE data (available through `value`) fails,
286    /// - appending files to a compressed or uncompressed package file fails,
287    /// - finishing a compressed or uncompressed package file fails,
288    /// - or creating a [`Package`] fails.
289    fn try_from(value: &PackageCreationConfig) -> Result<Self, Self::Error> {
290        let filename = PackageFileName::try_from(value)?;
291        let parent_dir: ExistingAbsoluteDir = value.output_dir().into();
292        let output_path = value.output_dir().join(filename.to_path_buf());
293
294        // Create the output file.
295        let file = File::create(output_path.as_path()).map_err(|source| crate::Error::IoPath {
296            path: output_path.clone(),
297            context: "creating a package file",
298            source,
299        })?;
300
301        // If compression is requested, create a dedicated compression encoder streaming to a file
302        // and a tar builder that streams to the compression encoder.
303        // Append all files and directories to it, then finish the tar builder and the compression
304        // encoder streams.
305        if let Some(compression) = value.compression() {
306            let encoder = CompressionEncoder::new(file, compression)?;
307            let mut builder = Builder::new(encoder);
308            // We do not want to follow symlinks but instead archive symlinks!
309            builder.follow_symlinks(false);
310            let builder = append_relative_files(
311                builder,
312                value.package_input().mtree()?,
313                &value.package_input().input_paths()?,
314            )?;
315            let encoder = builder
316                .into_inner()
317                .map_err(|source| Error::FinishArchive {
318                    package_path: output_path.clone(),
319                    source,
320                })?;
321            encoder.finish()?;
322        // If no compression is requested, only create a tar builder.
323        // Append all files and directories to it, then finish the tar builder stream.
324        } else {
325            let mut builder = Builder::new(file);
326            // We do not want to follow symlinks but instead archive symlinks!
327            builder.follow_symlinks(false);
328            let mut builder = append_relative_files(
329                builder,
330                value.package_input().mtree()?,
331                &value.package_input().input_paths()?,
332            )?;
333            builder.finish().map_err(|source| Error::FinishArchive {
334                package_path: output_path.clone(),
335                source,
336            })?;
337        }
338
339        Self::new(filename, parent_dir)
340    }
341}
342
343#[cfg(test)]
344mod tests {
345
346    use std::fs::create_dir;
347
348    use log::{LevelFilter, debug};
349    use simplelog::{ColorChoice, Config, TermLogger, TerminalMode};
350    use tempfile::{NamedTempFile, TempDir};
351    use testresult::TestResult;
352
353    use super::*;
354
355    /// Initializes a global [`TermLogger`].
356    fn init_logger() {
357        if TermLogger::init(
358            LevelFilter::Debug,
359            Config::default(),
360            TerminalMode::Mixed,
361            ColorChoice::Auto,
362        )
363        .is_err()
364        {
365            debug!("Not initializing another logger, as one is initialized already.");
366        }
367    }
368
369    /// Ensures that [`ExistingAbsoluteDir::new`] creates non-existing, absolute paths.
370    #[test]
371    fn absolute_dir_new_creates_dir() -> TestResult {
372        init_logger();
373
374        let temp_dir = TempDir::new()?;
375        let path = temp_dir.path().join("additional");
376
377        if let Err(error) = ExistingAbsoluteDir::new(path) {
378            return Err(format!("Failed although it should have succeeded: {error}").into());
379        }
380
381        Ok(())
382    }
383
384    /// Ensures that [`ExistingAbsoluteDir::new`] fails on non-absolute paths and those representing
385    /// a file.
386    #[test]
387    fn absolute_dir_new_fails() -> TestResult {
388        init_logger();
389
390        if let Err(error) = ExistingAbsoluteDir::new(PathBuf::from("test")) {
391            assert!(matches!(
392                error,
393                crate::Error::AlpmCommon(alpm_common::Error::NonAbsolutePaths { paths: _ })
394            ));
395        } else {
396            return Err("Succeeded although it should have failed".into());
397        }
398
399        let temp_file = NamedTempFile::new()?;
400        let path = temp_file.path();
401        if let Err(error) = ExistingAbsoluteDir::new(path.to_path_buf()) {
402            assert!(matches!(
403                error,
404                crate::Error::AlpmCommon(alpm_common::Error::NotADirectory { path: _ })
405            ));
406        } else {
407            return Err("Succeeded although it should have failed".into());
408        }
409
410        Ok(())
411    }
412
413    /// Ensures that utility methods of [`ExistingAbsoluteDir`] are functional.
414    #[test]
415    fn absolute_dir_utilities() -> TestResult {
416        let temp_dir = TempDir::new()?;
417        let path = temp_dir.path();
418
419        // Create from &Path
420        let absolute_dir: ExistingAbsoluteDir = path.try_into()?;
421
422        assert_eq!(absolute_dir.as_path(), path);
423        assert_eq!(absolute_dir.as_ref(), path);
424
425        Ok(())
426    }
427
428    /// Ensure that [`Package::new`] can succeeds.
429    #[test]
430    fn package_new() -> TestResult {
431        let temp_dir = TempDir::new()?;
432        let path = temp_dir.path();
433        let absolute_dir = ExistingAbsoluteDir::new(path.to_path_buf())?;
434        let package_name = "example-1.0.0-1-x86_64.pkg.tar.zst";
435        File::create(absolute_dir.join(package_name))?;
436
437        let Ok(_package) = Package::new(package_name.parse()?, absolute_dir.clone()) else {
438            return Err("Failed although it should have succeeded".into());
439        };
440
441        Ok(())
442    }
443
444    /// Ensure that [`Package::new`] fails on a non-existent file and on paths that are not a file.
445    #[test]
446    fn package_new_fails() -> TestResult {
447        let temp_dir = TempDir::new()?;
448        let path = temp_dir.path();
449        let absolute_dir = ExistingAbsoluteDir::new(path.to_path_buf())?;
450        let package_name = "example-1.0.0-1-x86_64.pkg.tar.zst";
451
452        // The file does not exist.
453        if let Err(error) = Package::new(package_name.parse()?, absolute_dir.clone()) {
454            assert!(matches!(error, crate::Error::PathDoesNotExist { path: _ }))
455        } else {
456            return Err("Succeeded although it should have failed".into());
457        }
458
459        // The file is a directory.
460        create_dir(absolute_dir.join(package_name))?;
461        if let Err(error) = Package::new(package_name.parse()?, absolute_dir.clone()) {
462            assert!(matches!(error, crate::Error::PathIsNotAFile { path: _ }))
463        } else {
464            return Err("Succeeded although it should have failed".into());
465        }
466
467        Ok(())
468    }
469
470    /// Ensure that [`Package::try_from`] fails on paths not providing a file name and paths not
471    /// providing a parent directory.
472    #[test]
473    fn package_try_from_path_fails() -> TestResult {
474        init_logger();
475
476        // Fail on trying to use a directory without a file name as a package.
477        assert!(Package::try_from(PathBuf::from("/").as_path()).is_err());
478
479        // Fail on trying to use a file without a parent
480        assert!(
481            Package::try_from(
482                PathBuf::from("/something_very_unlikely_to_ever_exist_in_a_filesystem").as_path()
483            )
484            .is_err()
485        );
486
487        Ok(())
488    }
489}