voa-core 0.4.1

File Hierarchy for the Verification of OS Artifacts (VOA)
Documentation
use std::{
    collections::{BTreeMap, btree_map::Entry},
    ffi::OsString,
    fs::read_dir,
    path::PathBuf,
};

use log::{debug, info, trace, warn};

use crate::{
    identifiers::{Context, Os, Purpose, Technology},
    load_path::LoadPathList,
    util::symlinks::{PathType, ResolvedSymlink, resolve_symlink},
    verifier::{Verifier, VoaLocation},
};

/// Access to the "File Hierarchy for the Verification of OS Artifacts (VOA)".
///
/// [`Voa`] provides lookup facilities for signature verifiers that are stored in a VOA hierarchy.
/// Lookup of verifiers is agnostic to the cryptographic technology later using the verifiers.
#[derive(Debug)]
pub struct Voa(LoadPathList);

impl Default for Voa {
    fn default() -> Self {
        Self::new()
    }
}

impl Voa {
    /// Creates a new [`Voa`] instance.
    ///
    /// The VOA instance is initialized with a set of load paths, either in system mode or
    /// user mode, based on the user id of the current process:
    ///
    /// - For user ids < 1000, the VOA instance is initialized in system mode. See [user mode].
    /// - For user ids >= 1000, the VOA instance is initialized in user mode. See [system mode].
    ///
    /// [user mode]:
    /// https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#user-mode
    /// [system mode]:
    /// https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#system-mode
    pub fn new() -> Self {
        info!("Initializing VOA instance");

        Self(LoadPathList::from_effective_user())
    }

