voa-core 0.4.1

File Hierarchy for the Verification of OS Artifacts (VOA)
Documentation
//! VOA [load path] handling.
//!
//! This module can produce a [`LoadPathList`] for both system and user mode.
//!
//! [load path]: https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#load-paths

use std::path::{Path, PathBuf};

use libc::geteuid;
use log::{debug, trace};

/// Load paths for "system mode" operation of VOA.
/// Triplets of *path name*, a flag for *emphemerality*, and a flag for *writability*.
const LOAD_PATHS_SYSTEM_MODE: &[(&str, bool, bool)] = &[
    ("/etc/voa/", false, true),
    ("/run/voa/", true, true),
    ("/usr/local/share/voa/", false, false),
    ("/usr/share/voa/", false, false),
];

/// A filter for [`LoadPath`]s.
#[derive(Clone, Debug)]
pub struct LoadPathFilter {
    /// Whether a filtered [`LoadPath`] should be ephemeral.
    pub ephemeral: bool,
    /// Whether a filtered [`LoadPath`] should be writable.
    pub writable: bool,
}

/// A VOA [load path].
///
/// [load path]: https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#load-paths
#[derive(Clone, Debug, PartialEq)]
pub struct LoadPath {
    /// The file system path represented by the load path.
    pub path: PathBuf,
    ephemeral: bool,
    writable: bool,
}

impl LoadPath {
    /// Creates a new [`LoadPath`] from a [`PathBuf`].
    ///
    /// When setting the `ephemeral` flag to `true` the [`LoadPath`] is considered to be in an
    /// ephemeral location, that is reset after reboot. When setting the `writable` flag to
    /// `true` the [`LoadPath`] is considered writable (e.g. for masking symlinks).
    pub(crate) fn new(path: impl Into<PathBuf>, ephemeral: bool, writable: bool) -> Self {
        Self {
            path: path.into(),
            ephemeral,
            writable,
        }
    }

    /// Returns whether the [`LoadPath`] is considered to be ephemeral.
    pub fn ephemeral(&self) -> bool {
        self.ephemeral
    }

    /// Returns whether the [`LoadPath`] is considered to be writable.
    pub fn writable(&self) -> bool {
        self.writable
    }

    /// Returns the path.
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// Checks whether a [`LoadPathFilter`] matches the properties of the [`LoadPath`].
    ///
    /// Returns `true` if the `filter` matches the properties of `self`, `false` otherwise.
    pub fn matches_filter(&self, filter: &LoadPathFilter) -> bool {
        self.ephemeral == filter.ephemeral && self.writable == filter.writable
    }
}

impl From<&(&str, bool, bool)> for LoadPath {
    fn from(value: &(&str, bool, bool)) -> Self {
        Self {
            path: value.0.into(),
            ephemeral: value.1,
            writable: value.2,
        }
    }
}

/// A list of load paths.
///
/// The order of the provided [`LoadPath`]s is important, as it defines the priority in which these
/// paths will be explored. Paths that come first (smaller index) have a higher priority.
#[derive(Debug)]
pub struct LoadPathList(Vec<LoadPath>);

impl LoadPathList {
    /// Returns the system mode load paths as [`LoadPathList`].
    ///
    /// Contains a [`LoadPath`] each for the following directories:
    ///
    /// - `/etc/voa/`
    /// - `/run/voa/`
    /// - `/usr/local/share/voa/`
    /// - `/usr/share/voa/`
    pub(crate) fn load_path_list_system() -> LoadPathList {
        let paths = LOAD_PATHS_SYSTEM_MODE.iter().map(Into::into).collect();

        LoadPathList(paths)
    }

    /// Returns the user mode load paths as [`LoadPathList`].
    ///
    /// Contains a [`LoadPath`] each for the following directories:
    ///
    /// - `$XDG_CONFIG_HOME/voa/`
    /// - the `./voa/` directory in each directory defined in `$XDG_CONFIG_DIRS`
    /// - `$XDG_RUNTIME_DIR/voa/`
    /// - `$XDG_DATA_HOME/voa/`
    /// - the `./voa/` directory in each directory defined in `$XDG_DATA_DIRS`
    pub(crate) fn load_path_list_user() -> LoadPathList {
        let mut paths = vec![];

        // Look into the XDG Base Directory Specification with qualifier "voa",
        // organization "VOA", and application "VOA".
        if let Some(proj_dirs) = directories::ProjectDirs::from("voa", "VOA", "VOA") {
            // 1. $XDG_CONFIG_HOME/voa/
            paths.push(LoadPath::new(
                proj_dirs.config_dir().to_path_buf(),
                false,
                true,
            ));

            // 2. the ./voa/ directory in each directory defined in $XDG_CONFIG_DIRS
            let xdg = xdg::BaseDirectories::with_prefix("voa");

            xdg.get_config_dirs()
                .into_iter()
                .for_each(|dir| paths.push(LoadPath::new(dir, false, false)));

            // 3. $XDG_RUNTIME_DIR/voa/
            if let Some(runtime_dir) = proj_dirs.runtime_dir() {
                paths.push(LoadPath::new(runtime_dir, true, true));
            }

            // 4. $XDG_DATA_HOME/voa/
            paths.push(LoadPath::new(proj_dirs.data_dir(), false, false));

            // 5. the ./voa/ directory in each directory defined in $XDG_DATA_DIRS
            let mut data_dirs = xdg.get_data_dirs();

            // If $XDG_DATA_DIRS is either not set or empty, a value equal to
            // /usr/local/share/:/usr/share/ should be used.
            if data_dirs.is_empty() {
                data_dirs.push("/usr/local/share/voa/".into());
                data_dirs.push("/usr/share/voa/".into());
            }

            data_dirs
                .into_iter()
                .for_each(|dir| paths.push(LoadPath::new(dir, false, false)));
        }

        LoadPathList(paths)
    }

