Skip to content

Commit 9ba8460

Browse files
authored
fix(builtins): read builtin respects local variable scoping (#901)
## Summary - The `read` builtin wrote directly to the global variables map (`ctx.variables`), bypassing function-local scoping. When `local k` was declared, `read -r k` wrote to global scope while the local shadowed it, resulting in empty values. - Fix: `read` now returns `SetVariable` side effects (like `read -a` already returns `SetArray`), applied by the interpreter through `set_variable()` which checks `call_stack.locals` first. - Includes regression test and updated unit tests. ## Test plan - [x] `cargo test --all-features -p bashkit --lib` — all 2123 tests pass - [x] `cargo clippy --all-targets --all-features -- -D warnings` — clean - [x] `cargo fmt --check` — clean - [x] Regression test `test_read_respects_local_scope` covers the exact bug
1 parent b2132b8 commit 9ba8460

File tree

4 files changed

+80
-31
lines changed

4 files changed

+80
-31
lines changed

crates/bashkit/src/builtins/read.rs

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -158,27 +158,30 @@ impl Builtin for Read {
158158
var_args
159159
};
160160

161-
// Assign words to variables
161+
// Assign words to variables via side effects (respects local scoping)
162+
let mut result = ExecResult::ok(String::new());
162163
for (i, var_name) in var_names.iter().enumerate() {
163164
// THREAT[TM-INJ-009]: Block internal variable prefix injection via read
164165
if is_internal_variable(var_name) {
165166
continue;
166167
}
167-
if i == var_names.len() - 1 {
168+
let value = if i == var_names.len() - 1 {
168169
// Last variable gets all remaining words
169170
let remaining: Vec<&str> = words.iter().skip(i).copied().collect();
170-
let value = remaining.join(" ");
171-
ctx.variables.insert(var_name.to_string(), value);
171+
remaining.join(" ")
172172
} else if i < words.len() {
173-
ctx.variables
174-
.insert(var_name.to_string(), words[i].to_string());
173+
words[i].to_string()
175174
} else {
176175
// Not enough words - set to empty
177-
ctx.variables.insert(var_name.to_string(), String::new());
178-
}
176+
String::new()
177+
};
178+
result.side_effects.push(BuiltinSideEffect::SetVariable {
179+
name: var_name.to_string(),
180+
value,
181+
});
179182
}
180183

181-
Ok(ExecResult::ok(String::new()))
184+
Ok(result)
182185
}
183186
}
184187

@@ -199,6 +202,17 @@ mod tests {
199202
(fs, cwd, variables)
200203
}
201204

205+
/// Extract SetVariable side effects into a map for easy assertion.
206+
fn extract_vars(result: &ExecResult) -> HashMap<String, String> {
207+
let mut map = HashMap::new();
208+
for effect in &result.side_effects {
209+
if let BuiltinSideEffect::SetVariable { name, value } = effect {
210+
map.insert(name.clone(), value.clone());
211+
}
212+
}
213+
map
214+
}
215+
202216
// ==================== no stdin ====================
203217

204218
#[tokio::test]
@@ -228,7 +242,8 @@ mod tests {
228242
);
229243
let result = Read.execute(ctx).await.unwrap();
230244
assert_eq!(result.exit_code, 0);
231-
assert_eq!(variables.get("REPLY").unwrap(), "hello world");
245+
let vars = extract_vars(&result);
246+
assert_eq!(vars.get("REPLY").unwrap(), "hello world");
232247
}
233248

234249
// ==================== read into named variable ====================
@@ -248,7 +263,8 @@ mod tests {
248263
);
249264
let result = Read.execute(ctx).await.unwrap();
250265
assert_eq!(result.exit_code, 0);
251-
assert_eq!(variables.get("MY_VAR").unwrap(), "test_value");
266+
let vars = extract_vars(&result);
267+
assert_eq!(vars.get("MY_VAR").unwrap(), "test_value");
252268
}
253269

