voa 0.7.3

Command line interface and library for interacting with the File Hierarchy for the Verification of OS Artifacts (VOA)
Documentation
//! Commands to interact with VOA.

use std::{
    collections::{BTreeMap, HashSet},
    fmt::Debug,
    io::stdin,
    path::{Path, PathBuf},
};

use log::info;
use voa_config::{TechnologySettings, VoaConfig};
use voa_core::{
    LoadPathList,
    Verifier,
    VerifierWriter,
    Voa,
    identifiers::{Context, Os, Purpose, Technology},
};
use voa_openpgp::{
    ModelBasedVerifier,
    OpenPgpImport,
    OpenpgpCert,
    OpenpgpSignature,
    OpenpgpSignatureCheck,
    VoaOpenpgp,
    import::destructured::load_from_dir,
};

use crate::{
    Error,
    utils::{DirOrFile, DirOrFileType, RegularFile},
};

/// Returns a writable VOA load path.
///
/// Gathers the list of writable VOA load paths for the calling user and returns the first from the
/// list.
/// If `runtime` is `true`, the ephemeral load path of the calling user is selected instead.
///
/// # Errors
///
/// Returns an error if no [`LoadPath`][voa_core::LoadPath] can be found.
///
/// # Examples
///
/// ```
/// use voa::commands::get_writable_load_path;
///
/// # fn main() -> Result<(), voa::Error> {
/// let config_dir = get_writable_load_path(false)?;
/// let runtime_dir = get_writable_load_path(true)?;
/// # Ok(())
/// # }
/// ```
pub fn get_writable_load_path(runtime: bool) -> Result<PathBuf, Error> {
    let load_path_list = LoadPathList::from_effective_user();

    let filter = voa_core::LoadPathFilter {
        ephemeral: runtime,
        writable: true,
    };
    let load_path = load_path_list
        .filter(&filter)
        .first()
        .cloned()
        .ok_or(Error::NoLoadPath)?;

    Ok(load_path.path.clone())
}

/// Returns an implementation of [`VerifierWriter`] from an input.
///
/// Depending on `technology`, attempts to load a verifier from file or directory if `input` is a
/// [`DirOrFile`]. Attempts to load a verifier from [`stdin`] if `input` is [`None`].
///
/// # Note
///
/// Currently only supports [`Technology::Openpgp`].
///
/// # Errors
///
/// Returns an error if
///
/// - a verifier cannot be loaded from file/directory or stdin,
/// - an unsupported `technology` is provided.
///
/// # Examples
///
/// ```
/// use std::io::Write;
///
/// use tempfile::{NamedTempFile, tempdir};
/// use voa::commands::load_verifier;
///
/// # fn main() -> testresult::TestResult {
/// // Write a generic OpenPGP certificate to a temporary file.
/// let cert = r#"-----BEGIN PGP PUBLIC KEY BLOCK-----
///
/// xjMEaNBDAhYJKwYBBAHaRw8BAQdAzjzrpQ/AEteCmzjd1xTdXGaHV0VKSm4HLy6l
/// HVcmWT3NH0pvaG4gRG9lIDxqb2huLmRvZUBleGFtcGxlLm9yZz7CmgQQFggAQgUC
/// aNBDAhYhBEauMg3lOimFWKbyoPtSEBy0DfYKAhsDAh4BBAsJCAcGFQ4KCQwIARYN
/// JwkCCAIHAgkBCAEHAQIZAQAKCRD7UhActA32CkhIAP9bhoLJeZRCAc+q1kFEkstT
/// uXBPlzHagF6ghuUfToMmVQD+KaakONKSekglKR4rJxzhleQJ4qsptt1gjXX13QgF
/// Xwo=
/// =Pkv9
/// -----END PGP PUBLIC KEY BLOCK-----"#;
/// let mut temp_file = NamedTempFile::new()?;
/// write!(temp_file, "{cert}")?;
/// let input_path = temp_file.path();
///
/// // Load an OpenPGP verifier from file.
/// let verifier = load_verifier(Some(input_path.try_into()?), "openpgp".parse()?)?;
///
/// // Loading a verifier from file for an unknown technology will fail.
/// assert!(load_verifier(Some(input_path.try_into()?), "foo".parse()?).is_err());
/// # Ok(())
/// # }
/// ```
pub fn load_verifier(
    input: Option<DirOrFile>,
    technology: Technology,
) -> Result<impl VerifierWriter + Debug, Error> {
    match technology {
        Technology::Openpgp => Ok(if let Some(path) = input {
            match path.typ {
                DirOrFileType::Dir => load_from_dir(&path)?,
                DirOrFileType::File => OpenPgpImport::from_file(&path)?,
            }
        } else {
            OpenPgpImport::from_reader(stdin())?
        }),
        technology => Err(Error::UnsupportedTechnology { technology }),
    }
}

