Skip to content

Commit 978d076

Browse files
authored
feat(interpreter): implement BASH_SOURCE array variable (#832)
## Summary - Implement BASH_SOURCE array variable tracking source file names across the call stack - Add `bash_source_stack: Vec<String>` field to Interpreter - Push/pop in execute_script_content, execute_source, and execute_function_call - Supports the source guard pattern: `[[ "${BASH_SOURCE[0]}" == "$0" ]]` ## Test plan - [x] `bash_source_set_in_script` — BASH_SOURCE[0] set when executing script by path - [x] `bash_source_set_in_sourced_file` — BASH_SOURCE[0] set when sourcing - [x] `bash_source_guard_direct_execution` — guard detects direct execution - [x] `bash_source_guard_sourced` — guard detects sourced context - [x] Full test suite passes Closes #825
1 parent b94c526 commit 978d076

2 files changed

Lines changed: 103 additions & 2 deletions

File tree

crates/bashkit/src/interpreter/mod.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,8 @@ pub struct Interpreter {
414414
functions: HashMap<String, FunctionDef>,
415415
/// Call stack for local variable scoping
416416
call_stack: Vec<CallFrame>,
417+
/// Source file stack for BASH_SOURCE array
418+
bash_source_stack: Vec<String>,
417419
/// Resource limits
418420
limits: ExecutionLimits,
419421
/// Session-level resource limits (persist across exec() calls)
@@ -753,6 +755,7 @@ impl Interpreter {
753755
builtins,
754756
functions: HashMap::new(),
755757
call_stack: Vec::new(),
758+
bash_source_stack: Vec::new(),
756759
limits: ExecutionLimits::default(),
757760
session_limits: SessionLimits::default(),
758761
memory_limits: crate::limits::MemoryLimits::default(),
@@ -800,6 +803,18 @@ impl Interpreter {
800803
}
801804

802805
/// Check if errexit (set -e) is enabled.
806+
/// Sync the internal bash_source_stack to the BASH_SOURCE indexed array.
807+
fn update_bash_source(&mut self) {
808+
let arr: HashMap<usize, String> = self
809+
.bash_source_stack
810+
.iter()
811+
.rev()
812+
.enumerate()
813+
.map(|(i, s)| (i, s.clone()))
814+
.collect();
815+
self.arrays.insert("BASH_SOURCE".to_string(), arr);
816+
}
817+
803818
fn is_errexit_enabled(&self) -> bool {
804819
self.variables
805820
.get("SHOPT_e")
@@ -4014,6 +4029,11 @@ impl Interpreter {
40144029
positional: args.to_vec(),
40154030
}];
40164031

4032+
// Set up BASH_SOURCE for the subprocess
4033+
let saved_source_stack = self.bash_source_stack.clone();
4034+
self.bash_source_stack = vec![name.to_string()];
4035+
self.update_bash_source();
4036+
40174037
// Forward pipeline stdin so commands inside the script (cat, read, etc.) can consume it
40184038
let prev_pipeline_stdin = self.pipeline_stdin.take();
40194039
self.pipeline_stdin = stdin;
@@ -4030,6 +4050,7 @@ impl Interpreter {
40304050
self.last_exit_code = saved_exit;
40314051
self.aliases = saved_aliases;
40324052
self.coproc_buffers = saved_coproc;
4053+
self.bash_source_stack = saved_source_stack;
40334054
self.pipeline_stdin = prev_pipeline_stdin;
40344055

40354056
match result {
@@ -4161,12 +4182,18 @@ impl Interpreter {
41614182
))
41624183
})?;
41634184

4185+
// Track source file for BASH_SOURCE
4186+
self.bash_source_stack.push(filename.clone());
4187+
self.update_bash_source();
4188+
41644189
// Execute the script commands in the current shell context.
41654190
// Use execute_script_body (not execute) to preserve depth counters.
41664191
let exec_result = self.execute_script_body(&script, false).await;
41674192

4168-
// Pop source depth (always, even on error)
4193+
// Pop source depth and BASH_SOURCE (always, even on error)
41694194
self.counters.pop_function();
4195+
self.bash_source_stack.pop();
4196+
self.update_bash_source();
41704197

41714198
let mut result = exec_result?;
41724199

@@ -4281,6 +4308,11 @@ impl Interpreter {
42814308
.collect();
42824309
let prev_funcname = self.arrays.insert("FUNCNAME".to_string(), funcname_arr);
42834310

4311+
// BASH_SOURCE: duplicate current top entry for function calls
4312+
let current_source = self.bash_source_stack.last().cloned().unwrap_or_default();
4313+
self.bash_source_stack.push(current_source);
4314+
self.update_bash_source();
4315+
42844316
// Forward pipeline stdin to function body
42854317
let prev_pipeline_stdin = self.pipeline_stdin.take();
42864318
self.pipeline_stdin = stdin;
@@ -4291,9 +4323,11 @@ impl Interpreter {
42914323
// Restore previous pipeline stdin
42924324
self.pipeline_stdin = prev_pipeline_stdin;
42934325

4294-
// Pop call frame and function counter
4326+
// Pop call frame, function counter, and BASH_SOURCE
42954327
self.call_stack.pop();
42964328
self.counters.pop_function();
4329+
self.bash_source_stack.pop();
4330+
self.update_bash_source();
42974331

42984332
// Restore previous FUNCNAME (or set from remaining stack)
42994333
if self.call_stack.is_empty() {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//! Tests for BASH_SOURCE array variable
2+
3+
use bashkit::Bash;
4+
use std::path::Path;
5+
6+
/// BASH_SOURCE[0] is set when executing a script by path
7+
#[tokio::test]
8+
async fn bash_source_set_in_script() {
9+
let mut bash = Bash::new();
10+
let fs = bash.fs();
11+
fs.write_file(
12+
Path::new("/test.sh"),
13+
b"#!/bin/bash\necho \"source=${BASH_SOURCE[0]}\"",
14+
)
15+
.await
16+
.unwrap();
17+
fs.chmod(Path::new("/test.sh"), 0o755).await.unwrap();
18+
19+
let result = bash.exec("/test.sh").await.unwrap();
20+
assert_eq!(result.stdout.trim(), "source=/test.sh");
21+
}
22+
23+
/// BASH_SOURCE[0] is set when sourcing a file
24+
#[tokio::test]
25+
async fn bash_source_set_in_sourced_file() {
26+
let mut bash = Bash::new();
27+
let fs = bash.fs();
28+
fs.write_file(Path::new("/lib.sh"), b"echo \"source=${BASH_SOURCE[0]}\"")
29+
.await
30+
.unwrap();
31+
32+
let result = bash.exec("source /lib.sh").await.unwrap();
33+
assert_eq!(result.stdout.trim(), "source=/lib.sh");
34+
}
35+
36+
/// Source guard pattern: BASH_SOURCE[0] == $0 when executed directly
37+
#[tokio::test]
38+
async fn bash_source_guard_direct_execution() {
39+
let mut bash = Bash::new();
40+
let fs = bash.fs();
41+
fs.write_file(
42+
Path::new("/guard.sh"),
43+
b"#!/bin/bash\nif [[ \"${BASH_SOURCE[0]}\" == \"$0\" ]]; then echo direct; else echo sourced; fi",
44+
)
45+
.await
46+
.unwrap();
47+
fs.chmod(Path::new("/guard.sh"), 0o755).await.unwrap();
48+
49+
let result = bash.exec("/guard.sh").await.unwrap();
50+
assert_eq!(result.stdout.trim(), "direct");
51+
}
52+
53+
/// Source guard pattern: BASH_SOURCE[0] != $0 when sourced
54+
#[tokio::test]
55+
async fn bash_source_guard_sourced() {
56+
let mut bash = Bash::new();
57+
let fs = bash.fs();
58+
fs.write_file(
59+
Path::new("/guard.sh"),
60+
b"if [[ \"${BASH_SOURCE[0]}\" == \"$0\" ]]; then echo direct; else echo sourced; fi",
61+
)
62+
.await
63+
.unwrap();
64+
65+
let result = bash.exec("source /guard.sh").await.unwrap();
66+
assert_eq!(result.stdout.trim(), "sourced");
67+
}

0 commit comments

Comments
 (0)