From e3fd5433dccce04c5388e94576b334c41b8205e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 01:07:50 +0000 Subject: [PATCH 1/2] fix(interpreter): make prefix env assignments visible to commands In bash, `VAR=value command` temporarily sets VAR in the command's environment. bashkit was storing prefix assignments in self.variables but not self.env, so builtins like printenv couldn't see them. Fix: temporarily inject prefix assignments into self.env for the duration of command execution, then restore. Also ensure prefix assignments don't persist in shell variables after the command. Extracted dispatch code into execute_dispatched_command to enable single-point save/restore around the call. https://claude.ai/code/session_01HNrHwsipGrStdSYThP77Rk --- crates/bashkit/src/interpreter/mod.rs | 72 +++++++++++++++++++++++++-- crates/bashkit/src/lib.rs | 28 +++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index db0e001..378c51d 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1766,6 +1766,15 @@ impl Interpreter { command: &SimpleCommand, stdin: Option, ) -> Result { + // Save old variable values before applying prefix assignments. + // If there's a command, these assignments are temporary (bash behavior: + // `VAR=value cmd` sets VAR only for cmd's duration). + let var_saves: Vec<(String, Option)> = command + .assignments + .iter() + .map(|a| (a.name.clone(), self.variables.get(&a.name).cloned())) + .collect(); + // Process variable assignments first for assignment in &command.assignments { match &assignment.value { @@ -1837,11 +1846,64 @@ impl Interpreter { let name = self.expand_word(&command.name).await?; - // If name is empty, this is an assignment-only command + // If name is empty, this is an assignment-only command - keep permanently if name.is_empty() { return Ok(ExecResult::ok(String::new())); } + // Has a command: prefix assignments are temporary (bash behavior). + // Inject scalar prefix assignments into self.env so builtins/functions + // can see them via ctx.env (e.g., `MYVAR=hello printenv MYVAR`). + let mut env_saves: Vec<(String, Option)> = Vec::new(); + for assignment in &command.assignments { + if assignment.index.is_none() { + if let Some(value) = self.variables.get(&assignment.name).cloned() { + let old = self.env.insert(assignment.name.clone(), value); + env_saves.push((assignment.name.clone(), old)); + } + } + } + + // Dispatch to the appropriate handler + let result = self.execute_dispatched_command(&name, command, stdin).await; + + // Restore env (prefix assignments are command-scoped) + for (name, old) in env_saves { + match old { + Some(v) => { + self.env.insert(name, v); + } + None => { + self.env.remove(&name); + } + } + } + + // Restore variables (prefix assignments don't persist when there's a command) + for (name, old) in var_saves { + match old { + Some(v) => { + self.variables.insert(name, v); + } + None => { + self.variables.remove(&name); + } + } + } + + result + } + + /// Execute a command after name resolution and prefix assignment setup. + /// + /// Handles argument expansion, stdin processing, and dispatch to + /// functions, special builtins, regular builtins, or command-not-found. + async fn execute_dispatched_command( + &mut self, + name: &str, + command: &SimpleCommand, + stdin: Option, + ) -> Result { // Expand arguments with brace and glob expansion let mut args: Vec = Vec::new(); for word in &command.args { @@ -1906,13 +1968,13 @@ impl Interpreter { }; // Check for functions first - if let Some(func_def) = self.functions.get(&name).cloned() { + if let Some(func_def) = self.functions.get(name).cloned() { // Check function depth limit self.counters.push_function(&self.limits)?; // Push call frame with positional parameters self.call_stack.push(CallFrame { - name: name.clone(), + name: name.to_string(), locals: HashMap::new(), positional: args.clone(), }); @@ -1972,7 +2034,7 @@ impl Interpreter { // Handle `bash` and `sh` specially - execute scripts using the interpreter if name == "bash" || name == "sh" { return self - .execute_shell(&name, &args, stdin, &command.redirects) + .execute_shell(name, &args, stdin, &command.redirects) .await; } @@ -1987,7 +2049,7 @@ impl Interpreter { } // Check for builtins - if let Some(builtin) = self.builtins.get(name.as_str()) { + if let Some(builtin) = self.builtins.get(name) { let ctx = builtins::Context { args: &args, env: &self.env, diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 1f63345..f033544 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -1687,6 +1687,34 @@ mod tests { assert_eq!(result.stdout, "1 2 3\n"); } + #[tokio::test] + async fn test_prefix_assignment_visible_in_env() { + let mut bash = Bash::new(); + // VAR=value command should make VAR visible in the command's environment + let result = bash.exec("MYVAR=hello printenv MYVAR").await.unwrap(); + assert_eq!(result.stdout, "hello\n"); + } + + #[tokio::test] + async fn test_prefix_assignment_temporary() { + let mut bash = Bash::new(); + // Prefix assignment should NOT persist after the command + bash.exec("MYVAR=hello printenv MYVAR").await.unwrap(); + let result = bash.exec("echo ${MYVAR:-unset}").await.unwrap(); + assert_eq!(result.stdout, "unset\n"); + } + + #[tokio::test] + async fn test_prefix_assignment_does_not_clobber_existing_env() { + let mut bash = Bash::new(); + // Set up existing env var + let result = bash + .exec("EXISTING=original; export EXISTING; EXISTING=temp printenv EXISTING") + .await + .unwrap(); + assert_eq!(result.stdout, "temp\n"); + } + #[tokio::test] async fn test_printf_string() { let mut bash = Bash::new(); From 318c83981a6e61507981646aa368f6ec4143d931 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 01:33:06 +0000 Subject: [PATCH 2/2] test(interpreter): add prefix env assignment tests, update specs and docs - Add 6 spec tests in variables.test.sh (positive + negative) - Add 6 differential edge cases in proptest_differential.rs - Add proptest strategy + property test for prefix assignments - Add 5 unit tests in lib.rs (empty value, multiple vars, persistence) - Update 005-builtins.md: document prefix env assignment behavior - Update 009-implementation-status.md: test counts, partially impl note - Update compatibility.md: prefix env assignment status table https://claude.ai/code/session_01HNrHwsipGrStdSYThP77Rk --- crates/bashkit/docs/compatibility.md | 9 ++++ crates/bashkit/src/lib.rs | 45 ++++++++++++++++ crates/bashkit/tests/proptest_differential.rs | 54 +++++++++++++++++++ .../tests/spec_cases/bash/variables.test.sh | 44 +++++++++++++++ specs/005-builtins.md | 8 +++ specs/009-implementation-status.md | 9 ++-- 6 files changed, 165 insertions(+), 4 deletions(-) diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index 9e52253..487938e 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -205,6 +205,15 @@ for sandbox security reasons. See the compliance spec for details. | `${var^}` | ❌ | - | Uppercase first | | `${var,}` | ❌ | - | Lowercase first | +### Prefix Environment Assignments + +| Syntax | Status | Example | Description | +|--------|--------|---------|-------------| +| `VAR=val cmd` | ✅ | `TOKEN=abc printenv TOKEN` | Temporary env for command | +| Multiple prefix | ✅ | `A=1 B=2 cmd` | Multiple vars in one command | +| No persist | ✅ | `X=1 cmd; echo $X` | Var not set after command | +| Assignment-only | ✅ | `X=1` (no cmd) | Persists in shell variables | + ### Command Substitution | Syntax | Status | Example | diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index f033544..089ca55 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -1715,6 +1715,51 @@ mod tests { assert_eq!(result.stdout, "temp\n"); } + #[tokio::test] + async fn test_prefix_assignment_multiple_vars() { + let mut bash = Bash::new(); + // Multiple prefix assignments on same command + let result = bash.exec("A=one B=two printenv A").await.unwrap(); + assert_eq!(result.stdout, "one\n"); + assert_eq!(result.exit_code, 0); + } + + #[tokio::test] + async fn test_prefix_assignment_empty_value() { + let mut bash = Bash::new(); + // Empty value is still set in environment + let result = bash.exec("MYVAR= printenv MYVAR").await.unwrap(); + assert_eq!(result.stdout, "\n"); + assert_eq!(result.exit_code, 0); + } + + #[tokio::test] + async fn test_prefix_assignment_not_found_without_prefix() { + let mut bash = Bash::new(); + // printenv for a var that was never set should fail + let result = bash.exec("printenv NONEXISTENT").await.unwrap(); + assert_eq!(result.stdout, ""); + assert_eq!(result.exit_code, 1); + } + + #[tokio::test] + async fn test_prefix_assignment_does_not_persist_in_variables() { + let mut bash = Bash::new(); + // After prefix assignment with command, var should not be in shell scope + bash.exec("TMPVAR=gone echo ok").await.unwrap(); + let result = bash.exec("echo \"${TMPVAR:-unset}\"").await.unwrap(); + assert_eq!(result.stdout, "unset\n"); + } + + #[tokio::test] + async fn test_assignment_only_persists() { + let mut bash = Bash::new(); + // Assignment without a command should persist (not a prefix assignment) + bash.exec("PERSIST=yes").await.unwrap(); + let result = bash.exec("echo $PERSIST").await.unwrap(); + assert_eq!(result.stdout, "yes\n"); + } + #[tokio::test] async fn test_printf_string() { let mut bash = Bash::new(); diff --git a/crates/bashkit/tests/proptest_differential.rs b/crates/bashkit/tests/proptest_differential.rs index 91f4188..c023ccd 100644 --- a/crates/bashkit/tests/proptest_differential.rs +++ b/crates/bashkit/tests/proptest_differential.rs @@ -215,6 +215,22 @@ fn function_strategy() -> impl Strategy { ) } +/// Generate a prefix assignment command (VAR=value command) +fn prefix_assignment_strategy() -> impl Strategy { + prop_oneof![ + // Single prefix assignment with printenv + (var_name_strategy(), safe_value_strategy()) + .prop_map(|(name, value)| format!("{}={} printenv {}", name, value, name)), + // Single prefix assignment with echo (variable visible via $VAR) + (var_name_strategy(), safe_value_strategy()) + .prop_map(|(name, value)| format!("{}={} echo done", name, value)), + // Prefix assignment then check it doesn't persist + (var_name_strategy(), safe_value_strategy()).prop_map(|(name, value)| { + format!("{}={} echo done; echo ${{{}:-unset}}", name, value, name) + }), + ] +} + /// Generate a complete valid bash script fn valid_script_strategy() -> impl Strategy { prop_oneof![ @@ -229,6 +245,7 @@ fn valid_script_strategy() -> impl Strategy { 3 => command_subst_strategy(), 3 => logical_ops_strategy(), 2 => function_strategy(), + 3 => prefix_assignment_strategy(), ] } @@ -446,6 +463,30 @@ proptest! { ); } + /// Prefix assignments should produce identical output + #[test] + fn prefix_assignments_match_bash(script in prefix_assignment_strategy()) { + let (bash_out, bash_exit) = run_real_bash(&script); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let (bashkit_out, bashkit_exit) = rt.block_on(run_bashkit(&script)); + + prop_assert_eq!( + &bashkit_out, &bash_out, + "Output mismatch for script: {}\nBashkit: {:?}\nBash: {:?}", + script, bashkit_out, bash_out + ); + prop_assert_eq!( + bashkit_exit, bash_exit, + "Exit code mismatch for script: {}", + script + ); + } + /// Multi-statement scripts should produce identical output #[test] fn multi_statement_matches_bash(script in multi_statement_strategy()) { @@ -494,6 +535,19 @@ async fn differential_edge_cases() { "X=hello; Y=world; echo $X $Y", "X=hello; Y=world; echo $X $Y", ), + // Prefix environment assignments + ("prefix assign visible", "X=hello printenv X"), + ( + "prefix assign temporary", + "X=hello printenv X; echo ${X:-unset}", + ), + ( + "prefix assign no clobber", + "X=original; X=temp echo done; echo $X", + ), + ("prefix assign empty", "X= printenv X"), + ("multiple prefix assigns", "A=1 B=2 printenv A"), + ("assignment only persists", "X=persist; echo $X"), // Arithmetic ("echo $((1 + 2))", "echo $((1 + 2))"), ("echo $((10 - 3))", "echo $((10 - 3))"), diff --git a/crates/bashkit/tests/spec_cases/bash/variables.test.sh b/crates/bashkit/tests/spec_cases/bash/variables.test.sh index 66eec4f..684d3c2 100644 --- a/crates/bashkit/tests/spec_cases/bash/variables.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/variables.test.sh @@ -264,3 +264,47 @@ X=value; ref=X; echo ${!ref} ### expect value ### end + +### prefix_assign_visible_in_env +# Prefix assignment visible to command via printenv +MYVAR=hello printenv MYVAR +### expect +hello +### end + +### prefix_assign_multiple +# Multiple prefix assignments visible to command +A=one B=two printenv A +### expect +one +### end + +### prefix_assign_temporary +# Prefix assignment does not persist after command +TMPVAR=gone printenv TMPVAR; echo ${TMPVAR:-unset} +### expect +gone +unset +### end + +### prefix_assign_no_clobber +# Prefix assignment does not overwrite pre-existing var permanently +X=original; X=temp echo done; echo $X +### expect +done +original +### end + +### prefix_assign_empty_value +# Prefix assignment with empty value is still set (exit code 0) +MYVAR= printenv MYVAR > /dev/null; echo $? +### expect +0 +### end + +### prefix_assign_only_no_command +# Assignment without command persists (not a prefix assignment) +PERSIST=yes; echo $PERSIST +### expect +yes +### end diff --git a/specs/005-builtins.md b/specs/005-builtins.md index 0388cda..4e2d2af 100644 --- a/specs/005-builtins.md +++ b/specs/005-builtins.md @@ -128,6 +128,14 @@ Bash::builder() - `printenv` - Print environment variable values - `history` - Command history (virtual mode: limited, no persistent history) +#### Prefix Environment Assignments + +Bash supports `VAR=value command` syntax where the assignment is temporary and +scoped to the command's environment. Bashkit implements this: prefix assignments +are injected into `ctx.env` for the command's duration, then both `env` and +`variables` are restored. Assignment-only commands (`VAR=value` with no command) +persist in shell variables as usual. + #### Pipeline Control - `xargs` - Build commands from stdin (`-I REPL`, `-n MAX`, `-d DELIM`) - `tee` - Write to files and stdout (`-a` append) diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index abb59ba..3632678 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -107,17 +107,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See ## Spec Test Coverage -**Total spec test cases:** 962 +**Total spec test cases:** 968 | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 588 | Yes | 524 | 64 | `bash_spec_tests` in CI | +| Bash (core) | 594 | Yes | 530 | 64 | `bash_spec_tests` in CI | | AWK | 89 | Yes | 72 | 17 | loops, arrays, -v, ternary, field assign | | Grep | 63 | Yes | 58 | 5 | now with -z, -r, -a, -b, -H, -h, -f, -P | | Sed | 68 | Yes | 56 | 12 | hold space, change, regex ranges, -E | | JQ | 97 | Yes | 87 | 10 | reduce, walk, regex funcs | | Python | 57 | Yes | 51 | 6 | **Experimental.** VFS bridging, pathlib, env vars | -| **Total** | **962** | **Yes** | **848** | **114** | | +| **Total** | **968** | **Yes** | **854** | **114** | | ### 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 (2 skipped) | | time.test.sh | 11 | Wall-clock only (user/sys always 0) | | timeout.test.sh | 16 | | -| variables.test.sh | 38 | includes special vars | +| variables.test.sh | 44 | includes special vars, prefix env assignments | | wc.test.sh | 20 | word count (5 skipped) | ## Shell Features @@ -181,6 +181,7 @@ Features that may be added in the future (not intentionally excluded): | Feature | What Works | What's Missing | |---------|------------|----------------| +| Prefix env assignments | `VAR=val cmd` temporarily sets env for cmd | Array prefix assignments not in env | | `local` | Declaration | Proper scoping in nested functions | | `return` | Basic usage | Return value propagation | | Heredocs | Basic | Variable expansion inside |