/// Writes a `verifier` to a VOA hierarchy in a directory.
///
/// The [`VerifierWriter`] implementation writes its on-disk representation to a specific location
/// in a VOA hierarchy in a VOA base path directory based on the `os`, `purpose` and optional
/// `context` identifier.
///
/// # Errors
///
/// Returns an error if [`VerifierWriter::write_to_hierarchy`] fails.
///
/// # Examples
///
/// ```
/// use std::io::Write;
///
/// use tempfile::{NamedTempFile, tempdir};
/// use voa::commands::{load_verifier, write_verifier_to_hierarchy};
///
/// # fn main() -> testresult::TestResult {
/// // Write a generic OpenPGP certificate to a temporary file.
/// let cert = r#"-----BEGIN PGP PUBLIC KEY BLOCK-----
///
/// xjMEaNBDAhYJKwYBBAHaRw8BAQdAzjzrpQ/AEteCmzjd1xTdXGaHV0VKSm4HLy6l
/// HVcmWT3NH0pvaG4gRG9lIDxqb2huLmRvZUBleGFtcGxlLm9yZz7CmgQQFggAQgUC
/// aNBDAhYhBEauMg3lOimFWKbyoPtSEBy0DfYKAhsDAh4BBAsJCAcGFQ4KCQwIARYN
/// JwkCCAIHAgkBCAEHAQIZAQAKCRD7UhActA32CkhIAP9bhoLJeZRCAc+q1kFEkstT
/// uXBPlzHagF6ghuUfToMmVQD+KaakONKSekglKR4rJxzhleQJ4qsptt1gjXX13QgF
/// Xwo=
/// =Pkv9
/// -----END PGP PUBLIC KEY BLOCK-----"#;
/// let mut temp_file = NamedTempFile::new()?;
/// write!(temp_file, "{cert}")?;
/// let input_path = temp_file.path();
/// // Load an OpenPGP verifier from file.
/// let verifier = load_verifier(Some(input_path.try_into()?), "openpgp".parse()?)?;
/// // Prepare a temporary output directory.
/// let temp_dir = tempdir()?;
///
/// // Write a verifier to a location in a temporary VOA hierarchy.
/// write_verifier_to_hierarchy(verifier, temp_dir, "os".parse()?, "packages".parse()?, None)?;
/// # Ok(())
/// # }
/// ```
pub fn write_verifier_to_hierarchy(
    verifier: impl VerifierWriter,
    base_path: impl AsRef<Path>,
    os: Os,
    purpose: Purpose,
    context: Option<Context>,
) -> Result<(), Error> {
    let base_path = base_path.as_ref();
    info!("Writing verifier to VOA base path: {base_path:?}");
    verifier.write_to_hierarchy(base_path, os, purpose, context)?;
    Ok(())
}

/// Searches for all verifiers matching a query of VOA identifiers.
pub fn search_verifiers(
    os: Os,
    purpose: Purpose,
    context: Option<Context>,
    technology: Option<Technology>,
) -> Result<BTreeMap<PathBuf, Vec<Verifier>>, Error> {
    let context = if let Some(context) = context {
        context
    } else {
        Context::Default
    };
    let technology = if let Some(technology) = technology {
        technology
    } else {
        Technology::Openpgp
    };

    let voa = Voa::new();
    let verifiers = voa.lookup(os, purpose, context, technology);

    Ok(verifiers)
}

/// Reads OpenPGP signatures from a list of regular files.
///
/// # Errors
///
/// Returns an error if one of the `signatures` cannot be read.
pub fn read_openpgp_signatures(
    signatures: &HashSet<RegularFile>,
) -> Result<Vec<OpenpgpSignature>, Error> {
    let mut openpgp_sigs = Vec::new();
    for path in signatures {
        info!("Reading {:?} as OpenPGP signature", path.as_ref());
        openpgp_sigs.push(OpenpgpSignature::from_file(path).map_err(Error::VoaOpenPgp)?)
    }

    Ok(openpgp_sigs)
}

/// Reads all OpenPGP certificates that match a VOA query.
pub fn read_openpgp_verifiers(os: Os, purpose: Purpose, context: Context) -> Vec<OpenpgpCert> {
    info!(
        "Reading all OpenPGP certificates matching os={os}, purpose={purpose}, context={context}"
    );
    let voa = VoaOpenpgp::new();

    voa.lookup(os, purpose, context)
}

