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>

//! # Toolbx Environment Handler
//!
//! This module handles command-not-found errors that occur while executing inside a Toolbx
//! container. Currently, this means that commands are forwarded to the host using `flatpak-spawn`.
//! If `flatpak-spawn` isn't present, an error is thrown instead.
use super::prelude::*;

use users::{get_current_gid, get_current_uid};

use std::{io::IsTerminal, path::Path};

const TOOLBX_ENV: &str = "/run/.toolboxenv";
const CONTAINER_ENV: &str = "/run/.containerenv";
const OS_RELEASE: &str = "/etc/os-release";

/// Environment for a Toolbx container
#[derive(PartialEq, Eq, Debug, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Toolbx {
    name: String,
}

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

impl Toolbx {
    /// Spawn a Toolbx container with a given name.
    ///
    /// Checks if the toolbx container exists and starts it, if necessary. If `None` is given as
    /// name, will try to determine the default toolbx name and start that instead. Returns an
    /// error if unsuccessful.
    pub fn new(name: Option<String>) -> Result<Toolbx, NewToolbxError> {
        let name = match name {
            Some(name) if !name.is_empty() => name,
            _ => match Toolbx::default_name() {
                Ok(toolbx_name) => toolbx_name,
                Err(e) => return Err(NewToolbxError::UnknownDefault(e)),
            },
        };

        // Do an optimistic start:
        // - If the container exists and isn't started, it will be started
        // - If the container exists and is started, nothing happens
        // - If the container doesn't exist, we get an error and report that
        let ret = Self { name: name.clone() };
        ret.start()
            .map_err(|e| NewToolbxError::CannotStart { source: e, name })?;
        Ok(ret)
    }

    /// Starts a given toolbx container.
    ///
    /// This function is automatically called by `new()` above and should only ever be called when
    /// creating a `Toolbx` object without using the constructor. This is currently the case when
    /// executing aliases in `cnf`, as the `Toolbx` instance is deserialized from the config in
    /// that case.
    pub fn start(&self) -> Result<(), StartToolbxError> {
        let output = std::process::Command::new("podman")
            .args(["start", &self.name])
            .output()
            .map_err(|e| match e.kind() {
                std::io::ErrorKind::NotFound => StartToolbxError::NeedPodman,
                _ => StartToolbxError::IoError(e),
            })?;
        if output.status.success() {
            // All good
            Ok(())
        } else {
            let matcher = OutputMatcher::new(&output);
            if matcher.starts_with("Error: no container with name or ID")
                && matcher.contains("found: no such container")
            {
                Err(StartToolbxError::NonExistent(self.name.clone()))
            } else {
                Err(StartToolbxError::Podman(output))
            }
        }
    }

    /// Get the Toolbx container currently executing CNF.
    ///
    /// Will return an error if the current execution environment isn't Toolbx.
    pub fn current() -> Result<Toolbx, CurrentToolbxError> {
        if !detect() {
            return Err(CurrentToolbxError::NotAToolbx);
        }

        let content = std::fs::read_to_string(CONTAINER_ENV).map_err(|e| {
            CurrentToolbxError::Environment {
                env_file: CONTAINER_ENV.to_string(),
                source: e,
            }
        })?;
        let name = content
            .lines()
            .find(|line| line.contains("name=\""))
            .ok_or_else(|| CurrentToolbxError::Name(CONTAINER_ENV.to_string()))?
            .trim_start_matches("name=\"")
            .trim_end_matches('"');

        Ok(Toolbx {
            name: name.to_string(),
        })
    }

    /// Get the name of the default toolbx to lookup/execute commands in.
    ///
    /// The default toolbx container name is assembled from the contents of `/etc/os-release`.
    pub fn default_name() -> Result<String, DefaultToolbxError> {
        // Construct default toolbox name by hand. Format is $ID-toolbox-$VERSION_ID, with ID
        // and VERSION_ID taken from /etc/os-release. See here:
        // https://containertoolbx.org/distros/
        debug!("Determining default toolbx name via {}", OS_RELEASE);

        let content =
            std::fs::read_to_string(OS_RELEASE).map_err(|e| DefaultToolbxError::UnknownOs {
                file: OS_RELEASE.to_string(),
                source: e,
            })?;
        let id = content
            .lines()
            .find(|line| line.starts_with("ID="))
            .map(|line| line.trim_start_matches("ID=").trim_matches('"'))
            .ok_or(DefaultToolbxError::Id)?;
        let version_id = content
            .lines()
            .find(|line| line.starts_with("VERSION_ID="))
            .map(|line| line.trim_start_matches("VERSION_ID=").trim_matches('"'))
            .ok_or(DefaultToolbxError::VersionId)?;

        Ok(format!("{}-toolbox-{}", id, version_id))
    }
}

#[async_trait]
impl environment::IsEnvironment for Toolbx {
    type Err = Error;

    async fn exists(&self) -> bool {
        if detect() {
            true
        } else if let Environment::Host(host) = environment::current() {
            // The result in this case is indeed `Infallible`, but switching an `if-let` for an
            // `unwrap` is outright stupid IMO.
            #[allow(irrefutable_let_patterns)]
            if let Ok(mut cmd) = host
                .execute(crate::environment::cmd!("toolbox", "--version"))
                .await
            {
                cmd.stdout(std::process::Stdio::null())
                    .stderr(std::process::Stdio::null())
                    .status()
                    .await
                    .map(|status| status.success())
                    .unwrap_or(false)
            } else {
                false
            }
        } else {
            false
        }
    }