254270
// ==================== read into multiple variables ====================
@@ -268,10 +284,11 @@ mod tests {
268284
);
269285
let result = Read.execute(ctx).await.unwrap();
270286
assert_eq!(result.exit_code, 0);
271-
assert_eq!(variables.get("A").unwrap(), "one");
272-
assert_eq!(variables.get("B").unwrap(), "two");
287+
let vars = extract_vars(&result);
288+
assert_eq!(vars.get("A").unwrap(), "one");
289+
assert_eq!(vars.get("B").unwrap(), "two");
273290
// Last var gets remaining words
274-
assert_eq!(variables.get("C").unwrap(), "three four");
291+
assert_eq!(vars.get("C").unwrap(), "three four");
275292
}
276293

277294
#[tokio::test]
@@ -289,9 +306,10 @@ mod tests {
289306
);
290307
let result = Read.execute(ctx).await.unwrap();
291308
assert_eq!(result.exit_code, 0);
292-
assert_eq!(variables.get("A").unwrap(), "one");
293-
assert_eq!(variables.get("B").unwrap(), "");
294-
assert_eq!(variables.get("C").unwrap(), "");
309+
let vars = extract_vars(&result);
310+
assert_eq!(vars.get("A").unwrap(), "one");
311+
assert_eq!(vars.get("B").unwrap(), "");
312+
assert_eq!(vars.get("C").unwrap(), "");
295313
}
296314

297315
// ==================== -r flag (raw mode) ====================
@@ -311,7 +329,8 @@ mod tests {
311329
);
312330
let result = Read.execute(ctx).await.unwrap();
313331
assert_eq!(result.exit_code, 0);
314-
assert_eq!(variables.get("LINE").unwrap(), "hello\\world");
332+
let vars = extract_vars(&result);
333+
assert_eq!(vars.get("LINE").unwrap(), "hello\\world");
315334
}
316335

317336
#[tokio::test]
@@ -329,8 +348,9 @@ mod tests {
329348
);
330349
let result = Read.execute(ctx).await.unwrap();
331350
assert_eq!(result.exit_code, 0);
351+
let vars = extract_vars(&result);
332352
// Without -r, backslash-newline is line continuation
333-
assert_eq!(variables.get("LINE").unwrap(), "helloworld");
353+
assert_eq!(vars.get("LINE").unwrap(), "helloworld");
334354
}
335355

336356
// ==================== -n flag (read N chars) ====================
@@ -350,7 +370,8 @@ mod tests {
350370
);
351371
let result = Read.execute(ctx).await.unwrap();
352372
assert_eq!(result.exit_code, 0);
353-
assert_eq!(variables.get("CHUNK").unwrap(), "abc");
373+
let vars = extract_vars(&result);
374+
assert_eq!(vars.get("CHUNK").unwrap(), "abc");
354375
}
355376

356377
#[tokio::test]
@@ -368,7 +389,8 @@ mod tests {
368389
);
369390
let result = Read.execute(ctx).await.unwrap();
370391
assert_eq!(result.exit_code, 0);
371-
assert_eq!(variables.get("CHUNK").unwrap(), "hi");
392+
let vars = extract_vars(&result);
393+
assert_eq!(vars.get("CHUNK").unwrap(), "hi");
372394
}
373395

374396
// ==================== -d flag (delimiter) ====================
@@ -388,7 +410,8 @@ mod tests {
388410
);
389411
let result = Read.execute(ctx).await.unwrap();
390412
assert_eq!(result.exit_code, 0);
391-
assert_eq!(variables.get("FIELD").unwrap(), "first");
413+
let vars = extract_vars(&result);
414+
assert_eq!(vars.get("FIELD").unwrap(), "first");
392415
}
393416

394417
// ==================== -a flag (array mode) ====================
@@ -458,7 +481,8 @@ mod tests {
458481
);
459482
let result = Read.execute(ctx).await.unwrap();
460483
assert_eq!(result.exit_code, 0);
461-
assert_eq!(variables.get("V").unwrap(), "path\\to\\file");
484+
let vars = extract_vars(&result);
485+
assert_eq!(vars.get("V").unwrap(), "path\\to\\file");
462486
}
463487

