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` |