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>

//! # Environment handlers
//!
//! This module contains handlers for various [environments](Environment). Generally a handler is
//! characterized by implementing the [`IsEnvironment`] trait and integrating with the [`current`]
//! function.
//!
//! Especially [`IsEnvironment::execute`] function isn't usually trivial to implement, since the
//! precise commands to run (and wrap) depend on the *current* and *target* execution environment.
pub mod container;
pub mod distrobox;
pub mod host;
pub mod toolbx;
#[cfg(test)]
use crate::test::prelude::*;
use futures::TryFutureExt;
use prelude::*;
use tracing::Instrument;

#[allow(unused_imports)]
pub(crate) mod prelude {
    pub(crate) use crate::{
        environment::{self, Environment},
        util::{CommandLine, OutputMatcher, cmd},
    };
    pub(crate) use async_trait::async_trait;
    pub(crate) use displaydoc::Display;
    pub(crate) use serde_derive::{Deserialize, Serialize};
    pub(crate) use std::fmt;
    pub(crate) use thiserror::Error as ThisError;
    pub(crate) use tokio::process::Command;
    pub(crate) use tracing::{debug, error, info, span, trace, warn};
}

/// Trait for usable environments.
#[async_trait]
pub trait IsEnvironment: fmt::Debug + fmt::Display {
    /// Error produced by this environment impl.
    type Err;

    /// Returns true if the given Env is available at all.
    ///
    /// We assume the environment `Host` to be available unconditionally. Other environments, such
    /// as `toolbx`, can only be available when at least the `toolbx` executable is present, or we
    /// are currently inside a toolbx.
    async fn exists(&self) -> bool;

    /// Execute a command within this environment
    ///
    /// [`IsProvider`](crate::provider::IsProvider) implementations should prefer calling
    /// [`output_of()`] instead of interacting with an [`Environment`] instance directly.
    /// Refer to [`output_of()`] for details.
    ///
    /// [`output_of()`]: Environment::output_of()
    async fn execute(&self, command: CommandLine) -> Result<Command, Self::Err>;
}

/// All the execution environments known to the applicaiton.
#[derive(PartialEq, Eq, Debug, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Environment {
    //Container,
    /// The host system
    Host(host::Host),
    /// A distrobox instance
    Distrobox(distrobox::Distrobox),
    /// A toolbx instance
    Toolbx(toolbx::Toolbx),
    /// Mock environment (test only)
    #[cfg(test)]
    Mock(Mock),
}

impl Environment {
    /// Execute a command in this environment and collect stdout, if possible.
    ///
    /// Executes `cmd` inside this env, collecting all output. If execution finishes successfully,
    /// the stdout is returned as [`String`]. Otherwise, an [`ExecutionError`] is returned instead.
    ///
    /// The [`ExecutionError`] type takes care of a lot of boilerplate code that is otherwise necessary
    /// to detect specific error conditions caused by *different* environments (because they can
    /// produce different output for similar/identical error conditions).
    ///
    /// Refer to [`OutputMatcher`] for added convenience when trying to recover from a
    /// [`NonZero`](ExecutionError::NonZero) error.
    ///
    ///
    /// ### Test integration
    ///
    /// In conjunction with the [`Mock`](crate::test::mock::Mock) type, this function allows
    /// replaying the output of called commands. Use the [`quick_test!`](crate::test::quick_test)
    /// macro to simulate command outputs inside tests.
    #[cfg(not(test))]
    pub async fn output_of(&self, cmd: CommandLine) -> Result<String, ExecutionError> {
        let main_command = cmd.command();
        let output = self
            .execute(cmd)
            .await
            .map_err(ExecutionError::Environment)?
            // NOTE: This bit is very important. If ommitted, **all** `Command` instances spawned
            // by the application ever will block on each other waiting to read things from stdin.
            .stdin(std::process::Stdio::null())
            .output()
            .await
            .map_err(|err| match err.kind() {
                std::io::ErrorKind::NotFound => ExecutionError::NotFound(main_command.clone()),
                _ => ExecutionError::Unknown(err),
            })?;

        if output.status.success() {
            Ok(String::from_utf8_lossy(&output.stdout).to_string())
        } else {
            let matcher = OutputMatcher::new(&output);
            if matcher.starts_with(
            // When calling into toolbx from host
                &format!(
                    "Error: crun: executable file `{}` not found in $PATH: No such file or directory",
                    main_command.clone()
                )
            ) ||
            // When calling into host from toolbx
            matcher.starts_with(
                    "Portal call failed: Failed to start command: Failed to execute child process"
                ) && matcher.ends_with("(No such file or directory)")
            {
                Err(ExecutionError::NotFound(main_command))
            } else {
                Err(ExecutionError::NonZero {
                    command: main_command,
                    output,
                })
            }
        }
    }

    /// Instrumented test version of `output_of`.
    #[cfg(test)]
    pub async fn output_of(&self, _command: CommandLine) -> Result<String, ExecutionError> {
        match self {
            Environment::Mock(mock) => mock.pop_raw(),
            _ => panic!("cannot execute commands in tests with regular envs"),
        }
    }

    /// Serialize an environment to a JSON string.
    pub fn to_json(&self) -> String {
        serde_json::to_string(&self)
            .unwrap_or_else(|_| panic!("failed to serialize env '{}' to JSON", self))
    }

    /// Start an environment manually.
    ///
    /// This is usually done automatically when creating environment instances through their
    /// constructors. For some environments, this is a no-op.
    pub fn start(&self) -> Result<(), anyhow::Error> {
        match self {
            Self::Distrobox(val) => Ok(val.start()?),
            Self::Toolbx(val) => Ok(val.start()?),
            _ => Ok(()),
        }
    }
}

