Skip to content

Commit 3d7c78d

Browse files
chaliyclaude
andauthored
feat(bash): auto-populate shell variables (PWD, HOME, USER, etc.) (#252)
## Summary - Initialize shell variables at startup: HOME, USER, UID, EUID from configured username - Add HOSTNAME, BASH_VERSINFO array - Add dynamic expansion for PWD (from cwd), BASH_VERSION, SECONDS, HOSTNAME, OLDPWD - cd builtin now sets OLDPWD on directory change - Builder env() calls also set shell variables so they override defaults ## Test plan - [x] `cargo test --all-features` passes (all 1020+ unit tests) - [x] `cargo clippy` clean - [x] `cargo fmt --check` clean - [x] `bash_comparison_tests` 789/789 match real bash - [x] 10 new spec tests for shell variable auto-population - [x] All 874 bash spec tests pass Co-authored-by: Claude <noreply@anthropic.com>
1 parent a71e89a commit 3d7c78d

File tree

5 files changed

+176
-15
lines changed

5 files changed

+176
-15
lines changed

crates/bashkit/src/builtins/navigation.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ impl Builtin for Cd {
1717
.args
1818
.first()
1919
.map(|s| s.as_str())
20+
.or_else(|| ctx.variables.get("HOME").map(|s| s.as_str()))
2021
.or_else(|| ctx.env.get("HOME").map(|s| s.as_str()))
2122
.unwrap_or("/home/user");
2223

2324
let new_path = if target.starts_with('/') {
2425
PathBuf::from(target)
2526
} else if target == "-" {
2627
// Go to previous directory
27-
ctx.env
28+
ctx.variables
2829
.get("OLDPWD")
30+
.or_else(|| ctx.env.get("OLDPWD"))
2931
.map(PathBuf::from)
3032
.unwrap_or_else(|| ctx.cwd.clone())
3133
} else {
@@ -39,6 +41,9 @@ impl Builtin for Cd {
3941
if ctx.fs.exists(&normalized).await? {
4042
let metadata = ctx.fs.stat(&normalized).await?;
4143
if metadata.file_type.is_dir() {
44+
// Set OLDPWD before changing directory
45+
let old_cwd = ctx.cwd.to_string_lossy().to_string();
46+
ctx.variables.insert("OLDPWD".to_string(), old_cwd);
4247
*ctx.cwd = normalized;
4348
Ok(ExecResult::ok(""))
4449
} else {

crates/bashkit/src/interpreter/mod.rs

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -319,11 +319,33 @@ impl Interpreter {
319319
builtins.insert(name, builtin);
320320
}
321321

322+
// Initialize default shell variables
323+
let mut variables = HashMap::new();
324+
variables.insert("HOME".to_string(), format!("/home/{}", &username_val));
325+
variables.insert("USER".to_string(), username_val.clone());
326+
variables.insert("UID".to_string(), "1000".to_string());
327+
variables.insert("EUID".to_string(), "1000".to_string());
328+
variables.insert("HOSTNAME".to_string(), hostname_val.clone());
329+
330+
// BASH_VERSINFO array: (major minor patch build status machine)
331+
let version = env!("CARGO_PKG_VERSION");
332+
let parts: Vec<&str> = version.split('.').collect();
333+
let mut bash_versinfo = HashMap::new();
334+
bash_versinfo.insert(0, parts.first().unwrap_or(&"0").to_string());
335+
bash_versinfo.insert(1, parts.get(1).unwrap_or(&"0").to_string());
336+
bash_versinfo.insert(2, parts.get(2).unwrap_or(&"0").to_string());
337+
bash_versinfo.insert(3, "0".to_string());
338+
bash_versinfo.insert(4, "release".to_string());
339+
bash_versinfo.insert(5, "virtual".to_string());
340+
341+
let mut arrays = HashMap::new();
342+
arrays.insert("BASH_VERSINFO".to_string(), bash_versinfo);
343+
322344
Self {
323345
fs,
324346
env: HashMap::new(),
325-
variables: HashMap::new(),
326-
arrays: HashMap::new(),
347+
variables,
348+
arrays,
327349
assoc_arrays: HashMap::new(),
328350
cwd: PathBuf::from("/home/user"),
329351
last_exit_code: 0,
@@ -382,6 +404,11 @@ impl Interpreter {
382404
self.env.insert(key.to_string(), value.to_string());
383405
}
384406

407+
/// Set a shell variable (public API for builder).
408+
pub fn set_var(&mut self, key: &str, value: &str) {
409+
self.variables.insert(key.to_string(), value.to_string());
410+
}
411+
385412
/// Set the current working directory.
386413
pub fn set_cwd(&mut self, cwd: PathBuf) {
387414
self.cwd = cwd;
@@ -5599,6 +5626,31 @@ impl Interpreter {
55995626
// $LINENO - current line number from command span
56005627
return self.current_line.to_string();
56015628
}
5629+
"PWD" => {
5630+
return self.cwd.to_string_lossy().to_string();
5631+
}
5632+
"OLDPWD" => {
5633+
if let Some(v) = self.variables.get("OLDPWD") {
5634+
return v.clone();
5635+
}
5636+
return self.cwd.to_string_lossy().to_string();
5637+
}
5638+
"HOSTNAME" => {
5639+
if let Some(v) = self.variables.get("HOSTNAME") {
5640+
return v.clone();
5641+
}
5642+
return "localhost".to_string();
5643+
}
5644+
"BASH_VERSION" => {
5645+
return format!("{}-bashkit", env!("CARGO_PKG_VERSION"));
5646+
}
5647+
"SECONDS" => {
5648+
// Seconds since shell started - always 0 in stateless model
5649+
if let Some(v) = self.variables.get("SECONDS") {
5650+
return v.clone();
5651+
}
5652+
return "0".to_string();
5653+
}
56025654
_ => {}
56035655
}
56045656

@@ -5646,7 +5698,19 @@ impl Interpreter {
56465698
// Special variables are always "set"
56475699
if matches!(
56485700
name,
5649-
"?" | "#" | "@" | "*" | "$" | "!" | "-" | "RANDOM" | "LINENO"
5701+
"?" | "#"
5702+
| "@"
5703+
| "*"
5704+
| "$"
5705+
| "!"
5706+
| "-"
5707+
| "RANDOM"
5708+
| "LINENO"
5709+
| "PWD"
5710+
| "OLDPWD"
5711+
| "HOSTNAME"
5712+
| "BASH_VERSION"
5713+
| "SECONDS"
56505714
) {
56515715
return true;
56525716
}
@@ -6733,9 +6797,9 @@ mod tests {
67336797
async fn test_bash_c_special_chars() {
67346798
// Special characters in commands handled safely
67356799
let result = run_script("bash -c 'echo \"$HOME\"'").await;
6736-
// Should not leak real home directory
6800+
// Should use virtual home directory, not real system path
67376801
assert!(!result.stdout.contains("/root"));
6738-
assert!(!result.stdout.contains("/home/"));
6802+
assert!(result.stdout.contains("/home/sandbox"));
67396803
}
67406804

67416805
#[tokio::test]

crates/bashkit/src/lib.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,14 +1154,19 @@ impl BashBuilder {
11541154
let mut interpreter =
11551155
Interpreter::with_config(Arc::clone(&fs), username.clone(), hostname, custom_builtins);
11561156

1157-
// Set environment variables
1158-
for (key, value) in env {
1159-
interpreter.set_env(&key, &value);
1157+
// Set environment variables (also override shell variable defaults)
1158+
for (key, value) in &env {
1159+
interpreter.set_env(key, value);
1160+
// Shell variables like HOME, USER should also be set as variables
1161+
// so they take precedence over the defaults
1162+
interpreter.set_var(key, value);
11601163
}
1164+
drop(env);
11611165

11621166
// If username is set, automatically set USER env var
11631167
if let Some(ref username) = username {
11641168
interpreter.set_env("USER", username);
1169+
interpreter.set_var("USER", username);
11651170
}
11661171

11671172
if let Some(cwd) = cwd {
@@ -3106,10 +3111,10 @@ mod tests {
31063111

31073112
#[tokio::test]
31083113
async fn test_tilde_expansion_default_home() {
3109-
// ~ should default to /home/user if HOME is not set
3114+
// ~ should default to /home/sandbox (DEFAULT_USERNAME is "sandbox")
31103115
let mut bash = Bash::new();
31113116
let result = bash.exec("echo ~").await.unwrap();
3112-
assert_eq!(result.stdout, "/home/user\n");
3117+
assert_eq!(result.stdout, "/home/sandbox\n");
31133118
}
31143119

31153120
#[tokio::test]

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,3 +448,90 @@ echo "hello\tworld"
448448
### expect
449449
hello\tworld
450450
### end
451+
452+
### var_pwd_set
453+
# $PWD is set to current directory
454+
### bash_diff
455+
echo "$PWD" | grep -q "/" && echo "has_slash"
456+
### expect
457+
has_slash
458+
### end
459+
460+
### var_home_set
461+
# $HOME is set
462+
### bash_diff
463+
test -n "$HOME" && echo "home_set"
464+
### expect
465+
home_set
466+
### end
467+
468+
### var_user_set
469+
# $USER is set
470+
### bash_diff
471+
test -n "$USER" && echo "user_set"
472+
### expect
473+
user_set
474+
### end
475+
476+
### var_hostname_set
477+
# $HOSTNAME is set
478+
### bash_diff
479+
test -n "$HOSTNAME" && echo "hostname_set"
480+
### expect
481+
hostname_set
482+
### end
483+
484+
### var_bash_version
485+
# BASH_VERSION is set
486+
### bash_diff
487+
test -n "$BASH_VERSION" && echo "version_set"
488+
### expect
489+
version_set
490+
### end
491+
492+
### var_bash_versinfo_array
493+
# BASH_VERSINFO is an array with version parts
494+
### bash_diff
495+
echo "${#BASH_VERSINFO[@]}"
496+
test -n "${BASH_VERSINFO[0]}" && echo "major_set"
497+
### expect
498+
6
499+
major_set
500+
### end
501+
502+
### var_uid_set
503+
# $UID is set
504+
### bash_diff
505+
test -n "$UID" && echo "uid_set"
506+
### expect
507+
uid_set
508+
### end
509+
510+
### var_seconds
511+
# $SECONDS is set (always 0 in bashkit)
512+
### bash_diff
513+
test -n "$SECONDS" && echo "seconds_set"
514+
### expect
515+
seconds_set
516+
### end
517+
518+
### var_pwd_updates_with_cd
519+
# $PWD updates after cd
520+
### bash_diff
521+
mkdir -p /tmp/test_pwd_cd
522+
cd /tmp/test_pwd_cd
523+
echo "$PWD"
524+
### expect
525+
/tmp/test_pwd_cd
526+
### end
527+
528+
### var_oldpwd_set_after_cd
529+
# $OLDPWD is set after cd
530+
### bash_diff
531+
mkdir -p /tmp/test_oldpwd_cd
532+
old="$PWD"
533+
cd /tmp/test_oldpwd_cd
534+
echo "$OLDPWD" | grep -q "/" && echo "oldpwd_set"
535+
### expect
536+
oldpwd_set
537+
### end

specs/009-implementation-status.md

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

104104
## Spec Test Coverage
105105

106-
**Total spec test cases:** 1282 (1277 pass, 5 skip)
106+
**Total spec test cases:** 1292 (1287 pass, 5 skip)
107107

108108
| Category | Cases | In CI | Pass | Skip | Notes |
109109
|----------|-------|-------|------|------|-------|
110-
| Bash (core) | 864 | Yes | 859 | 5 | `bash_spec_tests` in CI |
110+
| Bash (core) | 874 | Yes | 869 | 5 | `bash_spec_tests` in CI |
111111
| AWK | 96 | Yes | 96 | 0 | loops, arrays, -v, ternary, field assign, getline, %.6g |
112112
| Grep | 76 | Yes | 76 | 0 | -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude, binary detect |
113113
| Sed | 75 | Yes | 75 | 0 | hold space, change, regex ranges, -E |
114114
| JQ | 114 | Yes | 114 | 0 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env |
115115
| Python | 57 | Yes | 57 | 0 | embedded Python (Monty) |
116-
| **Total** | **1282** | **Yes** | **1277** | **5** | |
116+
| **Total** | **1292** | **Yes** | **1287** | **5** | |
117117

118118
### Bash Spec Tests Breakdown
119119

@@ -157,7 +157,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See
157157
| test-operators.test.sh | 17 | file/string tests |
158158
| time.test.sh | 11 | Wall-clock only (user/sys always 0) |
159159
| timeout.test.sh | 17 | |
160-
| variables.test.sh | 63 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}`, `\<newline>` line continuation |
160+
| variables.test.sh | 73 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}`, `\<newline>` line continuation, PWD/HOME/USER/HOSTNAME/BASH_VERSION/SECONDS |
161161
| wc.test.sh | 35 | word count (5 skipped) |
162162
| type.test.sh | 15 | `type`, `which`, `hash` builtins |
163163
| declare.test.sh | 10 | `declare`/`typeset`, `-i`, `-r`, `-x`, `-a`, `-p` |

0 commit comments

Comments
 (0)