Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/runtime/shell.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ You can redirect from these JavaScript objects:
- `Bun.file(path)`, `Bun.file(fd)` (reads from the file)
- `Response` (reads from the body)

When the body of a `Response` is `Bun.file(path)`, Bun Shell opens that file. A missing file fails the command.

### Example: Redirect stdin -> file

```js
Expand Down
50 changes: 17 additions & 33 deletions src/runtime/shell/Builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use std::sync::Arc;
use crate::shell::ExitCode;
use crate::shell::ast;
use crate::shell::interpreter::{
Interpreter, NodeId, OutputNeedsIOSafeGuard, ParseError, is_pollable_from_mode, shell_openat,
Interpreter, NodeId, OutputNeedsIOSafeGuard, ParseError, RedirectTarget, is_pollable_from_mode,
shell_openat,
};
use crate::shell::io::{InKind, OutFd, OutKind};
use crate::shell::io_reader::IOReader;
Expand Down Expand Up @@ -525,30 +526,24 @@ impl Builtin {
let node: &ast::Cmd = &*interp.as_cmd(cmd).node;
let redirect = node.redirect;

match &node.redirect_file {
Some(ast::Redirect::Atom(_)) => {
let target = match &node.redirect_file {
Some(redirect_file) => match interp.redirect_target(cmd, redirect_file) {
Ok(target) => Some(target),
Err(_) => return Some(Yield::Failed(cmd)),
},
None => None,
};

match target {
Some(RedirectTarget::Path(path_buf, response)) => {
// ── File redirect (`> path` / `< path` / `>> path` / `&> path`).
if interp.as_cmd(cmd).redirection_file.is_empty() {
if path_buf.is_empty() {
return Some(Self::cmd_write_failing_error(
interp,
cmd,
format_args!("bun: ambiguous redirect: at `{}`\n", kind.as_str()),
));
}

// `redirection_file` was NUL-terminated by Expansion; build a
// `&ZStr` over it (path = bytes excluding the trailing NUL).
// Clone the path bytes so the
// `&mut interp` open call below doesn't overlap a borrow into
// the Cmd node.
let path_buf: Vec<u8> = {
let raw = &interp.as_cmd(cmd).redirection_file;
let len = raw.len().saturating_sub(1);
let mut v = raw[..len].to_vec();
v.push(0);
v
};
// SAFETY: `path_buf` ends in NUL by construction.
let path = bun_core::ZStr::from_slice_with_nul(&path_buf[..]);
let perm: bun_sys::Mode = 0o666;
let cwd_fd = Self::cwd(interp, cmd);
Expand Down Expand Up @@ -605,6 +600,9 @@ impl Builtin {
Ok(f) => f,
}
};
if let Some(response) = response {
response.consume_body();
}

let interp_ptr: *mut Interpreter = interp.as_ctx_ptr();
if redirect.stdin() {
Expand Down Expand Up @@ -647,22 +645,8 @@ impl Builtin {
});
}
}
Some(ast::Redirect::JsBuf(jsbuf)) => {
Some(RedirectTarget::Object(global, jsval)) => {
// ── JS object redirect (`> ${arraybuf}` / `> ${blob}`).
// Safe accessor — single `unsafe` deref lives in
// `Interpreter::global_this_ref`.
let Some(global) = interp.global_this_ref() else {
interp.throw(crate::shell::ShellErr::Custom(
b"Invalid JS object reference in shell"
.to_vec()
.into_boxed_slice(),
));
return Some(Yield::Failed(cmd));
};
let Ok(jsval) = interp.jsobj(global, jsbuf.idx) else {
return Some(Yield::Failed(cmd));
};

if jsval.js_type().is_array_buffer_like() {
// Each slot gets its own pin + GC root; `None` has thrown OOM.
let root = || {
Expand Down
63 changes: 57 additions & 6 deletions src/runtime/shell/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,30 @@ pub(crate) enum OutputNeedsIOSafeGuard {
/// The shell promise's `reject` function and the error to call it with. Kept on the stack, where the GC sees it.
pub(crate) type Rejection = (crate::jsc::JSValue, crate::jsc::JSValue);

/// What the redirect of a command targets.
pub(crate) enum RedirectTarget<'a> {
/// A file that the shell opens: the path word, NUL-terminated, or empty when the word is
/// ambiguous. A `Response` over `Bun.file(path)` is that word too, as the bare
/// `${Bun.file(path)}` is since template time (`handle_template_value`).
Path(Vec<u8>, Option<ResponseFile>),
/// Any other JS object.
Object(&'a crate::jsc::JSGlobalObject, crate::jsc::JSValue),
}

/// The `Response` whose body is the file of a [`RedirectTarget::Path`].
pub(crate) struct ResponseFile(crate::jsc::JSValue);

impl ResponseFile {
/// The redirect took the body. Call it once the file is open: a failed open leaves the body unread.
pub(crate) fn consume_body(self) {
if let Some(response) = self.0.as_class_ref::<crate::webcore::Response>() {
let body = response.get_body_value();
drop(body.use_());
debug_assert!(matches!(body, crate::webcore::body::Value::Used));
}
}
}

// ────────────────────────────────────────────────────────────────────────────
// Interpreter
// ────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1088,20 +1112,47 @@ impl Interpreter {
Some(crate::jsc::JSGlobalObject::opaque_ref(g))
}

/// The `idx`th JS object interpolated into the script.
pub(crate) fn jsobj(
/// What `redirect`, the redirect of the command `cmd`, targets.
pub(crate) fn redirect_target(
&self,
global: &crate::jsc::JSGlobalObject,
idx: u32,
) -> crate::jsc::JsResult<crate::jsc::JSValue> {
cmd: NodeId,
redirect: &ast::Redirect,
) -> crate::jsc::JsResult<RedirectTarget<'_>> {
let idx = match redirect {
ast::Redirect::Atom(_) => {
let path = self.as_cmd(cmd).redirection_file.clone();
debug_assert!(path.is_empty() || path.last() == Some(&0));
return Ok(RedirectTarget::Path(path, None));
}
ast::Redirect::JsBuf(jsbuf) => jsbuf.idx,
};
let Some(global) = self.global_this_ref() else {
ShellErr::Custom(
b"Invalid JS object reference in shell"
.to_vec()
.into_boxed_slice(),
)
.throw_mini()
};
let jsobj = match self.this_value.try_get().and_then(js::jsobjs_get_cached) {
Some(jsobjs) => jsobjs.get_direct_index(global, idx)?,
None => crate::jsc::JSValue::ZERO,
};
if jsobj.is_empty() {
return Err(global.throw(format_args!("Invalid JS object reference in shell")));
}
Ok(jsobj)
if let Some(response) = jsobj.as_class_ref::<crate::webcore::Response>()
&& let Some(store) = response.get_body_value().file_store(global)
&& let Some(path) = crate::shell::shell_body::file_store_path(global, &store)?
{
let mut path = path.to_vec();
// An empty path stays an empty word, as an expansion to nothing does.
if !path.is_empty() {
path.push(0);
}
return Ok(RedirectTarget::Path(path, Some(ResponseFile(jsobj))));
}
Ok(RedirectTarget::Object(global, jsobj))
}

/// `mini` prints and exits(1); `js` raises a JS exception. Dispatch on
Expand Down
60 changes: 38 additions & 22 deletions src/runtime/shell/shell_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,36 @@ pub(crate) fn shell_cmd_from_js(

const MAX_TEMPLATE_ARRAY_DEPTH: u32 = 100;

/// The path of the `Bun.file(path)` that `store` is. As a redirect target that
/// file is a path word: the shell opens it itself, with its own cwd and the
/// flags of the operator. `None` for `Bun.file(fd)` and for a store over no file.
pub(crate) fn file_store_path<'a>(
global: &JSGlobalObject,
store: &'a crate::webcore::blob::Store,
) -> JsResult<Option<&'a [u8]>> {
let crate::webcore::blob::store::Data::File(file) = &store.data else {
return Ok(None);
};
let crate::node::PathOrFileDescriptor::Path(p) = &file.pathlike else {
return Ok(None);
};
let path: &[u8] = p.slice();

// Check for null bytes in path (security: prevent null byte injection)
if strings::index_of_char(path, 0).is_some() {
return Err(global
.err(
jsc::ErrorCode::INVALID_ARG_VALUE,
format_args!(
"The shell argument must be a string without null bytes. Received {}",
bun_core::fmt::quote(path)
),
)
.throw());
}
Ok(Some(path))
}

pub(crate) fn handle_template_value(
global: &JSGlobalObject,
template_value: JSValue,
Expand Down Expand Up @@ -333,29 +363,15 @@ pub(crate) fn handle_template_value(
}

if let Some(blob) = template_value.as_class_ref::<crate::webcore::Blob>() {
if let Some(store) = blob.store.get().as_deref() {
if let crate::webcore::blob::store::Data::File(file) = &store.data {
if let crate::node::PathOrFileDescriptor::Path(p) = &file.pathlike {
let path: &[u8] = p.slice();

// Check for null bytes in path (security: prevent null byte injection)
if strings::index_of_char(path, 0).is_some() {
return Err(global
.err(jsc::ErrorCode::INVALID_ARG_VALUE, format_args!(
"The shell argument must be a string without null bytes. Received {}",
bun_core::fmt::quote(path)
))
.throw());
}

if !builder.append_utf8::<true>(path)? {
return Err(global.throw(format_args!(
"Shell script string contains invalid UTF-16"
)));
}
return Ok(());
}
if let Some(store) = blob.store.get().as_deref()
&& let Some(path) = file_store_path(global, store)?
{
if !builder.append_utf8::<true>(path)? {
return Err(
global.throw(format_args!("Shell script string contains invalid UTF-16"))
);
}
return Ok(());
}

let idx = out_jsobjs.size();
Expand Down
30 changes: 11 additions & 19 deletions src/runtime/shell/states/Cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
use crate::shell::ExitCode;
use crate::shell::ast;
use crate::shell::builtin::{Builtin, Kind as BuiltinKind};
use crate::shell::interpreter::{CowFd, Interpreter, Node, NodeId, ShellExecEnv, StateKind, log};
use crate::shell::interpreter::{
CowFd, Interpreter, Node, NodeId, RedirectTarget, ShellExecEnv, StateKind, log,
};
use crate::shell::io::{IO, OutKind as IoOutKind};
use crate::shell::shell_body::subproc::{Readable, ShellSubprocess, StdioKind};
use crate::shell::states::assigns::{AssignCtx, Assigns};
Expand All @@ -23,6 +25,7 @@ pub(crate) struct Cmd {
pub(crate) io: IO,
pub(crate) state: CmdState,
pub args: Vec<Vec<u8>>,
/// The expanded word of a path redirect with its NUL, or empty when the word is ambiguous.
pub(crate) redirection_file: Vec<u8>,
pub(crate) redirection_fd: Option<*mut CowFd>,
pub(crate) exec: Exec,
Expand Down Expand Up @@ -718,15 +721,8 @@ impl Cmd {
return Ok(None);
};

match redirect {
ast::Redirect::JsBuf(val) => {
// Safe accessor — single `unsafe` deref lives in
// `Interpreter::global_this_ref`.
let global = interp
.global_this_ref()
.expect("JS values not allowed in this context");
let jsval = interp.jsobj(global, val.idx)?;

match interp.redirect_target(this, redirect)? {
RedirectTarget::Object(global, jsval) => {
if let Some(buf) = jsval.as_array_buffer(global) {
if flags.stdin() {
let bytes = buf.byte_slice();
Expand Down Expand Up @@ -799,8 +795,8 @@ impl Cmd {
)));
}
}
ast::Redirect::Atom(_) => {
if interp.as_cmd(this).redirection_file.is_empty() {
RedirectTarget::Path(path_buf, response) => {
if path_buf.is_empty() {
let argv0 = interp
.as_cmd(this)
.args
Expand All @@ -817,13 +813,6 @@ impl Cmd {
),
)));
}
let path_buf: Vec<u8> = {
let raw = &interp.as_cmd(this).redirection_file;
let len = raw.len().saturating_sub(1);
let mut v = raw[..len].to_vec();
v.push(0);
v
};
let path = bun_core::ZStr::from_buf(&path_buf, path_buf.len() - 1);
log!("Expanded Redirect: {}\n", bstr::BStr::new(path.as_bytes()));
let cwd_fd = interp.as_cmd(this).base.shell().cwd_fd;
Expand All @@ -849,6 +838,9 @@ impl Cmd {
};
interp.as_cmd_mut(this).redirection_fd = Some(CowFd::init(redirfd));
set_stdio_from_redirect(stdio, flags, redirfd);
if let Some(response) = response {
response.consume_body();
}
}
}
Ok(None)
Expand Down
22 changes: 22 additions & 0 deletions src/runtime/webcore/Body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,28 @@ impl Value {
}
}

/// The store of the `Bun.file()` that this body reads: the store of its Blob, or of an
/// unread stream over one, which [`Self::to_blob_if_possible`] turns back into that Blob.
pub(crate) fn file_store(
&self,
global: &JSGlobalObject,
) -> Option<bun_ptr::RefPtr<blob::Store>> {
match self {
Value::Blob(blob) if blob.needs_to_read_file() => blob.store.get().clone(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
robobun marked this conversation as resolved.
Resolved
Value::Locked(locked) if locked.promise.is_none() => {
let stream = locked.readable.get()?;
if stream.is_disturbed_or_locked(global) {
return None;
}
match stream.ptr.file()?.lazy.get() {
webcore::file_reader::Lazy::Blob(store) => Some(store.clone()),
webcore::file_reader::Lazy::None => None,
}
}
_ => None,
}
}

pub(crate) fn size(&mut self) -> blob::SizeType {
match self {
Value::Blob(b) => b.get_size_for_bindings() as blob::SizeType,
Expand Down
Loading
Loading