Skip to content

Commit fc51ce0

Browse files
chaliyclaude
andauthored
fix(interpreter): reset OPTIND between bash script invocations (#478)
## Summary - Save/restore OPTIND and _OPTCHAR_IDX around execute_shell calls - Prevents getopts state from leaking between script invocations - Matches real bash behavior where each `bash script.sh` starts fresh Closes #397 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ed543c commit fc51ce0

File tree

1 file changed

+119
-0
lines changed
  • crates/bashkit/src/interpreter

1 file changed

+119
-0
lines changed

crates/bashkit/src/interpreter/mod.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2784,16 +2784,33 @@ impl Interpreter {
27842784
});
27852785

27862786
// Save and apply shell options (-e, -x, -u, -o pipefail, etc.)
2787+
// Also save/restore OPTIND so getopts state doesn't leak between scripts
27872788
let mut saved_opts: Vec<(String, Option<String>)> = Vec::new();
27882789
for (var, val) in &shell_opts {
27892790
let prev = self.variables.get(*var).cloned();
27902791
saved_opts.push((var.to_string(), prev));
27912792
self.variables.insert(var.to_string(), val.to_string());
27922793
}
2794+
let saved_optind = self.variables.get("OPTIND").cloned();
2795+
let saved_optchar = self.variables.get("_OPTCHAR_IDX").cloned();
2796+
self.variables.insert("OPTIND".to_string(), "1".to_string());
2797+
self.variables.remove("_OPTCHAR_IDX");
27932798

27942799
// Execute the script
27952800
let result = self.execute(&script).await;
27962801

2802+
// Restore OPTIND and internal getopts state
2803+
if let Some(val) = saved_optind {
2804+
self.variables.insert("OPTIND".to_string(), val);
2805+
} else {
2806+
self.variables.remove("OPTIND");
2807+
}
2808+
if let Some(val) = saved_optchar {
2809+
self.variables.insert("_OPTCHAR_IDX".to_string(), val);
2810+
} else {
2811+
self.variables.remove("_OPTCHAR_IDX");
2812+
}
2813+
27972814
// Restore shell options
27982815
for (var, prev) in saved_opts {
27992816
if let Some(val) = prev {
@@ -9567,4 +9584,106 @@ mod tests {
95679584
let result = bash.exec("x=10; (( x += 5 )); echo $x").await.unwrap();
95689585
assert_eq!(result.stdout.trim(), "15");
95699586
}
9587+
9588+
#[tokio::test]
9589+
async fn test_getopts_while_loop() {
9590+
// Issue #397: getopts in while loop should iterate over all options
9591+
let mut bash = crate::Bash::new();
9592+
let result = bash
9593+
.exec(
9594+
r#"
9595+
set -- -f json -v
9596+
while getopts "f:vh" opt; do
9597+
case "$opt" in
9598+
f) FORMAT="$OPTARG" ;;
9599+
v) VERBOSE=1 ;;
9600+
esac
9601+
done
9602+
echo "FORMAT=$FORMAT VERBOSE=$VERBOSE"
9603+
"#,
9604+
)
9605+
.await
9606+
.unwrap();
9607+
assert_eq!(result.exit_code, 0);
9608+
assert_eq!(result.stdout.trim(), "FORMAT=json VERBOSE=1");
9609+
}
9610+
9611+
#[tokio::test]
9612+
async fn test_getopts_script_with_args() {
9613+
// Issue #397: getopts via bash -c with script args
9614+
let mut bash = crate::Bash::new();
9615+
// Write a script that uses getopts, then invoke it with arguments
9616+
let result = bash
9617+
.exec(
9618+
r#"
9619+
cat > /tmp/test_getopts.sh << 'SCRIPT'
9620+
while getopts "f:vh" opt; do
9621+
case "$opt" in
9622+
f) FORMAT="$OPTARG" ;;
9623+
v) VERBOSE=1 ;;
9624+
esac
9625+
done
9626+
echo "FORMAT=$FORMAT VERBOSE=$VERBOSE"
9627+
SCRIPT
9628+
bash /tmp/test_getopts.sh -f json -v
9629+
"#,
9630+
)
9631+
.await
9632+
.unwrap();
9633+
assert_eq!(result.stdout.trim(), "FORMAT=json VERBOSE=1");
9634+
}
9635+
9636+
#[tokio::test]
9637+
async fn test_getopts_bash_c_with_args() {
9638+
// Issue #397: getopts via bash -c 'script' -- args
9639+
let mut bash = crate::Bash::new();
9640+
let result = bash
9641+
.exec(
9642+
r#"bash -c '
9643+
FORMAT="csv"
9644+
VERBOSE=0
9645+
while getopts "f:vh" opt; do
9646+
case "$opt" in
9647+
f) FORMAT="$OPTARG" ;;
9648+
v) VERBOSE=1 ;;
9649+
esac
9650+
done
9651+
echo "FORMAT=$FORMAT VERBOSE=$VERBOSE"
9652+
' -- -f json -v"#,
9653+
)
9654+
.await
9655+
.unwrap();
9656+
assert_eq!(result.stdout.trim(), "FORMAT=json VERBOSE=1");
9657+
}
9658+
9659+
#[tokio::test]
9660+
async fn test_getopts_optind_reset_between_scripts() {
9661+
// Issue #397: OPTIND persists across bash script invocations, causing
9662+
// getopts to skip all options on the second run
9663+
let mut bash = crate::Bash::new();
9664+
let result = bash
9665+
.exec(
9666+
r#"
9667+
cat > /tmp/opts.sh << 'SCRIPT'
9668+
FORMAT="csv"
9669+
VERBOSE=0
9670+
while getopts "f:vh" opt; do
9671+
case "$opt" in
9672+
f) FORMAT="$OPTARG" ;;
9673+
v) VERBOSE=1 ;;
9674+
esac
9675+
done
9676+
echo "FORMAT=$FORMAT VERBOSE=$VERBOSE"
9677+
SCRIPT
9678+
bash /tmp/opts.sh -f json -v
9679+
bash /tmp/opts.sh -f xml -v
9680+
"#,
9681+
)
9682+
.await
9683+
.unwrap();
9684+
let lines: Vec<&str> = result.stdout.trim().lines().collect();
9685+
assert_eq!(lines.len(), 2, "expected 2 lines: {}", result.stdout);
9686+
assert_eq!(lines[0], "FORMAT=json VERBOSE=1");
9687+
assert_eq!(lines[1], "FORMAT=xml VERBOSE=1");
9688+
}
95709689
}

0 commit comments

Comments
 (0)