diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index cd2c13e29..fdcb2f7fa 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -643,6 +643,32 @@ fn decode_file_bytes_for_path(_path: &Path, bytes: &[u8]) -> String { /// Check if a path refers to /dev/null after normalization. /// Handles attempts to bypass via paths like `/dev/../dev/null`. fn is_dev_null(path: &Path) -> bool { + normalize_dev_path(path) == Path::new(DEV_NULL) +} + +/// File descriptor aliased by `/dev/stdin`, `/dev/stdout`, `/dev/stderr` or +/// `/dev/fd/N` (after `..` normalization), as on Linux. +/// +/// Decision: these paths are resolved at the interpreter level like +/// `/dev/null`, never through the VFS. Writing them as regular files silently +/// swallowed `echo err > /dev/stderr`, a pattern agents use constantly. +fn dev_fd_alias(path: &Path) -> Option { + let normalized = normalize_dev_path(path); + match normalized.to_str()? { + "/dev/stdin" => Some(0), + "/dev/stdout" => Some(1), + "/dev/stderr" => Some(2), + other => { + let n = other.strip_prefix("/dev/fd/")?; + if n.is_empty() || n.len() > 9 || !n.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + n.parse().ok() + } + } +} + +fn normalize_dev_path(path: &Path) -> PathBuf { // Normalize the path to handle .. and . components let mut normalized = PathBuf::new(); for component in path.components() { @@ -659,7 +685,7 @@ fn is_dev_null(path: &Path) -> bool { if normalized.as_os_str().is_empty() { normalized.push("/"); } - normalized == Path::new(DEV_NULL) + normalized } /// THREAT[TM-INJ-009,TM-INJ-016]: Check if a variable name is an internal marker. @@ -6157,7 +6183,10 @@ impl Interpreter { let target_path = self.expand_word(&redirect.target).await?; let path = self.resolve_path(&target_path); self.ensure_persistent_fd_capacity(fd)?; - if is_dev_null(&path) { + if let Some(target_fd) = dev_fd_alias(&path) { + let entry = self.exec_fd_alias_target(target_fd); + self.exec_fd_table.insert(fd, entry); + } else if is_dev_null(&path) { self.exec_fd_table.insert(fd, FdTarget::DevNull); } else { // Truncate file on open (like real exec >file) @@ -6171,7 +6200,10 @@ impl Interpreter { let target_path = self.expand_word(&redirect.target).await?; let path = self.resolve_path(&target_path); self.ensure_persistent_fd_capacity(fd)?; - if is_dev_null(&path) { + if let Some(target_fd) = dev_fd_alias(&path) { + let entry = self.exec_fd_alias_target(target_fd); + self.exec_fd_table.insert(fd, entry); + } else if is_dev_null(&path) { self.exec_fd_table.insert(fd, FdTarget::DevNull); } else { self.exec_fd_table @@ -6186,16 +6218,7 @@ impl Interpreter { self.exec_fd_table.remove(&fd); } else if let Ok(target_fd) = target.parse::() { // exec N>&M duplicates fd M to fd N - let target_entry = if target_fd == 1 { - FdTarget::Stdout - } else if target_fd == 2 { - FdTarget::Stderr - } else { - self.exec_fd_table - .get(&target_fd) - .cloned() - .unwrap_or(FdTarget::Stdout) - }; + let target_entry = self.exec_fd_alias_target(target_fd); self.ensure_persistent_fd_capacity(fd)?; self.exec_fd_table.insert(fd, target_entry); } @@ -6207,6 +6230,20 @@ impl Interpreter { self.apply_redirections(result, redirects).await } + /// Target for `exec N>&M` / `exec N>/dev/fd/M`. + fn exec_fd_alias_target(&self, target_fd: i32) -> FdTarget { + if target_fd == 1 { + FdTarget::Stdout + } else if target_fd == 2 { + FdTarget::Stderr + } else { + self.exec_fd_table + .get(&target_fd) + .cloned() + .unwrap_or(FdTarget::Stdout) + } + } + fn ensure_persistent_fd_capacity(&self, fd: i32) -> Result<()> { if fd < 0 { return Err(crate::error::Error::Execution(format!( diff --git a/crates/bashkit/src/interpreter/redirection.rs b/crates/bashkit/src/interpreter/redirection.rs index 5d350a8ce..1002a8fd4 100644 --- a/crates/bashkit/src/interpreter/redirection.rs +++ b/crates/bashkit/src/interpreter/redirection.rs @@ -44,7 +44,9 @@ impl Interpreter { let target_path = self.expand_word(&redirect.target).await?; let path = self.resolve_path(&target_path); // Handle /dev/null at interpreter level - cannot be bypassed - if is_dev_null(&path) { + if dev_fd_alias(&path) == Some(0) { + // `< /dev/stdin` re-reads the current stdin: no-op. + } else if is_dev_null(&path) { stdin = Some(crate::StreamData::new()); // EOF } else if self.shell_profile.is_logic_only() { return Err(crate::error::Error::Execution(format!( @@ -134,7 +136,10 @@ impl Interpreter { RedirectKind::Output | RedirectKind::Clobber => { let target_path = self.expand_word(&redirect.target).await?; let path = self.resolve_path(&target_path); - if is_dev_null(&path) { + if let Some(target_fd) = dev_fd_alias(&path) { + self.dup_output_fast(&mut result, redirect.fd.unwrap_or(1), target_fd) + .await?; + } else if is_dev_null(&path) { match redirect.fd { Some(2) => result.stderr = crate::StreamData::new(), _ => result.stdout = crate::StreamData::new(), @@ -183,7 +188,10 @@ impl Interpreter { RedirectKind::Append => { let target_path = self.expand_word(&redirect.target).await?; let path = self.resolve_path(&target_path); - if is_dev_null(&path) { + if let Some(target_fd) = dev_fd_alias(&path) { + self.dup_output_fast(&mut result, redirect.fd.unwrap_or(1), target_fd) + .await?; + } else if is_dev_null(&path) { match redirect.fd { Some(2) => result.stderr = crate::StreamData::new(), _ => result.stdout = crate::StreamData::new(), @@ -221,7 +229,18 @@ impl Interpreter { RedirectKind::OutputBoth => { let target_path = self.expand_word(&redirect.target).await?; let path = self.resolve_path(&target_path); - if is_dev_null(&path) { + if let Some(target_fd) = dev_fd_alias(&path) { + // `&> /dev/fd/N` == `>&N 2>&N`, resolved against the + // original descriptors. + if target_fd == 2 { + let mut combined = std::mem::take(&mut result.stdout); + combined.append(&result.stderr); + result.stderr = combined; + } else { + self.dup_output_fast(&mut result, 1, target_fd).await?; + self.dup_output_fast(&mut result, 2, target_fd).await?; + } + } else if is_dev_null(&path) { result.stdout = crate::StreamData::new(); result.stderr = crate::StreamData::new(); } else { @@ -241,48 +260,7 @@ impl Interpreter { let target = self.expand_word(&redirect.target).await?; let target_fd: i32 = target.parse().unwrap_or(1); let src_fd = redirect.fd.unwrap_or(1); - - // Check exec_fd_table for persistent fd targets - if 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) - }; - match &fd_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?; - } - } - } else { - match (src_fd, target_fd) { - (2, 1) => { - result.stdout.append(&result.stderr); - result.stderr = crate::StreamData::new(); - } - (1, 2) => { - result.stderr.append(&result.stdout); - result.stdout = crate::StreamData::new(); - } - (src, dst) if dst >= 3 => { - let data = if src == 2 { - std::mem::take(&mut result.stderr) - } else { - std::mem::take(&mut result.stdout) - }; - if self.pending_fd_capture_depth > 0 { - // Move content to pending_fd_output for compound - // redirect routing (e.g. `echo msg 1>&3` inside - // `{ ... } 3>&1 >file`). - self.append_pending_fd_output(dst, &data); - } - } - _ => {} - } - } + self.dup_output_fast(&mut result, src_fd, target_fd).await?; } RedirectKind::Input | RedirectKind::HereString @@ -295,6 +273,57 @@ impl Interpreter { Ok(result) } + /// `src_fd>&target_fd` on the fast (single-pass) redirect path. + async fn dup_output_fast( + &mut self, + result: &mut ExecResult, + 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() { + let data = if src_fd == 2 { + std::mem::take(&mut result.stderr) + } else { + std::mem::take(&mut result.stdout) + }; + match &fd_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?; + } + } + } else { + match (src_fd, target_fd) { + (2, 1) => { + result.stdout.append(&result.stderr); + result.stderr = crate::StreamData::new(); + } + (1, 2) => { + result.stderr.append(&result.stdout); + result.stdout = crate::StreamData::new(); + } + (src, dst) if dst >= 3 => { + let data = if src == 2 { + std::mem::take(&mut result.stderr) + } else { + std::mem::take(&mut result.stdout) + }; + if self.pending_fd_capture_depth > 0 { + // Move content to pending_fd_output for compound + // redirect routing (e.g. `echo msg 1>&3` inside + // `{ ... } 3>&1 >file`). + self.append_pending_fd_output(dst, &data); + } + } + _ => {} + } + } + Ok(()) + } + pub(super) fn logic_only_redirect_error(&self, redirects: &[Redirect]) -> Option { if !self.shell_profile.is_logic_only() { return None; @@ -342,6 +371,11 @@ impl Interpreter { RedirectKind::Output | RedirectKind::Clobber => { let target_path = self.expand_word(&redirect.target).await?; let path = self.resolve_path(&target_path); + if let Some(target_fd) = dev_fd_alias(&path) { + let src_fd = redirect.fd.unwrap_or(1); + self.dup_output_fd_table(src_fd, target_fd, &mut fd1, &mut fd2); + continue; + } if redirect.kind == RedirectKind::Output && self.scoped.variables.get("SHOPT_C").map(|v| v.as_str()) == Some("1") @@ -370,6 +404,11 @@ impl Interpreter { RedirectKind::Append => { let target_path = self.expand_word(&redirect.target).await?; let path = self.resolve_path(&target_path); + if let Some(target_fd) = dev_fd_alias(&path) { + let src_fd = redirect.fd.unwrap_or(1); + self.dup_output_fd_table(src_fd, target_fd, &mut fd1, &mut fd2); + continue; + } let target = if is_dev_null(&path) { FdTarget::DevNull } else { @@ -383,6 +422,11 @@ impl Interpreter { RedirectKind::OutputBoth => { let target_path = self.expand_word(&redirect.target).await?; let path = self.resolve_path(&target_path); + if let Some(target_fd) = dev_fd_alias(&path) { + self.dup_output_fd_table(1, target_fd, &mut fd1, &mut fd2); + self.dup_output_fd_table(2, 1, &mut fd1, &mut fd2); + continue; + } let target = if is_dev_null(&path) { FdTarget::DevNull } else { @@ -395,32 +439,7 @@ impl Interpreter { let target = self.expand_word(&redirect.target).await?; let target_fd: i32 = target.parse().unwrap_or(1); let src_fd = redirect.fd.unwrap_or(1); - - // Look up exec_fd_table for persistent fd targets - if let Some(exec_target) = self.exec_fd_table.get(&target_fd).cloned() { - match src_fd { - 2 => fd2 = exec_target, - _ => fd1 = exec_target, - } - } else { - // Resolve target from current fd table state - let resolved = match target_fd { - 1 => Some(fd1.clone()), - 2 => Some(fd2.clone()), - _ => None, - }; - if let Some(target) = resolved { - match src_fd { - 1 => fd1 = target, - 2 => fd2 = target, - n if n >= 3 => { - // Store fd3+ target for routing pending_fd_output later - self.pending_fd_targets.push((n, target)); - } - _ => {} - } - } - } + self.dup_output_fd_table(src_fd, target_fd, &mut fd1, &mut fd2); } RedirectKind::Input | RedirectKind::HereString @@ -463,4 +482,39 @@ impl Interpreter { result.stderr = new_stderr; Ok(result) } + + /// `src_fd>&target_fd` on the fd-table redirect path. + fn dup_output_fd_table( + &mut self, + src_fd: i32, + target_fd: i32, + 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() { + match src_fd { + 2 => *fd2 = exec_target, + _ => *fd1 = exec_target, + } + } else { + // Resolve target from current fd table state + let resolved = match target_fd { + 1 => Some(fd1.clone()), + 2 => Some(fd2.clone()), + _ => None, + }; + if let Some(target) = resolved { + match src_fd { + 1 => *fd1 = target, + 2 => *fd2 = target, + n if n >= 3 => { + // Store fd3+ target for routing pending_fd_output later + self.pending_fd_targets.push((n, target)); + } + _ => {} + } + } + } + } } diff --git a/crates/bashkit/tests/integration/dev_fd_path_tests.rs b/crates/bashkit/tests/integration/dev_fd_path_tests.rs new file mode 100644 index 000000000..831275de1 --- /dev/null +++ b/crates/bashkit/tests/integration/dev_fd_path_tests.rs @@ -0,0 +1,107 @@ +//! Redirections to `/dev/stdin`, `/dev/stdout`, `/dev/stderr`, `/dev/fd/N`. +//! +//! On Linux these paths alias the shell's current file descriptors, so +//! `echo err > /dev/stderr` behaves like `echo err >&2`. Bashkit resolves +//! them at the interpreter level (like `/dev/null`): they never reach the +//! filesystem, so no regular VFS file named `/dev/stderr` is ever created and +//! error messages are never silently swallowed. + +use bashkit::Bash; +use std::path::Path; + +async fn run(script: &str) -> (Bash, bashkit::ExecResult) { + let mut bash = Bash::builder().build(); + let result = bash.exec(script).await.unwrap(); + (bash, result) +} + +#[tokio::test] +async fn stdout_to_dev_stderr_goes_to_stderr() { + let (bash, r) = run("echo err > /dev/stderr").await; + assert_eq!(r.stdout, ""); + assert_eq!(r.stderr, "err\n"); + assert!(!bash.fs().exists(Path::new("/dev/stderr")).await.unwrap()); +} + +#[tokio::test] +async fn append_to_dev_stderr_goes_to_stderr() { + let (bash, r) = run("echo a >> /dev/stderr; echo b >>/dev/stderr").await; + assert_eq!(r.stderr, "a\nb\n"); + assert!(!bash.fs().exists(Path::new("/dev/stderr")).await.unwrap()); +} + +#[tokio::test] +async fn stderr_to_dev_stdout_goes_to_stdout() { + let (bash, r) = run("echo err >&2 2> /dev/stdout; { echo e2 >&2; } 2>/dev/stdout").await; + // `>&2 2>/dev/stdout`: fd1 dups the original fd2 first, so the first + // line stays on stderr; the group's stderr is redirected to stdout. + assert_eq!(r.stderr, "err\n"); + assert_eq!(r.stdout, "e2\n"); + assert!(!bash.fs().exists(Path::new("/dev/stdout")).await.unwrap()); +} + +#[tokio::test] +async fn dev_fd_numbers_alias_descriptors() { + let (_, r) = run("echo one > /dev/fd/2; echo two > /dev/fd/1").await; + assert_eq!(r.stderr, "one\n"); + assert_eq!(r.stdout, "two\n"); +} + +#[tokio::test] +async fn dev_fd_path_follows_exec_opened_descriptor() { + let (bash, r) = run("exec 3>/tmp/log; echo hi > /dev/fd/3; exec 3>&-; cat /tmp/log").await; + assert_eq!(r.stdout, "hi\n"); + assert!(!bash.fs().exists(Path::new("/dev/fd/3")).await.unwrap()); +} + +#[tokio::test] +async fn dev_stderr_variable_target() { + let (_, r) = run("LOG=/dev/stderr; echo warn >> \"$LOG\"").await; + assert_eq!(r.stdout, ""); + assert_eq!(r.stderr, "warn\n"); +} + +#[tokio::test] +async fn dev_stderr_respects_outer_redirect() { + let (_, r) = run("f() { echo err > /dev/stderr; }; f 2>/dev/null; echo done").await; + assert_eq!(r.stdout, "done\n"); + assert_eq!(r.stderr, ""); +} + +#[tokio::test] +async fn both_to_dev_stderr() { + let (_, r) = run("{ echo out; echo err >&2; } &> /dev/stderr").await; + assert_eq!(r.stdout, ""); + assert_eq!(r.stderr, "out\nerr\n"); +} + +#[tokio::test] +async fn mixed_dup_and_dev_stdout() { + // Exercises the fd-table path: `2>&1` mixed with a /dev path target. + // `2>&1` points fd2 at stdout, so `/dev/stderr` (fd2's current target) + // is stdout too: everything lands on stdout, as on Linux with a pipe. + let (_, r) = run("{ echo out; echo err >&2; } 2>&1 >/dev/stderr").await; + assert_eq!(r.stdout, "out\nerr\n"); + assert_eq!(r.stderr, ""); +} + +#[tokio::test] +async fn exec_redirect_to_dev_stderr() { + let (bash, r) = run("exec 3>/dev/stderr; echo via3 >&3; exec 3>&-").await; + assert_eq!(r.stdout, ""); + assert_eq!(r.stderr, "via3\n"); + assert!(!bash.fs().exists(Path::new("/dev/stderr")).await.unwrap()); +} + +#[tokio::test] +async fn input_from_dev_stdin_keeps_stdin() { + let (_, r) = run("echo piped | cat < /dev/stdin; cat < /dev/fd/0 <<< here").await; + assert_eq!(r.stdout, "piped\nhere\n"); +} + +#[tokio::test] +async fn dot_dot_spelling_is_normalized() { + let (bash, r) = run("echo err > /dev/../dev/stderr").await; + assert_eq!(r.stderr, "err\n"); + assert!(!bash.fs().exists(Path::new("/dev/stderr")).await.unwrap()); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index d188a3553..a0821da85 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -53,6 +53,7 @@ pub mod date_timezone_differential_tests; pub mod date_timezone_no_tzdata_tests; #[cfg(feature = "tzdata")] pub mod date_timezone_tests; +pub mod dev_fd_path_tests; pub mod dev_null_tests; pub mod exec_options_tests; pub mod execution_budget_tests; diff --git a/crates/bashkit/tests/spec_cases/bash/dev-fd-paths.test.sh b/crates/bashkit/tests/spec_cases/bash/dev-fd-paths.test.sh new file mode 100644 index 000000000..da5c0e258 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/dev-fd-paths.test.sh @@ -0,0 +1,56 @@ +# /dev/stdout, /dev/stderr, /dev/fd/N as redirection targets alias the +# shell's current file descriptors (Linux semantics). + +### dev_stderr_redirect +{ echo err > /dev/stderr; echo out; } 2>/dev/null +### expect +out +### end + +### dev_stderr_captured_by_2to1 +{ echo err > /dev/stderr; } 2>&1 +### expect +err +### end + +### dev_stderr_append +{ echo a >> /dev/stderr; echo b >> /dev/stderr; } 2>&1 +### expect +a +b +### end + +### dev_stdout_from_stderr +{ echo e > /dev/stderr; } 2>/dev/stdout +### expect +e +### end + +### dev_fd_1_and_2 +{ echo one > /dev/fd/2; echo two > /dev/fd/1; } 2>/dev/null +### expect +two +### end + +### dev_fd_exec_opened +exec 3>/tmp/devfd_log +echo hi > /dev/fd/3 +exec 3>&- +cat /tmp/devfd_log +rm -f /tmp/devfd_log +### expect +hi +### end + +### dev_stderr_via_variable +LOG=/dev/stderr +{ echo warn >> "$LOG"; } 2>&1 +### expect +warn +### end + +### dev_stdin_input +echo piped | cat < /dev/stdin +### expect +piped +### end diff --git a/knowledge/foundations/vfs.md b/knowledge/foundations/vfs.md index bb0e60deb..49eea716e 100644 --- a/knowledge/foundations/vfs.md +++ b/knowledge/foundations/vfs.md @@ -156,6 +156,17 @@ Handled at the **interpreter level**, not filesystem. Security-critical: custom filesystem implementations cannot intercept `/dev/null` behavior. Path normalization handles bypass attempts. +#### /dev/stdin, /dev/stdout, /dev/stderr, /dev/fd/N +Redirection targets resolved at the **interpreter level** (`dev_fd_alias` in +`interpreter/mod.rs`), like `/dev/null`: `> /dev/stderr` is `>&2`, +`> /dev/fd/3` is `>&3`, `< /dev/stdin` keeps the current stdin, and +`exec 3>/dev/stderr` dups fd 2. The target is the descriptor's *current* +target, so `2>&1 >/dev/stderr` sends both streams to stdout, as on Linux. +These paths never reach the VFS: writing them as regular files silently +swallowed agents' `echo err > /dev/stderr`. Same `..` normalization as +`/dev/null`. Builtin file operands (`tee /dev/stderr`, `cat /dev/stdin`) +are not aliased yet. + #### /dev/urandom and /dev/random Handled at filesystem level: return 8192 bytes of random data per read (bounded to prevent memory growth).