Skip to content

Commit 4bcff08

Browse files
authored
fix(interpreter): correct left-to-right redirect ordering for fd dup + file combos (#863)
Closes #853 — `result=$(cmd 2>&1 >file)` now correctly captures stderr in result and writes stdout to file.\n\nAdds fd-table model for redirect processing when DupOutput and file redirects are mixed. Updates dev_null tests that asserted old incorrect behavior.
1 parent 4f75c0c commit 4bcff08

File tree

4 files changed

+296
-35
lines changed

4 files changed

+296
-35
lines changed

crates/bashkit/src/interpreter/mod.rs

Lines changed: 180 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2741,6 +2741,26 @@ impl Interpreter {
27412741
}
27422742
}
27432743
}
2744+
2745+
/// Fd target for redirect fd-table modeling.
2746+
/// Bash processes redirects left-to-right, building an fd table where each
2747+
/// dup copies the *current* target of the source fd. This matters for
2748+
/// patterns like `2>&1 >file` where stderr must capture stdout's original
2749+
/// destination before stdout is redirected to the file.
2750+
#[derive(Clone, Debug)]
2751+
enum FdTarget {
2752+
/// The original stdout pipe (terminal / command-substitution capture).
2753+
Stdout,
2754+
/// The original stderr pipe.
2755+
Stderr,
2756+
/// Write (truncate) to a file.
2757+
WriteFile(PathBuf, String),
2758+
/// Append to a file.
2759+
AppendFile(PathBuf, String),
2760+
/// Discard (/dev/null).
2761+
DevNull,
2762+
}
2763+
27442764
impl Interpreter {
27452765
/// Execute a sequence of commands (with errexit checking)
27462766
async fn execute_command_sequence(&mut self, commands: &[Command]) -> Result<ExecResult> {
@@ -5437,20 +5457,36 @@ impl Interpreter {
54375457
mut result: ExecResult,
54385458
redirects: &[Redirect],
54395459
) -> Result<ExecResult> {
5460+
// Skip the fd-table path when there are no DupOutput redirects mixed
5461+
// with file redirects — the simple single-pass logic is sufficient and
5462+
// avoids any behavioural delta for the common case.
5463+
let has_dup_output = redirects.iter().any(|r| r.kind == RedirectKind::DupOutput);
5464+
let has_file_redirect = redirects.iter().any(|r| {
5465+
matches!(
5466+
r.kind,
5467+
RedirectKind::Output
5468+
| RedirectKind::Clobber
5469+
| RedirectKind::Append
5470+
| RedirectKind::OutputBoth
5471+
)
5472+
});
5473+
5474+
if has_dup_output && has_file_redirect {
5475+
return self.apply_redirections_fd_table(result, redirects).await;
5476+
}
5477+
5478+
// --- Fast path: no mixed dup+file redirects ---
54405479
for redirect in redirects {
54415480
match redirect.kind {
54425481
RedirectKind::Output | RedirectKind::Clobber => {
54435482
let target_path = self.expand_word(&redirect.target).await?;
54445483
let path = self.resolve_path(&target_path);
5445-
// Handle /dev/null at interpreter level - cannot be bypassed
54465484
if is_dev_null(&path) {
5447-
// Discard output without calling filesystem
54485485
match redirect.fd {
54495486
Some(2) => result.stderr = String::new(),
54505487
_ => result.stdout = String::new(),
54515488
}
54525489
} else {
5453-
// noclobber check: > fails if file exists (>| bypasses)
54545490
if redirect.kind == RedirectKind::Output
54555491
&& self.variables.get("SHOPT_C").map(|v| v.as_str()) == Some("1")
54565492
&& self.fs.stat(&path).await.is_ok()
@@ -5461,26 +5497,21 @@ impl Interpreter {
54615497
result.exit_code = 1;
54625498
return Ok(result);
54635499
}
5464-
// Check which fd we're redirecting
54655500
match redirect.fd {
54665501
Some(2) => {
5467-
// 2> - redirect stderr to file
54685502
if let Err(e) =
54695503
self.fs.write_file(&path, result.stderr.as_bytes()).await
54705504
{
5471-
// Redirect failed - set exit code and report error
54725505
result.stderr = format!("bash: {}: {}\n", target_path, e);
54735506
result.exit_code = 1;
54745507
return Ok(result);
54755508
}
54765509
result.stderr = String::new();
54775510
}
54785511
_ => {
5479-
// Default (stdout) - write stdout to file
54805512
if let Err(e) =
54815513
self.fs.write_file(&path, result.stdout.as_bytes()).await
54825514
{
5483-
// Redirect failed - output is lost, set exit code and report error
54845515
result.stdout = String::new();
54855516
result.stderr = format!("bash: {}: {}\n", target_path, e);
54865517
result.exit_code = 1;
@@ -5494,18 +5525,14 @@ impl Interpreter {
54945525
RedirectKind::Append => {
54955526
let target_path = self.expand_word(&redirect.target).await?;
54965527
let path = self.resolve_path(&target_path);
5497-
// Handle /dev/null at interpreter level - cannot be bypassed
54985528
if is_dev_null(&path) {
5499-
// Discard output without calling filesystem
55005529
match redirect.fd {
55015530
Some(2) => result.stderr = String::new(),
55025531
_ => result.stdout = String::new(),
55035532
}
55045533
} else {
5505-
// Check which fd we're appending
55065534
match redirect.fd {
55075535
Some(2) => {
5508-
// 2>> - append stderr to file
55095536
if let Err(e) =
55105537
self.fs.append_file(&path, result.stderr.as_bytes()).await
55115538
{
@@ -5516,11 +5543,9 @@ impl Interpreter {
55165543
result.stderr = String::new();
55175544
}
55185545
_ => {
5519-
// Default (stdout) - append stdout to file
55205546
if let Err(e) =
55215547
self.fs.append_file(&path, result.stdout.as_bytes()).await
55225548
{
5523-
// Redirect failed - output is lost
55245549
result.stdout = String::new();
55255550
result.stderr = format!("bash: {}: {}\n", target_path, e);
55265551
result.exit_code = 1;
@@ -5532,16 +5557,12 @@ impl Interpreter {
55325557
}
55335558
}
55345559
RedirectKind::OutputBoth => {
5535-
// &> - redirect both stdout and stderr to file
55365560
let target_path = self.expand_word(&redirect.target).await?;
55375561
let path = self.resolve_path(&target_path);
5538-
// Handle /dev/null at interpreter level - cannot be bypassed
55395562
if is_dev_null(&path) {
5540-
// Discard both outputs without calling filesystem
55415563
result.stdout = String::new();
55425564
result.stderr = String::new();
55435565
} else {
5544-
// Write both stdout and stderr to file
55455566
let combined = format!("{}{}", result.stdout, result.stderr);
55465567
if let Err(e) = self.fs.write_file(&path, combined.as_bytes()).await {
55475568
result.stderr = format!("bash: {}: {}\n", target_path, e);
@@ -5553,40 +5574,170 @@ impl Interpreter {
55535574
}
55545575
}
55555576
RedirectKind::DupOutput => {
5556-
// Handle fd duplication (e.g., 2>&1, >&2)
55575577
let target = self.expand_word(&redirect.target).await?;
55585578
let target_fd: i32 = target.parse().unwrap_or(1);
55595579
let src_fd = redirect.fd.unwrap_or(1);
55605580

55615581
match (src_fd, target_fd) {
55625582
(2, 1) => {
5563-
// 2>&1 - redirect stderr to stdout
55645583
result.stdout.push_str(&result.stderr);
55655584
result.stderr = String::new();
55665585
}
55675586
(1, 2) => {
5568-
// >&2 or 1>&2 - redirect stdout to stderr
55695587
result.stderr.push_str(&result.stdout);
55705588
result.stdout = String::new();
55715589
}
5572-
_ => {
5573-
// Other fd duplications not yet supported
5574-
}
5590+
_ => {}
55755591
}
55765592
}
55775593
RedirectKind::Input
55785594
| RedirectKind::HereString
55795595
| RedirectKind::HereDoc
5580-
| RedirectKind::HereDocStrip => {
5581-
// Input redirections handled in process_input_redirections
5596+
| RedirectKind::HereDocStrip => {}
5597+
RedirectKind::DupInput => {}
5598+
}
5599+
}
5600+
5601+
Ok(result)
5602+
}
5603+
5604+
/// Apply redirections using an fd-table model for correct left-to-right
5605+
/// ordering when DupOutput and file redirects are mixed (e.g. `2>&1 >file`).
5606+
async fn apply_redirections_fd_table(
5607+
&mut self,
5608+
mut result: ExecResult,
5609+
redirects: &[Redirect],
5610+
) -> Result<ExecResult> {
5611+
// Build fd table: fd1 = stdout pipe, fd2 = stderr pipe
5612+
let mut fd1 = FdTarget::Stdout;
5613+
let mut fd2 = FdTarget::Stderr;
5614+
5615+
for redirect in redirects {
5616+
match redirect.kind {
5617+
RedirectKind::Output | RedirectKind::Clobber => {
5618+
let target_path = self.expand_word(&redirect.target).await?;
5619+
let path = self.resolve_path(&target_path);
5620+
5621+
if redirect.kind == RedirectKind::Output
5622+
&& self.variables.get("SHOPT_C").map(|v| v.as_str()) == Some("1")
5623+
&& !is_dev_null(&path)
5624+
&& self.fs.stat(&path).await.is_ok()
5625+
{
5626+
result.stdout = String::new();
5627+
result.stderr =
5628+
format!("bash: {}: cannot overwrite existing file\n", target_path);
5629+
result.exit_code = 1;
5630+
return Ok(result);
5631+
}
5632+
5633+
let target = if is_dev_null(&path) {
5634+
FdTarget::DevNull
5635+
} else {
5636+
FdTarget::WriteFile(path, target_path)
5637+
};
5638+
match redirect.fd {
5639+
Some(2) => fd2 = target,
5640+
_ => fd1 = target,
5641+
}
55825642
}
5583-
RedirectKind::DupInput => {
5584-
// Input fd duplication - handled in process_input_redirections
5585-
// for coproc FDs; other cases not yet supported
5643+
RedirectKind::Append => {
5644+
let target_path = self.expand_word(&redirect.target).await?;
5645+
let path = self.resolve_path(&target_path);
5646+
let target = if is_dev_null(&path) {
5647+
FdTarget::DevNull
5648+
} else {
5649+
FdTarget::AppendFile(path, target_path)
5650+
};
5651+
match redirect.fd {
5652+
Some(2) => fd2 = target,
5653+
_ => fd1 = target,
5654+
}
5655+
}
5656+
RedirectKind::OutputBoth => {
5657+
let target_path = self.expand_word(&redirect.target).await?;
5658+
let path = self.resolve_path(&target_path);
5659+
let target = if is_dev_null(&path) {
5660+
FdTarget::DevNull
5661+
} else {
5662+
FdTarget::WriteFile(path, target_path)
5663+
};
5664+
fd1 = target.clone();
5665+
fd2 = target;
5666+
}
5667+
RedirectKind::DupOutput => {
5668+
let target = self.expand_word(&redirect.target).await?;
5669+
let target_fd: i32 = target.parse().unwrap_or(1);
5670+
let src_fd = redirect.fd.unwrap_or(1);
5671+
5672+
match (src_fd, target_fd) {
5673+
(2, 1) => fd2 = fd1.clone(),
5674+
(1, 2) => fd1 = fd2.clone(),
5675+
_ => {}
5676+
}
55865677
}
5678+
RedirectKind::Input
5679+
| RedirectKind::HereString
5680+
| RedirectKind::HereDoc
5681+
| RedirectKind::HereDocStrip
5682+
| RedirectKind::DupInput => {}
5683+
}
5684+
}
5685+
5686+
// Route original stdout/stderr based on final fd table.
5687+
// Collect file writes to avoid double-borrow issues.
5688+
let orig_stdout = std::mem::take(&mut result.stdout);
5689+
let orig_stderr = std::mem::take(&mut result.stderr);
5690+
5691+
// Determine what goes where
5692+
let mut new_stdout = String::new();
5693+
let mut new_stderr = String::new();
5694+
// file_path -> (content, is_append, display_path)
5695+
let mut file_writes: std::collections::HashMap<PathBuf, (String, bool, String)> =
5696+
std::collections::HashMap::new();
5697+
5698+
for (data, fd_target) in [(&orig_stdout, &fd1), (&orig_stderr, &fd2)].iter() {
5699+
if data.is_empty() {
5700+
continue;
5701+
}
5702+
match fd_target {
5703+
FdTarget::Stdout => new_stdout.push_str(data),
5704+
FdTarget::Stderr => new_stderr.push_str(data),
5705+
FdTarget::DevNull => {}
5706+
FdTarget::WriteFile(path, display) => {
5707+
file_writes
5708+
.entry(path.clone())
5709+
.or_insert_with(|| (String::new(), false, display.clone()))
5710+
.0
5711+
.push_str(data);
5712+
}
5713+
FdTarget::AppendFile(path, display) => {
5714+
file_writes
5715+
.entry(path.clone())
5716+
.or_insert_with(|| (String::new(), true, display.clone()))
5717+
.0
5718+
.push_str(data);
5719+
}
5720+
}
5721+
}
5722+
5723+
// Write files
5724+
for (path, (content, is_append, display_path)) in &file_writes {
5725+
let write_result = if *is_append {
5726+
self.fs.append_file(path, content.as_bytes()).await
5727+
} else {
5728+
self.fs.write_file(path, content.as_bytes()).await
5729+
};
5730+
if let Err(e) = write_result {
5731+
new_stderr = format!("bash: {}: {}\n", display_path, e);
5732+
result.exit_code = 1;
5733+
result.stdout = new_stdout;
5734+
result.stderr = new_stderr;
5735+
return Ok(result);
55875736
}
55885737
}
55895738

5739+
result.stdout = new_stdout;
5740+
result.stderr = new_stderr;
55905741
Ok(result)
55915742
}
55925743

crates/bashkit/tests/dev_null_tests.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,21 @@ async fn test_dev_null_stdout_redirect() {
2929
#[tokio::test]
3030
async fn test_dev_null_stderr_redirect() {
3131
let mut bash = Bash::builder().build();
32-
// Use a command that produces stderr
33-
let result = bash.exec("echo error >&2 2>/dev/null").await.unwrap();
32+
// Produce real stderr output and redirect it to /dev/null
33+
let result = bash.exec("echo error 2>/dev/null >&2").await.unwrap();
34+
assert_eq!(result.stderr, "");
35+
assert_eq!(result.stdout, "");
36+
assert_eq!(result.exit_code, 0);
37+
}
38+
39+
#[tokio::test]
40+
async fn test_dev_null_stderr_redirect_direct() {
41+
let mut bash = Bash::builder().build();
42+
// Direct stderr → /dev/null without fd dup interaction
43+
let result = bash
44+
.exec("f() { echo err >&2; }; f 2>/dev/null")
45+
.await
46+
.unwrap();
3447
assert_eq!(result.stderr, "");
3548
assert_eq!(result.exit_code, 0);
3649
}
@@ -46,7 +59,11 @@ async fn test_dev_null_append_stdout() {
4659
#[tokio::test]
4760
async fn test_dev_null_append_stderr() {
4861
let mut bash = Bash::builder().build();
49-
let result = bash.exec("echo error >&2 2>>/dev/null").await.unwrap();
62+
// Produce real stderr and append-redirect it to /dev/null
63+
let result = bash
64+
.exec("f() { echo err >&2; }; f 2>>/dev/null")
65+
.await
66+
.unwrap();
5067
assert_eq!(result.stderr, "");
5168
assert_eq!(result.exit_code, 0);
5269
}

0 commit comments

Comments
 (0)