Refuse an S3-backed Blob at spawn stdin, in the shell, in `Bun.build` files and in redis

RO
17d ago
+485-50

Related to #43931

Problem

  • An S3-backed Blob is read as empty at four sites: spawn stdin, a $ redirect, Bun.build files and redis arguments. There is no request and no error.
  • Where stdin is a pipe (off Linux, and in the shell), stdin never closes and the call never ends.
  • The guard needs_to_read_file() (src/jsc/webcore_types.rs:402) names only a file-backed Blob. Found by fuzzing: no user report.

Fix

  • BlobExt::in_memory_view() and the $ tag match the store kinds exhaustively. Stdio::extract_blob, the $ tag and BlobOrStringOrBuffer::from_js throw for an S3-backed Blob.
  • For stdin, extract_blob goes by the bytes, not the size field. A Blob emptied by new Response([blob]) no longer leaves stdin open.
  • Correct: an S3 object has no path or fd for the child. WebSocket.send (#45223) throws for the same input.
  • Verified: spawn-empty-arrayBufferOrBlob.test.ts, bunshell-file.test.ts, bundler_files.test.ts and buffer-operations.test.ts fail on 1.4.3. Also ran spawn.test.ts, bunshell.test.ts and bindgen. Self-reviewed: 9 concerns raised, 9 addressed.

Background

  • A Blob views a store: memory, a file, or an S3 key. Only memory has bytes at once. Stdio::extract_blob makes a Blob a child's stdin.
  • BlobOrStringOrBuffer::from_js parses Bun.build files and redis arguments. Its opt-out FileBlobs::Allow had one user, redis, and is deleted.
  • Considered || is_s3() at each guard: a fourth store kind passes them. Considered streaming into stdin: a failed GET exits 0.

Downsides

  • These now throw: echo hi < ${s3file} (printed hi), s3file.slice(0, 0) as stdin (was empty), redis.set(key, Bun.file(p)) (stored "").
  • An in-memory Blob stdin costs 69 to 82 instructions in extract_blob. .text: +256 bytes. No new allocation or syscall.
  • Still read as empty: socket.write(s3file), import() of an S3File blob: URL, new Blob(["x", s3file]) (#33600).
Notes

Origin. Found by fuzzing. No user reported these sites. #43931 described the class and was closed as not a user report. The only user reports in the class, #25851 and #28459, are about sites that this PR does not change.

Repro. A loopback Bun.serve stands in for S3 and counts requests. Linux x64.

const log = [];
const srv = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(q) { log.push(q.method); return new Response("0123456789"); } });
const s3 = new Bun.S3Client({ endpoint: srv.url.href, accessKeyId: "k", secretAccessKey: "s", bucket: "b" });
const p = Bun.spawn(["cat"], { stdin: s3.file("o"), stdout: "pipe" });
console.log(JSON.stringify(await p.stdout.text()), await p.exited, log); // main: "" 0 []
InputmainThis PR
Bun.spawn / Bun.spawnSync stdin or stdio[0]: an S3File, Bun.file("s3://..."), a slice, new Blob([s3file]), a Request body, new Response([s3file])child reads "", exit 0, 0 requests. With BUN_FEATURE_FLAG_DISABLE_MEMFD=1 (the path of macOS and Windows) the call never endsTypeError (ERR_INVALID_ARG_TYPE), 0 requests
$`cat < ${s3file}`, $`wc -c < ${s3file}`never endsthe tag throws before a command runs
$`echo hi < ${s3file}`prints hithe tag throws
$`echo hi > ${s3file}`throws when the command runs (Cannot redirect stdout/stderr to an immutable blob)the tag throws the S3 text, which names Bun.write for this direction
$`cat < ${new Response([s3file])}`never endscat as an external command: TypeError from spawn when the command starts. A builtin that reads stdin still gets "" (see below)
Bun.build({ files: { "/entry.js": s3file } })builds an empty moduleTypeError: File blob cannot be used here
redis.set(key, s3file), redis.set(key, Bun.file(path))sends SET key ""TypeError: File blob cannot be used here, no command sent
stdin or <: a Blob emptied by new Response([blob])never ends (spawn with memfd off, and the shell always)empty stdin, the command ends
stdin: await s3file.bytes(), < ${await s3file.bytes()}workwork
stdout: s3file, stdio[3]: s3fileexisting TypeError textsunchanged
stdio[3]: a Blob emptied by new Response([blob])TypeError: Blob cannot be used for stdio[3] yetunchanged (a new test pins it)
< ${Bun.file(fd)}, < ${Bun.file(path)}workunchanged (a new test pins the fd form)