/// Verifies a regular file using a set of OpenPGP certificates and signatures.
///
/// # Errors
///
/// Returns an error if [`ModelBasedVerifier::verify_file_with_signatures`] fails.
pub fn openpgp_verify<'a>(
    model_verifier: &'a ModelBasedVerifier,
    signatures: &'a [OpenpgpSignature],
    file: &RegularFile,
) -> Result<Vec<OpenpgpSignatureCheck<'a>>, Error> {
    model_verifier
        .verify_file_with_signatures(file, signatures)
        .map_err(Error::VoaOpenPgp)
}

/// Returns the current, fully resolved VOA configuration object for the system.
///
/// The [`VoaConfig`] object contains OS and context-level configuration for technology backends.
pub fn get_voa_config() -> VoaConfig {
    VoaConfig::load()
}

/// A [`Purpose`] and a [`Context`].
#[derive(Debug)]
pub struct PurposeAndContext {
    purpose: Purpose,
    context: Context,
}

impl PurposeAndContext {
    /// Creates a new [`PurposeAndContext`] from an optional [`Purpose`] and [`Context`].
    ///
    /// Returns [`None`] if `purpose` is [`None`].
    /// Uses [`Context::default`], if `context` is [`None`] and `purpose` is [`Some`].
    pub fn new(purpose: Option<Purpose>, context: Option<Context>) -> Option<Self> {
        let purpose = purpose?;
        let context = context.unwrap_or_default();

        Some(Self { purpose, context })
    }

    /// Returns the [`Purpose`].
    pub fn purpose(&self) -> &Purpose {
        &self.purpose
    }

    /// Returns the [`Context`].
    pub fn context(&self) -> &Context {
        &self.context
    }
}

/// Returns a [`TechnologySettings`] for an OS or a specific context of an OS.
pub fn get_technology_settings<'a>(
    config: &'a VoaConfig,
    os: &Os,
    purpose_and_context: Option<&PurposeAndContext>,
) -> &'a TechnologySettings {
    if let Some(purpose_and_context) = purpose_and_context {
        config.settings_for_context_or_default(
            os,
            purpose_and_context.purpose(),
            purpose_and_context.context(),
        )
    } else {
        config.settings_for_os_or_default(os)
    }
}

#[cfg(test)]
mod tests {
    use libc::geteuid;
    use rstest::rstest;
    use testresult::TestResult;

    use super::*;

    #[rstest]
    #[case::runtime_dir(true)]
    #[case::config_dir(false)]
    fn get_writable_load_path_succeeds(#[case] runtime: bool) -> TestResult {
        let load_path = get_writable_load_path(runtime)?;

        let euid = unsafe { geteuid() };

        eprintln!("Load path: {load_path:?}");
        if runtime {
            assert!(load_path.starts_with("/run"))
        } else if euid < 1000 {
            assert_eq!(load_path, PathBuf::from("/etc/voa"))
        } else {
            assert!(load_path.ends_with(".config/voa"))
        }

        Ok(())
    }

    #[test]
    fn load_verifier_fails_on_unsupported_technology() -> TestResult {
        let result = load_verifier(None, Technology::Custom("foo".parse()?));
        match result {
            Err(Error::UnsupportedTechnology { .. }) => {}
            Err(error) => panic!("Did not raise Error::UnsupportedTechnology but {error}"),
            Ok(verifier) => {
                panic!("Is expected to fail, but succeeded to load verifier: {verifier:?}")
            }
        }

        Ok(())
    }

    #[rstest]
    #[case::purpose_and_context_is_none(
        "example".parse()?,
        PurposeAndContext::new(None, None),
    )]
    #[case::purpose_and_context_purpose_is_none(
        "example".parse()?,
        PurposeAndContext::new(None, Some("context".parse()?)),
    )]
    #[case::purpose_and_context_context_is_none(
        "example".parse()?,
        PurposeAndContext::new(Some("purpose".parse()?), None),
    )]
    #[case::purpose_and_context_is_some(
        "example".parse()?,
        PurposeAndContext::new(Some("purpose".parse()?), Some("context".parse()?)),
    )]
    fn get_technology_settings_os_or_context(
        #[case] os: Os,
        #[case] purpose_and_context: Option<PurposeAndContext>,
    ) -> TestResult {
        let config = get_voa_config();
        let settings = get_technology_settings(&config, &os, purpose_and_context.as_ref());

        println!("{settings}");
        Ok(())
    }
}