From 57355900d7e8a600d5791f6db59a269188d9969b Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 26 Mar 2026 14:36:46 +0000 Subject: [PATCH] feat(interpreter): implement BASH_SOURCE array variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #825 — add BASH_SOURCE tracking via bash_source_stack field on Interpreter. Updated in execute_script_content, execute_source, and execute_function_call to maintain source file stack. BASH_SOURCE[0] reflects the current source file, with depth increasing for nested source/function calls. Supports the common source guard pattern: `[[ "${BASH_SOURCE[0]}" == "$0" ]] && main "$@"` --- crates/bashkit/src/interpreter/mod.rs | 38 ++++++++++++- crates/bashkit/tests/bash_source_tests.rs | 67 +++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 crates/bashkit/tests/bash_source_tests.rs diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index a90b249a..8e896127 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -414,6 +414,8 @@ pub struct Interpreter { functions: HashMap, /// Call stack for local variable scoping call_stack: Vec, + /// Source file stack for BASH_SOURCE array + bash_source_stack: Vec, /// Resource limits limits: ExecutionLimits, /// Session-level resource limits (persist across exec() calls) @@ -753,6 +755,7 @@ impl Interpreter { builtins, functions: HashMap::new(), call_stack: Vec::new(), + bash_source_stack: Vec::new(), limits: ExecutionLimits::default(), session_limits: SessionLimits::default(), memory_limits: crate::limits::MemoryLimits::default(), @@ -800,6 +803,18 @@ impl Interpreter { } /// Check if errexit (set -e) is enabled. + /// Sync the internal bash_source_stack to the BASH_SOURCE indexed array. + fn update_bash_source(&mut self) { + let arr: HashMap = self + .bash_source_stack + .iter() + .rev() + .enumerate() + .map(|(i, s)| (i, s.clone())) + .collect(); + self.arrays.insert("BASH_SOURCE".to_string(), arr); + } + fn is_errexit_enabled(&self) -> bool { self.variables .get("SHOPT_e") @@ -4014,6 +4029,11 @@ impl Interpreter { positional: args.to_vec(), }]; + // Set up BASH_SOURCE for the subprocess + let saved_source_stack = self.bash_source_stack.clone(); + self.bash_source_stack = vec![name.to_string()]; + self.update_bash_source(); + // Forward pipeline stdin so commands inside the script (cat, read, etc.) can consume it let prev_pipeline_stdin = self.pipeline_stdin.take(); self.pipeline_stdin = stdin; @@ -4030,6 +4050,7 @@ impl Interpreter { self.last_exit_code = saved_exit; self.aliases = saved_aliases; self.coproc_buffers = saved_coproc; + self.bash_source_stack = saved_source_stack; self.pipeline_stdin = prev_pipeline_stdin; match result { @@ -4161,12 +4182,18 @@ impl Interpreter { )) })?; + // Track source file for BASH_SOURCE + self.bash_source_stack.push(filename.clone()); + self.update_bash_source(); + // Execute the script commands in the current shell context. // Use execute_script_body (not execute) to preserve depth counters. let exec_result = self.execute_script_body(&script, false).await; - // Pop source depth (always, even on error) + // Pop source depth and BASH_SOURCE (always, even on error) self.counters.pop_function(); + self.bash_source_stack.pop(); + self.update_bash_source(); let mut result = exec_result?; @@ -4281,6 +4308,11 @@ impl Interpreter { .collect(); let prev_funcname = self.arrays.insert("FUNCNAME".to_string(), funcname_arr); + // BASH_SOURCE: duplicate current top entry for function calls + let current_source = self.bash_source_stack.last().cloned().unwrap_or_default(); + self.bash_source_stack.push(current_source); + self.update_bash_source(); + // Forward pipeline stdin to function body let prev_pipeline_stdin = self.pipeline_stdin.take(); self.pipeline_stdin = stdin; @@ -4291,9 +4323,11 @@ impl Interpreter { // Restore previous pipeline stdin self.pipeline_stdin = prev_pipeline_stdin; - // Pop call frame and function counter + // Pop call frame, function counter, and BASH_SOURCE self.call_stack.pop(); self.counters.pop_function(); + self.bash_source_stack.pop(); + self.update_bash_source(); // Restore previous FUNCNAME (or set from remaining stack) if self.call_stack.is_empty() { diff --git a/crates/bashkit/tests/bash_source_tests.rs b/crates/bashkit/tests/bash_source_tests.rs new file mode 100644 index 00000000..ae3eb51a --- /dev/null +++ b/crates/bashkit/tests/bash_source_tests.rs @@ -0,0 +1,67 @@ +//! Tests for BASH_SOURCE array variable + +use bashkit::Bash; +use std::path::Path; + +/// BASH_SOURCE[0] is set when executing a script by path +#[tokio::test] +async fn bash_source_set_in_script() { + let mut bash = Bash::new(); + let fs = bash.fs(); + fs.write_file( + Path::new("/test.sh"), + b"#!/bin/bash\necho \"source=${BASH_SOURCE[0]}\"", + ) + .await + .unwrap(); + fs.chmod(Path::new("/test.sh"), 0o755).await.unwrap(); + + let result = bash.exec("/test.sh").await.unwrap(); + assert_eq!(result.stdout.trim(), "source=/test.sh"); +} + +/// BASH_SOURCE[0] is set when sourcing a file +#[tokio::test] +async fn bash_source_set_in_sourced_file() { + let mut bash = Bash::new(); + let fs = bash.fs(); + fs.write_file(Path::new("/lib.sh"), b"echo \"source=${BASH_SOURCE[0]}\"") + .await + .unwrap(); + + let result = bash.exec("source /lib.sh").await.unwrap(); + assert_eq!(result.stdout.trim(), "source=/lib.sh"); +} + +/// Source guard pattern: BASH_SOURCE[0] == $0 when executed directly +#[tokio::test] +async fn bash_source_guard_direct_execution() { + let mut bash = Bash::new(); + let fs = bash.fs(); + fs.write_file( + Path::new("/guard.sh"), + b"#!/bin/bash\nif [[ \"${BASH_SOURCE[0]}\" == \"$0\" ]]; then echo direct; else echo sourced; fi", + ) + .await + .unwrap(); + fs.chmod(Path::new("/guard.sh"), 0o755).await.unwrap(); + + let result = bash.exec("/guard.sh").await.unwrap(); + assert_eq!(result.stdout.trim(), "direct"); +} + +/// Source guard pattern: BASH_SOURCE[0] != $0 when sourced +#[tokio::test] +async fn bash_source_guard_sourced() { + let mut bash = Bash::new(); + let fs = bash.fs(); + fs.write_file( + Path::new("/guard.sh"), + b"if [[ \"${BASH_SOURCE[0]}\" == \"$0\" ]]; then echo direct; else echo sourced; fi", + ) + .await + .unwrap(); + + let result = bash.exec("source /guard.sh").await.unwrap(); + assert_eq!(result.stdout.trim(), "sourced"); +}