    /// Find applicable signature verifiers for a set of identifiers.
    ///
    /// Verifiers are found based on the provided [`Os`], [`Purpose`], [`Context`] and
    /// [`Technology`] identifiers.
    ///
    /// This searches all VOA load paths that apply in this VOA instance.
    ///
    /// Warnings are emitted (via the Rust `log` mechanism) for all unusable files and directories
    /// in the subset of the VOA hierarchy specified by the set of identifiers.
    ///
    /// Returns a map of "canonicalized path" to lists of [`Verifier`]s.
    /// The same canonicalized verifier path can potentially be found via multiple load paths.
    /// This return type gives callers full transparency into what has been found.
    ///
    /// # Note
    ///
    /// Many callers may find it sufficient to just use the `.keys()` of this result as a set
    /// of verifier paths.
    ///
    /// # Examples
    ///
    /// ```
    /// use voa_core::{
    ///     Voa,
    ///     identifiers::{Context, Mode, Os, Purpose, Role, Technology},
    /// };
    ///
    /// # fn main() -> Result<(), voa_core::Error> {
    /// let voa = Voa::new(); // Auto-detects System or User mode
    ///
    /// let verifiers = voa.lookup(
    ///     Os::new("arch".parse()?, None, None, None, None),
    ///     Purpose::new(Role::Packages, Mode::ArtifactVerifier),
    ///     Context::Default,
    ///     Technology::Openpgp,
    /// );
    ///
    /// # Ok(())
    /// # }
    /// ```
    pub fn lookup(
        &self,
        os: Os,
        purpose: Purpose,
        context: Context,
        technology: Technology,
    ) -> BTreeMap<PathBuf, Vec<Verifier>> {
        // Collects all verifiers that we find for this set of search parameters
        let mut verifiers = Vec::new();

        // A set of filenames that we will mask out of `verifiers` in the end
        let mut masked_names = Vec::new();

        // Search in each load path
        for load_path in self.0.paths() {
            debug!("Looking for signature verifiers in the load path {load_path:?}");

            // Load paths that symlinks from this load path may link into (or traverse through)
            let legal_symlink_paths = self.0.legal_symlink_load_paths(load_path);

            // The VOA leaf location implied by this `load_path`
            let voa_location = VoaLocation::new(
                load_path.clone(),
                os.clone(),
                purpose.clone(),
                context.clone(),
                technology.clone(),
            );

            info!("Looking at location: {voa_location:?}");
            info!("Legal symlink paths: {legal_symlink_paths:?}");

            // Get the validated and canonicalized path for this VOA location
            let canonicalized = match voa_location.check_and_canonicalize(&legal_symlink_paths) {
                Ok(canonicalized) => {
                    trace!(
                        "VoaLocation::check_and_canonicalize canonicalized path: {canonicalized:?}"
                    );
                    canonicalized
                }
                Err(err) => {
                    warn!(
                        "Error while canonicalizing for load path {:?}: {err} (skipping)",
                        load_path.path
                    );
                    continue;
                }
            };

            // Get the entries of this verifier directory
            trace!("Scanning verifiers in canonicalized VOA path {canonicalized:?}");
            let dir = match read_dir(canonicalized) {
                Ok(dir) => dir,
                Err(err) => {
                    // This should be unreachable, `check_and_canonicalize` only accepts directories
                    warn!(
                        "⤷ Inconsistent state: Canonicalized load path is not a directory {err:?} (skipping)"
                    );
                    continue; // try next load path
                }
            };

            // Loop through (potential) verifier files
            for res in dir {
                let entry = match res {
                    Ok(entry) => entry,
                    Err(err) => {
                        warn!("⤷ Invalid directory entry:\n{err} (skipping)");
                        continue;
                    }
                };

                let Ok(file_type) = entry.file_type() else {
                    warn!("⤷ Cannot get file type of directory entry {entry:?} (skipping)");
                    continue;
                };

                // Get the checked and canonicalized path for the verifier file behind this
                // directory entry
                let verifier = if file_type.is_file() {
                    entry.path()
                } else if file_type.is_symlink() {
                    let resolved = match resolve_symlink(
                        &entry.path(),
                        &legal_symlink_paths,
                        PathType::File,
                    ) {
                        Ok(resolved) => resolved,
                        Err(err) => {
                            warn!(
                                "⤷ Symlink {:?} is invalid for use with VOA ({err:?}) (skipping)",
                                &entry.path()
                            );
                            continue;
                        }
                    };

                    match resolved {
                        ResolvedSymlink::File(path) => path,
                        ResolvedSymlink::Dir(d) => {
                            warn!(
                                "⤷ Symlink points to a directory {:?}: {d:?}  (skipping)",
                                &entry.path()
                            );
                            continue;
                        }
                        ResolvedSymlink::Masked => {
                            // Masking symlinks are only expected in writable load paths
                            if !load_path.writable() {
                                warn!(
                                    "Masked file name {entry:?} is illegal in non-writable load path {load_path:?} (ignoring)"
                                );
                                continue;
                            }

                            // Store masked verifier name for filtering in the final output step
                            masked_names.push(entry.file_name());

                            continue;
                        }
                    }
                } else {
                    warn!("⤷ Unexpected file type {file_type:?} for entry {entry:?} (skipping)");
                    continue;
                };

                if verifier.is_file() {
                    trace!("⤷ Found verifier file {verifier:?}");
                    verifiers.push(Verifier::new(voa_location.clone(), verifier));
                } else {
                    trace!("⤷ Verifier path {verifier:?} is not a file (ignoring)");
                }
            }
        }

        // Filter out masked verifiers ...
        let filtered = filter_verifiers(verifiers, masked_names);

        // ... and group the remaining verifiers as a map.
        group_verifiers(filtered)
    }
}

/// Filter out masked verifiers, and verifiers with non-UTF-8 filenames
fn filter_verifiers(verifiers: Vec<Verifier>, masked_names: Vec<OsString>) -> Vec<Verifier> {
    verifiers
        .into_iter()
        .filter(|verifier| {
            if let Some(filename) = verifier.filename() {
                // Filter out masked verifiers
                !masked_names.contains(&filename.into())
            } else {
                // verifier doesn't have a filename, filter it out
                false
            }
        })
        .collect()
}

/// Build the return format: A map from "canonicalized path" to lists of Verifiers
fn group_verifiers(verifiers: Vec<Verifier>) -> BTreeMap<PathBuf, Vec<Verifier>> {
    let mut map: BTreeMap<PathBuf, Vec<Verifier>> = BTreeMap::new();

    // Restructure the verifiers `Vec` into a map
    verifiers.into_iter().for_each(|verifier| {
        let canonicalized: PathBuf = verifier.canonicalized().into();
        let e = map.entry(canonicalized);
        match e {
            Entry::Vacant(ve) => {
                ve.insert(vec![verifier]);
            }
            Entry::Occupied(mut oe) => oe.get_mut().push(verifier),
        }
    });

    map
}