cnf-lib 0.6.0

Distribution-agnostic 'command not found'-handler
Documentation
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: (C) 2023 Andreas Hartmann <hartan@7x.de>
// This file is part of cnf-lib, available at <https://gitlab.com/hartang/rust/cnf>

//! # Search packages with APT
//!
//! Searches for an executable in the APT cache via `apt-file`.
use crate::provider::prelude::*;
use futures::StreamExt;
use std::ops::Not;

/// Provider for the `apt` package manager.
#[derive(Default, Debug, PartialEq, Clone)]
// In the future these may get (mutable) internal state.
#[allow(missing_copy_implementations)]
pub struct Apt {}

impl Apt {
    /// Create a new instance.
    pub fn new() -> Apt {
        Default::default()
    }

    /// Search for a given command.
    async fn search(&self, env: &Arc<Environment>, command: &str) -> Result<String, Error> {
        env.output_of(cmd!(
            "apt-file",
            "search",
            "--regexp",
            &format!("bin.*/{}$", command)
        ))
        .await
        .map_err(Error::from)
    }

    /// Update the `apt` cache.
    async fn update_cache(&self, env: &Arc<Environment>) -> Result<(), CacheError> {
        env.output_of(cmd!("apt-file", "update").privileged())
            .await
            .map(|_| ())
            .map_err(CacheError::from)
    }

    /// Obtain a list of candidates from [`Apt::search`] output.
    async fn get_candidates_from_output(
        &self,
        output: &str,
        env: Arc<Environment>,
    ) -> Vec<Candidate> {
        futures::stream::iter(output.lines())
            .then(|line| {
                async {
                    let line = line.to_owned();
                    let cloned_env = env.clone();
                    let mut candidate = Candidate::default();

                    if let Some((package, bin)) = line.split_once(": ") {
                        candidate.package = package.to_string();
                        candidate.actions.execute = cmd!(bin);

                        // Try to get additional package information
                        if let Ok(output) = cloned_env
                            .output_of(cmd!("apt-cache", "show", package))
                            .await
                            .with_context(|| {
                                format!("failed to gather additional info for '{}'", line)
                            })
                            .to_log()
                        {
                            for line in output.lines() {
                                match line.split_once(": ") {
                                    Some(("Version", version)) => {
                                        candidate.version = version.to_string()
                                    }
                                    Some(("Origin", origin)) => {
                                        candidate.origin = origin.to_string()
                                    }
                                    Some(("Description", text)) => {
                                        candidate.description = text.to_string()
                                    }
                                    _ => continue,
                                }
                            }
                        }

                        // Check whether the package is installed
                        candidate.actions.install = cloned_env
                            .output_of(cmd!("dpkg-query", "-W", package))
                            .await
                            // dpkg returned success, package is installed
                            .map(|_| true)
                            // Package not installed
                            .unwrap_or(false)
                            // Invert the result: We only add install commands if the package isn't
                            // present
                            .not()
                            // If the package really isn't installed
                            .then_some(cmd!("apt", "install", "-y", package).privileged());
                    }
                    candidate
                }
                .in_current_span()
            })
            .collect::<Vec<Candidate>>()
            .await
    }
}

impl fmt::Display for Apt {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "APT")
    }
}

#[async_trait]
impl IsProvider for Apt {
    async fn search_internal(
        &self,
        command: &str,
        target_env: Arc<Environment>,
    ) -> ProviderResult<Vec<Candidate>> {
        let stdout = match self.search(&target_env, command).await {
            Ok(val) => val,
            Err(Error::NoCache) => {
                self.update_cache(&target_env)
                    .await
                    .map_err(Error::Cache)
                    .map_err(|e| e.into_provider(command))?;
                self.search(&target_env, command)
                    .await
                    .map_err(|e| e.into_provider(command))?
            }
            Err(e) => return Err(e.into_provider(command)),
        };

        Ok(self.get_candidates_from_output(&stdout, target_env).await)
    }
}

