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}