bitbazaar/cli/
bash.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4};
5
6use super::{errs::ShellErr, shell::Shell, BashErr, BashOut};
7use crate::prelude::*;
8
9/// Execute an arbitrary bash script.
10///
11/// WARNING: this opens up the possibility of dependency injection attacks, so should only be used when the command is trusted.
12/// If compiled usage is all that's needed, use something like rust_cmd_lib instead, which only provides a macro literal interface.
13/// <https://github.com/rust-shell-script/rust_cmd_lib>
14///
15/// This is a pure rust implementation and doesn't rely on bash being available to make it compatible with windows.
16/// Given that, it only implements a subset of bash features, and is not intended to be a full bash implementation.
17///
18/// Purposeful deviations from bash:
19/// - set -e is enabled by default, each cmd line will stop if it fails
20///
21/// Assume everything is unimplemented unless stated below:
22/// - `&&` and
23/// - `||` or
24/// - `!` exit code negation
25/// - `|` pipe
26/// - `~` home dir
27/// - `foo=bar` param setting
28/// - `$foo` param substitution
29/// - `$(echo foo)` command substitution
30/// - `'` quotes
31/// - `"` double quotes
32/// - `\` escaping
33/// - `(...)` simple compound commands e.g. (echo foo && echo bar)
34/// - Basic file/stderr/stdout redirection
35///
36/// This should theoretically work with multi line full bash scripts but only tested with single line commands.
37pub struct Bash {
38    // The commands that will be loaded in to run, treated as && separated (only running the next if the last succeeded):
39    cmds: Vec<String>,
40    // Optional override of the root dir to run the commands in:
41    root_dir: Option<PathBuf>,
42    // Extra environment variables to run the commands with:
43    env_vars: HashMap<String, String>,
44}
45
46impl Default for Bash {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl Bash {
53    /// Create a new [`Bash`] builder.
54    pub fn new() -> Self {
55        Self {
56            cmds: Vec::new(),
57            root_dir: None,
58            env_vars: HashMap::new(),
59        }
60    }
61
62    /// Add a new piece of logic to the bash script. E.g. a line of bash.
63    ///
64    /// Multiple commands added to a [`Bash`] instance will be treated as newline separated.
65    pub fn cmd(self, cmd: impl Into<String>) -> Self {
66        let mut cmds = self.cmds;
67        cmds.push(cmd.into());
68        Self {
69            cmds,
70            root_dir: self.root_dir,
71            env_vars: self.env_vars,
72        }
73    }
74
75    /// Set the root directory to run the commands in.
76    ///
77    /// By default, the current process's root directory is used.
78    pub fn chdir(self, root_dir: &Path) -> Self {
79        Self {
80            cmds: self.cmds,
81            root_dir: Some(root_dir.to_path_buf()),
82            env_vars: self.env_vars,
83        }
84    }
85
86    /// Add an environment variable to the bash script.
87    pub fn env(self, name: impl Into<String>, val: impl Into<String>) -> Self {
88        let mut env_vars = self.env_vars;
89        env_vars.insert(name.into(), val.into());
90        Self {
91            cmds: self.cmds,
92            root_dir: self.root_dir,
93            env_vars,
94        }
95    }
96
97    /// Execute the current contents of the bash script.
98    pub fn run(self) -> RResult<BashOut, BashErr> {
99        if self.cmds.is_empty() {
100            return Ok(BashOut::empty());
101        }
102
103        let mut shell = Shell::new(self.env_vars, self.root_dir)
104            .map_err(|e| shell_to_bash_err(BashOut::empty(), e))?;
105
106        if let Err(e) = shell.execute_command_strings(self.cmds) {
107            return Err(shell_to_bash_err(shell.into(), e));
108        }
109
110        Ok(shell.into())
111    }
112}
113
114fn shell_to_bash_err(
115    mut bash_out: BashOut,
116    e: error_stack::Report<ShellErr>,
117) -> error_stack::Report<BashErr> {
118    // Doesn't really make sense, but set the exit code to 1 if 0, as technically the command errored even though it was the runner itself that errored and the command might not have been attempted.
119    if bash_out.code() == 0 {
120        bash_out.override_code(1);
121    }
122    match e.current_context() {
123        ShellErr::Exit => e.change_context(BashErr::InternalError(bash_out)).attach_printable(
124            "Shouldn't occur, shell exit errors should have been managed internally, not an external error.",
125        ),
126        ShellErr::InternalError => e.change_context(BashErr::InternalError(bash_out)),
127        ShellErr::BashFeatureUnsupported => e.change_context(BashErr::BashFeatureUnsupported(bash_out)),
128        ShellErr::BashSyntaxError => e.change_context(BashErr::BashSyntaxError(bash_out)),
129    }
130}