From f7b385571ae648625be99b449f4fccec6ddb7f35 Mon Sep 17 00:00:00 2001 From: Zak Stucke Date: Thu, 25 Jan 2024 17:20:49 +0200 Subject: [PATCH] Pure rust basic bash implementation, should be easier to get it to work on windows --- rust/Cargo.lock | 75 +-- rust/Cargo.toml | 22 +- rust/bitbazaar/cli/bash.rs | 548 ++++++++++++++++++++ rust/bitbazaar/cli/cmd_err.rs | 23 + rust/bitbazaar/cli/cmd_out.rs | 43 ++ rust/bitbazaar/cli/mod.rs | 105 +++- rust/bitbazaar/cli/run_cmd.rs | 69 +-- rust/bitbazaar/errors/macros.rs | 28 + rust/bitbazaar/logging/create_subscriber.rs | 12 + rust/bitbazaar/logging/mod.rs | 5 +- rust/bitbazaar/prelude.rs | 5 +- 11 files changed, 788 insertions(+), 147 deletions(-) create mode 100644 rust/bitbazaar/cli/bash.rs create mode 100644 rust/bitbazaar/cli/cmd_err.rs create mode 100644 rust/bitbazaar/cli/cmd_out.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 77138cb3..a8660b5d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -200,36 +200,15 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" -[[package]] -name = "bitbazaar" -version = "0.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd4771c10da11f3f8c23d1fce9f50cadeb3cfb6c35d099f3a05afaca48b2c2dd" -dependencies = [ - "chrono", - "clap", - "colored", - "comfy-table", - "error-stack", - "once_cell", - "parking_lot", - "regex", - "shlex", - "time", - "tracing", - "tracing-appender", - "tracing-subscriber", -] - [[package]] name = "bitbazaar" version = "0.0.21" dependencies = [ - "bitbazaar 0.0.20", "chrono", "clap", "colored", "comfy-table", + "conch-parser", "deadpool-redis", "error-stack", "homedir", @@ -242,12 +221,11 @@ dependencies = [ "redis", "regex", "rstest", - "run_script", "serde", "serde_json", "serial_test", "sha1_smol", - "shlex", + "strum", "tempfile", "time", "tokio", @@ -395,6 +373,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "conch-parser" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf61cea7edff80a7d8a9a4c219d391664715559e9d4e7129494b45278705a41" +dependencies = [ + "void", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -501,12 +488,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "dunce" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" - [[package]] name = "either" version = "1.9.0" @@ -575,16 +556,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fsio" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad0ce30be0cc441b325c5d705c8b613a0ca0d92b6a8953d41bd236dc09a36d0" -dependencies = [ - "dunce", - "rand", -] - [[package]] name = "futures" version = "0.3.30" @@ -1503,15 +1474,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "run_script" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829f98fdc58d78989dd9af83be28bc15c94a7d77f9ecdb54abbbc0b1829ba9c7" -dependencies = [ - "fsio", -] - [[package]] name = "rustc-demangle" version = "0.1.23" @@ -1732,12 +1694,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "slab" version = "0.4.9" @@ -1780,6 +1736,9 @@ name = "strum" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] [[package]] name = "strum_macros" @@ -2243,6 +2202,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "want" version = "0.3.1" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ef6588eb..e02988ba 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -5,13 +5,20 @@ comfy-table = '7.1' error-stack = '0.4' once_cell = '1.18' regex = '1.10' -shlex = '1.2' tracing = '0.1' tracing-appender = '0.2' -[dependencies.bitbazaar] -features = [] -version = '0.0.20' +[dependencies.conch-parser] +version = '0.1.1' +optional = true + +[dependencies.homedir] +version = '0.2.1' +optional = true + +[dependencies.strum] +features = ['derive'] +version = '0.25' [dependencies.clap] features = ['derive', 'string'] @@ -48,10 +55,6 @@ features = ['aio', 'json'] optional = true version = '0.24' -[dependencies.run_script] -optional = true -version = '0.10' - [dependencies.serde] features = ['derive'] optional = true @@ -82,7 +85,6 @@ features = ['fmt', 'std', 'time'] version = '0.3' [dev-dependencies] -homedir = '0.2.1' portpicker = '0.1.1' rstest = '0.18.2' serial_test = '2.0' @@ -94,7 +96,7 @@ features = ['v4'] version = '1.6.1' [features] -cli = ['dep:run_script'] +cli = ['dep:homedir', 'dep:conch-parser'] opentelemetry = ['dep:tracing-opentelemetry', 'dep:opentelemetry', 'dep:opentelemetry-otlp', 'dep:opentelemetry_sdk', 'dep:tonic'] redis = ['dep:deadpool-redis', 'dep:redis', 'dep:serde', 'dep:serde_json', 'dep:sha1_smol'] diff --git a/rust/bitbazaar/cli/bash.rs b/rust/bitbazaar/cli/bash.rs new file mode 100644 index 00000000..df6d5ac8 --- /dev/null +++ b/rust/bitbazaar/cli/bash.rs @@ -0,0 +1,548 @@ +use std::{ + collections::HashMap, + io::Read, + process::{Child, Command, Stdio}, + str, +}; + +use conch_parser::{ast, lexer::Lexer, parse::DefaultParser}; + +use super::{CmdErr, CmdOut}; +use crate::prelude::*; + +/// Execute an arbitrary bash script. +/// +/// WARNING: this opens up the possibility of dependency injection attacks, so should only be used when the command is trusted. +/// If compiled usage is all that's needed, use something like rust_cmd_lib instead, which only provides a macro literal interface. +/// https://github.com/rust-shell-script/rust_cmd_lib +/// +/// This is a pure rust implementation and doesn't rely on bash being available to make it compatible with windows. +/// Given that, it only implements a subset of bash features, and is not intended to be a full bash implementation. +/// +/// Assume everything is unimplemented unless stated below: +/// - `&&` and +/// - `||` or +/// - `!` exit code negation +/// - `|` pipe +/// - `~` home dir +/// - `foo=bar` param setting +/// - `$foo` param substitution +/// - `$(echo foo)` command substitution +/// - `'` quotes +/// - `"` double quotes +/// - `\` escaping +/// +/// This should theoretically work with multi line full bash scripts but only tested with single line commands. +pub fn execute_bash(cmd_str: &str) -> Result { + let cmd_str = cmd_str.trim(); + + let lex = Lexer::new(cmd_str.chars()); + let parser = DefaultParser::new(lex); + + let top_cmds = parser + .into_iter() + .collect::, _>>() + .change_context(CmdErr::BashSyntaxError)?; + + Shell::new().run_top_cmds(top_cmds) +} +struct WordConcatState<'a> { + active: usize, + words: &'a Vec, +} + +struct Shell { + vars: HashMap, + /// The stderr from subcommands that should be included at the start of each parent shell (and eventually the root CmdOut stderr) + sub_stderr: String, +} + +impl Shell { + fn new() -> Self { + Self { + vars: HashMap::new(), + sub_stderr: String::new(), + } + } + + fn run_top_cmds(&mut self, cmds: Vec>) -> Result { + let mut out = CmdOut::new(); + + // Each res equates to a line in a multi line bash script. E.g. a single line command will only have one res. + for cmd in cmds { + match cmd.0 { + ast::Command::Job(job) => { + return Err(err!( + CmdErr::BashFeatureUnsupported, + "Jobs, i.e. asynchronous commands using '&' are not supported." + ) + .attach_printable(format!("{job:?}"))) + } + ast::Command::List(list) => { + // Run the first command in the chain: + out.merge(self.run_listable_command(list.first)?); + + // Run the remaining commands in the chain, breaking dependent on and/or with the last exit code: + for chain_cmd in list.rest.into_iter() { + match chain_cmd { + ast::AndOr::And(cmd) => { + // Only run if the last succeeded: + if out.code == 0 { + out.merge(self.run_listable_command(cmd)?); + } + } + ast::AndOr::Or(cmd) => { + // Only run if the last didn't succeed: + if out.code != 0 { + out.merge(self.run_listable_command(cmd)?); + } + } + } + } + } + } + } + + // Add all the sub stderr to the start of the cmdout's stderr (as they will have happened prior to the current cmdout's stderr) + out.stderr = format!("{}{}", self.sub_stderr, out.stderr); + + Ok(out) + } + + fn run_listable_command(&mut self, cmd: ast::DefaultListableCommand) -> Result { + let (final_child, other_stderrs, negate_code) = match cmd { + ast::ListableCommand::Single(cmd) => { + debug!("Running single cmd: {:?}", cmd); + let child = self.spawn_pipeable_command(&cmd, None)?; + (child, Vec::new(), false) + } + ast::ListableCommand::Pipe(negate_code, mut cmds) => { + debug!("Running pipeable cmds: {:?}", cmds); + + let last_cmd = if let Some(last_cmd) = cmds.pop() { + last_cmd + } else { + return Err(err!( + CmdErr::InternalError, + "No commands to pipe in pipeable command." + )); + }; + + let mut stderrs = Vec::with_capacity(cmds.len()); + let mut stdout_pipe = None; + for cmd in cmds { + let child = self.spawn_pipeable_command(&cmd, stdout_pipe.take())?; + if let Some(child) = child { + stdout_pipe = Some(Stdio::from(child.stdout.unwrap())); + if let Some(stderr) = child.stderr { + stderrs.push(stderr); + } + } + } + + ( + self.spawn_pipeable_command(&last_cmd, stdout_pipe.take())?, + stderrs, + negate_code, + ) + } + }; + + // Get all intermediary sterrs: + let mut final_stderr = String::new(); + for mut stderr in other_stderrs { + stderr + .read_to_string(&mut final_stderr) + .change_context(CmdErr::InternalError)?; + } + + let (stdout, code) = if let Some(final_child) = final_child { + // Wait for the output of the final command: + let output = final_child + .wait_with_output() + .change_context(CmdErr::InternalError)?; + + // Add on the stderr from the final command: + final_stderr.push_str( + str::from_utf8(&output.stderr) + .change_context(CmdErr::BashUTF8Error)? + .to_string() + .as_str(), + ); + + ( + str::from_utf8(&output.stdout) + .change_context(CmdErr::BashUTF8Error)? + .to_string(), + output.status.code().unwrap_or(1), + ) + } else { + ("".to_string(), 0) + }; + + Ok(CmdOut { + stdout, + stderr: final_stderr, + code: if negate_code { + if code == 0 { + 1 + } else { + 0 + } + } else { + code + }, + }) + } + + fn spawn_pipeable_command( + &mut self, + cmd: &ast::DefaultPipeableCommand, + stdin: Option, + ) -> Result, CmdErr> { + let cmd = self.build_command(cmd)?; + + if let Some(mut cmd) = cmd { + // Pipe in stdin if needed: + if let Some(stdin) = stdin { + cmd.stdin(stdin); + } + + // Might fail to spawn, but that's ok and just need to continue, + // e.g. this happens at some point in echo foo $(echo bar && exit 1) ree + if let Ok(child) = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn() { + Ok(Some(child)) + } else { + Ok(None) + } + } else { + Ok(None) + } + } + + fn build_command( + &mut self, + cmd: &ast::DefaultPipeableCommand, + ) -> Result, CmdErr> { + Ok(match cmd { + ast::PipeableCommand::Simple(cmd) => self.build_simple_command(cmd)?, + ast::PipeableCommand::Compound(cmd) => Err(err!( + CmdErr::BashFeatureUnsupported, + "Pipeable compound commands not implemented." + ) + .attach_printable(format!("{cmd:?}")))?, + ast::PipeableCommand::FunctionDef(a, b) => Err(err!( + CmdErr::BashFeatureUnsupported, + "Functions not implemented." + ) + .attach_printable(a.to_string()) + .attach_printable(format!("{b:?}")))?, + }) + } + + fn build_simple_command( + &mut self, + cmd: &ast::DefaultSimpleCommand, + ) -> Result, CmdErr> { + // Get the environment variables the command (and all inner) need: + let env = cmd + .redirects_or_env_vars + .iter() + .map(|env_var| match env_var { + ast::RedirectOrEnvVar::Redirect(_) => Err(err!( + CmdErr::BashFeatureUnsupported, + "Redirection not implemented." + ) + .attach_printable(format!("{env_var:?}"))), + ast::RedirectOrEnvVar::EnvVar(name, val) => { + let value = if let Some(val) = val { + self.process_complex_word(&val.0)? + } else { + "".to_string() + }; + debug!("Setting env var: '{}'='{}'", name, value); + Ok((name, value)) + } + }) + .collect::, _>>()?; + + let mut args = Vec::with_capacity(cmd.redirects_or_cmd_words.len()); + for arg in cmd.redirects_or_cmd_words.iter() { + let arg_str = match arg { + ast::RedirectOrCmdWord::Redirect(redirect) => { + return Err(err!( + CmdErr::BashFeatureUnsupported, + "Redirection not implemented." + ) + .attach_printable(format!("{redirect:?}"))) + } + ast::RedirectOrCmdWord::CmdWord(word) => self.process_complex_word(&word.0)?, + }; + + // Don't include empty whitespace: + if !arg_str.is_empty() { + args.push(arg_str); + } + } + + debug!("Final command args: {:?}", args); + + // Add the env vars to the current shell to in this command, later and parser expansions etc: + for (name, val) in env.iter() { + self.vars.insert(name.to_string(), val.to_string()); + } + + // If e.g. this command was "bar=3;" then no args will exist, and a command shouldn't be run: + let command = if let Some(first_arg) = args.first() { + let mut command = Command::new(first_arg); + command.args(&args[1..]); + + // Add all the shell args to the env of the command: + command.envs(self.vars.clone()); + + Some(command) + } else { + None + }; + + Ok(command) + } + + fn process_complex_word(&mut self, word: &ast::DefaultComplexWord) -> Result { + match word { + ast::ComplexWord::Single(word) => self.process_word(word, None, false), + ast::ComplexWord::Concat(words) => { + // Need to do some lookarounds, keep track of the active part of the complex word: + let mut concat_state = WordConcatState { active: 0, words }; + let result = words + .iter() + .enumerate() + .map(|(index, word)| { + concat_state.active = index; + self.process_word(word, Some(&concat_state), false) + }) + .collect::, _>>()? + .join(""); + Ok(result) + } + } + } + + fn process_word( + &mut self, + word: &ast::DefaultWord, + concat_state: Option<&'_ WordConcatState<'_>>, + is_lookaround: bool, + ) -> Result { + Ok(match word { + // Single quoted means no processing inside needed: + ast::Word::SingleQuoted(word) => word.to_string(), + ast::Word::Simple(word) => { + self.process_simple_word(word, concat_state, is_lookaround)? + } + ast::Word::DoubleQuoted(words) => words + .iter() + .map(|word| self.process_simple_word(word, concat_state, is_lookaround)) + .collect::, _>>()? + .into_iter() + // Filter out empty strings to prevent creating whitespace: (e.g. unfound param substitutions) + .filter(|word| !word.is_empty()) + .collect::>() + .join(""), + }) + } + + fn process_simple_word( + &mut self, + word: &ast::DefaultSimpleWord, + concat_state: Option<&'_ WordConcatState<'_>>, + is_lookaround: bool, + ) -> Result { + Ok(match word { + ast::SimpleWord::Literal(lit) => lit.to_string(), + ast::SimpleWord::Escaped(a) => a.to_string(), + ast::SimpleWord::Tilde => { + if self.expand_tilde(concat_state, is_lookaround)? { + // Convert to the user's home directory: + let home_dir = + homedir::get_my_home().change_context(CmdErr::NoHomeDirectory)?; + if let Some(home_dir) = home_dir { + home_dir.to_string_lossy().to_string() + } else { + return Err(err!(CmdErr::NoHomeDirectory)); + } + } else { + "~".to_string() + } + } + ast::SimpleWord::Param(param) => self.process_param(param)?, + ast::SimpleWord::Subst(sub) => self.process_substitution(sub)?, + ast::SimpleWord::Colon => { + return Err(unsup("':', useful for handling tilde expansions.")); + } + ast::SimpleWord::Question => { + return Err(unsup("'?', useful for handling pattern expansions.")); + } + ast::SimpleWord::Star => { + return Err(unsup("'*', useful for handling pattern expansions.")); + } + ast::SimpleWord::SquareOpen => { + return Err(unsup("'[', useful for handling pattern expansions.")); + } + ast::SimpleWord::SquareClose => { + return Err(unsup("']', useful for handling pattern expansions.")); + } + }) + } + + fn process_param(&mut self, param: &ast::DefaultParameter) -> Result { + Ok(match param { + ast::Parameter::Var(var) => { + // First try variables in current shell, otherwise try env: + let value = if let Some(val) = self.vars.get(var) { + val.clone() + } else { + // Return the env var, or empty string if not set: + std::env::var(var).unwrap_or_else(|_| "".to_string()) + }; + debug!("Substituting param: '{}'='{}'", var, value); + value + } + ast::Parameter::Positional(_) => { + return Err(unsup("positional, e.g. '$0, $1, ..., $9, ${100}'.")); + } + ast::Parameter::At => { + return Err(unsup("$@'.")); + } + ast::Parameter::Star => { + return Err(unsup("'$*'.")); + } + ast::Parameter::Pound => { + return Err(unsup("'$#'.")); + } + ast::Parameter::Question => { + return Err(unsup("'$?'.")); + } + ast::Parameter::Dash => { + return Err(unsup("'$-'.")); + } + ast::Parameter::Dollar => { + return Err(unsup("'$$'.")); + } + ast::Parameter::Bang => { + return Err(unsup("'$!'.")); + } + }) + } + + fn process_substitution( + &mut self, + sub: &ast::DefaultParameterSubstitution, + ) -> Result { + match sub { + ast::ParameterSubstitution::Command(cmds) => { + // Run the nested command, from my tests with terminal: + // - exit code doesn't matter + // - stdout is injected but trailing newlines removed + // - stderr prints to console so in our case it should be added to the root stderr + // - It runs in its own shell, so shell vars aren't shared + // TODO check the stderr part + debug!("Running nested command: {:?}", cmds); + let nested_out = Shell::new().run_top_cmds(cmds.clone())?; + self.sub_stderr.push_str(&nested_out.stderr); + Ok(nested_out.stdout.trim_end().to_string()) + }, + ast::ParameterSubstitution::Alternative(..) => { + Err(unsup("If the parameter is NOT null or unset, a provided word will be used, e.g. '${param:+[word]}'. The boolean indicates the presence of a ':', and that if the parameter has a null value, that situation should be treated as if the parameter is unset.")) + } + ast::ParameterSubstitution::Len(_) => { + Err(unsup( + "Returns the length of the value of a parameter, e.g. '${#param}'", + )) + } + ast::ParameterSubstitution::Arith(_) => { + Err(unsup( + "Returns the resulting value of an arithmetic substitution, e.g. '$(( x++ ))'", + )) + } + ast::ParameterSubstitution::Default(_, _, _) => { + Err(unsup( + "Use a provided value if the parameter is null or unset, e.g. '${param:-[word]}'. The boolean indicates the presence of a ':', and that if the parameter has a null value, that situation should be treated as if the parameter is unset.", + )) + } + ast::ParameterSubstitution::Assign(_, _, _) => { + Err(unsup( + "Assign a provided value to the parameter if it is null or unset, e.g. '${param:=[word]}'. The boolean indicates the presence of a ':', and that if the parameter has a null value, that situation should be treated as if the parameter is unset.", + )) + } + ast::ParameterSubstitution::Error(_, _, _) => { + Err(unsup( + "If the parameter is null or unset, an error should result with the provided message, e.g. '${param:?[word]}'. The boolean indicates the presence of a ':', and that if the parameter has a null value, that situation should be treated as if the parameter is unset.", + )) + } + ast::ParameterSubstitution::RemoveSmallestSuffix(_, _) => Err(unsup( + "Remove smallest suffix pattern from a parameter's value, e.g. '${param%pattern}'", + )), + ast::ParameterSubstitution::RemoveLargestSuffix(_, _) => Err(unsup( + "Remove largest suffix pattern from a parameter's value, e.g. '${param%%pattern}'", + )), + ast::ParameterSubstitution::RemoveSmallestPrefix(_, _) => Err(unsup( + "Remove smallest prefix pattern from a parameter's value, e.g. '${param#pattern}'", + )), + ast::ParameterSubstitution::RemoveLargestPrefix(_, _) => Err(unsup( + "Remove largest prefix pattern from a parameter's value, e.g. '${param##pattern}'", + )), + } + } + + /// Decide whether a tilde should be expanded to the user's home directory or not based on the surrounding context. + /// https://www.gnu.org/software/bash/manual/html_node/Tilde-Expansion.html + /// Above are the proper tilde rules, only implementing the basics : + /// + /// yes for: + /// - ~ + /// - ~/foo + /// + /// no for: + /// - ~~ + /// - foo~ + /// - ~foo + /// - foo/~ + /// - foo~bar + fn expand_tilde( + &mut self, + concat_state: Option<&'_ WordConcatState<'_>>, + is_lookaround: bool, + ) -> Result { + // Handle infinite loop: + if is_lookaround { + return Ok(false); + } + + if let Some(concat_words) = concat_state { + // Shouldn't expand if not the first: + if concat_words.active != 0 { + Ok(false) + } else if let Some(next) = concat_words.words.get(1) { + // If the next starts with a forward slash, then should expand: + // Marking as a lookaround so doesn't cause stack overflow and always returns false if 2 tildes in a row: + let next_str = self.process_word(&next.clone(), concat_state, true)?; + Ok(next_str.starts_with('/')) + } else { + Ok(false) + } + } else { + // If its on its own then should expand: + Ok(true) + } + } +} + +/// Helper to create unsupported error message. +fn unsup(desc: &'static str) -> error_stack::Report { + err!( + CmdErr::BashFeatureUnsupported, + "Used valid bash syntax not implemented: {}", + desc + ) +} diff --git a/rust/bitbazaar/cli/cmd_err.rs b/rust/bitbazaar/cli/cmd_err.rs new file mode 100644 index 00000000..a3bcee67 --- /dev/null +++ b/rust/bitbazaar/cli/cmd_err.rs @@ -0,0 +1,23 @@ +/// Error type for `execute_bash()`. +#[derive(Debug, strum::Display)] +pub enum CmdErr { + /// Bash feature in script unsupported. + #[strum(serialize = "CmdErr (BashFeatureUnsupported): Bash feature in script unsupported.")] + BashFeatureUnsupported, + /// Bash syntax error. + #[strum(serialize = "CmdErr (BashSyntaxError): Bash syntax error.")] + BashSyntaxError, + /// Could not decode UTF-8 from cmd. + #[strum(serialize = "CmdErr (BashUTF8Error): Could not decode UTF-8 from cmd.")] + BashUTF8Error, + /// A tilde (~) is used in a script, can't find home directory. + #[strum( + serialize = "CmdErr (NoHomeDirectory): A tilde (~) is used in a script, can't find home directory." + )] + NoHomeDirectory, + /// Most like something wrong internally. + #[strum(serialize = "CmdErr (Internal): most like something wro ng internally.")] + InternalError, +} + +impl error_stack::Context for CmdErr {} diff --git a/rust/bitbazaar/cli/cmd_out.rs b/rust/bitbazaar/cli/cmd_out.rs new file mode 100644 index 00000000..619a6bfc --- /dev/null +++ b/rust/bitbazaar/cli/cmd_out.rs @@ -0,0 +1,43 @@ +/// The result of running a command +pub struct CmdOut { + /// The stdout of the command: + pub stdout: String, + /// The stderr of the command: + pub stderr: String, + /// The exit code of the command: + pub code: i32, +} + +impl CmdOut { + /// Returns true when the command exited with a zero exit code. + pub fn success(&self) -> bool { + self.code == 0 + } + + /// Combines the stdout and stderr into a single string. + pub fn std_all(&self) -> String { + if !self.stdout.is_empty() && !self.stderr.is_empty() { + format!("{}\n{}", self.stdout, self.stderr) + } else if !self.stdout.is_empty() { + self.stdout.clone() + } else { + self.stderr.clone() + } + } +} + +impl CmdOut { + pub(crate) fn new() -> Self { + Self { + stdout: String::new(), + stderr: String::new(), + code: 0, + } + } + + pub(crate) fn merge(&mut self, other: CmdOut) { + self.stdout.push_str(&other.stdout); + self.stderr.push_str(&other.stderr); + self.code = other.code; + } +} diff --git a/rust/bitbazaar/cli/mod.rs b/rust/bitbazaar/cli/mod.rs index fa969dc0..79586cc7 100644 --- a/rust/bitbazaar/cli/mod.rs +++ b/rust/bitbazaar/cli/mod.rs @@ -1,46 +1,99 @@ -mod run_cmd; - -pub use run_cmd::{run_cmd, CmdOut}; - +mod bash; +mod cmd_err; +mod cmd_out; +pub use bash::execute_bash; +pub use cmd_err::CmdErr; +pub use cmd_out::CmdOut; #[cfg(test)] mod tests { + use once_cell::sync::Lazy; use rstest::*; use super::*; - use crate::errors::prelude::*; + use crate::{errors::prelude::*, logging::default_stdout_global_logging}; + + #[fixture] + fn logging() -> () { + default_stdout_global_logging(tracing::Level::DEBUG).unwrap(); + } + + static HOME_DIR: Lazy = Lazy::new(|| { + homedir::get_my_home() + .unwrap() + .unwrap() + .to_string_lossy() + .to_string() + }); + + fn home() -> String { + HOME_DIR.clone() + } #[rstest] // <-- basics: - #[case("echo 'hello world'", "hello world", 0)] - #[case("echo hello world", "hello world", 0)] + #[case("echo 'hello world'", "hello world", 0, None, None)] + #[case("echo hello world", "hello world", 0, None, None)] // <-- and: - #[case("echo hello && echo world", "hello\nworld", 0)] - #[case("echo hello && false && echo world", "hello", 1)] - #[case("true && echo world", "world", 0)] + #[case("echo hello && echo world", "hello\nworld", 0, None, None)] + #[case("echo hello && false && echo world", "hello", 1, None, None)] + #[case("true && echo world", "world", 0, None, None)] // <-- or: - #[case("echo hello || echo world", "hello", 0)] - #[case("false || echo world", "world", 0)] - #[case("false || false || echo world", "world", 0)] + #[case("echo hello || echo world", "hello", 0, None, None)] + #[case("false || echo world", "world", 0, None, None)] + #[case("false || false || echo world", "world", 0, None, None)] + // <-- negations: + #[case("! echo hello || echo world", "hello\nworld", 0, None, None)] // <-- pipe: - #[case("echo 'foo\nbar\nree' | grep -E 'foo|ree'", "foo\nree", 0)] - #[case("echo 'foo\nbar\nree' | grep -E 'foo|ree' | wc -l", "2", 0)] - // <-- home dir: - #[case("echo ~", format!("{}", homedir::get_my_home().unwrap().unwrap().to_string_lossy()), 0)] - #[case("echo ~/foo", format!("{}/foo", homedir::get_my_home().unwrap().unwrap().to_string_lossy()), 0)] - // Should ignore home dir when not at beginning: - #[case("echo foo~", "foo~", 0)] + #[case("echo 'foo\nbar\nree' | grep -E 'foo|ree'", "foo\nree", 0, None, None)] + #[case("echo 'foo\nbar\nree' | grep -E 'foo|ree' | wc -l", "2", 0, None, None)] + // <-- command substitution: + #[case("echo $(echo foo)", "foo", 0, None, None)] + #[case("echo $(echo foo) $(echo bar)", "foo bar", 0, None, None)] + #[case("echo foo $(echo bar) ree", "foo bar ree", 0, None, None)] + #[case("echo foo $(echo bar && exit 1) ree", "foo bar ree", 0, None, None)] // Exit code should be ignored from subs + // <-- home dir (tilde): + #[case("echo ~", format!("{}", home()), 0, None, None)] + #[case("echo ~ ~", format!("{} {}", home(), home()), 0, None, None)] + #[case("echo ~/foo", format!("{}/foo", home()), 0, None, None)] + // <-- params, should be settable, stick to their current shell etc: + #[case( + // First should print nothing, as not set yet, gets set to 1 in outer shell, 2 in inner shell + "echo -n \"before.$LAH. \"; LAH=1; echo outer.$LAH. $(LAH=2; echo inner.$LAH.) outer.$LAH.", + "before.. outer.1. inner.2. outer.1.", + 0, None, None + )] + // Should ignore tilde in most circumstances: + #[case("echo ~~", "~~", 0, None, None)] + #[case("echo foo~", "foo~", 0, None, None)] + #[case("echo ~foo", "~foo", 0, None, None)] + #[case("echo foo/~", "foo/~", 0, None, None)] + #[case("echo foo~bar", "foo~bar", 0, None, None)] + #[case("echo \"~\"", "~", 0, None, None)] + #[case("echo \"~/foo\"", "~/foo", 0, None, None)] // <-- all ignored when in quotes: - #[case("echo false '&& echo bar'", "false && echo bar", 0)] - #[case("echo false '|| echo bar'", "false || echo bar", 0)] - #[case("echo false '| echo bar'", "false | echo bar", 0)] - #[case("echo '~'", "~", 0)] - fn test_run_cmd>( + #[case("echo false '&& echo bar'", "false && echo bar", 0, None, None)] + #[case("echo false '|| echo bar'", "false || echo bar", 0, None, None)] + #[case("echo false '| echo bar'", "false | echo bar", 0, None, None)] + #[case("echo false '$(echo bar)'", "false $(echo bar)", 0, None, None)] + #[case("echo '~'", "~", 0, None, None)] + fn test_execute_bash>( #[case] cmd_str: &str, #[case] exp_std_all: S, #[case] code: i32, + #[case] exp_stdout: Option<&str>, // Only check if Some() + #[case] exp_sterr: Option<&str>, // Only check if Some() + #[allow(unused_variables)] logging: (), ) -> Result<(), AnyErr> { - let res = run_cmd(cmd_str).change_context(AnyErr)?; + let res = execute_bash(cmd_str).change_context(AnyErr)?; assert_eq!(res.code, code, "{}", res.std_all()); + + if let Some(exp_stdout) = exp_stdout { + assert_eq!(res.stdout.trim(), exp_stdout); + } + if let Some(exp_sterr) = exp_sterr { + assert_eq!(res.stderr.trim(), exp_sterr); + } + assert_eq!(res.std_all().trim(), exp_std_all.into()); Ok(()) } diff --git a/rust/bitbazaar/cli/run_cmd.rs b/rust/bitbazaar/cli/run_cmd.rs index 1ffc99a8..73388178 100644 --- a/rust/bitbazaar/cli/run_cmd.rs +++ b/rust/bitbazaar/cli/run_cmd.rs @@ -1,45 +1,20 @@ -use crate::errors::prelude::*; +use super::CmdOut; +use crate::{cli::bash, errors::prelude::*}; -/// The result of running a command -pub struct CmdOut { - /// The stdout of the command: - pub stdout: String, - /// The stderr of the command: - pub stderr: String, - /// The exit code of the command: - pub code: i32, -} - -impl CmdOut { - /// Returns true when the command exited with a zero exit code. - pub fn success(&self) -> bool { - self.code == 0 - } - - /// Combines the stdout and stderr into a single string. - pub fn std_all(&self) -> String { - if !self.stdout.is_empty() && !self.stderr.is_empty() { - format!("{}\n{}", self.stdout, self.stderr) - } else if !self.stdout.is_empty() { - self.stdout.clone() - } else { - self.stderr.clone() - } - } -} - -#[derive(Debug)] +#[derive(Debug, strum::Display)] pub enum CmdErr { - /// An arbitrary downstream error: - Unknown(String), -} - -impl std::fmt::Display for CmdErr { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CmdErr::Unknown(msg) => write!(f, "{}", msg), - } - } + #[strum(serialize = "CmdErr (BashFeatureUnsupported): Bash feature in script unsupported.")] + BashFeatureUnsupported, + #[strum(serialize = "CmdErr (BashSyntaxError): Bash syntax error.")] + BashSyntaxError, + #[strum(serialize = "CmdErr (BashUTF8Error): Could not decode UTF-8 from cmd.")] + BashUTF8Error, + #[strum( + serialize = "CmdErr (NoHomeDirectory): A tilde (~) is used in a script, can't find home directory." + )] + NoHomeDirectory, + #[strum(serialize = "CmdErr (Internal): most like something wrong internally.")] + InternalError, } impl error_stack::Context for CmdErr {} @@ -55,17 +30,7 @@ impl error_stack::Context for CmdErr {} /// - `|` pipe /// - `~` home dir pub fn run_cmd>(cmd_str: S) -> Result { - let mut options = run_script::ScriptOptions::new(); - if cfg!(windows) { - // Defaults to cmd.exe, this doesn't print the commands to the stdout, polluting it like default does: - options.runner = Some("start.exe".to_string()) - } - let (code, output, error) = run_script::run(cmd_str.into().as_str(), &vec![], &options) - .map_err(|e| CmdErr::Unknown(e.to_string()))?; + let cmd_str = cmd_str.into(); - Ok(CmdOut { - stdout: output, - stderr: error, - code, - }) + bash::execute_bash(cmd_str) } diff --git a/rust/bitbazaar/errors/macros.rs b/rust/bitbazaar/errors/macros.rs index 342eb66f..1fad0705 100644 --- a/rust/bitbazaar/errors/macros.rs +++ b/rust/bitbazaar/errors/macros.rs @@ -29,6 +29,34 @@ macro_rules! anyerr { }}; } +/// A macro for building `Report` objects with string context easily. +/// +/// `err!(Err)` is equivalent to `Report::new(Err)` +/// +/// `err!(Err, "foo")` is equivalent to `Report::new(Err).attach_printable("foo")` +/// +/// `err!(Err, "foo: {}", "bar")` is equivalent to `Report::new(Err).attach_printable(format!("foo: {}", "bar"))`/// +#[macro_export] +macro_rules! err { + ($err_variant:expr) => {{ + use error_stack::Report; + + Report::new($err_variant) + }}; + + ($err_variant:expr, $str:expr) => {{ + use error_stack::Report; + + Report::new($err_variant).attach_printable($str) + }}; + + ($err_variant:expr, $str:expr, $($arg:expr),*) => {{ + use error_stack::Report; + + Report::new($err_variant).attach_printable(format!($str, $($arg),*)) + }}; +} + /// When working in a function that cannot return a result, use this to auto panic with the formatted error if something goes wrong. /// /// Allows use of e.g. `?` in the block. diff --git a/rust/bitbazaar/logging/create_subscriber.rs b/rust/bitbazaar/logging/create_subscriber.rs index 45d00c97..72d4b4d8 100644 --- a/rust/bitbazaar/logging/create_subscriber.rs +++ b/rust/bitbazaar/logging/create_subscriber.rs @@ -147,7 +147,9 @@ pub enum SubLayerVariant { // When registering globally, hoist the guards out into here, to allow the CreatedSubscriber to go out of scope but keep the guards permanently. static GLOBAL_GUARDS: Lazy>>> = Lazy::new(Mutex::default); +/// The created subscriber, returned from [`create_subscriber`]. pub struct CreatedSubscriber { + /// The log dispatcher, which can be used to read logging events. pub dispatch: Dispatch, /// Need to store these guards, when they go out of scope the logging may stop. /// When made global these are hoisted into a static lazy var. @@ -163,6 +165,16 @@ impl CreatedSubscriber { } } +/// A wrapper around [`create_subscriber`] and `sub.into_global()` for the simple stdout usecase with default params. +pub fn default_stdout_global_logging(level: Level) -> Result<(), AnyErr> { + let sub = create_subscriber(vec![SubLayer { + filter: SubLayerFilter::Above(level), // Only this leveland above + ..Default::default() + }])?; + sub.into_global(); + Ok(()) +} + /// Simple interface to setup a sub and output to a given target. /// Returns the sub, must run `sub.apply()?` To actually enable it as the global sub, this can only be done once. /// diff --git a/rust/bitbazaar/logging/mod.rs b/rust/bitbazaar/logging/mod.rs index a98ffa29..0fd885c6 100644 --- a/rust/bitbazaar/logging/mod.rs +++ b/rust/bitbazaar/logging/mod.rs @@ -6,7 +6,8 @@ mod macros; pub use clap_log_level_args::ClapLogLevelArgs; pub use create_subscriber::{ - create_subscriber, SubCustomWriter, SubLayer, SubLayerFilter, SubLayerVariant, + create_subscriber, default_stdout_global_logging, CreatedSubscriber, SubCustomWriter, SubLayer, + SubLayerFilter, SubLayerVariant, }; #[cfg(test)] @@ -129,7 +130,7 @@ mod tests { assert_eq!( into_vec(&LOGS), - vec!["DEBUG DLOG\n at bitbazaar/logging/mod.rs:127"] + vec!["DEBUG DLOG\n at bitbazaar/logging/mod.rs:128"] ); Ok(()) diff --git a/rust/bitbazaar/prelude.rs b/rust/bitbazaar/prelude.rs index c668559e..fa960328 100644 --- a/rust/bitbazaar/prelude.rs +++ b/rust/bitbazaar/prelude.rs @@ -1,6 +1,7 @@ #[allow(unused_imports)] -pub use bitbazaar::{anyerr, errors::AnyErr, panic_on_err}; -#[allow(unused_imports)] pub use error_stack::{Result, ResultExt}; #[allow(unused_imports)] pub use tracing::{debug, error, info, warn}; + +#[allow(unused_imports)] +pub use crate::{anyerr, err, errors::prelude::*, panic_on_err};