Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/bashkit/docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
72 changes: 67 additions & 5 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1766,6 +1766,15 @@ impl Interpreter {
command: &SimpleCommand,
stdin: Option<String>,
) -> Result<ExecResult> {
// 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<String>)> = 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 {
Expand Down Expand Up @@ -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<String>)> = 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);
Comment on lines +1883 to +1889

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve builtin writes to prefixed variables

This unconditional rollback reverts any variable updates the executed command makes to names that also appeared in prefix assignments. For example, X=tmp export X=perm (or X=tmp readonly X) should leave X set after the builtin runs, but this loop restores/removes X from var_saves, so the builtin’s write is lost. The regression happens whenever a builtin/function mutates a prefixed variable name, and it diverges from bash behavior plus the export builtin’s own contract of setting variables.

Useful? React with 👍 / 👎.

}
}
}

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<String>,
) -> Result<ExecResult> {
// Expand arguments with brace and glob expansion
let mut args: Vec<String> = Vec::new();
for word in &command.args {
Expand Down Expand Up @@ -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(),
});
Expand Down Expand Up @@ -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;
}

Expand All @@ -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,
Expand Down
73 changes: 73 additions & 0 deletions crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1687,6 +1687,79 @@ 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_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();
Expand Down
54 changes: 54 additions & 0 deletions crates/bashkit/tests/proptest_differential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,22 @@ fn function_strategy() -> impl Strategy<Value = String> {
)
}

/// Generate a prefix assignment command (VAR=value command)
fn prefix_assignment_strategy() -> impl Strategy<Value = String> {
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<Value = String> {
prop_oneof![
Expand All @@ -229,6 +245,7 @@ fn valid_script_strategy() -> impl Strategy<Value = String> {
3 => command_subst_strategy(),
3 => logical_ops_strategy(),
2 => function_strategy(),
3 => prefix_assignment_strategy(),
]
}

Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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))"),
Expand Down
44 changes: 44 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/variables.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions specs/005-builtins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions specs/009-implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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 |
Expand Down
Loading