change-user-run 0.1.2

Run commands as other users and create users
Documentation
//! Running of commands as a different user.

use std::{
    collections::HashMap,
    env::vars,
    fmt::Display,
    io::Write,
    process::{Command, ExitStatus, Stdio},
    str::from_utf8,
};

use log::debug;

use crate::{Error, get_command};

/// Data on a command that has been executed.
///
/// Tracks the command that has been executed, its stdout, stderr and status code.
#[derive(Debug)]
pub struct CommandOutput {
    /// The command that has been executed.
    pub command: String,

    /// Status code of [`Command`].
    pub status: ExitStatus,

    /// Standard output of [`Command`].
    pub stdout: String,

    /// Standard error of [`Command`].
    pub stderr: String,
}

impl Display for CommandOutput {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        writeln!(f, "{}", self.command)?;
        writeln!(
            f,
            "⤷ status: {}",
            self.status
                .code()
                .map(|code| code.to_string())
                .unwrap_or("n/a".to_string())
        )?;
        writeln!(f, "⤷ stdout:\n{}", self.stdout)?;
        writeln!(f, "⤷ stderr:\n{}", self.stderr)?;
        Ok(())
    }
}

/// Runs `command` with `command_args` and optional `command_input` as `user`.
///
/// Uses [runuser] to run the `command` as the specific `user`.
///
/// An `env_list` can be passed in to pass on a specific set of environment variables to the
/// environment of the `user` when calling `command`.
/// An optional [`HashMap`] of `envs` can be passed in to provide specific overrides of environment
/// variables passed in to the environment of the `user` when calling `command`.
///
/// # Note
///
/// Running as another user is a privileged action which requires calling this function as root.
///
/// # Errors
///
/// Returns an error if
///
/// - [runuser] cannot be found,
/// - `command` cannot be found,
/// - `command` cannot be run in background,
/// - `command_input` is provided, but stdin cannot be attached or written to,
/// - `command` cannot be executed,
/// - or a UTF-8 error occurred while converting stdout or stderr of the command to string.
///
/// # Examples
///
/// ```no_run
/// use std::collections::HashMap;
///
/// use change_user_run::run_command_as_user;
///
/// # fn main() -> testresult::TestResult {
/// // Run `whoami` as the user `test`.
/// run_command_as_user("whoami", &[], None, &[], None, "test");
///
/// // Run `example` as the user `test`, pass in environment variables relevant for `cargo-llvm-cov`.
/// // Here, we assume that `example` has been compiled with required coverage instrumentation.
/// let env_list = [
///     "LLVM_PROFILE_FILE",
///     "CARGO_LLVM_COV",
///     "CARGO_LLVM_COV_SHOW_ENV",
///     "CARGO_LLVM_COV_TARGET_DIR",
///     "RUSTFLAGS",
///     "RUSTDOCFLAGS",
/// ];
/// let mut envs: HashMap<String, String> = HashMap::new();
/// // Note: This instructs relevant .profraw data to be written to /tmp.
/// envs.insert(
///     "LLVM_PROFILE_FILE".to_string(),
///     "/tmp/project-%p-%16m.profraw".to_string(),
/// );
/// run_command_as_user("example", &["--help"], None, &env_list, Some(envs), "test");
/// # Ok(())
/// # }
/// ```
///
/// [runuser]: https://man.archlinux.org/man/runuser.1
pub fn run_command_as_user(
    cmd: &str,
    cmd_args: &[&str],
    cmd_input: Option<&[u8]>,
    env_list: &[&str],
    envs: Option<HashMap<String, String>>,
    user: &str,
) -> Result<CommandOutput, Error> {
    let runuser_command = get_command("runuser")?;
    debug!("Checking availability of command {cmd}");
    get_command(cmd)?;

    // Prepare environment variables to pass in.
    let mut envs_to_pass: HashMap<String, String> = HashMap::new();
    if !env_list.is_empty() {
        for env_var in vars().filter(|(key, _)| env_list.contains(&key.as_str())) {
            envs_to_pass.insert(env_var.0, env_var.1);
        }
    }
    if let Some(envs) = envs {
        envs_to_pass.extend(envs);
    }

    // Run command as user.
    let mut command = Command::new(runuser_command);
    command.arg("--user").arg(user);

    // Allow and pass in the prepared environment variables.
    if !envs_to_pass.is_empty() {
        command
            .arg(format!(
                "--whitelist-environment={}",
                envs_to_pass
                    .keys()
                    .map(|key| key.as_str())
                    .collect::<Vec<&str>>()
                    .join(",")
            ))
            .envs(&envs_to_pass);
    }

    // Add command (and arguments) to run as user.
    command.arg("--").arg(cmd);
    for cmd_arg in cmd_args {
        command.arg(cmd_arg);
    }

    command
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .stdin(if cmd_input.is_none() {
            Stdio::null()
        } else {
            Stdio::piped()
        });

    debug!("Running command {command:?}");
    let mut command_child = command.spawn().map_err(|source| Error::CommandBackground {
        command: format!("{command:?}"),
        source,
    })?;

    if let Some(input) = cmd_input {
        command_child
            .stdin
            .take()
            .ok_or(Error::CommandAttachToStdin {
                command: format!("{command:?}"),
            })?
            .write_all(input)
            .map_err(|source| Error::CommandWriteToStdin {
                command: format!("{command:?}"),
                source,
            })?;
    }

    let command_output = command_child
        .wait_with_output()
        .map_err(|source| Error::CommandExec {
            command: format!("{command:?}"),
            source,
        })?;

    Ok(CommandOutput {
        status: command_output.status,
        stdout: from_utf8(&command_output.stdout)
            .map_err(|source| Error::Utf8String {
                context: format!("processing the stdout of {command:?}"),
                source,
            })?
            .to_string(),
        stderr: from_utf8(&command_output.stderr)
            .map_err(|source| Error::Utf8String {
                context: format!("processing the stderr of {command:?}"),
                source,
            })?
            .to_string(),
        command: format!("{command:?}"),
    })
}