diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index d3d2e706..97240901 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -118,8 +118,7 @@ struct CallFrame { pub struct ShellOptions { /// Exit immediately if a command exits with non-zero status (set -e) pub errexit: bool, - /// Print commands before execution (set -x) - stored but not enforced - #[allow(dead_code)] + /// Print commands before execution (set -x) pub xtrace: bool, /// Return rightmost non-zero exit code from pipeline (set -o pipefail) pub pipefail: bool, @@ -394,6 +393,16 @@ impl Interpreter { .unwrap_or(false) } + /// Check if xtrace (set -x) is enabled + fn is_xtrace_enabled(&self) -> bool { + self.options.xtrace + || self + .variables + .get("SHOPT_x") + .map(|v| v == "1") + .unwrap_or(false) + } + /// Set execution limits. pub fn set_limits(&mut self, limits: ExecutionLimits) { self.limits = limits; @@ -1865,7 +1874,6 @@ impl Interpreter { // compatibility with scripts that set them, but not enforced // in virtual mode: // -e (errexit): would need per-command exit code checking - // -x (xtrace): would need trace output to stderr // -v (verbose): would need input echoing // -u (nounset): would need unset variable detection // -o (option): would need set -o pipeline @@ -2536,6 +2544,32 @@ impl Interpreter { } } + // Emit xtrace (set -x): build trace line for stderr + let xtrace_line = if self.is_xtrace_enabled() { + let ps4 = self + .variables + .get("PS4") + .cloned() + .unwrap_or_else(|| "+ ".to_string()); + let mut trace = ps4; + trace.push_str(&name); + for word in &command.args { + let expanded = self.expand_word(word).await.unwrap_or_default(); + trace.push(' '); + if expanded.contains(' ') || expanded.contains('\t') || expanded.is_empty() { + trace.push('\''); + trace.push_str(&expanded.replace('\'', "'\\''")); + trace.push('\''); + } else { + trace.push_str(&expanded); + } + } + trace.push('\n'); + Some(trace) + } else { + None + }; + // Dispatch to the appropriate handler let result = self.execute_dispatched_command(&name, command, stdin).await; @@ -2563,7 +2597,16 @@ impl Interpreter { } } - result + // Prepend xtrace to stderr (like real bash, xtrace goes to the + // shell's stderr, unaffected by per-command redirections like 2>&1). + if let Some(trace) = xtrace_line { + result.map(|mut r| { + r.stderr = trace + &r.stderr; + r + }) + } else { + result + } } /// Execute a command after name resolution and prefix assignment setup. @@ -6836,4 +6879,78 @@ mod tests { assert!(result.stdout.contains("first: a")); assert!(result.stdout.contains("all: a b c")); } + + #[tokio::test] + async fn test_xtrace_basic() { + // set -x sends trace to stderr + let result = run_script("set -x; echo hello").await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "hello\n"); + assert!( + result.stderr.contains("+ echo hello"), + "stderr should contain xtrace: {:?}", + result.stderr + ); + } + + #[tokio::test] + async fn test_xtrace_multiple_commands() { + let result = run_script("set -x; echo one; echo two").await; + assert_eq!(result.stdout, "one\ntwo\n"); + assert!(result.stderr.contains("+ echo one")); + assert!(result.stderr.contains("+ echo two")); + } + + #[tokio::test] + async fn test_xtrace_expanded_variables() { + // Trace shows expanded values, not variable names + let result = run_script("x=hello; set -x; echo $x").await; + assert_eq!(result.stdout, "hello\n"); + assert!( + result.stderr.contains("+ echo hello"), + "xtrace should show expanded value: {:?}", + result.stderr + ); + } + + #[tokio::test] + async fn test_xtrace_disable() { + // set +x disables tracing; set +x itself is traced + let result = run_script("set -x; echo traced; set +x; echo not_traced").await; + assert_eq!(result.stdout, "traced\nnot_traced\n"); + assert!(result.stderr.contains("+ echo traced")); + assert!( + result.stderr.contains("+ set +x"), + "set +x should be traced: {:?}", + result.stderr + ); + assert!( + !result.stderr.contains("+ echo not_traced"), + "echo after set +x should NOT be traced: {:?}", + result.stderr + ); + } + + #[tokio::test] + async fn test_xtrace_no_trace_without_flag() { + let result = run_script("echo hello").await; + assert_eq!(result.stdout, "hello\n"); + assert!( + result.stderr.is_empty(), + "no xtrace without set -x: {:?}", + result.stderr + ); + } + + #[tokio::test] + async fn test_xtrace_not_captured_by_redirect() { + // 2>&1 should NOT capture xtrace (matches real bash behavior) + let result = run_script("set -x; echo hello 2>&1").await; + assert_eq!(result.stdout, "hello\n"); + assert!( + result.stderr.contains("+ echo hello"), + "xtrace should stay in stderr even with 2>&1: {:?}", + result.stderr + ); + } } diff --git a/crates/bashkit/tests/spec_cases/bash/variables.test.sh b/crates/bashkit/tests/spec_cases/bash/variables.test.sh index 13d46f47..8fb3f213 100644 --- a/crates/bashkit/tests/spec_cases/bash/variables.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/variables.test.sh @@ -535,3 +535,48 @@ echo "$OLDPWD" | grep -q "/" && echo "oldpwd_set" ### expect oldpwd_set ### end + +### xtrace_stdout_unaffected +# set -x does not alter stdout +set -x +echo hello +### expect +hello +### end + +### xtrace_multiple_stdout +# set -x does not alter stdout for multiple commands +set -x +echo one +echo two +### expect +one +two +### end + +### xtrace_disable_stdout +# set +x properly disables tracing, stdout unaffected +set -x +echo traced +set +x +echo not_traced +### expect +traced +not_traced +### end + +### xtrace_expanded_vars_stdout +# set -x with variables does not alter stdout +x=hello +set -x +echo $x +### expect +hello +### end + +### xtrace_no_output_without_flag +# Without set -x, no trace output +echo hello +### expect +hello +### end diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 477fe81c..dd85226d 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -103,17 +103,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See ## Spec Test Coverage -**Total spec test cases:** 1292 (1287 pass, 5 skip) +**Total spec test cases:** 1297 (1292 pass, 5 skip) | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 874 | Yes | 869 | 5 | `bash_spec_tests` in CI | +| Bash (core) | 879 | Yes | 874 | 5 | `bash_spec_tests` in CI | | AWK | 96 | Yes | 96 | 0 | loops, arrays, -v, ternary, field assign, getline, %.6g | | Grep | 76 | Yes | 76 | 0 | -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude, binary detect | | Sed | 75 | Yes | 75 | 0 | hold space, change, regex ranges, -E | | JQ | 114 | Yes | 114 | 0 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env | | Python | 57 | Yes | 57 | 0 | embedded Python (Monty) | -| **Total** | **1292** | **Yes** | **1287** | **5** | | +| **Total** | **1297** | **Yes** | **1292** | **5** | | ### Bash Spec Tests Breakdown @@ -157,7 +157,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | test-operators.test.sh | 17 | file/string tests | | time.test.sh | 11 | Wall-clock only (user/sys always 0) | | timeout.test.sh | 17 | | -| variables.test.sh | 73 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}`, `\` line continuation, PWD/HOME/USER/HOSTNAME/BASH_VERSION/SECONDS | +| variables.test.sh | 78 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}`, `\` line continuation, PWD/HOME/USER/HOSTNAME/BASH_VERSION/SECONDS, `set -x` xtrace | | wc.test.sh | 35 | word count (5 skipped) | | type.test.sh | 15 | `type`, `which`, `hash` builtins | | declare.test.sh | 10 | `declare`/`typeset`, `-i`, `-r`, `-x`, `-a`, `-p` | diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 010ed373..fc2868e9 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -991,7 +991,7 @@ version = "1.1.4" criteria = "safe-to-run" [[exemptions.rustls]] -version = "0.23.36" +version = "0.23.37" criteria = "safe-to-deploy" [[exemptions.rustls-native-certs]]