    /// Returns the paths as [`LoadPathList`], depending on calling user.
    ///
    /// Checks the effective User ID of the calling process and
    /// if the User ID is < `1000` returns the [system mode] load path list, else
    /// the [user mode] load path list.
    ///
    /// # Safety
    ///
    /// Calls the unsafe [`libc::geteuid`] to determine the effective User ID of the process which
    /// may panic.
    /// A user is not guaranteed to exist after calling this function!
    ///
    /// [system mode]: https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#system-mode
    /// [user mode]: https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#user-mode
    pub fn from_effective_user() -> Self {
        let euid = unsafe { geteuid() };
        trace!("LoadPathList::from_effective_user called with process user id {euid}");

        if euid < 1000 {
            debug!("⤷ Using system mode load paths");
            Self::load_path_list_system()
        } else {
            debug!("⤷ Using user mode load paths");
            Self::load_path_list_user()
        }
    }

    /// Returns a list of [`LoadPath`] references into which a provided [`LoadPath`] may point a
    /// symlink.
    ///
    /// According to the VOA specification, symlinks from one `LoadPath` may only point to
    /// locations in the same `LoadPath` or to locations in a `LoadPath` of **lower priority**.
    ///
    /// If `current` is not contained in `self`, an empty list is returned.
    /// Otherwise, `current` and any [`LoadPath`] with lower priority will be returned.
    ///
    /// # Note
    ///
    /// Any _ephemeral_ [`LoadPath`] is excluded from the result.
    pub(crate) fn legal_symlink_load_paths(&self, current: &LoadPath) -> Vec<&LoadPath> {
        let mut legal = vec![];

        // We're searching for "source" in self
        let mut searching = true;

        // This logic relies on `self.0` starting with the highest priority, with each following
        // entry being of respectively lower priority than the previous one.
        // -> We only start adding paths once we encounter `current`.
        for path in &self.0 {
            if searching {
                if path.path == current.path {
                    searching = false;

                    if !path.ephemeral {
                        legal.push(path);
                    }
                }
            } else if !path.ephemeral {
                legal.push(path);
            }
        }

        legal
    }

    /// Returns a reference to the list of contained [`LoadPath`] instances.
    pub fn paths(&self) -> &[LoadPath] {
        &self.0
    }

    /// Returns a filtered list of the contained [`LoadPath`] instances
    pub fn filter(&self, filter: &LoadPathFilter) -> Vec<&LoadPath> {
        self.0
            .iter()
            .filter(|load_path| load_path.matches_filter(filter))
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use rstest::rstest;

    use super::*;

    #[rstest]
    #[case(
        ("/etc/voa/", false, true),
        &[("/etc/voa/", false, true),
          ("/usr/local/share/voa/", false, false),
          ("/usr/share/voa/", false, false)
        ]
    )]
    #[case(
        ("/run/voa/", true, true),
        &[("/usr/local/share/voa/", false, false),
          ("/usr/share/voa/", false, false)
        ]
    )]
    #[case(
        ("/usr/local/share/voa/", false, false),
        &[("/usr/local/share/voa/", false, false),
          ("/usr/share/voa/", false, false)
        ]
    )]
    #[case(
        ("/usr/share/voa/", false, false),
        &[("/usr/share/voa/", false, false)]
    )]
    #[case(
        ("/foo/bar/", false, false),
        &[]
    )]
    fn test_legal_symlink_load_paths(
        #[case] current: (&str, bool, bool),
        #[case] expected: &[(&str, bool, bool)],
    ) -> testresult::TestResult {
        let load_path_list = LoadPathList(LOAD_PATHS_SYSTEM_MODE.iter().map(Into::into).collect());
        let expected_paths: Vec<_> = expected.iter().map(Into::into).collect();

        let legal = load_path_list.legal_symlink_load_paths(&(&current).into());

        assert_eq!(legal, expected_paths.iter().collect::<Vec<_>>());

        Ok(())
    }
}