Error texts.

  • spawn: An S3-backed Blob cannot be used as stdin. Pass await blob.bytes() instead (TypeError, ERR_INVALID_ARG_TYPE, like the other stdio refusals in extract_blob).
  • shell: An S3-backed Blob is not supported in a shell command yet. To redirect from it, pass its bytes: `< ${await blob.bytes()}`. To redirect to it, write the output of the command: `await Bun.write(blob, output)` (TypeError, ERR_INVALID_ARG_TYPE, the same name and code as the spawn refusal). The tag throws it before it can see the operator, so the text names both directions. A test runs both remedies against the S3 stand-in.
  • Bun.build files and redis: File blob cannot be used here (TypeError, ERR_INVALID_ARG_TYPE): the text that Bun.file already got in files, and that bindgen uses for both kinds.
  • The texts name await blob.bytes() and not blob.stream(): with a stream as stdin a failed GET is lost (the child reads "" and exits 0). That is handed off.

Why a throw and not a read. A file-backed Blob reaches the child as a path or an fd, and Bun never reads it. An S3 object has neither, and spawnSync cannot wait for a GET. Node also refuses when the call is made: a Blob in stdio throws ERR_INVALID_ARG_VALUE from spawn (v26.3.0).

Measurements. Release builds (--profile=release, ThinLTO) of main 6292c6539d and of that commit plus the commits of this PR, Linux x64. Instruction counts are gdb stepi from the first instruction of the function to its return, callees included, 3 runs each, every count repeated. The numbers are of the last commit (c200a79c58), except where a line names the first push.

  • extract_blob, in-memory Blob stdin: 69 -> 82 instructions per call (gdb stepi, release, main 6292c6539d vs PR)
  • Blob__sharedView, in-memory Blob: 48 -> 48 instructions per call
  • extract("ignore") 195 -> 195, template string 503 -> 504 instructions (expected equal). socket.write(string) 1414 -> 1414 at the first push: no commit of this PR changes that function
  • release binary: .text +256 bytes, .rodata +0 bytes (llvm-size -A, main 6292c6539d vs PR). 0 host functions
  • Blob-stdin spawnSync: 5 syscalls, 77 allocator calls per call on main. 5 and 77 on PR. spawn_memfd per S3 stdin: 1 -> 0 (taken at the first push. The later commits add no call on this path: the 82 instructions of extract_blob include its callees)
  • S3 requests across 63 refusing calls: 0 (expected 0). Dummy 4th store kind: 20 compile errors
  • File-only guard before a byte accessor: 8 sites on main, 6 on PR (write_or_end and its node:http twin, shell builtin stdin for a Response body, the blob: module source and its bundler twin, the Bun.serve Blob body). Conflicted files vs #46522/#46528/#46491/#41193: 0/0/0/0 new (#46528 conflicts with main already in 29 files, #41193 in 3)
  • BlobOrStringOrBuffer parse (Bun.build files): a 17-byte string 388 -> 387, an in-memory Blob 199 -> 195 instructions

Where the numbers that are not equal come from:

  • extract_blob (+13): one match on the store kind, the length of the view and the tests on it, where main loads the size field. The first push measured 88. The form with early returns that the review led to measures 82.
  • template string (+1): the prologue of handle_template_value spills depth (two stack moves) and runs one alignment nop less. No branch is added on that path.
  • .text (+256): extract_blob +94 bytes, handle_template_value +47, the parser -27 (from_js_maybe_file 849, now from_js 822), and section alignment.
  • The merge check used git merge-tree against the heads of the listed PRs. #42786 has a one-hunk conflict with this PR in docs/runtime/shell.mdx: both add a paragraph after the same list.