464488
// ==================== multiline input ====================
@@ -478,7 +502,8 @@ mod tests {
478502
);
479503
let result = Read.execute(ctx).await.unwrap();
480504
assert_eq!(result.exit_code, 0);
481-
assert_eq!(variables.get("LINE").unwrap(), "first");
505+
let vars = extract_vars(&result);
506+
assert_eq!(vars.get("LINE").unwrap(), "first");
482507
}
483508

484509
// ==================== custom IFS ====================
@@ -499,8 +524,9 @@ mod tests {
499524
);
500525
let result = Read.execute(ctx).await.unwrap();
501526
assert_eq!(result.exit_code, 0);
502-
assert_eq!(variables.get("A").unwrap(), "foo");
503-
assert_eq!(variables.get("B").unwrap(), "bar baz");
527+
let vars = extract_vars(&result);
528+
assert_eq!(vars.get("A").unwrap(), "foo");
529+
assert_eq!(vars.get("B").unwrap(), "bar baz");
504530
}
505531

506532
#[tokio::test]
@@ -524,10 +550,11 @@ mod tests {
524550
);
525551
let result = Read.execute(ctx).await.unwrap();
526552
assert_eq!(result.exit_code, 0);
527-
assert_eq!(variables.get("A").unwrap(), "one");
528-
assert_eq!(variables.get("B").unwrap(), "");
529-
assert_eq!(variables.get("C").unwrap(), "three");
530-
assert_eq!(variables.get("D").unwrap(), "");
553+
let vars = extract_vars(&result);
554+
assert_eq!(vars.get("A").unwrap(), "one");
555+
assert_eq!(vars.get("B").unwrap(), "");
556+
assert_eq!(vars.get("C").unwrap(), "three");
557+
assert_eq!(vars.get("D").unwrap(), "");
531558
}
532559

533560
#[tokio::test]
@@ -546,6 +573,7 @@ mod tests {
546573
);
547574
let result = Read.execute(ctx).await.unwrap();
548575
assert_eq!(result.exit_code, 0);
549-
assert_eq!(variables.get("LINE").unwrap(), "no splitting here");
576+
let vars = extract_vars(&result);
577+
assert_eq!(vars.get("LINE").unwrap(), "no splitting here");
550578
}
551579
}

crates/bashkit/src/interpreter/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5370,6 +5370,9 @@ impl Interpreter {
53705370
builtins::BuiltinSideEffect::SetLastExitCode(code) => {
53715371
self.last_exit_code = *code;
53725372
}
5373+
builtins::BuiltinSideEffect::SetVariable { name, value } => {
5374+
self.set_variable(name.clone(), value.clone());
5375+
}
53735376
}
53745377
}
53755378
}

crates/bashkit/src/interpreter/state.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ pub enum BuiltinSideEffect {
4242
ClearHistory,
4343
/// Set the last exit code (for wait builtin).
4444
SetLastExitCode(i32),
45+
/// Set a shell variable (respects local scoping via `set_variable`).
46+
SetVariable { name: String, value: String },
4547
}
4648

4749
/// Result of executing a bash script.

crates/bashkit/src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2469,6 +2469,22 @@ mod tests {
24692469
assert_eq!(result.stdout, "a b c\n");
24702470
}
24712471

2472+
#[tokio::test]
2473+
async fn test_read_respects_local_scope() {
2474+
// Regression: `local k; read -r k <<< "val"` must set k in local scope
2475+
let mut bash = Bash::new();
2476+
let result = bash
2477+
.exec(
2478+
r#"
2479+
fn() { local k; read -r k <<< "test"; echo "$k"; }
2480+
fn
2481+
"#,
2482+
)
2483+
.await
2484+
.unwrap();
2485+
assert_eq!(result.stdout, "test\n");
2486+
}
2487+
24722488
#[tokio::test]
24732489
async fn test_glob_star() {
24742490
let mut bash = Bash::new();

0 commit comments

Comments
 (0)