Skip to content

Commit b0e807b

Browse files
committed
fix(interpreter): apply redirects left-to-right for correct fd duplication
Closes #853
1 parent 8191b3b commit b0e807b

File tree

2 files changed

+201
-0
lines changed

2 files changed

+201
-0
lines changed

crates/bashkit/src/interpreter/mod.rs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5436,6 +5436,174 @@ impl Interpreter {
54365436
&mut self,
54375437
mut result: ExecResult,
54385438
redirects: &[Redirect],
5439+
) -> Result<ExecResult> {
5440+
// Model fd destinations to handle left-to-right evaluation order correctly.
5441+
// In bash, `2>&1 >file` means: fd2 duplicates fd1's current target (stdout),
5442+
// THEN fd1 is redirected to file. So fd2->stdout, fd1->file.
5443+
// We track where each fd points and resolve content routing at the end.
5444+
5445+
// Use fd-table modeling when DupOutput coexists with file redirects.
5446+
// Without this, `>file 2>&1` or `2>&1 >file` would route content
5447+
// incorrectly because simple sequential application can't track
5448+
// that a dup should alias the file target rather than the buffer.
5449+
let has_dup = redirects
5450+
.iter()
5451+
.any(|r| matches!(r.kind, RedirectKind::DupOutput));
5452+
let has_file = redirects.iter().any(|r| {
5453+
matches!(
5454+
r.kind,
5455+
RedirectKind::Output
5456+
| RedirectKind::Clobber
5457+
| RedirectKind::Append
5458+
| RedirectKind::OutputBoth
5459+
)
5460+
});
5461+
let needs_fd_table = has_dup && has_file;
5462+
5463+
if !needs_fd_table {
5464+
// Fast path: no dup-then-redirect combos, use simple direct application.
5465+
return self.apply_redirections_simple(result, redirects).await;
5466+
}
5467+
5468+
// Fd routing table: track where fd1 (stdout) and fd2 (stderr) point.
5469+
#[derive(Clone, Debug)]
5470+
enum FdTarget {
5471+
/// The original stdout buffer (returned in result.stdout)
5472+
Stdout,
5473+
/// The original stderr buffer (returned in result.stderr)
5474+
Stderr,
5475+
/// Write to a file (path, display_name, append)
5476+
File(PathBuf, String, bool),
5477+
/// Discard output
5478+
DevNull,
5479+
}
5480+
5481+
let mut fd1_target = FdTarget::Stdout;
5482+
let mut fd2_target = FdTarget::Stderr;
5483+
5484+
// Build the fd routing table by processing redirects left-to-right.
5485+
for redirect in redirects {
5486+
match redirect.kind {
5487+
RedirectKind::Output | RedirectKind::Clobber => {
5488+
let target_path = self.expand_word(&redirect.target).await?;
5489+
let path = self.resolve_path(&target_path);
5490+
if is_dev_null(&path) {
5491+
match redirect.fd {
5492+
Some(2) => fd2_target = FdTarget::DevNull,
5493+
_ => fd1_target = FdTarget::DevNull,
5494+
}
5495+
} else {
5496+
// noclobber check
5497+
if redirect.kind == RedirectKind::Output
5498+
&& self.variables.get("SHOPT_C").map(|v| v.as_str()) == Some("1")
5499+
&& self.fs.stat(&path).await.is_ok()
5500+
{
5501+
result.stdout = String::new();
5502+
result.stderr =
5503+
format!("bash: {}: cannot overwrite existing file\n", target_path);
5504+
result.exit_code = 1;
5505+
return Ok(result);
5506+
}
5507+
let target = FdTarget::File(path, target_path, false);
5508+
match redirect.fd {
5509+
Some(2) => fd2_target = target,
5510+
_ => fd1_target = target,
5511+
}
5512+
}
5513+
}
5514+
RedirectKind::Append => {
5515+
let target_path = self.expand_word(&redirect.target).await?;
5516+
let path = self.resolve_path(&target_path);
5517+
if is_dev_null(&path) {
5518+
match redirect.fd {
5519+
Some(2) => fd2_target = FdTarget::DevNull,
5520+
_ => fd1_target = FdTarget::DevNull,
5521+
}
5522+
} else {
5523+
let target = FdTarget::File(path, target_path, true);
5524+
match redirect.fd {
5525+
Some(2) => fd2_target = target,
5526+
_ => fd1_target = target,
5527+
}
5528+
}
5529+
}
5530+
RedirectKind::OutputBoth => {
5531+
let target_path = self.expand_word(&redirect.target).await?;
5532+
let path = self.resolve_path(&target_path);
5533+
if is_dev_null(&path) {
5534+
fd1_target = FdTarget::DevNull;
5535+
fd2_target = FdTarget::DevNull;
5536+
} else {
5537+
fd1_target = FdTarget::File(path.clone(), target_path.clone(), false);
5538+
fd2_target = FdTarget::File(path, target_path, false);
5539+
}
5540+
}
5541+
RedirectKind::DupOutput => {
5542+
let target = self.expand_word(&redirect.target).await?;
5543+
let target_fd: i32 = target.parse().unwrap_or(1);
5544+
let src_fd = redirect.fd.unwrap_or(1);
5545+
// Duplicate: make src_fd point where target_fd currently points
5546+
match (src_fd, target_fd) {
5547+
(2, 1) => fd2_target = fd1_target.clone(),
5548+
(1, 2) => fd1_target = fd2_target.clone(),
5549+
_ => {}
5550+
}
5551+
}
5552+
RedirectKind::Input
5553+
| RedirectKind::HereString
5554+
| RedirectKind::HereDoc
5555+
| RedirectKind::HereDocStrip
5556+
| RedirectKind::DupInput => {
5557+
// Input redirections handled elsewhere
5558+
}
5559+
}
5560+
}
5561+
5562+
// Route original stdout/stderr content to final destinations.
5563+
let orig_stdout = std::mem::take(&mut result.stdout);
5564+
let orig_stderr = std::mem::take(&mut result.stderr);
5565+
5566+
// Collect file writes to handle same-file merging.
5567+
let mut file_writes: std::collections::HashMap<PathBuf, (String, String, bool)> =
5568+
std::collections::HashMap::new();
5569+
5570+
for (content, target) in [(&orig_stdout, &fd1_target), (&orig_stderr, &fd2_target)] {
5571+
match target {
5572+
FdTarget::Stdout => result.stdout.push_str(content),
5573+
FdTarget::Stderr => result.stderr.push_str(content),
5574+
FdTarget::DevNull => {}
5575+
FdTarget::File(path, display_name, append) => {
5576+
let entry = file_writes
5577+
.entry(path.clone())
5578+
.or_insert_with(|| (String::new(), display_name.clone(), *append));
5579+
entry.0.push_str(content);
5580+
}
5581+
}
5582+
}
5583+
5584+
// Write accumulated file content
5585+
for (path, (content, display_name, append)) in &file_writes {
5586+
let write_result = if *append {
5587+
self.fs.append_file(path, content.as_bytes()).await
5588+
} else {
5589+
self.fs.write_file(path, content.as_bytes()).await
5590+
};
5591+
if let Err(e) = write_result {
5592+
result.stderr = format!("bash: {}: {}\n", display_name, e);
5593+
result.exit_code = 1;
5594+
return Ok(result);
5595+
}
5596+
}
5597+
5598+
Ok(result)
5599+
}
5600+
5601+
/// Simple redirect application without fd-table modeling.
5602+
/// Used when there are no DupOutput redirects followed by other redirects.
5603+
async fn apply_redirections_simple(
5604+
&mut self,
5605+
mut result: ExecResult,
5606+
redirects: &[Redirect],
54395607
) -> Result<ExecResult> {
54405608
for redirect in redirects {
54415609
match redirect.kind {

crates/bashkit/tests/spec_cases/bash/pipes-redirects.test.sh

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,36 @@ END
212212
### expect
213213
value is expanded
214214
### end
215+
216+
### redirect_2_to_1_then_file
217+
# 2>&1 >file: stderr captured, stdout to file (left-to-right order)
218+
f() { echo "stdout_line"; echo "stderr_line" >&2; }
219+
result=$(f 2>&1 >/tmp/redir_order.txt)
220+
echo "result=[$result]"
221+
cat /tmp/redir_order.txt
222+
### expect
223+
result=[stderr_line]
224+
stdout_line
225+
### end
226+
227+
### redirect_file_then_2_to_1
228+
# >file 2>&1: both stdout and stderr go to file, nothing captured
229+
g() { echo "out"; echo "err" >&2; }
230+
result=$(g >/tmp/redir_order2.txt 2>&1)
231+
echo "result=[$result]"
232+
cat /tmp/redir_order2.txt
233+
### expect
234+
result=[]
235+
out
236+
err
237+
### end
238+
239+
### redirect_2_to_1_only
240+
# 2>&1 alone: stderr merges into stdout (both captured)
241+
i() { echo "out"; echo "err" >&2; }
242+
result=$(i 2>&1)
243+
echo "result=[$result]"
244+
### expect
245+
result=[out
246+
err]
247+
### end

0 commit comments

Comments
 (0)