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
7 changes: 6 additions & 1 deletion crates/bashkit/src/builtins/navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ 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");

let new_path = if target.starts_with('/') {
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 {
Expand All @@ -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 {
Expand Down
74 changes: 69 additions & 5 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
_ => {}
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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]
Expand Down
15 changes: 10 additions & 5 deletions crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand Down
87 changes: 87 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/variables.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 4 additions & 4 deletions specs/009-implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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 |
| 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}`, `\<newline>` line continuation |
| variables.test.sh | 73 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}`, `\<newline>` 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` |
Expand Down
Loading