    async fn execute(&self, command: CommandLine) -> Result<Command, Self::Err> {
        debug!("preparing execution: {}", command);
        let mut cmd: Command;

        match environment::current() {
            Environment::Distrobox(_) => {
                return Err(Error::Unimplemented(
                    "running in a toolbx from a distrobox".to_string(),
                ));
            }
            Environment::Toolbx(t) => {
                if self == &t {
                    // This is the toolbx container we are currently running in
                    // We expect toolbx containers to *always* run a unix OS, or at least something
                    // that has `sudo`.
                    if command.get_privileged() {
                        cmd = Command::new("sudo");
                        if !command.get_interactive() {
                            cmd.arg("-n");
                        }

                        cmd.arg(command.command());
                    } else {
                        cmd = Command::new(command.command());
                    }

                    cmd.args(command.args());
                } else {
                    return Err(Error::Unimplemented(
                        "running in a toolbx from another toolbx".to_string(),
                    ));
                }
            }
            Environment::Host(_) => {
                cmd = Command::new("podman");

                cmd.args(["exec", "-i"]);
                // The toolbx container by default isn't launched with the `--user` option, we must
                // take care of this ourselves.
                cmd.arg("--user");
                cmd.arg(format!("{}:{}", get_current_uid(), get_current_gid()));
                // Fix the working directory
                cmd.arg("--workdir");
                cmd.arg(std::env::current_dir().map_err(Error::UnknownCwd)?);
                // Keep some env vars
                for var in environment::read_env_vars() {
                    cmd.args(["-e", &var]);
                }

                // Avoid accidental detach from container
                cmd.args(["--detach-keys", ""]);

                // Only attach to the tty if we really have a tty, too
                if std::io::stdout().is_terminal() && std::io::stdin().is_terminal() {
                    cmd.arg("-t");
                }

                // Can't run command in toolbx if we don't have one
                cmd.arg(&self.name);

                // This is the real command we're looking for (with arguments)
                if command.get_privileged() {
                    cmd.args(["sudo", "-S", "-E"]);
                    // NOTE: We ignore `get_interactive` here. because toolbox seems to do weird
                    // things regarding sudo. When adding the `-n` flag to request non-interactive
                    // auth, sudo will fail, requiring a pssword. However, factually running `sudo`
                    // in a toolbx container *does not* require a password under normal
                    // circumstances. Just ignoring interactivity here solves this issue (but don't
                    // ask me why).
                }

                cmd.arg(command.command()).args(command.args());
            }
            #[cfg(test)]
            Environment::Mock(_) => unimplemented!(),
        }

        trace!("full command: {:?}", cmd);
        Ok(cmd)
    }
}

/// Detect if the current execution environment is a Toolbx container.
///
/// Checks for the presence of the `.toolboxenv` files.
pub fn detect() -> bool {
    Path::new(TOOLBX_ENV).exists()
}

/// Errors related to starting concrete Toolbx instances.
#[derive(Debug, ThisError, Display)]
pub enum StartToolbxError {
    /// working with toolbx containers requires the 'podman' executable
    NeedPodman,

    /// podman exited with non-zero code: {0:#?}
    Podman(std::process::Output),

    /// no toolbx with name {0} exists
    NonExistent(String),

    /// unknown I/O error occured
    IoError(#[from] std::io::Error),
}

/// Errors related to starting a named Toolbx instance.
#[derive(Debug, ThisError, Display)]
pub enum NewToolbxError {
    /// failed to determine default toolbx name
    UnknownDefault(#[from] DefaultToolbxError),

    /// failed to start toolbx container with name '{name}': {source}
    CannotStart {
        /// Underlying error source.
        source: StartToolbxError,
        /// Name of the Toolbx that failed to start.
        name: String,
    },
}

/// Errors related to distrobox as environment that launched `cnf`.
#[derive(Debug, ThisError, Display)]
pub enum CurrentToolbxError {
    /// cannot read toolbx info from environment file '{env_file}': {source}
    Environment {
        /// environment file that couldn't be read.
        env_file: String,
        /// Error from trying to read the environment file.
        source: std::io::Error,
    },

    /// program currently isn't run from a toolbx
    NotAToolbx,

    /// failed to read toolbx name from environment file '{0}'
    Name(String),
}

/// Errors related to the configured default distrobox container.
#[derive(Debug, ThisError, Display)]
pub enum DefaultToolbxError {
    /// failed to read OS information from '{file}': {source}
    UnknownOs {
        /// File that couldn't be read.
        file: String,
        /// Error from trying to read the file.
        source: std::io::Error,
    },

    /// cannot determine OS ID from os-release info
    Id,

    /// cannot determine OS VERSION_ID from os-release info
    VersionId,
}

/// Error type for environment impl.
#[derive(Debug, ThisError, Display)]
pub enum Error {
    /// cannot determine current working directory
    UnknownCwd(#[from] std::io::Error),

    /// not implemented: {0}
    Unimplemented(String),
}