From a3208f263a314087d5a25356df8c07d89182f464 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 17:25:43 +0000 Subject: [PATCH] feat(bash): auto-populate PWD, HOME, USER, HOSTNAME, BASH_VERSION, SECONDS Initialize shell variables at startup: HOME, USER, UID, EUID from configured username (default: sandbox), HOSTNAME from configured value, BASH_VERSINFO array. Add dynamic expansion for PWD (from cwd), BASH_VERSION, SECONDS, HOSTNAME, OLDPWD. cd builtin now sets OLDPWD on directory change and reads HOME from variables. Builder env() calls also set shell variables so they override defaults. 10 spec tests covering PWD, HOME, USER, HOSTNAME, BASH_VERSION, BASH_VERSINFO, UID, SECONDS, cd updates. --- crates/bashkit/src/builtins/navigation.rs | 7 +- crates/bashkit/src/interpreter/mod.rs | 74 ++++++++++++++-- crates/bashkit/src/lib.rs | 15 ++-- .../tests/spec_cases/bash/variables.test.sh | 87 +++++++++++++++++++ specs/009-implementation-status.md | 8 +- 5 files changed, 176 insertions(+), 15 deletions(-) diff --git a/crates/bashkit/src/builtins/navigation.rs b/crates/bashkit/src/builtins/navigation.rs index 69dcc639..963686ba 100644 --- a/crates/bashkit/src/builtins/navigation.rs +++ b/crates/bashkit/src/builtins/navigation.rs @@ -17,6 +17,7 @@ impl Builtin for Cd { .args .first() .map(|s| s.as_str()) + .or_else(|| ctx.variables.get("HOME").map(|s| s.as_str())) .or_else(|| ctx.env.get("HOME").map(|s| s.as_str())) .unwrap_or("/home/user"); @@ -24,8 +25,9 @@ impl Builtin for Cd { PathBuf::from(target) } else if target == "-" { // Go to previous directory - ctx.env + ctx.variables .get("OLDPWD") + .or_else(|| ctx.env.get("OLDPWD")) .map(PathBuf::from) .unwrap_or_else(|| ctx.cwd.clone()) } else { @@ -39,6 +41,9 @@ impl Builtin for Cd { if ctx.fs.exists(&normalized).await? { let metadata = ctx.fs.stat(&normalized).await?; if metadata.file_type.is_dir() { + // Set OLDPWD before changing directory + let old_cwd = ctx.cwd.to_string_lossy().to_string(); + ctx.variables.insert("OLDPWD".to_string(), old_cwd); *ctx.cwd = normalized; Ok(ExecResult::ok("")) } else { diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 2884690c..d3d2e706 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -319,11 +319,33 @@ impl Interpreter { builtins.insert(name, builtin); } + // Initialize default shell variables + let mut variables = HashMap::new(); + variables.insert("HOME".to_string(), format!("/home/{}", &username_val)); + variables.insert("USER".to_string(), username_val.clone()); + variables.insert("UID".to_string(), "1000".to_string()); + variables.insert("EUID".to_string(), "1000".to_string()); + variables.insert("HOSTNAME".to_string(), hostname_val.clone()); + + // BASH_VERSINFO array: (major minor patch build status machine) + let version = env!("CARGO_PKG_VERSION"); + let parts: Vec<&str> = version.split('.').collect(); + let mut bash_versinfo = HashMap::new(); + bash_versinfo.insert(0, parts.first().unwrap_or(&"0").to_string()); + bash_versinfo.insert(1, parts.get(1).unwrap_or(&"0").to_string()); + bash_versinfo.insert(2, parts.get(2).unwrap_or(&"0").to_string()); + bash_versinfo.insert(3, "0".to_string()); + bash_versinfo.insert(4, "release".to_string()); + bash_versinfo.insert(5, "virtual".to_string()); + + let mut arrays = HashMap::new(); + arrays.insert("BASH_VERSINFO".to_string(), bash_versinfo); + Self { fs, env: HashMap::new(), - variables: HashMap::new(), - arrays: HashMap::new(), + variables, + arrays, assoc_arrays: HashMap::new(), cwd: PathBuf::from("/home/user"), last_exit_code: 0, @@ -382,6 +404,11 @@ impl Interpreter { self.env.insert(key.to_string(), value.to_string()); } + /// Set a shell variable (public API for builder). + pub fn set_var(&mut self, key: &str, value: &str) { + self.variables.insert(key.to_string(), value.to_string()); + } + /// Set the current working directory. pub fn set_cwd(&mut self, cwd: PathBuf) { self.cwd = cwd; @@ -5599,6 +5626,31 @@ impl Interpreter { // $LINENO - current line number from command span return self.current_line.to_string(); } + "PWD" => { + return self.cwd.to_string_lossy().to_string(); + } + "OLDPWD" => { + if let Some(v) = self.variables.get("OLDPWD") { + return v.clone(); + } + return self.cwd.to_string_lossy().to_string(); + } + "HOSTNAME" => { + if let Some(v) = self.variables.get("HOSTNAME") { + return v.clone(); + } + return "localhost".to_string(); + } + "BASH_VERSION" => { + return format!("{}-bashkit", env!("CARGO_PKG_VERSION")); + } + "SECONDS" => { + // Seconds since shell started - always 0 in stateless model + if let Some(v) = self.variables.get("SECONDS") { + return v.clone(); + } + return "0".to_string(); + } _ => {} } @@ -5646,7 +5698,19 @@ impl Interpreter { // Special variables are always "set" if matches!( name, - "?" | "#" | "@" | "*" | "$" | "!" | "-" | "RANDOM" | "LINENO" + "?" | "#" + | "@" + | "*" + | "$" + | "!" + | "-" + | "RANDOM" + | "LINENO" + | "PWD" + | "OLDPWD" + | "HOSTNAME" + | "BASH_VERSION" + | "SECONDS" ) { return true; } @@ -6733,9 +6797,9 @@ mod tests { async fn test_bash_c_special_chars() { // Special characters in commands handled safely let result = run_script("bash -c 'echo \"$HOME\"'").await; - // Should not leak real home directory + // Should use virtual home directory, not real system path assert!(!result.stdout.contains("/root")); - assert!(!result.stdout.contains("/home/")); + assert!(result.stdout.contains("/home/sandbox")); } #[tokio::test] diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index a3b78f4c..f2e0237e 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -1154,14 +1154,19 @@ impl BashBuilder { let mut interpreter = Interpreter::with_config(Arc::clone(&fs), username.clone(), hostname, custom_builtins); - // Set environment variables - for (key, value) in env { - interpreter.set_env(&key, &value); + // Set environment variables (also override shell variable defaults) + for (key, value) in &env { + interpreter.set_env(key, value); + // Shell variables like HOME, USER should also be set as variables + // so they take precedence over the defaults + interpreter.set_var(key, value); } + drop(env); // If username is set, automatically set USER env var if let Some(ref username) = username { interpreter.set_env("USER", username); + interpreter.set_var("USER", username); } if let Some(cwd) = cwd { @@ -3106,10 +3111,10 @@ mod tests { #[tokio::test] async fn test_tilde_expansion_default_home() { - // ~ should default to /home/user if HOME is not set + // ~ should default to /home/sandbox (DEFAULT_USERNAME is "sandbox") let mut bash = Bash::new(); let result = bash.exec("echo ~").await.unwrap(); - assert_eq!(result.stdout, "/home/user\n"); + assert_eq!(result.stdout, "/home/sandbox\n"); } #[tokio::test] diff --git a/crates/bashkit/tests/spec_cases/bash/variables.test.sh b/crates/bashkit/tests/spec_cases/bash/variables.test.sh index 7754e4cc..13d46f47 100644 --- a/crates/bashkit/tests/spec_cases/bash/variables.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/variables.test.sh @@ -448,3 +448,90 @@ echo "hello\tworld" ### expect hello\tworld ### end + +### var_pwd_set +# $PWD is set to current directory +### bash_diff +echo "$PWD" | grep -q "/" && echo "has_slash" +### expect +has_slash +### end + +### var_home_set +# $HOME is set +### bash_diff +test -n "$HOME" && echo "home_set" +### expect +home_set +### end + +### var_user_set +# $USER is set +### bash_diff +test -n "$USER" && echo "user_set" +### expect +user_set +### end + +### var_hostname_set +# $HOSTNAME is set +### bash_diff +test -n "$HOSTNAME" && echo "hostname_set" +### expect +hostname_set +### end + +### var_bash_version +# BASH_VERSION is set +### bash_diff +test -n "$BASH_VERSION" && echo "version_set" +### expect +version_set +### end + +### var_bash_versinfo_array +# BASH_VERSINFO is an array with version parts +### bash_diff +echo "${#BASH_VERSINFO[@]}" +test -n "${BASH_VERSINFO[0]}" && echo "major_set" +### expect +6 +major_set +### end + +### var_uid_set +# $UID is set +### bash_diff +test -n "$UID" && echo "uid_set" +### expect +uid_set +### end + +### var_seconds +# $SECONDS is set (always 0 in bashkit) +### bash_diff +test -n "$SECONDS" && echo "seconds_set" +### expect +seconds_set +### end + +### var_pwd_updates_with_cd +# $PWD updates after cd +### bash_diff +mkdir -p /tmp/test_pwd_cd +cd /tmp/test_pwd_cd +echo "$PWD" +### expect +/tmp/test_pwd_cd +### end + +### var_oldpwd_set_after_cd +# $OLDPWD is set after cd +### bash_diff +mkdir -p /tmp/test_oldpwd_cd +old="$PWD" +cd /tmp/test_oldpwd_cd +echo "$OLDPWD" | grep -q "/" && echo "oldpwd_set" +### expect +oldpwd_set +### end diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index ad2e9926..477fe81c 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:** 1282 (1277 pass, 5 skip) +**Total spec test cases:** 1292 (1287 pass, 5 skip) | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 864 | Yes | 859 | 5 | `bash_spec_tests` in CI | +| Bash (core) | 874 | Yes | 869 | 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** | **1282** | **Yes** | **1277** | **5** | | +| **Total** | **1292** | **Yes** | **1287** | **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 | 63 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}`, `\` line continuation | +| variables.test.sh | 73 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}`, `\` line continuation, PWD/HOME/USER/HOSTNAME/BASH_VERSION/SECONDS | | 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` |