diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index e33382e6a..f05ce9c58 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -132,7 +132,7 @@ through configurable limits. | Param expansion bomb (TM-DOS-059) | `${x//a/bigstring}` multiplicative amplification | `max_total_variable_bytes` + `max_stdout_bytes` | MITIGATED | | Sparse array huge-index (TM-DOS-060) | `arr[999999999]=x` | HashMap storage; `max_array_entries` | MITIGATED | | Snapshot restore bypasses function/parser limits (TM-DOS-061) | Crafted snapshot with oversized/deep function bodies | Re-parse restored function source under current limits; re-check function memory budget | MITIGATED | -| Persistent fd exhaustion (TM-DOS-063) | `exec N>/tmp/f` across many `N` values | `max_file_descriptors` caps custom persistent descriptors | MITIGATED | +| Persistent fd exhaustion (TM-DOS-063) | `exec N>/tmp/f` across many `N` values, or `cmd {v}>/tmp/f` in a loop | `max_file_descriptors` caps custom persistent descriptors | MITIGATED | **Configuration:** ```rust diff --git a/crates/bashkit/src/analysis.rs b/crates/bashkit/src/analysis.rs index 4d3058546..6e6c5376e 100644 --- a/crates/bashkit/src/analysis.rs +++ b/crates/bashkit/src/analysis.rs @@ -512,9 +512,11 @@ impl Walker { fn walk_redirects(&mut self, redirects: &[Redirect], ctx: CommandContext) { for redirect in redirects { let mode = match redirect.kind { - RedirectKind::Output | RedirectKind::Clobber | RedirectKind::OutputBoth => { - RedirectMode::Write - } + // `<>` may create and overwrite the file: gate it as a write. + RedirectKind::Output + | RedirectKind::Clobber + | RedirectKind::OutputBoth + | RedirectKind::ReadWrite => RedirectMode::Write, RedirectKind::Append => RedirectMode::Append, RedirectKind::Input => RedirectMode::Read, // Fd duplication, here-documents and here-strings do not name a diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 5d868630e..053032f01 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1321,6 +1321,7 @@ struct SubshellSnapshot { cwd: PathBuf, memory_budget: crate::limits::MemoryBudget, exec_fd_table: HashMap, + exec_input_fds: HashSet, random_state: u32, getopts_char_idx: usize, last_bg_pid: Option, @@ -1490,6 +1491,15 @@ pub struct Interpreter { /// 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, + /// Fds opened by `exec N<&0` (or a dup of such an fd): they read the + /// shell's stdin, which is what `<&N` falls back to, so only their + /// "open" state needs tracking (fd allocation, `Bad file descriptor`). + exec_input_fds: HashSet, + /// Fds (>= 3) that the redirections of the commands currently executing + /// (a function called as `f 3>&1`, `{ ...; } 3>file`) make available to + /// their bodies. Routing of those is done by the pending-fd machinery; + /// this only keeps `>&3` inside them from being a bad descriptor. + fd_redirect_scope: Vec, /// 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. @@ -2013,8 +2023,10 @@ impl Interpreter { history_loaded: false, subst_generation: 0, coproc_buffers: HashMap::new(), - coproc_next_fd: 63, + coproc_next_fd: redirection::COPROC_FIRST_FD, exec_fd_table: HashMap::new(), + exec_input_fds: HashSet::new(), + fd_redirect_scope: Vec::new(), exec_passthrough: Default::default(), pending_fd_output: HashMap::new(), pending_fd_targets: Vec::new(), @@ -2179,6 +2191,8 @@ impl Interpreter { coproc_buffers: HashMap::new(), coproc_next_fd: self.coproc_next_fd, exec_fd_table: self.exec_fd_table.clone(), + exec_input_fds: self.exec_input_fds.clone(), + fd_redirect_scope: self.fd_redirect_scope.clone(), exec_passthrough: Default::default(), pending_fd_output: HashMap::new(), pending_fd_targets: Vec::new(), @@ -7091,6 +7105,42 @@ impl Interpreter { args: Vec, command: &'a SimpleCommand, stdin: Option, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + // `cmd {fd}>file`: bash never undoes a `{var}` redirection, so it + // opens a descriptor in the shell like `exec` would. `exec` + // itself applies its redirects in order. + if name != "exec" && command.redirects.iter().any(|r| r.fd_var.is_some()) { + let remaining = match self.apply_named_fd_redirects(&command.redirects).await? { + Ok(remaining) => remaining, + Err(err) => return Ok(err), + }; + let command = SimpleCommand { + redirects: remaining, + ..command.clone() + }; + return self + .execute_dispatched_command(name, args, &command, stdin) + .await; + } + // Fds this command's redirects open stay valid inside it + // (`f 3>&1` where `f` writes `>&3`). + let scope_len = self.fd_redirect_scope.len(); + redirection::push_redirect_scope_fds(&mut self.fd_redirect_scope, &command.redirects); + let result = self + .execute_dispatched_command_inner(name, args, command, stdin) + .await; + self.fd_redirect_scope.truncate(scope_len); + result + }) + } + + fn execute_dispatched_command_inner<'a>( + &'a mut self, + name: &'a str, + args: Vec, + command: &'a SimpleCommand, + stdin: Option, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { // The stage's own `yes`/`seq` writes straight into the pipe. @@ -7248,145 +7298,8 @@ impl Interpreter { }); } for redirect in redirects { - // Resolve fd from either explicit fd or {var} fd-variable syntax - let resolved_fd_var: Option = redirect.fd_var.as_ref().and_then(|var_name| { - self.expand_name_or_array_element(var_name) - .trim() - .parse::() - .ok() - }); - match redirect.kind { - RedirectKind::Input => { - let target_path = self.expand_word(&redirect.target).await?; - let path = self.resolve_path(&target_path); - let content = match self.fs.read_file(&path).await { - Ok(c) => c, - Err(e) => { - return Ok(ExecResult::err( - format!( - "bash: {target_path}: {}\n", - crate::error::io_error_reason(&e) - ), - 1, - )); - } - }; - let text = decode_file_bytes_for_path(&path, &content); - let fd = redirect.fd.or(resolved_fd_var); - if let Some(fd) = fd { - self.ensure_persistent_fd_capacity(fd)?; - let lines: Vec = - text.lines().rev().map(|l| l.to_string()).collect(); - self.coproc_buffers.insert(fd, lines); - } else { - // exec < file: redirect stdin for subsequent commands - self.pipeline_stdin = Some(text.into()); - } - } - RedirectKind::DupInput => { - let target = self.expand_word(&redirect.target).await?; - let fd = redirect.fd.or(resolved_fd_var); - if (target == "-" || target == "&-") - && let Some(fd) = fd - { - self.coproc_buffers.remove(&fd); - } - } - RedirectKind::Output | RedirectKind::Clobber => { - // 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)?; - 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); 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 => { - // 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)?; - 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 { - 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?; - // 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. For 1 and 2 the entry stays - // so a later write reports a closed descriptor instead - // of reaching the terminal. - if fd == 1 || fd == 2 { - self.ensure_persistent_fd_capacity(fd)?; - self.exec_fd_table.insert(fd, FdTarget::Closed); - } else { - 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 = self.exec_fd_alias_target(target_fd); - self.ensure_persistent_fd_capacity(fd)?; - self.exec_fd_table.insert(fd, target_entry); - } - } - _ => {} + if let Some(err) = self.apply_persistent_redirect(redirect).await? { + return Ok(err); } } // fd 1/2 pointed back at the original streams (`exec 1>&3`) is no @@ -7397,8 +7310,10 @@ impl Interpreter { 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 + // Every redirect was applied to the shell above (and validated + // there): re-applying them to `exec`'s empty output would check + // `1>&3` against the state after `3>&-`. + Ok(ExecResult::default()) } /// Target for `exec N>&M` / `exec N>/dev/fd/M`. @@ -7511,12 +7426,14 @@ impl Interpreter { if (0..=2).contains(&fd) || self.exec_fd_table.contains_key(&fd) || self.coproc_buffers.contains_key(&fd) + || self.exec_input_fds.contains(&fd) { return Ok(()); } let mut open_fds: HashSet = self.exec_fd_table.keys().copied().collect(); open_fds.extend(self.coproc_buffers.keys().copied()); + open_fds.extend(self.exec_input_fds.iter().copied()); if open_fds.len() >= self.limits.max_file_descriptors { return Err(crate::limits::LimitExceeded::MaxFileDescriptors( @@ -8311,6 +8228,7 @@ impl Interpreter { let saved_env = self.env.clone(); let saved_memory_budget = self.memory_budget.clone(); let saved_exec_fd_table = self.exec_fd_table.clone(); + let saved_exec_input_fds = self.exec_input_fds.clone(); // The child process has its own working directory (`cd` stays there). let saved_cwd = self.cwd.clone(); @@ -8379,6 +8297,7 @@ impl Interpreter { self.env = saved_env; self.memory_budget = saved_memory_budget; self.exec_fd_table = saved_exec_fd_table; + self.exec_input_fds = saved_exec_input_fds; self.bash_source_stack = saved_source_stack; self.cwd = saved_cwd; self.pipeline_stdin = if shares_caller_stdin { @@ -8674,7 +8593,10 @@ impl Interpreter { /// Format a Redirect back to its textual representation for alias expansion. fn format_redirect(redir: &Redirect) -> String { - let fd_prefix = redir.fd.map(|fd| fd.to_string()).unwrap_or_default(); + let fd_prefix = match &redir.fd_var { + Some(var) => format!("{{{var}}}"), + None => redir.fd.map(|fd| fd.to_string()).unwrap_or_default(), + }; let op = match redir.kind { RedirectKind::Output => ">", RedirectKind::Append => ">>", @@ -8686,6 +8608,7 @@ impl Interpreter { RedirectKind::DupOutput => ">&", RedirectKind::DupInput => "<&", RedirectKind::OutputBoth => "&>", + RedirectKind::ReadWrite => "<>", }; format!( "{}{}{}", @@ -10104,6 +10027,7 @@ impl Interpreter { cwd: self.cwd.clone(), memory_budget: self.memory_budget.clone(), exec_fd_table: self.exec_fd_table.clone(), + exec_input_fds: self.exec_input_fds.clone(), random_state: self.random_state.load(Ordering::Relaxed), getopts_char_idx: self.getopts_char_idx, last_bg_pid: self.last_bg_pid.clone(), @@ -10119,6 +10043,7 @@ impl Interpreter { self.cwd = snap.cwd; self.memory_budget = snap.memory_budget; self.exec_fd_table = snap.exec_fd_table; + self.exec_input_fds = snap.exec_input_fds; self.random_state .store(snap.random_state, Ordering::Relaxed); self.getopts_char_idx = snap.getopts_char_idx; @@ -10138,6 +10063,19 @@ impl Interpreter { self.last_exit_code = 1; return Ok(ExecResult::err(stderr, 1)); } + let named; + let redirects = if redirects.iter().any(|r| r.fd_var.is_some()) { + named = match self.apply_named_fd_redirects(redirects).await? { + Ok(remaining) => remaining, + Err(err) => { + self.last_exit_code = err.exit_code; + return Ok(err); + } + }; + named.as_slice() + } else { + redirects + }; match self.process_input_redirections(None, redirects).await { Ok(_) => {} Err(crate::error::Error::CommandFailure(msg)) => { @@ -10185,7 +10123,31 @@ impl Interpreter { if let Some(stderr) = self.disabled_redirect_error(redirects) { return Ok(ExecResult::err(stderr, 1)); } + if redirects.iter().any(|r| r.fd_var.is_some()) { + let remaining = match self.apply_named_fd_redirects(redirects).await? { + Ok(remaining) => remaining, + Err(err) => return Ok(err), + }; + return self + .execute_compound_with_redirects(compound, &remaining) + .await; + } + let scope_len = self.fd_redirect_scope.len(); + redirection::push_redirect_scope_fds(&mut self.fd_redirect_scope, redirects); + let result = self + .execute_compound_with_redirects_inner(compound, redirects) + .await; + self.fd_redirect_scope.truncate(scope_len); + result + }) + } + fn execute_compound_with_redirects_inner<'a>( + &'a mut self, + compound: &'a CompoundCommand, + redirects: &'a [Redirect], + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { // Process input redirections before executing compound let stdin = match self.process_input_redirections(None, redirects).await { Ok(s) => s, diff --git a/crates/bashkit/src/interpreter/redirection.rs b/crates/bashkit/src/interpreter/redirection.rs index 8c7bfd909..9468d7c7d 100644 --- a/crates/bashkit/src/interpreter/redirection.rs +++ b/crates/bashkit/src/interpreter/redirection.rs @@ -29,9 +29,44 @@ impl Interpreter { redirects: &[Redirect], ) -> Result> { let mut stdin = existing_stdin; + // Readable fds this command's own redirects open (`4< = Vec::new(); for redirect in redirects { + // Fd N >= 1 for an input-side redirect (`3f`). + let high_fd = redirect.fd.filter(|&fd| fd != 0); match redirect.kind { + RedirectKind::ReadWrite => { + // `<>`: open (creating) the file read-write. + let target_path = self.expand_word(&redirect.target).await?; + let path = self.resolve_path(&target_path); + let content = if is_dev_null(&path) { + crate::StreamData::new() + } else if !self.shell_features.has_file_redirects() { + return Err(crate::error::Error::Execution(format!( + "bash: {}: filesystem redirection disabled", + target_path + ))); + } else { + let opened = match self.fs.append_file(&path, b"").await { + Ok(()) => self.fs.read_file(&path).await, + Err(e) => Err(e), + }; + match opened { + Ok(content) => content.into(), + Err(e) => { + return Err(crate::error::Error::CommandFailure(format!( + "bash: {target_path}: {}\n", + io_error_reason(&e) + ))); + } + } + }; + match high_fd { + Some(fd) => own_fds.push((fd, content)), + None => stdin = Some(content), + } + } RedirectKind::Input => { if !self.shell_features.has_file_redirects() && !word_is_literal_dev_null(&redirect.target) @@ -56,7 +91,14 @@ impl Interpreter { } else { match self.fs.read_file(&path).await { Ok(content) => { - stdin = Some(content.into()); + let content: crate::StreamData = content.into(); + if let Some(fd) = high_fd { + own_fds.push((fd, content.clone())); + } + // WTF: `3 { return Err(crate::error::Error::CommandFailure(format!( @@ -70,22 +112,41 @@ impl Interpreter { RedirectKind::HereString => { // <<< string - use the target as stdin content let content = self.expand_word(&redirect.target).await?; - stdin = Some(format!("{}\n", content).into()); + let content: crate::StreamData = format!("{}\n", content).into(); + match high_fd { + Some(fd) => own_fds.push((fd, content)), + None => stdin = Some(content), + } } RedirectKind::HereDoc | RedirectKind::HereDocStrip => { - // << EOF / <<- EOF - use the heredoc content as stdin. - // `3< own_fds.push((fd, content)), + None => stdin = Some(content), } } RedirectKind::DupInput => { - // <&FD - if FD is a coproc read FD, consume next line let target = self.expand_word(&redirect.target).await?; - if let Ok(fd) = target.parse::() - && let Some(buf) = self.coproc_buffers.get_mut(&fd) - { + let Ok(src) = target.parse::() else { + continue; + }; + // `<&4` after this command's own `4< own_fds.push((fd, content)), + None => stdin = Some(content), + } + continue; + } + if high_fd.is_some() { + continue; + } + // <&FD - if FD is a coproc read FD, consume next line + if let Some(buf) = self.coproc_buffers.get_mut(&src) { if let Some(line) = buf.pop() { stdin = Some(format!("{}\n", line).into()); } else { @@ -129,6 +190,36 @@ impl Interpreter { ) }); + // `>&N` with fd N not open: bash reports it and does not run the + // command, so none of its output may show. + for redirect in redirects { + // Side-effect-free targets only (`>&3`, `>&$fd`): the word is + // expanded again when the redirect is applied. + if redirect.kind != RedirectKind::DupOutput + || !redirect + .target + .parts + .iter() + .all(|p| matches!(p, WordPart::Literal(_) | WordPart::Variable(_))) + { + continue; + } + let target = self.expand_word(&redirect.target).await?; + if let Ok(fd) = target.parse::() + && fd >= 3 + && !self.fd_is_open(fd) + && !redirects + .iter() + .any(|r| r.fd == Some(fd) && !matches!(r.kind, RedirectKind::DupOutput)) + { + self.clear_pending_fd_redirect_state(); + result.stdout = crate::StreamData::new(); + result.stderr = format!("bash: {target}: Bad file descriptor\n").into(); + result.exit_code = 1; + return Ok(result); + } + } + // `N>file` (N>=3) opens fd N without touching stdout; only the // fd-table path keeps fd N separate from fd 1. if (has_dup_output && has_file_redirect) || has_high_fd_file_redirect(redirects) { @@ -190,7 +281,10 @@ impl Interpreter { } } } - RedirectKind::Append => { + // `<>` on stdin is input only (see process_input_redirections). + RedirectKind::ReadWrite if matches!(redirect.fd, None | Some(0)) => {} + // WTF: `1<>file` appends instead of writing at offset 0. + RedirectKind::Append | RedirectKind::ReadWrite => { 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) { @@ -419,7 +513,8 @@ impl Interpreter { _ => fd1 = target, } } - RedirectKind::Append => { + RedirectKind::ReadWrite if matches!(redirect.fd, None | Some(0)) => {} + RedirectKind::Append | RedirectKind::ReadWrite => { 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) { @@ -546,7 +641,373 @@ pub(super) fn has_high_fd_file_redirect(redirects: &[Redirect]) -> bool { redirects.iter().any(|r| { matches!( r.kind, - RedirectKind::Output | RedirectKind::Clobber | RedirectKind::Append + RedirectKind::Output + | RedirectKind::Clobber + | RedirectKind::Append + | RedirectKind::ReadWrite ) && r.fd.is_some_and(|fd| fd >= 3) }) } + +// --------------------------------------------------------------------------- +// Persistent descriptors: `exec` redirections and `{var}` named fds. +// +// Important decisions: +// - The shell's descriptors are virtual, per interpreter (tenant isolation): +// output fds live in `exec_fd_table`, readable fds in `coproc_buffers` +// (remaining lines), and dups of stdin in `exec_input_fds`. No host fd is +// ever touched. +// - `{var}>file` allocates the lowest free fd >= 10 (bash's +// `fcntl(F_DUPFD, 10)`) and stores it in `var`. bash never undoes a +// `{var}` redirection, so on any command (not just `exec`) the fd stays +// open: it is applied here persistently and removed from the command's own +// redirect list. +// - `>&N` / `<&N` with N closed is `bash: N: Bad file descriptor` (status 1), +// unless an enclosing command's redirections provide N +// (`fd_redirect_scope`), whose routing the pending-fd machinery handles. +// - `N<>file` reads the file and appends writes to it. WTF: the shared +// read/write offset is not modelled; bash writes at the current offset +// (overwriting from the start of an existing file). +// --------------------------------------------------------------------------- + +/// First fd handed out by `{var}` redirections (bash keeps 0-9 for the user). +const NAMED_FD_BASE: i32 = 10; +/// First coproc read fd; coproc pairs are handed out downward from here. +pub(super) const COPROC_FIRST_FD: i32 = 63; + +/// Record the fds (>= 3) a command's own redirections open, so commands +/// running inside it may use them (`f 3>&1`, `{ ...; } 3>log`). +pub(super) fn push_redirect_scope_fds(scope: &mut Vec, redirects: &[Redirect]) { + for r in redirects { + if let Some(fd) = r.fd + && fd >= 3 + && r.fd_var.is_none() + && !matches!(r.target.parts.as_slice(), [WordPart::Literal(t)] if t == "-") + { + scope.push(fd); + } + } +} + +fn bad_fd_error(word: &str) -> ExecResult { + ExecResult::err(format!("bash: {word}: Bad file descriptor\n"), 1) +} + +fn ambiguous_redirect_error(word: &str) -> ExecResult { + ExecResult::err(format!("bash: {word}: ambiguous redirect\n"), 1) +} + +/// Remaining coproc-buffer lines (stored reversed) as stdin text. +fn buffer_text(lines: &[String]) -> crate::StreamData { + let mut text = String::new(); + for line in lines.iter().rev() { + text.push_str(line); + text.push('\n'); + } + text.into() +} + +/// Lines of `text`, reversed so `pop()` yields the first. +fn reversed_lines(text: &str) -> Vec { + text.lines().rev().map(str::to_string).collect() +} + +impl Interpreter { + /// Whether `fd` is open in this shell (or provided by an enclosing + /// command's redirections). + pub(super) fn fd_is_open(&self, fd: i32) -> bool { + if matches!(self.exec_fd_table.get(&fd), Some(FdTarget::Closed)) { + return false; + } + (0..=2).contains(&fd) + || self.exec_fd_table.contains_key(&fd) + || self.coproc_buffers.contains_key(&fd) + || self.exec_input_fds.contains(&fd) + || self.fd_redirect_scope.contains(&fd) + // coproc pairs, read and write ends + || (fd > self.coproc_next_fd && fd <= COPROC_FIRST_FD) + } + + fn close_fd(&mut self, fd: i32) { + self.exec_fd_table.remove(&fd); + self.coproc_buffers.remove(&fd); + self.exec_input_fds.remove(&fd); + } + + /// `N>&M` / `N<&M` for N >= 3: N becomes a copy of M. + fn dup_fd(&mut self, n: i32, m: i32) { + if n == m { + return; + } + let out = (matches!(m, 1 | 2) || self.exec_fd_table.contains_key(&m)) + .then(|| self.exec_fd_alias_target(m)); + let buf = self.coproc_buffers.get(&m).cloned(); + let input = m == 0 || self.exec_input_fds.contains(&m); + self.close_fd(n); + if let Some(target) = out { + self.exec_fd_table.insert(n, target); + } + if let Some(buf) = buf { + self.coproc_buffers.insert(n, buf); + } + if input { + self.exec_input_fds.insert(n); + } + } + + /// Lowest free fd >= 10, as bash allocates for `{var}` redirections. + fn allocate_named_fd(&self) -> Result { + let mut fd = NAMED_FD_BASE; + while self.fd_is_open(fd) { + fd += 1; + } + self.ensure_persistent_fd_capacity(fd)?; + Ok(fd) + } + + /// Apply a command's `{var}` redirections persistently and return the + /// rest, or the failed redirection's result. + pub(super) async fn apply_named_fd_redirects( + &mut self, + redirects: &[Redirect], + ) -> Result, ExecResult>> { + let mut remaining = Vec::with_capacity(redirects.len()); + for redirect in redirects { + if redirect.fd_var.is_some() { + if let Some(err) = self.apply_persistent_redirect(redirect).await? { + return Ok(Err(err)); + } + } else { + remaining.push(redirect.clone()); + } + } + Ok(Ok(remaining)) + } + + /// Apply one redirection to the shell itself (as `exec` does). Returns + /// the error result when it fails. + pub(super) async fn apply_persistent_redirect( + &mut self, + redirect: &Redirect, + ) -> Result> { + let Some(var) = redirect.fd_var.as_deref() else { + return self.open_persistent_fd(redirect, redirect.fd).await; + }; + let dup; + let mut redirect = redirect; + if matches!( + redirect.kind, + RedirectKind::DupOutput | RedirectKind::DupInput + ) { + let target = self.expand_word(&redirect.target).await?; + if target == "-" { + // `{var}>&-` closes the fd named by `$var`. + let value = self.expand_name_or_array_element(var); + if value.is_empty() { + return Ok(Some(ambiguous_redirect_error(var))); + } + if let Ok(fd) = value.trim().parse::() + && fd >= 0 + { + self.close_fd(fd); + } + return Ok(None); + } + if target.parse::().is_err() { + return Ok(Some(ambiguous_redirect_error(var))); + } + dup = Redirect { + target: Word::quoted_literal(target), + ..redirect.clone() + }; + redirect = &dup; + } + let fd = self.allocate_named_fd()?; + if let Some(err) = self.open_persistent_fd(redirect, Some(fd)).await? { + return Ok(Some(err)); + } + let base = var.split('[').next().unwrap_or(var); + let resolved = self.resolve_nameref(base).to_string(); + if self.var_attrs_get(&resolved).contains(VarAttrs::READONLY) { + self.close_fd(fd); + return Ok(Some(ExecResult::err( + format!( + "bash: {base}: readonly variable\nbash: {var}: cannot assign fd to variable\n" + ), + 1, + ))); + } + self.set_parameter_expansion_target(var, fd.to_string()); + Ok(None) + } + + /// Open `redirect` on `fd` (its default fd when `None`) for the rest of + /// the shell's life. + async fn open_persistent_fd( + &mut self, + redirect: &Redirect, + fd: Option, + ) -> Result> { + // `exec 0 { + let target_path = self.expand_word(&redirect.target).await?; + let path = self.resolve_path(&target_path); + let read_write = redirect.kind == RedirectKind::ReadWrite; + if is_dev_null(&path) { + match fd { + Some(n) => { + self.ensure_persistent_fd_capacity(n)?; + self.close_fd(n); + self.coproc_buffers.insert(n, Vec::new()); + if read_write { + self.exec_fd_table.insert(n, FdTarget::DevNull); + } + } + None => self.pipeline_stdin = Some(crate::StreamData::new()), + } + return Ok(None); + } + if read_write && let Err(e) = self.fs.append_file(&path, b"").await { + return Ok(Some(ExecResult::err( + format!("bash: {target_path}: {}\n", io_error_reason(&e)), + 1, + ))); + } + let content = match self.fs.read_file(&path).await { + Ok(c) => c, + Err(e) => { + return Ok(Some(ExecResult::err( + format!("bash: {target_path}: {}\n", io_error_reason(&e)), + 1, + ))); + } + }; + let text = decode_file_bytes_for_path(&path, &content); + match fd { + Some(n) => { + self.ensure_persistent_fd_capacity(n)?; + self.close_fd(n); + self.coproc_buffers.insert(n, reversed_lines(&text)); + if read_write { + self.exec_fd_table + .insert(n, FdTarget::AppendFile(path, target_path)); + } + } + // exec < file: redirect stdin for subsequent commands + None => self.pipeline_stdin = Some(text.into()), + } + } + RedirectKind::HereString | RedirectKind::HereDoc | RedirectKind::HereDocStrip => { + let mut content = self.expand_word(&redirect.target).await?; + if redirect.kind == RedirectKind::HereString { + content.push('\n'); + } + match fd { + Some(n) => { + self.ensure_persistent_fd_capacity(n)?; + self.close_fd(n); + self.coproc_buffers.insert(n, reversed_lines(&content)); + } + None => self.pipeline_stdin = Some(content.into()), + } + } + RedirectKind::DupInput | RedirectKind::DupOutput => { + let target = self.expand_word(&redirect.target).await?; + let default_fd = i32::from(redirect.kind == RedirectKind::DupOutput); + let n = fd.unwrap_or(default_fd); + if target == "-" || target == "&-" { + // `N>&-` and `N<&-` both close fd N. For 1 and 2 the + // entry stays so a later write reports a closed + // descriptor instead of reaching the caller. + if matches!(n, 1 | 2) { + self.ensure_persistent_fd_capacity(n)?; + self.exec_fd_table.insert(n, FdTarget::Closed); + } else if n >= 3 { + self.close_fd(n); + } + return Ok(None); + } + let Ok(m) = target.parse::() else { + if redirect.kind == RedirectKind::DupInput { + return Ok(Some(ambiguous_redirect_error(&target))); + } + // WTF: `exec >&file` (bash: `exec &>file`) is not + // implemented; it is ignored. + return Ok(None); + }; + if !self.fd_is_open(m) { + return Ok(Some(bad_fd_error(&target))); + } + match n { + 0 => { + // `exec <&N`: stdin reads what is left of fd N. + if let Some(buf) = self.coproc_buffers.get(&m) { + self.pipeline_stdin = Some(buffer_text(buf)); + } + } + 1 | 2 => { + // `exec N>&M` duplicates fd M to fd N + let target_entry = self.exec_fd_alias_target(m); + self.exec_fd_table.insert(n, target_entry); + } + _ => { + self.ensure_persistent_fd_capacity(n)?; + self.dup_fd(n, m); + } + } + } + RedirectKind::Output | RedirectKind::Clobber | RedirectKind::Append => { + let n = fd.unwrap_or(1); + let append = redirect.kind == RedirectKind::Append; + let target_path = self.expand_word(&redirect.target).await?; + let path = self.resolve_path(&target_path); + self.ensure_persistent_fd_capacity(n)?; + let entry = if let Some(target_fd) = dev_fd_alias(&path) { + self.exec_fd_alias_target(target_fd) + } else if is_dev_null(&path) { + FdTarget::DevNull + } else { + // Truncate (or create) the file on open, like real + // `exec >file`; a failed open leaves the fd as it was. + let opened = if append { + self.fs.append_file(&path, b"").await + } else { + self.fs.write_file(&path, b"").await + }; + if let Err(e) = opened { + return Ok(Some(ExecResult::err( + format!("bash: {target_path}: {}\n", io_error_reason(&e)), + 1, + ))); + } + if append { + FdTarget::AppendFile(path, target_path) + } else { + FdTarget::WriteFile(path, target_path) + } + }; + if n >= 3 { + self.close_fd(n); + } + self.exec_fd_table.insert(n, entry); + } + // WTF: `exec &>file` is not implemented; it is ignored. + RedirectKind::OutputBoth => {} + } + Ok(None) + } +} + +/// Whether the redirection's default fd is 0. +fn redirect_reads(kind: RedirectKind) -> bool { + !matches!( + kind, + RedirectKind::Output + | RedirectKind::Clobber + | RedirectKind::Append + | RedirectKind::DupOutput + | RedirectKind::OutputBoth + ) +} diff --git a/crates/bashkit/src/parser/ast.rs b/crates/bashkit/src/parser/ast.rs index c54f71741..72f9b674d 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -596,6 +596,8 @@ pub enum RedirectKind { DupInput, /// &> - redirect both stdout and stderr OutputBoth, + /// <> - open for reading and writing (fd 0 by default) + ReadWrite, } /// Variable assignment. diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index d44301020..2ce770053 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -272,6 +272,9 @@ impl<'a> Lexer<'a> { } else if self.peek_char() == Some('&') { self.advance(); Some(Token::DupInput) + } else if self.peek_char() == Some('>') { + self.advance(); + Some(Token::RedirectReadWrite) } else { Some(Token::RedirectIn) } @@ -421,121 +424,114 @@ impl<'a> Lexer<'a> { } } - /// Check if this is a file descriptor redirect (e.g., 2>, 2>>, 2>&1) - /// or just a regular word starting with a digit + /// Check if this is a file descriptor redirect (e.g., 2>, 2>>, 2>&1, + /// 10>file, 3<<file) or just a regular word starting with a + /// digit. Bash accepts any decimal fd number; more than + /// `MAX_FD_PREFIX_DIGITS` digits (out of `int` range) is a plain word. fn read_word_or_fd_redirect(&mut self) -> Option { - // We need to look ahead to see if this is a fd redirect pattern - // Collect the leading digits - let mut fd_str = String::new(); - - // Peek at the first digit - we know it's a digit from the match - if let Some(ch) = self.peek_char() - && ch.is_ascii_digit() - { - fd_str.push(ch); + const MAX_FD_PREFIX_DIGITS: usize = 9; + // Only the digits plus a 3-char redirect operator (e.g. "<<<", ">>") + // matter, so bound the lookahead: collecting all remaining input here + // made every digit-initial word O(n) and the whole lex O(n^2) + // (TM-DOS-024). + let input_remaining: String = self.lookahead().take(MAX_FD_PREFIX_DIGITS + 4).collect(); + let ndigits = input_remaining + .chars() + .take_while(|c| c.is_ascii_digit()) + .count(); + if ndigits == 0 || ndigits > MAX_FD_PREFIX_DIGITS { + return self.read_word(); } + let Ok(fd) = input_remaining[..ndigits].parse::() else { + return self.read_word(); + }; + let rest = &input_remaining[ndigits..]; + // Consume the fd digits plus `op_len` operator chars. + let consume = |lexer: &mut Self, op_len: usize| { + for _ in 0..ndigits + op_len { + lexer.advance(); + } + }; - // Check if it's a single digit followed by > or < - // We need to peek further without consuming. Only the digit plus a - // 2-char redirect operator (e.g. ">>", "<&", "<<") matter, so bound the - // lookahead — collecting all remaining input here made every - // digit-initial word O(n) and the whole lex O(n^2) (TM-DOS-024). - let input_remaining: String = self.lookahead().take(4).collect(); - - // Check patterns: "N>" "N>>" "N>&" "N<" "N<&" - if fd_str.len() == 1 - && let Some(first_digit) = fd_str.chars().next() - { - let rest = input_remaining.get(1..).unwrap_or(""); // Skip the digit we already matched - - if rest.starts_with(">>") { - // N>> - append redirect with fd - let fd: i32 = first_digit.to_digit(10).unwrap() as i32; - self.advance(); // consume digit - self.advance(); // consume > - self.advance(); // consume > - return Some(Token::RedirectFdAppend(fd)); - } else if rest.starts_with(">&") { - // N>&M - duplicate fd - let fd: i32 = first_digit.to_digit(10).unwrap() as i32; - self.advance(); // consume digit - self.advance(); // consume > - self.advance(); // consume & - - // Read the target fd number or '-' - let mut target_str = String::new(); - while let Some(c) = self.peek_char() { - if c.is_ascii_digit() || c == '-' { - target_str.push(c); - self.advance(); - if c == '-' { - break; - } - } else { + if rest.starts_with(">>") { + // N>> - append redirect with fd + consume(self, 2); + return Some(Token::RedirectFdAppend(fd)); + } else if rest.starts_with(">&") { + // N>&M - duplicate fd + consume(self, 2); + + // Read the target fd number or '-' + let mut target_str = String::new(); + while let Some(c) = self.peek_char() { + if c.is_ascii_digit() || c == '-' { + target_str.push(c); + self.advance(); + if c == '-' { break; } + } else { + break; } + } - if target_str == "-" { - return Some(Token::DupFdCloseOut(fd)); - } - if target_str.is_empty() { - // Just N>& without target - treat as DupOutput with fd - return Some(Token::RedirectFd(fd)); - } - - let target_fd: i32 = target_str.parse().unwrap_or(1); - return Some(Token::DupFd(fd, target_fd)); - } else if rest.starts_with('>') { - // N> - redirect with fd - let fd: i32 = first_digit.to_digit(10).unwrap() as i32; - self.advance(); // consume digit - self.advance(); // consume > + if target_str == "-" { + return Some(Token::DupFdCloseOut(fd)); + } + if target_str.is_empty() { + // Just N>& without target - treat as DupOutput with fd return Some(Token::RedirectFd(fd)); - } else if rest.starts_with("<&") { - // N<&M or N<&- - duplicate input fd - let fd: i32 = first_digit.to_digit(10).unwrap() as i32; - self.advance(); // consume digit - self.advance(); // consume < - self.advance(); // consume & - - // Read the target fd number or '-' - let mut target_str = String::new(); - while let Some(c) = self.peek_char() { - if c.is_ascii_digit() || c == '-' { - target_str.push(c); - self.advance(); - if c == '-' { - break; - } - } else { + } + + let target_fd: i32 = target_str.parse().unwrap_or(1); + return Some(Token::DupFd(fd, target_fd)); + } else if rest.starts_with('>') { + // N> - redirect with fd + consume(self, 1); + return Some(Token::RedirectFd(fd)); + } else if rest.starts_with("<&") { + // N<&M or N<&- - duplicate input fd + consume(self, 2); + + // Read the target fd number or '-' + let mut target_str = String::new(); + while let Some(c) = self.peek_char() { + if c.is_ascii_digit() || c == '-' { + target_str.push(c); + self.advance(); + if c == '-' { break; } + } else { + break; } + } - if target_str == "-" { - return Some(Token::DupFdClose(fd)); - } - let target_fd: i32 = target_str.parse().unwrap_or(0); - return Some(Token::DupFdIn(fd, target_fd)); - } else if rest.starts_with("<<") && !rest.starts_with("<<<") { - // N<") { + // N<>file - open file read-write on fd N + consume(self, 2); + return Some(Token::RedirectFdReadWrite(fd)); + } else if rest.starts_with('<') { + // N< - input redirect with fd + consume(self, 1); + return Some(Token::RedirectFdIn(fd)); } // Not a fd redirect pattern, read as regular word diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 563dd71e4..e9702fbf9 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -756,7 +756,48 @@ impl<'a> Parser<'a> { | Some(tokens::Token::HereDocFd(..)) => { // Rest-of-line tokens are re-injected by the lexer: more // redirects may follow (`done <out`). - self.parse_heredoc_redirect(&mut redirects)?; + self.parse_heredoc_redirect(&mut redirects, None)?; + } + Some( + tokens::Token::RedirectReadWrite + | tokens::Token::RedirectFdReadWrite(_) + | tokens::Token::HereStringFd(_), + ) => { + self.parse_simple_redirect(&mut Vec::new(), &mut redirects)?; + } + // `} {fd}>file`: a `{name}` word followed by a redirect + // operator names the fd variable. + Some(tokens::Token::Word(w)) if Self::is_fd_var_word(w) => { + let mut words = vec![Word::literal(w.clone())]; + if !matches!( + self.peek_next(), + Some( + tokens::Token::RedirectOut + | tokens::Token::Clobber + | tokens::Token::RedirectAppend + | tokens::Token::RedirectIn + | tokens::Token::HereString + | tokens::Token::DupOutput + | tokens::Token::DupInput + | tokens::Token::RedirectReadWrite + | tokens::Token::RedirectBothAppend + | tokens::Token::HereDoc + | tokens::Token::HereDocStrip + ) + ) { + break; + } + self.advance(); + match self.current_token { + Some(tokens::Token::HereDoc | tokens::Token::HereDocStrip) => { + let fd_var = Self::pop_fd_var(&mut words); + self.parse_heredoc_redirect(&mut redirects, fd_var)?; + } + Some(tokens::Token::RedirectBothAppend) => { + self.parse_append_both(&mut words, &mut redirects)?; + } + _ => self.parse_simple_redirect(&mut words, &mut redirects)?, + } } _ => break, } @@ -2624,7 +2665,11 @@ impl<'a> Parser<'a> { // Out of line: its body-reading temporaries must not grow the frame // of the recursive command parser. #[inline(never)] - fn parse_heredoc_redirect(&mut self, redirects: &mut Vec) -> Result<()> { + fn parse_heredoc_redirect( + &mut self, + redirects: &mut Vec, + fd_var: Option, + ) -> Result<()> { let (fd, strip_tabs) = match self.current_token { Some(tokens::Token::HereDocFd(fd, strip)) => (Some(fd), strip), Some(tokens::Token::HereDocStrip) => (None, true), @@ -2702,7 +2747,7 @@ impl<'a> Parser<'a> { redirects.push(Redirect { fd, - fd_var: None, + fd_var, kind, target, heredoc_delim: Some(printed), @@ -2766,15 +2811,7 @@ impl<'a> Parser<'a> { .collect::>() }); if let Some(ref s) = joined - && s.starts_with('{') - && s.ends_with('}') - && s.len() > 2 - // `{fd}` or `{arr[i]}` (a coproc hands its descriptors out as - // `${NAME[0]}` / `${NAME[1]}`, and `exec {NAME[1]}>&-` closes one). - && s[1..s.len() - 1] - .chars() - .all(|c| c.is_alphanumeric() || matches!(c, '_' | '[' | ']')) - && s[1..s.len() - 1].starts_with(|c: char| c.is_alphabetic() || c == '_') + && Self::is_fd_var_word(s) { let var_name = s[1..s.len() - 1].to_string(); words.pop(); @@ -2783,6 +2820,18 @@ impl<'a> Parser<'a> { None } + /// `{fd}` or `{arr[i]}` (a coproc hands its descriptors out as + /// `${NAME[0]}` / `${NAME[1]}`, and `exec {NAME[1]}>&-` closes one). + fn is_fd_var_word(s: &str) -> bool { + s.starts_with('{') + && s.ends_with('}') + && s.len() > 2 + && s[1..s.len() - 1] + .chars() + .all(|c| c.is_alphanumeric() || matches!(c, '_' | '[' | ']')) + && s[1..s.len() - 1].starts_with(|c: char| c.is_alphabetic() || c == '_') + } + fn parse_simple_command(&mut self) -> Result> { self.tick()?; self.skip_newlines()?; @@ -2810,7 +2859,13 @@ impl<'a> Parser<'a> { | Some(tokens::Token::HereDocFd(..)) => { // Words and redirects may follow on the same line // (`cat < { let word = self.expect_word()?; @@ -2831,7 +2886,10 @@ impl<'a> Parser<'a> { | tokens::Token::DupInput | tokens::Token::DupFdIn(..) | tokens::Token::DupFdClose(_) - | tokens::Token::RedirectFdIn(_), + | tokens::Token::RedirectFdIn(_) + | tokens::Token::RedirectReadWrite + | tokens::Token::RedirectFdReadWrite(_) + | tokens::Token::HereStringFd(_), ) => { self.parse_simple_redirect(&mut words, &mut redirects)?; } @@ -3160,6 +3218,35 @@ impl<'a> Parser<'a> { heredoc_delim: None, }); } + Some(tokens::Token::RedirectReadWrite) => { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: None, + fd_var, + kind: RedirectKind::ReadWrite, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::RedirectFdReadWrite(fd) | tokens::Token::HereStringFd(fd)) => { + let fd = *fd; + let kind = if matches!(self.current_token, Some(tokens::Token::HereStringFd(_))) { + RedirectKind::HereString + } else { + RedirectKind::ReadWrite + }; + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: Some(fd), + fd_var: None, + kind, + target, + heredoc_delim: None, + }); + } _ => {} } Ok(()) diff --git a/crates/bashkit/src/parser/print_cmd.rs b/crates/bashkit/src/parser/print_cmd.rs index 417e7f6ca..693feac03 100644 --- a/crates/bashkit/src/parser/print_cmd.rs +++ b/crates/bashkit/src/parser/print_cmd.rs @@ -612,6 +612,7 @@ fn redirection_text(r: &Redirect) -> String { RedirectKind::Append => format!("{}>> {target}", redirector(r, 1)), RedirectKind::Input => format!("{}< {target}", redirector(r, 0)), RedirectKind::HereString => format!("{}<<< {target}", redirector(r, 0)), + RedirectKind::ReadWrite => format!("{}<> {target}", redirector(r, 0)), RedirectKind::OutputBoth => format!("&> {target}"), RedirectKind::DupOutput => { let fd_is_default = r.fd_var.is_none() && r.fd.is_none_or(|fd| fd == 1); diff --git a/crates/bashkit/src/parser/tokens.rs b/crates/bashkit/src/parser/tokens.rs index 04d1b083e..0cd2f25eb 100644 --- a/crates/bashkit/src/parser/tokens.rs +++ b/crates/bashkit/src/parser/tokens.rs @@ -76,6 +76,15 @@ pub enum Token { /// Here string (<<<) HereString, + /// Here string on a numbered descriptor (`3<<`) + RedirectReadWrite, + + /// Open read-write on a numbered descriptor (`4<>file`) + RedirectFdReadWrite(i32), + /// Left parenthesis (() LeftParen, diff --git a/crates/bashkit/tests/integration/threat_model_tests.rs b/crates/bashkit/tests/integration/threat_model_tests.rs index ce679dcaf..854ea19d8 100644 --- a/crates/bashkit/tests/integration/threat_model_tests.rs +++ b/crates/bashkit/tests/integration/threat_model_tests.rs @@ -108,39 +108,55 @@ mod resource_exhaustion { assert!(!fs.exists(Path::new("/out.zip")).await.unwrap()); } - /// TM-DOS-063: Negative fd vars must be rejected and must not bypass fd limits. + /// TM-DOS-063: `{v}>file` ignores a prior (negative) value of `v`: bash + /// allocates the lowest free fd >= 10 and stores it, so a crafted value + /// can neither pick the fd nor bypass the fd limit. #[tokio::test] - async fn fd_limit_rejects_negative_fd_var_output() { + async fn fd_var_output_ignores_negative_value_and_respects_limit() { let limits = ExecutionLimits::new().max_file_descriptors(1); let mut bash = Bash::builder().limits(limits).build(); - let result = bash.exec("v=-1; exec {v}>/tmp/neg-out").await; + let result = bash + .exec("v=-1; exec {v}>/tmp/neg-out; echo $v") + .await + .unwrap(); + assert_eq!(result.stdout, "10\n"); - assert!(result.is_err()); + let result = bash.exec("w=-1; exec {w}>/tmp/neg-out2").await; let err = result.unwrap_err().to_string(); - assert!( - err.contains("invalid file descriptor"), - "Expected invalid file descriptor error, got: {}", - err - ); + assert!(err.contains("file descriptor"), "got: {err}"); } - /// TM-DOS-063: Negative fd vars for input must be rejected. + /// TM-DOS-063: same for input `{v}/tmp/neg-in").await.unwrap(); - let result = bash.exec("v=-1; exec {v}file` on any command leaves the fd open (bash + /// never undoes `{var}` redirections), so a loop must hit the fd cap. + #[tokio::test] + async fn fd_var_on_plain_command_in_loop_hits_fd_limit() { + let limits = ExecutionLimits::new().max_file_descriptors(8); + let mut bash = Bash::builder().limits(limits).build(); + + let result = bash + .exec("for i in $(seq 100); do : {v}>/dev/null; done") + .await; + let err = result.unwrap_err().to_string(); + assert!(err.contains("file descriptor"), "got: {err}"); } /// Subsequent exec() calls recover after a prior call hits the command limit. diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-named-fd.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-named-fd.test.sh index a99613de9..3ad46d212 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-named-fd.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-named-fd.test.sh @@ -4,7 +4,6 @@ ### bashbox_named_fd_exec_opens_the_lowest_free_fd_from_10 # exec opens the lowest free fd from 10 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash exec {fd}>f; echo $fd; echo hi >&$fd; exec {fd}>&-; cat f ### expect 10 @@ -13,7 +12,6 @@ hi ### bashbox_named_fd_fds_in_use_are_skipped # fds in use are skipped -### skip: TODO bashbox corpus gap, bashkit output differs from real bash exec 10>x 11>y; exec {a}>f {b}>g; echo $a $b ### expect 12 13 @@ -21,7 +19,6 @@ exec 10>x 11>y; exec {a}>f {b}>g; echo $a $b ### bashbox_named_fd_a_regular_command_keeps_it_open # a regular command keeps it open -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo hi {fd}>f; echo $fd; echo there >&$fd; cat f ### expect hi @@ -31,7 +28,6 @@ there ### bashbox_named_fd_as_a_prefix # as a prefix -### skip: TODO bashbox corpus gap, bashkit output differs from real bash {fd}>f echo x; echo $fd ### expect x @@ -47,7 +43,6 @@ a b ### bashbox_named_fd_here_string_and_here_document # here-string and here-document -### skip: TODO bashbox corpus gap, bashkit output differs from real bash exec {h}<<<'line'; read x <&$h; echo "$x $h"; exec {d}<f; echo hi >&$fd; exec {a}>>f; echo more >&$a; cat f ### expect hi @@ -68,7 +62,6 @@ more ### bashbox_named_fd_duplicating_fds # duplicating fds -### skip: TODO bashbox corpus gap, bashkit output differs from real bash exec {fd}>&1; echo $fd; echo to-stdout >&$fd; exec {in}<&0; echo $in ### expect 10 @@ -78,7 +71,6 @@ to-stdout ### bashbox_named_fd_a_closed_fd_is_reused # a closed fd is reused -### skip: TODO bashbox corpus gap, bashkit output differs from real bash exec {fd}>f; echo $fd; exec {fd}>&-; exec {fd}>g; echo $fd ### expect 10 @@ -87,7 +79,6 @@ exec {fd}>f; echo $fd; exec {fd}>&-; exec {fd}>g; echo $fd ### bashbox_named_fd_writing_to_a_closed_named_fd # writing to a closed named fd -### skip: TODO bashbox corpus gap, bashkit output differs from real bash exec {fd}>f; exec {fd}>&-; echo hi >&$fd; echo $? ### expect 1 @@ -95,7 +86,6 @@ exec {fd}>f; exec {fd}>&-; echo hi >&$fd; echo $? ### bashbox_named_fd_a_dup_to_a_word_is_ambiguous # a dup to a word is ambiguous -### skip: TODO bashbox corpus gap, bashkit output differs from real bash exec {fd}>&f; echo "$? [$fd]" ### expect 1 [] @@ -103,7 +93,6 @@ exec {fd}>&f; echo "$? [$fd]" ### bashbox_named_fd_a_readonly_variable # a readonly variable -### skip: TODO bashbox corpus gap, bashkit output differs from real bash readonly fd=3; exec {fd}>f; echo $? ### expect 1 @@ -118,7 +107,6 @@ exec {fd}>/nonexistent/x; echo "$? [$fd]" ### bashbox_named_fd_into_a_local # into a local -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ local fd; exec {fd}>f; echo $fd; }; f; echo "[$fd]" ### expect 10 @@ -127,7 +115,6 @@ f(){ local fd; exec {fd}>f; echo $fd; }; f; echo "[$fd]" ### bashbox_named_fd_into_an_array_element # into an array element -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=(1 2); exec {x[1]}>f; echo ${x[@]}; exec {x[1]}>&-; echo $? ### expect 1 10 @@ -136,7 +123,6 @@ x=(1 2); exec {x[1]}>f; echo ${x[@]}; exec {x[1]}>&-; echo $? ### bashbox_named_fd_not_a_name_so_just_a_word # not a name, so just a word -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo {1x}>f; cat f ### expect {1x} @@ -144,7 +130,6 @@ echo {1x}>f; cat f ### bashbox_named_fd_on_a_compound_command # on a compound command -### skip: TODO bashbox corpus gap, bashkit output differs from real bash { echo in; } {fd}>f; echo $fd; cat f ### expect in @@ -153,7 +138,6 @@ in ### bashbox_named_fd_a_function_listing_keeps_the_names # a function listing keeps the names -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ echo a {fd}>f; cat {x}&1; read {y}<<&-; echo {q}>&2; echo {r}>&$x; cat {h}<&- 2>&1 echo "rc=$?" ### expect diff --git a/crates/bashkit/tests/spec_cases/bash/pipes-redirects.test.sh b/crates/bashkit/tests/spec_cases/bash/pipes-redirects.test.sh index 2c23f2bd8..b595d878e 100644 --- a/crates/bashkit/tests/spec_cases/bash/pipes-redirects.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/pipes-redirects.test.sh @@ -239,7 +239,6 @@ value is expanded ### end ### readwrite_redirect_opens_file -### skip: `<>` (read-write fd) is not implemented — parser rejects the operator # Bash opens the file read-write on fd 0 without truncating it. echo start > /tmp/rw.txt exec 3<> /tmp/rw.txt diff --git a/knowledge/integrations/script-analysis.md b/knowledge/integrations/script-analysis.md index a0c7808d9..e95dda206 100644 --- a/knowledge/integrations/script-analysis.md +++ b/knowledge/integrations/script-analysis.md @@ -146,9 +146,9 @@ a function definition, so it runs only if the function is called). | `path` | `string \| null` | Target path if fully literal, else `null` | | `mode` | `Read \| Write \| Append` | Access implied by the operator | -`>`, `>|`, and `&>` are `Write`; `>>` is `Append`; `<` is `Read`. Fd +`>`, `>|`, `&>`, and `<>` (it may create the file) are `Write`; `>>` is `Append`; `<` is `Read`. Fd duplications (`2>&1`), here-documents, and here-strings are not file targets and -are omitted. The parser does not support `<>`, so no read-write mode exists. +are omitted. ### Decisions diff --git a/knowledge/log.md b/knowledge/log.md index dc56341d8..fa507c3b5 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -89,6 +89,7 @@ * **Fix**: Arrays, scalars and `read` names follow bash. A scalar is element 0 of an array (`x=5; echo ${x[@]} ${#x[@]} ${!x[@]} ${x[0]}` prints `5 1 0 5`, `((x[1]=7))` promotes it); `${a[@]}`/`${!a[@]}` of an associative array use bash's hash order, like `declare -p`. `[[ -v name ]]` was missing (always true); it now tests set-ness, element 0 for an array, `name[sub]` and `name[@]`. `${!ref}` gives the end of a nameref chain, a nameref `for` variable is re-pointed at each word, and `read` through a circular nameref fails with status 1. `read` takes options only before the first name, stops with status 1 at an invalid name (earlier names keep their fields), validates `-a` names, and supports `-N` (exact count, no splitting). * **Fix**: Declaration builtins and local scope match bash. `declare`/`typeset`/`local`/`export`/`readonly` share one engine with bash's option sets, errors and `declare -p` format (quoting, assoc hash order); locals use shallow binding saved in the call frame, so nested functions, `unset` of a caller's local, `declare -g` past a local, and `local -n` behave as in bash. Compound operands stay one `CompoundAssignment` word part (keyed `[k]="v w"` and `` [k]=`cmd` `` elements lex as one word). Readonly assignment and circular namerefs abandon the line. See [Builtins](foundations/builtins.md#declaration-builtins-and-variable-scope). * **Fix**: Arithmetic follows bash's grammar and error model. New tokenizer + precedence-climbing evaluator (`interpreter/arithmetic.rs`): comma lists, chained/compound assignment, `**` right-assoc, `base#n` up to base 64, `noeval` short-circuit. `$((1/0))`, syntax errors and bad subscripts now print bash's message and abort the rest of the line (status 1) instead of yielding 0; `((...))` and `let` return 1. `((z <<= 65))` is read as raw text, not a heredoc. Bounds unchanged (`MAX_ARITHMETIC_DEPTH`, fuel; TM-DOS-026). See [Parser](foundations/parser.md#arithmetic-expressions). +* **Fix**: Named file descriptors follow bash. `{var}>file` (and `<`, `>>`, `<>`, `<<<`, `<<`, `>&N`, `<&N`) allocates the lowest free fd >= 10 into `var` (local, array element; readonly fails) on any command, simple or compound, and the fd stays open as bash never undoes `{var}` redirections; `{var}>&-` closes it and a closed fd is reused. `>&N`/`<&N` to a closed fd is `Bad file descriptor` (status 1), a dup to a word is `ambiguous redirect`. The lexer accepts multi-digit fds (`10>x`), `<>` and `N<<f` in a loop is capped by `max_file_descriptors` (TM-DOS-063 tests updated: a prior `$v` value is ignored). Gaps in [Limitations](operations/limitations.md) ("Named fds / `<>`"). ## 2026-10-06 diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 01622d433..10602dda2 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -42,7 +42,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance` | ID | Limitation | Why | Evidence | |----|------------|-----|----------| -| L-PROC-001 | `exec` does not replace the process; `exec cmd` runs cmd then stops execution. fd redirects work; `exec >log 2>&1` routes the shell's output per top-level command, so output written earlier in the same top-level command (not streamed yet) also lands in the log, and output under the redirect reaches a streaming caller only when that command ends. `exec N>&-` closes fd 1 or 2 for the rest of the script: later writes to it are dropped, the command reports `write error: Bad file descriptor` naming itself, and its status is 1. An `exec` redirect inside a subshell or a `bash -c` child does not take effect, because only the outermost command loop routes the exec fd table. `exec {var}>file` (allocate an fd into `var`) is not implemented | True process replace would break sandbox containment; there is no real fd 1 to swap, the shell's output is a value returned to the caller | TM-ESC-005, `exec-redirect-shell-output.test.sh` | +| L-PROC-001 | `exec` does not replace the process; `exec cmd` runs cmd then stops execution. fd redirects work; `exec >log 2>&1` routes the shell's output per top-level command, so output written earlier in the same top-level command (not streamed yet) also lands in the log, and output under the redirect reaches a streaming caller only when that command ends. `exec N>&-` closes fd 1 or 2 for the rest of the script: later writes to it are dropped, the command reports `write error: Bad file descriptor` naming itself, and its status is 1. An `exec` redirect inside a subshell or a `bash -c` child does not take effect, because only the outermost command loop routes the exec fd table. `exec &>file` and `exec >&word` are ignored | True process replace would break sandbox containment; there is no real fd 1 to swap, the shell's output is a value returned to the caller | TM-ESC-005, `exec-redirect-shell-output.test.sh` | | L-PROC-002 | Background jobs run concurrently (`jobs`, `wait -n`, `kill`, `ps`, `pgrep`), but cannot be stopped or resumed: `kill -STOP/-CONT` are ignored, there is no `suspend`/Ctrl-Z, `bg` is a no-op and `fg` just waits; jobs end when `exec()` returns | No terminal process groups in a virtual shell; a job must not outlive the call that owns it (TM-DOS-122) | `l_proc_002_no_job_control` | | L-PROC-003 | No process spawning; external commands run as builtins | Core sandbox model: no fork/exec escape surface | `l_proc_003_no_process_spawning` | | L-PROC-004 | `bash`/`sh` child shells nest at most 8 deep (and count against `max_function_depth`); deeper nesting fails `maximum nesting depth exceeded` | Child shells run in-process on one stack; unbounded nesting crashed the process (TM-DOS-125) | `l_proc_004_child_shell_depth` | @@ -96,7 +96,6 @@ Target: IEEE 1003.1-2024 Shell Command Language. | Feature | Priority | Notes | |---------|----------|-------| | History expansion | Out of scope | Interactive only | -| `<>` read-write redirect | Low | Parser rejects the operator; `ScriptAnalysis` therefore has no read-write mode (`readwrite_redirect_opens_file`, skipped) | ### Partially Implemented @@ -109,6 +108,7 @@ Target: IEEE 1003.1-2024 Shell Command Language. | `bash`/`sh` | `-c`, `-n`, `-e`, `-x`, `-u`, `-f`, `-o option`, script files, stdin, `--version`, `--help` | Login shell | | Expansion errors | `set -u` unbound variables stop the shell | `$((1/0))` evaluates to 0 and `${x!}` expands empty; bash reports them and drops the rest of the line the failing command ends on (`bashbox_line_number_an_expansion_error_drops_the_rest_of_the_line_it_ends_on`, skipped) | | Process substitution | `<(cmd)` and `>(cmd)` as filenames, nesting, redirections | The path is `/dev/fd/proc_sub_`, not bash's `/dev/fd/63`. Several interpreters can share one filesystem, so a per-shell descending number would let one tenant's substitution overwrite another's. A script that parses the number sees a different one (`scripts/bash-oracle/cases/process-substitution-dev-fd.sh`) | +| Named fds / `<>` | `{var}>file`, `{var}file`, `{var}>>file`, `{var}<<&N`/`<&N` allocate the lowest free fd >= 10 on any command (persisting, as in bash); `{var}>&-` closes; `>&N` with N closed is `Bad file descriptor`; `N<>file`, `N<<file` has no shared read/write offset (writes append); `3&1`, `( ... ) 3>&1`) is dropped; a `Bad file descriptor` message ignores redirect order (`2>&1 >&7`) | | Brace expansion | Runs before other expansions on unquoted literal text; lists, nesting, ranges with step and zero padding, quoted/escaped braces and commas | Works on parsed words, not raw text: `$v{1,2}` expands `$v` then appends `1`/`2` (bash reads `$v1`, `$v2`); write `${v}` | ## Builtins diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index e83f7396d..64dfd08b7 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -277,7 +277,7 @@ runaway scripts without permanently breaking the session. | TM-DOS-060 | Sparse array huge-index allocation | `arr[999999999]=x` could allocate ~1B empty slots if arrays are Vec-backed; negative indices could cause OOB | HashMap-based arrays; `max_array_entries` caps total entries | **MITIGATED** | | TM-DOS-061 | Snapshot function restore bypasses parser/function limits | Crafted snapshot restores functions exceeding the tenant's parser depth or function memory budget | Restored function source re-parsed with current `ExecutionLimits`; function memory budget re-applied before insertion | **MITIGATED** | | TM-DOS-062 | jq file binding amplification | Repeated `--rawfile` / `--slurpfile` bindings to one max-sized VFS file multiply retained jq globals and `$ARGS.named` values without consuming more VFS quota | `MAX_FILE_VAR_REQUESTS` caps binding count; `MAX_FILE_VAR_BYTES` counts cumulative file bytes per binding before retaining globals | **MITIGATED** | -| TM-DOS-063 | Persistent fd exhaustion | `exec N>/tmp/f` across many `N` values grows `exec_fd_table`/coproc fd buffers for the session | `ExecutionLimits::max_file_descriptors` (default 1024) caps persistent custom descriptors; reused fds and standard 0/1/2 don't count | **MITIGATED** | +| TM-DOS-063 | Persistent fd exhaustion | `exec N>/tmp/f` across many `N` values, or `cmd {v}>/tmp/f` in a loop (bash keeps `{var}` fds open), grows `exec_fd_table`/coproc fd buffers for the session | `ExecutionLimits::max_file_descriptors` (default 1024) caps persistent custom descriptors; reused fds and standard 0/1/2 don't count | **MITIGATED** | | TM-DOS-109 | Silent truncation at builtin caps | A builtin that stops at a resource cap but exits 0 with no diagnostic (awk loops/recursion/getline caps, `seq`, `yes`, `numfmt`, `history`), or an awk `sprintf` expression that reports a format cap yet returns an empty value, gives scripts and agents incomplete output as success (#2446, #2456, like sed #1005) | Reaching a builtin cap is never silent: `builtins::limits::cap_exceeded` keeps the partial output, writes `: limit () exceeded; output truncated` and exits 1; awk caps and formatting errors are gawk-style fatal errors (`awk: fatal: ...`, exit 2, END skipped). Regression tests: `builtin_cap_security_tests` | **MITIGATED** | | TM-DOS-110 | In-builtin memory growth aborts the host | A value that keeps growing inside awk (`s = s s`, `gsub` with a long replacement, `sprintf("%s%s%s", s, s, s)`, `$1000000000 = 1`, filling arrays, deep recursion shadowing large values) or jq (`until(false; . + .)`, `"x" * 1e12`, `[range(1e12)]`, a non-emitting `until(false; .)` growing evaluator state) allocates until the allocator fails and the embedding process aborts with SIGABRT (#2444) | awk: every string is checked against `AWK_MAX_STRING_BYTES` (16 MiB) *before* it is allocated (concat, `SUBSEP` keys, `sub`/`gsub`/`gensub` as the result grows, `sprintf`/`printf`, `print` joins, rebuilt `$0`); `$N` assignment is capped at `AWK_MAX_FIELD_INDEX`; variables, array elements and values parked in call frames are accounted and capped at `ExecutionLimits::max_live_intermediate_bytes` (default 32 MB); each is a fatal awk error (exit 2) and the script carries on. jq: jaq-json is vendored with a live-memory meter charging every string and array/object body to the same limit, checked before growth (`jq: error: value size limit (N bytes) exceeded`, exit 5), sticky across `try`; value operations poll the execution deadline so non-emitting loops stop at the timeout; output conversion is capped by `max_stdout_bytes`. Regression tests: `memory_growth_security_tests`. Jq evaluator contexts are now counted (64 live-context ceiling) and every context clone polls the deadline, so pure tail recursion times out and non-tail recursion fails before host stack exhaustion (#2465). The unwind-based abort requires `panic = "unwind"`; panic-abort embedders need process isolation for untrusted jq | **MITIGATED** | | TM-DOS-114 | Arrays bypass the retained-memory byte budget | `max_array_entries` counted entries but never their keys or values, so `x` doubled to 4 MiB and parked in ten array slots retained ~40 MB past the 10 MB variable-byte budget and exited 0 (#2462) | Array keys and values charge `max_total_variable_bytes` alongside scalars (`MemoryBudget::array_bytes`; indexed keys are machine integers already covered by the entry count, associative keys are attacker-controlled so they count). Element writes, `+=` appends, bulk `a=(...)` replacement, and `declare -A` compound writes are checked before insertion; bytes are released on element unset, whole-array unset, replacement, and local scope pop. An over-budget write records the first memory-limit error and fails execution (TM-DOS-111 mechanism) rather than dropping the value; reaching `max_array_entries` keeps its established silent-skip behaviour (TM-DOS-060). Regression tests: `array_budget_security_tests` | **MITIGATED** |