Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 163 additions & 7 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1487,8 +1487,14 @@ pub struct Interpreter {
/// Next virtual FD to assign for coproc read ends (starts at 63, like bash).
coproc_next_fd: i32,
/// Persistent fd output table set by `exec N>/path` redirections.
/// Maps fd number to its output target. Used by `>&N` redirections.
/// Maps fd number to its output target. Used by `>&N` redirections;
/// entries for fd 1 and 2 (`exec >log 2>&1`) route the shell's own
/// output at each top-level command (`route_exec_output`).
exec_fd_table: HashMap<i32, FdTarget>,
/// Output written to a saved copy of the original stdout/stderr
/// (`exec 3>&1 >log; echo hi >&3`) while fd 1/2 are redirected by
/// `exec`. It skips that routing and goes straight to the caller.
exec_passthrough: (crate::StreamData, crate::StreamData),
/// Temporary buffer for fd3+ output during compound body execution.
/// Populated by `1>&N` (N>=3) in apply_redirections, consumed by
/// apply_redirections_fd_table for compound redirect routing.
Expand Down Expand Up @@ -2031,6 +2037,7 @@ impl Interpreter {
coproc_buffers: HashMap::new(),
coproc_next_fd: 63,
exec_fd_table: HashMap::new(),
exec_passthrough: Default::default(),
pending_fd_output: HashMap::new(),
pending_fd_targets: Vec::new(),
pending_fd_capture_depth: 0,
Expand Down Expand Up @@ -2188,6 +2195,7 @@ impl Interpreter {
coproc_buffers: HashMap::new(),
coproc_next_fd: self.coproc_next_fd,
exec_fd_table: self.exec_fd_table.clone(),
exec_passthrough: Default::default(),
pending_fd_output: HashMap::new(),
pending_fd_targets: Vec::new(),
pending_fd_capture_depth: 0,
Expand Down Expand Up @@ -3055,6 +3063,27 @@ impl Interpreter {
if self.output_emit_count != emit_count_before {
return false;
}
// `exec >log` / `exec 2>/dev/null`: the shell's own output is routed
// at the top-level command (`route_exec_output`), not streamed. A
// pipeline stage's sink is a pipe, never the caller.
let (stdout, stderr) = if self.pipe_out.is_none() {
let empty = crate::StreamData::new();
(
if self.exec_fd_table.contains_key(&1) {
empty.clone()
} else {
stdout.clone()
},
if self.exec_fd_table.contains_key(&2) {
empty
} else {
stderr.clone()
},
)
} else {
(stdout.clone(), stderr.clone())
};
let (stdout, stderr) = (&stdout, &stderr);

let stdout_remaining = self
.limits
Expand Down Expand Up @@ -3199,6 +3228,10 @@ impl Interpreter {
for command in &script.commands {
self.check_cancelled()?;
let emit_before = self.output_emit_count;
let emitted_before = (
self.output_stream_stdout_bytes,
self.output_stream_stderr_bytes,
);
let mut result = self.execute_command(command).await?;
if top_level {
// Background jobs that finished meanwhile report here, as
Expand All @@ -3208,7 +3241,13 @@ impl Interpreter {
result.stderr.append(&err);
}
self.check_cancelled()?;
if top_level {
self.route_exec_output(&mut result, emitted_before).await?;
}
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
if top_level {
self.flush_exec_passthrough(&mut result);
}

// Accumulate stdout with truncation
if !stdout_truncated {
Expand Down Expand Up @@ -7251,7 +7290,15 @@ impl Interpreter {
}
}
RedirectKind::Output | RedirectKind::Clobber => {
let fd = redirect.fd.or(resolved_fd_var).unwrap_or(1);
// WTF: `exec {var}>file` (allocate a free fd into var) is not
// implemented; an unset var must not fall back to fd 1.
let Some(fd) = redirect
.fd
.or(resolved_fd_var)
.or(redirect.fd_var.is_none().then_some(1))
else {
continue;
};
let target_path = self.expand_word(&redirect.target).await?;
let path = self.resolve_path(&target_path);
self.ensure_persistent_fd_capacity(fd)?;
Expand All @@ -7261,14 +7308,31 @@ impl Interpreter {
} else if is_dev_null(&path) {
self.exec_fd_table.insert(fd, FdTarget::DevNull);
} else {
// Truncate file on open (like real exec >file)
let _ = self.fs.write_file(&path, b"").await;
// Truncate file on open (like real exec >file); a
// failed open leaves the fd as it was.
if let Err(e) = self.fs.write_file(&path, b"").await {
return Ok(ExecResult::err(
format!(
"bash: {target_path}: {}\n",
crate::error::io_error_reason(&e)
),
1,
));
}
self.exec_fd_table
.insert(fd, FdTarget::WriteFile(path, target_path));
}
}
RedirectKind::Append => {
let fd = redirect.fd.or(resolved_fd_var).unwrap_or(1);
// WTF: `exec {var}>file` (allocate a free fd into var) is not
// implemented; an unset var must not fall back to fd 1.
let Some(fd) = redirect
.fd
.or(resolved_fd_var)
.or(redirect.fd_var.is_none().then_some(1))
else {
continue;
};
let target_path = self.expand_word(&redirect.target).await?;
let path = self.resolve_path(&target_path);
self.ensure_persistent_fd_capacity(fd)?;
Expand All @@ -7278,13 +7342,30 @@ impl Interpreter {
} else if is_dev_null(&path) {
self.exec_fd_table.insert(fd, FdTarget::DevNull);
} else {
if let Err(e) = self.fs.append_file(&path, b"").await {
return Ok(ExecResult::err(
format!(
"bash: {target_path}: {}\n",
crate::error::io_error_reason(&e)
),
1,
));
}
self.exec_fd_table
.insert(fd, FdTarget::AppendFile(path, target_path));
}
}
RedirectKind::DupOutput => {
let target = self.expand_word(&redirect.target).await?;
let fd = redirect.fd.or(resolved_fd_var).unwrap_or(1);
// WTF: `exec {var}>file` (allocate a free fd into var) is not
// implemented; an unset var must not fall back to fd 1.
let Some(fd) = redirect
.fd
.or(resolved_fd_var)
.or(redirect.fd_var.is_none().then_some(1))
else {
continue;
};
if target == "-" || target == "&-" {
// exec N>&- closes the fd
self.exec_fd_table.remove(&fd);
Expand All @@ -7298,13 +7379,88 @@ impl Interpreter {
_ => {}
}
}
// fd 1/2 pointed back at the original streams (`exec 1>&3`) is no
// redirect at all.
if matches!(self.exec_fd_table.get(&1), Some(FdTarget::Stdout)) {
self.exec_fd_table.remove(&1);
}
if matches!(self.exec_fd_table.get(&2), Some(FdTarget::Stderr)) {
self.exec_fd_table.remove(&2);
}
let result = ExecResult::default();
self.apply_redirections(result, redirects).await
}

/// Target for `exec N>&M` / `exec N>/dev/fd/M`.
/// Send a top-level command's output where `exec` pointed fd 1 and 2
/// (`exec >log 2>&1`). Output a sub-call already streamed to the caller
/// (before the `exec` ran) stays; the rest goes to the target.
// WTF: output written in the same top-level command before `exec >log`
// ran, and not streamed, also goes to the log; routing happens per
// top-level command, not per write.
async fn route_exec_output(
&mut self,
result: &mut ExecResult,
emitted_before: (usize, usize),
) -> Result<()> {
if !self.exec_fd_table.contains_key(&1) && !self.exec_fd_table.contains_key(&2) {
return Ok(());
}
let streamed_out = self.output_stream_stdout_bytes - emitted_before.0;
let streamed_err = self.output_stream_stderr_bytes - emitted_before.1;
let take = |data: &mut crate::StreamData, keep: usize| {
let keep = keep.min(data.len());
let rest = crate::StreamData::from(&data.as_bytes()[keep..]);
*data = data.prefix(keep);
rest
};
let mut moved = Vec::new();
if let Some(target) = self.exec_fd_table.get(&1).cloned() {
moved.push((take(&mut result.stdout, streamed_out), target));
}
if let Some(target) = self.exec_fd_table.get(&2).cloned() {
moved.push((take(&mut result.stderr, streamed_err), target));
}
for (data, target) in moved {
if data.is_empty() {
continue;
}
match target {
FdTarget::Stdout => result.stdout.append(&data),
FdTarget::Stderr => result.stderr.append(&data),
FdTarget::DevNull => {}
FdTarget::WriteFile(path, _) | FdTarget::AppendFile(path, _) => {
self.fs.append_file(&path, data.as_bytes()).await?;
}
}
}
Ok(())
}

/// Hand output written to a saved original stdout/stderr (`>&3` after
/// `exec 3>&1 >log`) to the caller, unrouted.
fn flush_exec_passthrough(&mut self, result: &mut ExecResult) {
let (out, err) = std::mem::take(&mut self.exec_passthrough);
if out.is_empty() && err.is_empty() {
return;
}
if let Some(cb) = self.output_callback.as_mut() {
cb(&out, &err);
self.output_emit_count += 1;
self.output_stream_stdout_bytes += out.len();
self.output_stream_stderr_bytes += err.len();
}
result.stdout.append(&out);
result.stderr.append(&err);
}

fn exec_fd_alias_target(&self, target_fd: i32) -> FdTarget {
if target_fd == 1 {
// `exec 3>&1` copies where fd 1 points now (a file after `exec >log`).
if let Some(target) = self.exec_fd_table.get(&target_fd)
&& matches!(target_fd, 1 | 2)
{
target.clone()
} else if target_fd == 1 {
FdTarget::Stdout
} else if target_fd == 2 {
FdTarget::Stderr
Expand Down
24 changes: 20 additions & 4 deletions crates/bashkit/src/interpreter/redirection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,14 +285,27 @@ impl Interpreter {
src_fd: i32,
target_fd: i32,
) -> Result<()> {
// Check exec_fd_table for persistent fd targets
if let Some(fd_target) = self.exec_fd_table.get(&target_fd).cloned() {
// Check exec_fd_table for persistent fd targets. Fd 1 and 2 are the
// current streams here: `exec >log` is applied where the shell's
// own output leaves (top level), so `$(cmd 2>&1)` still captures.
if target_fd >= 3
&& let Some(fd_target) = self.exec_fd_table.get(&target_fd).cloned()
{
let data = if src_fd == 2 {
std::mem::take(&mut result.stderr)
} else {
std::mem::take(&mut result.stdout)
};
// A saved copy of the original stream (`exec 3>&1 >log`) skips
// the `exec` routing of fd 1/2.
let passthrough = match &fd_target {
FdTarget::Stdout => self.exec_fd_table.contains_key(&1),
FdTarget::Stderr => self.exec_fd_table.contains_key(&2),
_ => false,
};
match &fd_target {
FdTarget::Stdout if passthrough => self.exec_passthrough.0.append(&data),
FdTarget::Stderr if passthrough => self.exec_passthrough.1.append(&data),
FdTarget::Stdout => result.stdout.append(&data),
FdTarget::Stderr => result.stderr.append(&data),
FdTarget::DevNull => {}
Expand Down Expand Up @@ -496,8 +509,11 @@ impl Interpreter {
fd1: &mut FdTarget,
fd2: &mut FdTarget,
) {
// Look up exec_fd_table for persistent fd targets
if let Some(exec_target) = self.exec_fd_table.get(&target_fd).cloned() {
// Look up exec_fd_table for persistent fd targets (fd 1/2: see
// `dup_output_fast`).
if target_fd >= 3
&& let Some(exec_target) = self.exec_fd_table.get(&target_fd).cloned()
{
match src_fd {
2 => *fd2 = exec_target,
_ => *fd1 = exec_target,
Expand Down
23 changes: 23 additions & 0 deletions crates/bashkit/tests/integration/pipeline_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,26 @@ async fn redirected_function_output_is_not_streamed() {
assert_eq!(result.stdout, "out\nout\nerr\n");
assert_eq!(*streamed.lock().unwrap(), "out\nout\nerr\n");
}

/// `exec >log 2>&1` redirects the shell's own output: nothing reaches the
/// streaming callback until fd 1 is restored, and `>&3` to the saved
/// original stdout still does.
#[tokio::test]
async fn exec_redirected_output_is_not_streamed() {
let streamed = Arc::new(Mutex::new(String::new()));
let sink = streamed.clone();
let mut bash = Bash::new();
let result = bash
.exec_streaming(
"exec 3>&1 >/tmp/x.log 2>&1\necho one\nfor i in 1 2; do echo $i; done\necho saved >&3\nexec 1>&3 3>&-\necho back\ncat /tmp/x.log",
Box::new(move |stdout, stderr| {
let mut s = sink.lock().unwrap();
s.push_str(&stdout.to_string());
s.push_str(&stderr.to_string());
}),
)
.await
.unwrap();
assert_eq!(result.stdout, "saved\nback\none\n1\n2\n");
assert_eq!(*streamed.lock().unwrap(), "saved\nback\none\n1\n2\n");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
### exec_stdout_to_file_then_restore
exec 3>&1 > /tmp/ex.log 2>&1
echo one
ls /nonexistent_zz
echo two >&3
exec 1>&3 3>&-
echo back
cat /tmp/ex.log
### expect
two
back
one
ls: cannot access '/nonexistent_zz': No such file or directory
### end

### exec_inside_function_persists
exec 4>&1
main() { exec > /tmp/m.log; echo inside; }
main
echo after
exec >&4 4>&-
echo restored
cat /tmp/m.log
### expect
restored
inside
after
### end

### exec_redirect_spares_command_substitution
exec 3>&1 > /tmp/c.log
x=$(echo captured; ls /nonexistent_zz 2>&1 | wc -l)
echo "x=$x" | tr '\n' ' ' >&3
echo
exec >&3
echo "log=[$(cat /tmp/c.log)]"
### expect
x=captured 1 log=[]
### end

### exec_stderr_to_dev_null
exec 2>/dev/null
ls /nonexistent_zz
echo "rc=$?"
### expect
rc=2
### end

### exec_append_and_pipeline
echo first > /tmp/a.log
exec 3>&1 >> /tmp/a.log
printf 'b\na\n' | sort
for i in 1 2; do echo "loop $i"; done
exec >&3
cat /tmp/a.log
### expect
first
a
b
loop 1
loop 2
### end
Loading
Loading