diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 1a0004ca5..ffabc4f80 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -5804,11 +5804,21 @@ impl Interpreter { && self.subst_generation == pre_assign_subst_gen { 0 + } else if command.assignments.is_empty() && !command.redirects.is_empty() { + // Redirect-only null command (`> file`, `< file`). + 0 } else { self.last_exit_code }; - self.last_exit_code = exit_code; self.discard_deferred_proc_subs_from(deferred_proc_sub_start); + if !command.redirects.is_empty() { + // Null commands still perform their redirections: `> f` + // truncates, `< missing` fails with status 1. + return self + .execute_null_command_redirects(exit_code, &command.redirects) + .await; + } + self.last_exit_code = exit_code; return Ok(ExecResult { stdout: crate::StreamData::new(), stderr: crate::StreamData::new(), @@ -8827,6 +8837,55 @@ impl Interpreter { self.last_bg_pid = snap.last_bg_pid; } + /// Perform the redirections of a null command (no command word) and + /// return its result. Input redirects are opened and discarded. + async fn execute_null_command_redirects( + &mut self, + exit_code: i32, + redirects: &[Redirect], + ) -> Result { + if let Some(stderr) = self.logic_only_redirect_error(redirects) { + self.last_exit_code = 1; + return Ok(ExecResult::err(stderr, 1)); + } + match self.process_input_redirections(None, redirects).await { + Ok(_) => {} + Err(crate::error::Error::CommandFailure(msg)) => { + self.last_exit_code = 1; + return Ok(ExecResult::err(msg, 1)); + } + Err(e) => return Err(e), + } + let result = ExecResult { + exit_code, + ..Default::default() + }; + let result = self.apply_redirections(result, redirects).await?; + self.last_exit_code = result.exit_code; + Ok(result) + } + + /// Match `$( Option<&[Redirect]> { + let [Command::Simple(cmd)] = commands else { + return None; + }; + let bare_name = cmd.name.parts.is_empty() + || matches!(cmd.name.parts.as_slice(), [WordPart::Literal(s)] if s.is_empty()); + let [redirect] = cmd.redirects.as_slice() else { + return None; + }; + (bare_name + && !cmd.name.quoted + && cmd.args.is_empty() + && cmd.assignments.is_empty() + && redirect.kind == RedirectKind::Input + && redirect.fd_var.is_none() + && matches!(redirect.fd, None | Some(0))) + .then_some(cmd.redirects.as_slice()) + } + fn execute_cmd_subst<'a>( &'a mut self, commands: &'a [Command], @@ -8836,6 +8895,30 @@ impl Interpreter { // mutable state so mutations don't leak to the parent. let snapshot = self.snapshot_subshell_state(); let mut stdout = String::new(); + let file_read = Self::cmd_subst_file_read(commands); + if let Some(redirects) = file_read { + // `$( { + if let Some(content) = content { + stdout.push_str(&content.command_substitution_text()); + } + self.last_exit_code = 0; + } + Err(crate::error::Error::CommandFailure(_)) => { + self.last_exit_code = 1; + } + Err(e) => return Err(e), + } + } + let commands: &[Command] = if file_read.is_some() { &[] } else { commands }; for cmd in commands { let cmd_result = self.execute_command(cmd).await?; stdout.push_str(&cmd_result.stdout.command_substitution_text()); @@ -9406,17 +9489,13 @@ impl Interpreter { if self.counters.push_subst(&self.limits).is_err() { result.push('0'); } else { - let snapshot = self.snapshot_subshell_state(); - let cmd_result = - self.execute_command_sequence(&script.commands).await?; - self.restore_subshell_state(snapshot); - self.counters.pop_subst(); - let command_output = cmd_result.stdout.command_substitution_text(); - let trimmed = command_output.trim_end_matches('\n'); + // Same subshell semantics as `$(...)` in words, + // including the `$( Parser<'a> { } } - // Handle assignment-only commands (VAR=value with no command) - if words.is_empty() && !assignments.is_empty() { + // Handle assignment-only and redirect-only commands (`VAR=value`, + // `< file`, `> file`): bash runs them as null commands that still + // perform their assignments/redirections (#2448: `$( /tmp/cs_read +x=$( /tmp/cs_read2 +x=$(< /tmp/cs_read2) +echo "[$x]" +### expect +[a +b] +### end + +### subst_file_read_shorthand_var_target +# $(<"$f") expands the target word +f=/tmp/cs_read3 +echo value > "$f" +echo "[$(<"$f")]" +### expect +[value] +### end + +### subst_file_read_shorthand_missing +# $(/dev/null +echo "rc=$? [$x]" +### expect +rc=1 [] +### end + +### subst_file_read_backticks +# ` /tmp/cs_tick +x=` /tmp/cs_num +echo $(( $( /tmp/cs_words +set -- $( /tmp/nullcmd_out +> /tmp/nullcmd_out +wc -c < /tmp/nullcmd_out +echo "rc=$?" +### expect +0 +rc=0 +### end + +### null_command_input_redirect_missing +# A lone input redirect to a missing file fails with status 1 +{ < /tmp/nullcmd_missing; } 2>/dev/null +echo "rc=$?" +### expect +rc=1 +### end + +### null_command_redirect_resets_status +# A successful redirect-only command exits 0 +false +> /tmp/nullcmd_status +echo "rc=$?" +### expect +rc=0 +### end + +### assignment_with_redirect_creates_file +# x=1 > file assigns and still creates the file +x=1 > /tmp/nullcmd_assign +echo "$x" +test -f /tmp/nullcmd_assign && echo exists +### expect +1 +exists +### end