How the syscall and allocator numbers were taken: strace is not installed here, so gdb counted catch syscall stops (memfd_create, ftruncate, pwrite64, pipe2, close, socketpair) and breakpoint hits on mi_malloc, mi_zalloc, mi_realloc in the parent, for loops of 100 and 200 Bun.spawnSync({ cmd: ["true"], stdin: blob }). The numbers are the difference divided by 100.

The fourth-store-kind check: with a dummy variant added to store::Data, cargo check stops in bun_jsc (5 errors) and, with those patched, in bun_runtime (15 errors). Two of the 15 are new with this PR: in_memory_view (Blob.rs) and handle_template_value (shell_body.rs). That was run on the first shape of this PR. The two matches are unchanged since.

Sites that an S3-backed Blob still passes, with their trackers.

  • socket.write(s3file) and socket.end(s3file) send 0 bytes. write() and end() are typed string | BufferSource, and #46522 rewrites that function. Handed off, with measurements: a guard that only tests costs 15 instructions on each socket.write(string), a guard that returns the bytes saves 16.
  • < ${response} when the body is an S3 Blob and the command is a shell builtin that reads stdin (cat on Windows): "". Handed off to #42786, which owns the check of a Response body.
  • import() of a blob: URL (src/runtime/jsc_hooks.rs) and its bundler twin (src/bundler/options.rs): an empty module. Handed off.
  • new Blob(["x", s3file]) drops the S3 part when it is made, so every consumer sees "x" (#33600, reported for files as #25851).
  • A Bun.serve response whose body is an S3 Blob made by new Response([s3file]): empty. Tests from #46021 pin it.
  • Bun.Archive entries (#41193), FormData parts (#38364), structuredClone (#37905), formData() (#45161), FormData.from(blob), a macro that returns the Blob.
  • The node:http twin of the socket guard (src/runtime/socket/uws_jsc.rs): node:http refuses a Blob chunk in JS before it, and #46528 deletes the function.
  • Not an S3 site, same function: Bun.file(path).slice(a, b) as stdin gives the child the whole file (#44301, #41209).

Adjacent, not changed. stdin: s3file.stream() works, but a failed GET is lost: the child reads "" and exits 0. It is the same for every ReadableStream stdin that errors. Handed off (#36236 had a fix).

Redis. #36836 made the same change for redis with its own text. This PR takes its test (a local listener that answers HELLO and records commands, no Redis server) and refuses in the shared parser, so #36836 can close.

Tests.

  • The new tests are in test/js/bun/spawn/spawn-empty-arrayBufferOrBlob.test.ts (13), test/js/bun/shell/bunshell-file.test.ts (3), test/bundler/bundler_files.test.ts (1) and test/js/valkey/unit/buffer-operations.test.ts (2).
  • With the released 1.4.3 (USE_SYSTEM_BUN=1): 10 of the 13 spawn tests fail (2 by timeout, because the child never ends), the shell S3 test fails and the moved-out Blob test times out, the bundler test fails, and 1 of the 2 redis tests fails. All pass with the change. The Bun.file(fd) redirect test, the three spawn controls and the in-memory redis test pass on both: they pin what must not change.
  • Three mutations of the first shape each failed a named test: size-based emptiness in extract_blob (the zero-size cases and both moved-out cases), and two that belonged to hunks that left this PR.
  • Full files with the change, debug build: spawn-empty-arrayBufferOrBlob.test.ts 23 pass, bunshell-file.test.ts 4 pass, bundler_files.test.ts 29 pass, buffer-operations.test.ts 2 pass (9 need Docker). bunshell.test.ts and spawn.test.ts ran twice each with the change. Each had one clean run and one run with a single timeout in a test that this PR does not touch: subshell > sharp installs from the npm registry, and stdout can be read took 2.5 s alone on that build (it passed alone 3 of 3). On the debug build of the later commits the same test takes 4.9 s to 5.0 s alone, at its 5 s limit. It uses stdin: "ignore" and stdout: "pipe", no Blob, and CI ran spawn.test.ts green on every lane for the first push.
  • Also green: test/internal/bindgen.test.ts, bun-cryptohasher.test.ts, websocket-blob.test.ts, bun-serve-static.test.ts, s3-moved-store.test.ts, spawnSync.test.ts, spawn-empty-arrayBufferOrBlob.test.ts, shell yield, file-io, throw, bunshell-file, test/integration/bun-types/bun-types.test.ts.
  • bun run rust:check-all: 12 targets ok (run on the first shape. The re-cut removed code and added none that is platform-specific).

After the review of the first push. Four things changed. A Blob that has a size and no bytes is refused at an extra slot again, as on main: the first push left stdio[3] closed with no error (one mutation run confirms the new test fails on that form). The shell refusal is a TypeError with ERR_INVALID_ARG_TYPE. Its text names the write remedy. The emptiness tests in extract_blob are two plain conditions. One thing did not change: spawn takes the body of a Request or Response before extract_blob refuses it, so bodyUsed is true after the S3 refusal. The two older refusals in that function do the same on 1.4.3, and #41812 is the open PR about when that body is taken.

Self-review. A review of the first shape asked for a re-cut. Done: the socket guard and the Response case left this PR (handed off), redis is in, the error texts name the value to pass, the docs do not list APIs as if the list were complete, and the tests got the zero-length slice, the s3:// spelling, a Bun.file(fd) control and a check that can see a stray request. Not done: the review also offered to write the two shared_view() roots as exhaustive matches. Other open PRs rewrite those lines, so this PR narrows its claim and leaves them.


[human-review] gate passed · iteration 0 · 14 files touched

fails on main (without fix)
ASAN without fix: 14 failed, 9 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_files.test.ts test/js/bun/shell/bunshell-file.test.ts test/js/bun/spawn/spawn-empty-arrayBufferOrBlob.test.ts test/js/valkey/unit/buffer-operations.test.ts
bun test v1.4.4 (367d939d9)

test/bundler/bundler_files.test.ts:
(pass) bundler files option > basic in-memory file bundling [216.79ms]
(pass) bundler files option > in-memory file with imports [68.76ms]
(pass) bundler files option > in-memory file with relative imports (same directory) [91.69ms]
(pass) bundler files option > in-memory file with relative imports (subdirectory) [62.24ms]
(pass) bundler files option > in-memory file with relative imports (parent directory) [64.87ms]
(pass) bundler files option > in-memory file with relative imports between multiple files [84.50ms]
(pass) bundler files option > in-memory file with nested imports [80.24ms]
(pass) bundler files option > in-memory file with TypeScript [62.54ms]
(pass) bundler files option > in-memory file with JSX [330.16ms]
(pass) bundler files option > in-memory file with Blob content [78.89ms]
(pass
... (truncated)

release without fix: 21 failed, 9 skipped
bun test v1.4.3-canary.1 (367d939d9)

test/bundler/bundler_files.test.ts:
(pass) bundler files option > basic in-memory file bundling [332.71ms]
(pass) bundler files option > in-memory file with imports [10.02ms]
(pass) bundler files option > in-memory file with relative imports (same directory) [5.29ms]
(pass) bundler files option > in-memory file with relative imports (subdirectory) [4.25ms]
(pass) bundler files option > in-memory file with relative imports (parent directory) [2.92ms]
(pass) bundler files option > in-memory file with relative imports between multiple files [5.32ms]
(pass) bundler files option > in-memory file with nested imports [4.44ms]
(pass) bundler files option > in-memory file with TypeScript [2.33ms]
(pass) bundler files option > in-memory file with JSX [23.58ms]
(pass) bundler files option > in-memory file with Blob content [4.29ms]
(pass) bundler files option > in-memory file with a file-backed Blob is rejected [2.26ms]
265 |           entrypoints: ["/entry.js"],
266 |           files: {
267 |             "/entry.js": content,
268 |           },
269 |         }),
270 |       ).toThrow(
              ^
error: expect(received).toThrow(expect
... (truncated)
passes on PR (with fix)
ASAN with fix: 9 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_files.test.ts test/js/bun/shell/bunshell-file.test.ts test/js/bun/spawn/spawn-empty-arrayBufferOrBlob.test.ts test/js/valkey/unit/buffer-operations.test.ts
rustup spent 8s installing the pinned toolchain (nightly-2026-09-15); it was missing or incomplete on this machine
bun test v1.4.4 (367d939d9)

test/bundler/bundler_files.test.ts:
(pass) bundler files option > basic in-memory file bundling [167.07ms]
(pass) bundler files option > in-memory file with imports [65.05ms]
(pass) bundler files option > in-memory file with relative imports (same directory) [54.73ms]
(pass) bundler files option > in-memory file with relative imports (subdirectory) [47.78ms]
(pass) bundler files option > in-memory file with relative imports (parent directory) [87.50ms]
(pass) bundler files option > in-memory file with relative imports between multiple files [64.48ms]
(pass) bundler files option > in-memory file with nested imports [57.10ms]
(pass) bundler files option > in-memory file with TypeScript [58.91ms]
(pass) bundler files option 
... (truncated)

release with fix: 9 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     1b26951ce4
  features     lto, baseline

23 deps, 396 codegen, 1302 objects in 1912ms

ninja: Entering directory `/workspace/bun/build/release'
[1/4] fetch lolhtml
[lolhtml] up to date
[2/4] fetch rust-argon2
[rust-argon2] up to date
[2/4] cargo plan → /workspace/bun/build/release/rust-target/plan.json
282 units: 194 lib, 16 proc-macro (host), 27 custom-build (host), 23 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[3/4] reconfigure
[1/1672] mkdir stamps
[2/1672] mkdir codegen
[3/1672] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[4/1672] install /workspace/bun
bun install v1.4.3-canary.1 (367d939d9)

Checked 22 installs across 44 packages (no changes) [5.03s]
[5/1672] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (367d939d9)

Checked 1 install across 2 packages (no changes) [214.00ms]
[6/1672] rustc unicode_ident 
[7/1672] rustc unicode_xid 
[8/1672] gen string-map defines_table
[9/1672] rustc heck 
[1
... (truncated)
diff hotspot
docs/runtime/child-process.mdx                     |   2 +
 docs/runtime/s3.mdx                                |   2 +
 docs/runtime/shell.mdx                             |  10 ++
 packages/bun-types/bun.d.ts                        |   7 +-
 src/runtime/api/bun/spawn/stdio.rs                 |  11 +-
 src/runtime/node.rs                                |   2 +-
 src/runtime/node/types.rs                          |  26 +----
 src/runtime/shell/shell_body.rs                    |  47 +++++---
 src/runtime/valkey_jsc/js_valkey_functions.rs      |   6 +-
 src/runtime/webcore/Blob.rs                        |  14 ++-
 test/bundler/bundler_files.test.ts                 |  43 +++++++
 test/js/bun/shell/bunshell-file.test.ts            |  81 +++++++++++++
 .../spawn/spawn-empty-arrayBufferOrBlob.test.ts    | 129 +++++++++++++++++++++
 test/js/valkey/unit/buffer-operations.test.ts      | 127 ++++++++++++++++++++
 14 files changed, 457 insertions(+), 50 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                     reads  edits  tests
docs/runtime/child-process.mdx                               0      0     27
docs/runtime/s3.mdx                                          0      0     27
docs/runtime/shell.mdx                                       0      0     27
packages/bun-types/bun.d.ts                                  0      0     27
src/runtime/api/bun/spawn/stdio.rs                           1      1     27
src/runtime/node.rs                                          0      0     27
src/runtime/node/types.rs                                    1      0     28
src/runtime/shell/shell_body.rs                              1      0     28
src/runtime/valkey_jsc/js_valkey_functions.rs                0      0     27
src/runtime/webcore/Blob.rs                                  2      2     27
test/bundler/bundler_files.test.ts                           1      1     17
test/js/bun/shell/bunshell-file.test.ts                      0      0      7
test/js/bun/spawn/spawn-empty-arrayBufferOrBlob.test.ts      0      0      7
test/js/valkey/unit/buffer-operations.test.ts                0      0     14