impl fmt::Display for Environment {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Host(val) => write!(f, "{}", val),
            Self::Distrobox(val) => write!(f, "{}", val),
            Self::Toolbx(val) => write!(f, "{}", val),
            #[cfg(test)]
            Self::Mock(val) => write!(f, "{}", val),
        }
    }
}

#[async_trait]
impl IsEnvironment for Environment {
    type Err = Error;

    async fn exists(&self) -> bool {
        match self {
            Self::Host(val) => val.exists(),
            Self::Distrobox(val) => val.exists(),
            Self::Toolbx(val) => val.exists(),
            #[cfg(test)]
            Self::Mock(val) => val.exists(),
        }
        .await
    }

    async fn execute(&self, command: CommandLine) -> Result<Command, Self::Err> {
        async move {
            match self {
                Self::Host(val) => val.execute(command).map_err(Error::ExecuteOnHost).await,
                Self::Distrobox(val) => {
                    val.execute(command)
                        .map_err(|e| Self::Err::ExecuteInDistrobox {
                            distrobox: val.to_string(),
                            source: e,
                        })
                        .await
                }
                Self::Toolbx(val) => {
                    val.execute(command)
                        .map_err(|e| Self::Err::ExecuteInToolbx {
                            toolbx: val.to_string(),
                            source: e,
                        })
                        .await
                }
                #[cfg(test)]
                Self::Mock(val) => Ok(val.execute(command).await.unwrap()),
            }
        }
        .in_current_span()
        .await
    }
}

impl From<host::Host> for Environment {
    fn from(value: host::Host) -> Self {
        Self::Host(value)
    }
}

impl From<distrobox::Distrobox> for Environment {
    fn from(value: distrobox::Distrobox) -> Self {
        Self::Distrobox(value)
    }
}

impl From<toolbx::Toolbx> for Environment {
    fn from(value: toolbx::Toolbx) -> Self {
        Self::Toolbx(value)
    }
}

impl std::str::FromStr for Environment {
    type Err = SerializationError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let val: Self =
            serde_json::from_str(s).map_err(|_| SerializationError { raw: s.to_owned() })?;
        Ok(val)
    }
}

/// invalid environment specification '{raw}'
#[derive(Debug, ThisError, Serialize, Deserialize, Display)]
pub struct SerializationError {
    raw: String,
}

/// Return the current execution environment.
pub fn current() -> Environment {
    if toolbx::detect() {
        Environment::Toolbx(toolbx::Toolbx::current().unwrap())
    } else if distrobox::detect() {
        Environment::Distrobox(distrobox::Distrobox::current().unwrap())
    } else {
        Environment::Host(host::Host::new())
    }
}

/// Preserve the users environments.
///
/// Attempts to replicate all environment variables of the current process in the spawned
/// environment. Refer to the source code to see exactly which variables are preserved. Variables
/// are returned as a vector of strings with 'KEY=VALUE' notation.
pub fn read_env_vars() -> Vec<String> {
    let exclude = [
        "HOST",
        "HOSTNAME",
        "HOME",
        "LANG",
        "LC_CTYPE",
        "PATH",
        "PROFILEREAD",
        "SHELL",
    ];

    std::env::vars()
        .filter_map(|(mut key, value)| {
            if exclude.contains(&&key[..])
                || key.starts_with('_')
                || (key.starts_with("XDG_") && key.ends_with("_DIRS"))
            {
                None
            } else {
                key.push('=');
                key.push_str(&value);
                Some(key)
            }
        })
        .collect::<Vec<_>>()
}

/// Common errors from command execution.
#[derive(Debug, ThisError)]
pub enum ExecutionError {
    /// Requested executable cannot be found
    #[error("command not found: {0}")]
    NotFound(String),

    /// Error inside an [`Environment`]
    #[error(transparent)]
    Environment(#[from] Error),

    /// Error from [`Command`]
    #[error(transparent)]
    Unknown(#[from] std::io::Error),

    /// Error from the called command
    ///
    /// ## Note
    ///
    /// When calling from the host into a toolbx container, *stdout* and *stderr* are both merged
    /// into *stdout*. This is currently a shortcoming of the involved call to `podman exec -t
    /// ...`. Refer to the manpage of `podman-exec(1)`, in particular the *NOTE* attached to
    /// `--tty`.
    ///
    /// This means that when checking for messages in `output.stderr`, you should **always** check
    /// for the existence of your message in **both stderr and stdout**.
    #[error("command '{command}' exited with nonzero code: {output:?}")]
    NonZero {
        /// The command that produced the error
        command: String,
        /// The output associated with the error.
        output: std::process::Output,
    },
}

/// Error from starting up an environment.
#[derive(Debug, ThisError)]
pub enum StartError {
    /// Underlying Distrobox error.
    #[error(transparent)]
    Distrobox(#[from] distrobox::StartDistroboxError),

    /// Underlying Toolbx error.
    #[error(transparent)]
    Toolbx(#[from] toolbx::StartToolbxError),
}

/// Environment execution error.
#[derive(Debug, ThisError, Display)]
pub enum Error {
    /// failed to execute command on host
    ExecuteOnHost(#[from] <host::Host as IsEnvironment>::Err),

    /// failed to execute command in '{toolbx}': {source}
    ExecuteInToolbx {
        /// Toolbx where the error originates.
        toolbx: String,
        /// Underlying error source.
        source: <toolbx::Toolbx as IsEnvironment>::Err,
    },

    /// failed to execute command in '{distrobox}': {source}
    ExecuteInDistrobox {
        /// Distrobox where the error originates.
        distrobox: String,
        /// Underlying error source.
        source: <distrobox::Distrobox as IsEnvironment>::Err,
    },
}