/// Error type for the `apt` provider.
#[derive(Debug, ThisError, Display)]
pub enum Error {
    /// cache error
    Cache(#[from] CacheError),

    /// system cache is empty, please run 'apt-file update' as root first
    NoCache,

    /// requirement not fulfilled: '{0}'
    Requirements(String),

    /// command not found
    NotFound,

    /// execution terminated with nonzero exit code
    Execution(ExecutionError),
}

impl From<ExecutionError> for Error {
    fn from(value: ExecutionError) -> Self {
        match value {
            ExecutionError::NonZero { ref output, .. } => {
                let matcher = OutputMatcher::new(output);
                if matcher.contains("E: The cache is empty") {
                    Error::NoCache
                } else if matcher.is_empty() {
                    Error::NotFound
                } else {
                    Error::Execution(value)
                }
            }
            ExecutionError::NotFound(val) => Error::Requirements(val),
            _ => Error::Execution(value),
        }
    }
}

impl Error {
    /// Convert this [`Error`] instance into a [`ProviderError`].
    pub fn into_provider(self, command: &str) -> ProviderError {
        match self {
            Self::NotFound => ProviderError::NotFound(command.to_string()),
            Self::Requirements(what) => ProviderError::Requirements(what),
            Self::Execution(err) => ProviderError::Execution(err),
            _ => ProviderError::ApplicationError(anyhow::Error::new(self)),
        }
    }
}

#[derive(Debug, ThisError, Display)]
/// failed to update apt-file cache
pub struct CacheError(#[from] ExecutionError);

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test::prelude::*;

    #[test]
    fn initialize() {
        let _apt = Apt::new();
    }

    test::default_tests!(Apt::new());

    /// Searching with empty apt-cache
    ///
    /// - Searched with: apt 2.4.9 (apt-file has no version output...)
    /// - Search command: "apt-file search --regexp 'bin.*/btrbk$'"
    #[test]
    fn cache_empty() {
        let query = quick_test!(
            Apt::new(),
            Err(ExecutionError::NonZero {
                command: "apt-file".to_string(),
                output: std::process::Output {
                    stdout: r"Finding relevant cache files to search ...".into(),
                    stderr: r#"E: The cache is empty. You need to run "apt-file update" first.
"#
                    .into(),
                    status: ExitStatus::from_raw(3),
                },
            }),
            Err(ExecutionError::NonZero {
                command: "sudo".to_string(),
                output: std::process::Output {
                    stdout: r"".into(),
                    stderr: r#"sudo: a password is required\n"#.into(),
                    status: ExitStatus::from_raw(1),
                },
            })
        );

        assert::is_err!(query);
        assert::err::application!(query);
    }

    /// Searching an existent package
    ///
    /// - Searched with: apt 2.4.9
    /// - Search command: "apt-file search --regexp 'bin.*/btrbk$'"
    #[test]
    fn search_existent() {
        let query = quick_test!(Apt::new(),
            // Output from apt-file
            Ok("btrbk: /usr/bin/btrbk".to_string()),
            // Outout from apt-cache show
            Ok("
Package: btrbk
Architecture: all
Version: 0.31.3-1
Priority: optional
Section: universe/utils
Origin: Ubuntu
Maintainer: Ubuntu Developers <ubuntu-devel-discuss@lists.ubuntu.com>
Original-Maintainer: Axel Burri <axel@tty0.ch>
Bugs: https://bugs.launchpad.net/ubuntu/+filebug
Installed-Size: 404
Depends: perl, btrfs-progs (>= 4.12)
Recommends: openssh-client, mbuffer
Suggests: openssl, python3
Filename: pool/universe/b/btrbk/btrbk_0.31.3-1_all.deb
Size: 107482
MD5sum: ad4aaa293c91981fcde34a413f043f37
SHA1: 88734d2e6f6c5bc6597edd4da22f67bf86ae45ad
SHA256: b554489c952390da62c0c2de6012883f18a932b1b40157254846332fb6aaa889
SHA512: 5dcd7015b325fcc5f6acd9b70c4f2c511826aa03426b594a278386091b1f36af6fef6a05a99f5bcc0866badf96fa2342afb6a44a0249d7d67199f0d877f3614c
Homepage: https://digint.ch/btrbk/
Description: backup tool for btrfs subvolumes
Description-md5: 13434d9f502ec934b9db33ec622b0769
".to_string()),
            // Output from dpkg-query
            Err(ExecutionError::NonZero {
                command: "dpkg-query".to_string(),
                output: std::process::Output {
                    stdout: r"".into(),
                    stderr: r"dpkg-query: no packages found matching btrbk\n".into(),
                    status: ExitStatus::from_raw(1),
                }
            })
        );

        let result = query.results.unwrap();
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].package, "btrbk".to_string());
        assert_eq!(result[0].actions.execute, vec!["/usr/bin/btrbk"].into());
        assert_eq!(result[0].version, "0.31.3-1".to_string());
        assert_eq!(result[0].origin, "Ubuntu".to_string());
        assert_eq!(
            result[0].description,
            "backup tool for btrfs subvolumes".to_string()
        );
        assert!(result[0].actions.install.is_some());
    }

    /// Searching a non-existent package
    ///
    /// - Searched with: apt 2.4.9
    /// - Search command: "apt-file search --regexp 'bin.*/asdwasda$'"
    #[test]
    fn search_nonexistent() {
        let query = quick_test!(
            Apt::new(),
            Err(ExecutionError::NonZero {
                command: "apt-file".to_string(),
                output: std::process::Output {
                    stdout: r"".into(),
                    stderr: r"".into(),
                    status: ExitStatus::from_raw(1),
                }
            })
        );

        assert::is_err!(query);
        assert::err::not_found!(query);
    }
}