Skip to content

Commit 6aafb24

Browse files
chaliyclaude
andauthored
fix(interpreter): prefix env assignments visible to commands (#200)
## Summary - **Fix**: `VAR=value command` now temporarily injects VAR into the command's environment, matching bash behavior. Previously prefix assignments were stored in shell variables only, invisible to builtins like `printenv`. - **Fix**: Prefix assignments no longer persist in shell variables after the command completes (they are command-scoped, as in bash). - Extracted `execute_dispatched_command` method for clean save/restore around command execution. ## Test plan - [x] 8 unit tests in `lib.rs` (positive: visible in env, multiple vars, empty value; negative: not persistent, not found without prefix) - [x] 6 spec tests in `variables.test.sh` covering positive, negative, and edge cases - [x] 6 differential edge cases comparing against real bash in `proptest_differential.rs` - [x] New proptest strategy + property test for prefix assignments (50 random cases) - [x] `cargo fmt --check` clean - [x] `cargo clippy --all-targets --all-features -- -D warnings` clean - [x] `cargo test --all-features` all pass (934+ unit, 573 spec, 53 proptest) - [x] Updated specs (005-builtins, 009-implementation-status) and docs (compatibility.md) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6b0ec4f commit 6aafb24

7 files changed

Lines changed: 260 additions & 9 deletions

File tree

crates/bashkit/docs/compatibility.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,15 @@ for sandbox security reasons. See the compliance spec for details.
205205
| `${var^}` || - | Uppercase first |
206206
| `${var,}` || - | Lowercase first |
207207

208+
### Prefix Environment Assignments
209+
210+
| Syntax | Status | Example | Description |
211+
|--------|--------|---------|-------------|
212+
| `VAR=val cmd` || `TOKEN=abc printenv TOKEN` | Temporary env for command |
213+
| Multiple prefix || `A=1 B=2 cmd` | Multiple vars in one command |
214+
| No persist || `X=1 cmd; echo $X` | Var not set after command |
215+
| Assignment-only || `X=1` (no cmd) | Persists in shell variables |
216+
208217
### Command Substitution
209218

210219
| Syntax | Status | Example |

crates/bashkit/src/interpreter/mod.rs

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1766,6 +1766,15 @@ impl Interpreter {
17661766
command: &SimpleCommand,
17671767
stdin: Option<String>,
17681768
) -> Result<ExecResult> {
1769+
// Save old variable values before applying prefix assignments.
1770+
// If there's a command, these assignments are temporary (bash behavior:
1771+
// `VAR=value cmd` sets VAR only for cmd's duration).
1772+
let var_saves: Vec<(String, Option<String>)> = command
1773+
.assignments
1774+
.iter()
1775+
.map(|a| (a.name.clone(), self.variables.get(&a.name).cloned()))
1776+
.collect();
1777+
17691778
// Process variable assignments first
17701779
for assignment in &command.assignments {
17711780
match &assignment.value {
@@ -1837,11 +1846,64 @@ impl Interpreter {
18371846

18381847
let name = self.expand_word(&command.name).await?;
18391848

1840-
// If name is empty, this is an assignment-only command
1849+
// If name is empty, this is an assignment-only command - keep permanently
18411850
if name.is_empty() {
18421851
return Ok(ExecResult::ok(String::new()));
18431852
}
18441853

1854+
// Has a command: prefix assignments are temporary (bash behavior).
1855+
// Inject scalar prefix assignments into self.env so builtins/functions
1856+
// can see them via ctx.env (e.g., `MYVAR=hello printenv MYVAR`).
1857+
let mut env_saves: Vec<(String, Option<String>)> = Vec::new();
1858+
for assignment in &command.assignments {
1859+
if assignment.index.is_none() {
1860+
if let Some(value) = self.variables.get(&assignment.name).cloned() {
1861+
let old = self.env.insert(assignment.name.clone(), value);
1862+
env_saves.push((assignment.name.clone(), old));
1863+
}
1864+
}
1865+
}
1866+
1867+
// Dispatch to the appropriate handler
1868+
let result = self.execute_dispatched_command(&name, command, stdin).await;
1869+
1870+
// Restore env (prefix assignments are command-scoped)
1871+
for (name, old) in env_saves {
1872+
match old {
1873+
Some(v) => {
1874+
self.env.insert(name, v);
1875+
}
1876+
None => {
1877+
self.env.remove(&name);
1878+
}
1879+
}
1880+
}
1881+
1882+
// Restore variables (prefix assignments don't persist when there's a command)
1883+
for (name, old) in var_saves {
1884+
match old {
1885+
Some(v) => {
1886+
self.variables.insert(name, v);
1887+
}
1888+
None => {
1889+
self.variables.remove(&name);
1890+
}
1891+
}
1892+
}
1893+
1894+
result
1895+
}
1896+
1897+
/// Execute a command after name resolution and prefix assignment setup.
1898+
///
1899+
/// Handles argument expansion, stdin processing, and dispatch to
1900+
/// functions, special builtins, regular builtins, or command-not-found.
1901+
async fn execute_dispatched_command(
1902+
&mut self,
1903+
name: &str,
1904+
command: &SimpleCommand,
1905+
stdin: Option<String>,
1906+
) -> Result<ExecResult> {
18451907
// Expand arguments with brace and glob expansion
18461908
let mut args: Vec<String> = Vec::new();
18471909
for word in &command.args {
@@ -1906,13 +1968,13 @@ impl Interpreter {
19061968
};
19071969

19081970
// Check for functions first
1909-
if let Some(func_def) = self.functions.get(&name).cloned() {
1971+
if let Some(func_def) = self.functions.get(name).cloned() {
19101972
// Check function depth limit
19111973
self.counters.push_function(&self.limits)?;
19121974

19131975
// Push call frame with positional parameters
19141976
self.call_stack.push(CallFrame {
1915-
name: name.clone(),
1977+
name: name.to_string(),
19161978
locals: HashMap::new(),
19171979
positional: args.clone(),
19181980
});
@@ -1972,7 +2034,7 @@ impl Interpreter {
19722034
// Handle `bash` and `sh` specially - execute scripts using the interpreter
19732035
if name == "bash" || name == "sh" {
19742036
return self
1975-
.execute_shell(&name, &args, stdin, &command.redirects)
2037+
.execute_shell(name, &args, stdin, &command.redirects)
19762038
.await;
19772039
}
19782040

@@ -1987,7 +2049,7 @@ impl Interpreter {
19872049
}
19882050

19892051
// Check for builtins
1990-
if let Some(builtin) = self.builtins.get(name.as_str()) {
2052+
if let Some(builtin) = self.builtins.get(name) {
19912053
let ctx = builtins::Context {
19922054
args: &args,
19932055
env: &self.env,

crates/bashkit/src/lib.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1687,6 +1687,79 @@ mod tests {
16871687
assert_eq!(result.stdout, "1 2 3\n");
16881688
}
16891689

1690+
#[tokio::test]
1691+
async fn test_prefix_assignment_visible_in_env() {
1692+
let mut bash = Bash::new();
1693+
// VAR=value command should make VAR visible in the command's environment
1694+
let result = bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
1695+
assert_eq!(result.stdout, "hello\n");
1696+
}
1697+
1698+
#[tokio::test]
1699+
async fn test_prefix_assignment_temporary() {
1700+
let mut bash = Bash::new();
1701+
// Prefix assignment should NOT persist after the command
1702+
bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
1703+
let result = bash.exec("echo ${MYVAR:-unset}").await.unwrap();
1704+
assert_eq!(result.stdout, "unset\n");
1705+
}
1706+
1707+
#[tokio::test]
1708+
async fn test_prefix_assignment_does_not_clobber_existing_env() {
1709+
let mut bash = Bash::new();
1710+
// Set up existing env var
1711+
let result = bash
1712+
.exec("EXISTING=original; export EXISTING; EXISTING=temp printenv EXISTING")
1713+
.await
1714+
.unwrap();
1715+
assert_eq!(result.stdout, "temp\n");
1716+
}
1717+
1718+
#[tokio::test]
1719+
async fn test_prefix_assignment_multiple_vars() {
1720+
let mut bash = Bash::new();
1721+
// Multiple prefix assignments on same command
1722+
let result = bash.exec("A=one B=two printenv A").await.unwrap();
1723+
assert_eq!(result.stdout, "one\n");
1724+
assert_eq!(result.exit_code, 0);
1725+
}
1726+
1727+
#[tokio::test]
1728+
async fn test_prefix_assignment_empty_value() {
1729+
let mut bash = Bash::new();
1730+
// Empty value is still set in environment
1731+
let result = bash.exec("MYVAR= printenv MYVAR").await.unwrap();
1732+
assert_eq!(result.stdout, "\n");
1733+
assert_eq!(result.exit_code, 0);
1734+
}
1735+
1736+
#[tokio::test]
1737+
async fn test_prefix_assignment_not_found_without_prefix() {
1738+
let mut bash = Bash::new();
1739+
// printenv for a var that was never set should fail
1740+
let result = bash.exec("printenv NONEXISTENT").await.unwrap();
1741+
assert_eq!(result.stdout, "");
1742+
assert_eq!(result.exit_code, 1);
1743+
}
1744+
1745+
#[tokio::test]
1746+
async fn test_prefix_assignment_does_not_persist_in_variables() {
1747+
let mut bash = Bash::new();
1748+
// After prefix assignment with command, var should not be in shell scope
1749+
bash.exec("TMPVAR=gone echo ok").await.unwrap();
1750+
let result = bash.exec("echo \"${TMPVAR:-unset}\"").await.unwrap();
1751+
assert_eq!(result.stdout, "unset\n");
1752+
}
1753+
1754+
#[tokio::test]
1755+
async fn test_assignment_only_persists() {
1756+
let mut bash = Bash::new();
1757+
// Assignment without a command should persist (not a prefix assignment)
1758+
bash.exec("PERSIST=yes").await.unwrap();
1759+
let result = bash.exec("echo $PERSIST").await.unwrap();
1760+
assert_eq!(result.stdout, "yes\n");
1761+
}
1762+
16901763
#[tokio::test]
16911764
async fn test_printf_string() {
16921765
let mut bash = Bash::new();

crates/bashkit/tests/proptest_differential.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,22 @@ fn function_strategy() -> impl Strategy<Value = String> {
215215
)
216216
}
217217

218+
/// Generate a prefix assignment command (VAR=value command)
219+
fn prefix_assignment_strategy() -> impl Strategy<Value = String> {
220+
prop_oneof![
221+
// Single prefix assignment with printenv
222+
(var_name_strategy(), safe_value_strategy())
223+
.prop_map(|(name, value)| format!("{}={} printenv {}", name, value, name)),
224+
// Single prefix assignment with echo (variable visible via $VAR)
225+
(var_name_strategy(), safe_value_strategy())
226+
.prop_map(|(name, value)| format!("{}={} echo done", name, value)),
227+
// Prefix assignment then check it doesn't persist
228+
(var_name_strategy(), safe_value_strategy()).prop_map(|(name, value)| {
229+
format!("{}={} echo done; echo ${{{}:-unset}}", name, value, name)
230+
}),
231+
]
232+
}
233+
218234
/// Generate a complete valid bash script
219235
fn valid_script_strategy() -> impl Strategy<Value = String> {
220236
prop_oneof![
@@ -229,6 +245,7 @@ fn valid_script_strategy() -> impl Strategy<Value = String> {
229245
3 => command_subst_strategy(),
230246
3 => logical_ops_strategy(),
231247
2 => function_strategy(),
248+
3 => prefix_assignment_strategy(),
232249
]
233250
}
234251

@@ -446,6 +463,30 @@ proptest! {
446463
);
447464
}
448465

466+
/// Prefix assignments should produce identical output
467+
#[test]
468+
fn prefix_assignments_match_bash(script in prefix_assignment_strategy()) {
469+
let (bash_out, bash_exit) = run_real_bash(&script);
470+
471+
let rt = tokio::runtime::Builder::new_current_thread()
472+
.enable_all()
473+
.build()
474+
.unwrap();
475+
476+
let (bashkit_out, bashkit_exit) = rt.block_on(run_bashkit(&script));
477+
478+
prop_assert_eq!(
479+
&bashkit_out, &bash_out,
480+
"Output mismatch for script: {}\nBashkit: {:?}\nBash: {:?}",
481+
script, bashkit_out, bash_out
482+
);
483+
prop_assert_eq!(
484+
bashkit_exit, bash_exit,
485+
"Exit code mismatch for script: {}",
486+
script
487+
);
488+
}
489+
449490
/// Multi-statement scripts should produce identical output
450491
#[test]
451492
fn multi_statement_matches_bash(script in multi_statement_strategy()) {
@@ -494,6 +535,19 @@ async fn differential_edge_cases() {
494535
"X=hello; Y=world; echo $X $Y",
495536
"X=hello; Y=world; echo $X $Y",
496537
),
538+
// Prefix environment assignments
539+
("prefix assign visible", "X=hello printenv X"),
540+
(
541+
"prefix assign temporary",
542+
"X=hello printenv X; echo ${X:-unset}",
543+
),
544+
(
545+
"prefix assign no clobber",
546+
"X=original; X=temp echo done; echo $X",
547+
),
548+
("prefix assign empty", "X= printenv X"),
549+
("multiple prefix assigns", "A=1 B=2 printenv A"),
550+
("assignment only persists", "X=persist; echo $X"),
497551
// Arithmetic
498552
("echo $((1 + 2))", "echo $((1 + 2))"),
499553
("echo $((10 - 3))", "echo $((10 - 3))"),

crates/bashkit/tests/spec_cases/bash/variables.test.sh

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,47 @@ X=value; ref=X; echo ${!ref}
264264
### expect
265265
value
266266
### end
267+
268+
### prefix_assign_visible_in_env
269+
# Prefix assignment visible to command via printenv
270+
MYVAR=hello printenv MYVAR
271+
### expect
272+
hello
273+
### end
274+
275+
### prefix_assign_multiple
276+
# Multiple prefix assignments visible to command
277+
A=one B=two printenv A
278+
### expect
279+
one
280+
### end
281+
282+
### prefix_assign_temporary
283+
# Prefix assignment does not persist after command
284+
TMPVAR=gone printenv TMPVAR; echo ${TMPVAR:-unset}
285+
### expect
286+
gone
287+
unset
288+
### end
289+
290+
### prefix_assign_no_clobber
291+
# Prefix assignment does not overwrite pre-existing var permanently
292+
X=original; X=temp echo done; echo $X
293+
### expect
294+
done
295+
original
296+
### end
297+
298+
### prefix_assign_empty_value
299+
# Prefix assignment with empty value is still set (exit code 0)
300+
MYVAR= printenv MYVAR > /dev/null; echo $?
301+
### expect
302+
0
303+
### end
304+
305+
### prefix_assign_only_no_command
306+
# Assignment without command persists (not a prefix assignment)
307+
PERSIST=yes; echo $PERSIST
308+
### expect
309+
yes
310+
### end

specs/005-builtins.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,14 @@ Bash::builder()
128128
- `printenv` - Print environment variable values
129129
- `history` - Command history (virtual mode: limited, no persistent history)
130130

131+
#### Prefix Environment Assignments
132+
133+
Bash supports `VAR=value command` syntax where the assignment is temporary and
134+
scoped to the command's environment. Bashkit implements this: prefix assignments
135+
are injected into `ctx.env` for the command's duration, then both `env` and
136+
`variables` are restored. Assignment-only commands (`VAR=value` with no command)
137+
persist in shell variables as usual.
138+
131139
#### Pipeline Control
132140
- `xargs` - Build commands from stdin (`-I REPL`, `-n MAX`, `-d DELIM`)
133141
- `tee` - Write to files and stdout (`-a` append)

specs/009-implementation-status.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,17 +107,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See
107107

108108
## Spec Test Coverage
109109

110-
**Total spec test cases:** 962
110+
**Total spec test cases:** 968
111111

112112
| Category | Cases | In CI | Pass | Skip | Notes |
113113
|----------|-------|-------|------|------|-------|
114-
| Bash (core) | 588 | Yes | 524 | 64 | `bash_spec_tests` in CI |
114+
| Bash (core) | 594 | Yes | 530 | 64 | `bash_spec_tests` in CI |
115115
| AWK | 89 | Yes | 72 | 17 | loops, arrays, -v, ternary, field assign |
116116
| Grep | 63 | Yes | 58 | 5 | now with -z, -r, -a, -b, -H, -h, -f, -P |
117117
| Sed | 68 | Yes | 56 | 12 | hold space, change, regex ranges, -E |
118118
| JQ | 97 | Yes | 87 | 10 | reduce, walk, regex funcs |
119119
| Python | 57 | Yes | 51 | 6 | **Experimental.** VFS bridging, pathlib, env vars |
120-
| **Total** | **962** | **Yes** | **848** | **114** | |
120+
| **Total** | **968** | **Yes** | **854** | **114** | |
121121

122122
### Bash Spec Tests Breakdown
123123

@@ -157,7 +157,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See
157157
| test-operators.test.sh | 17 | file/string tests (2 skipped) |
158158
| time.test.sh | 11 | Wall-clock only (user/sys always 0) |
159159
| timeout.test.sh | 16 | |
160-
| variables.test.sh | 38 | includes special vars |
160+
| variables.test.sh | 44 | includes special vars, prefix env assignments |
161161
| wc.test.sh | 20 | word count (5 skipped) |
162162

163163
## Shell Features
@@ -181,6 +181,7 @@ Features that may be added in the future (not intentionally excluded):
181181

182182
| Feature | What Works | What's Missing |
183183
|---------|------------|----------------|
184+
| Prefix env assignments | `VAR=val cmd` temporarily sets env for cmd | Array prefix assignments not in env |
184185
| `local` | Declaration | Proper scoping in nested functions |
185186
| `return` | Basic usage | Return value propagation |
186187
| Heredocs | Basic | Variable expansion inside |

0 commit comments

Comments
 (0)