Skip to content

Blob: a null device part is an empty part - #43944

Open
robobun wants to merge 3 commits into
farm/a5b5509f/blob-file-part-bytesfrom
robobun/a5b5509f/blob-null-device-part
Open

robobun wants to merge 3 commits into
farm/a5b5509f/blob-file-part-bytesfrom
robobun/a5b5509f/blob-null-device-part

Conversation

@robobun

@robobun robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #33600, stacked on its branch. Related to #25851.

Problem

  • On the head of Blob: read file-backed parts in multi-part new Blob([...]) #33600, new Blob([Bun.file("/dev/null"), "x"]) throws TypeError: Blob parts backed by a pipe, socket or device cannot be read synchronously; await .bytes() or .arrayBuffer() first. main and Node v26.3.0 give a Blob of size 1.
  • push_file_part (src/runtime/webcore/Blob.rs) refuses each part that is not a regular file. That includes the null device.

Fix

  • A null device part is an empty part. A path is still not opened.
  • A part is the null device when it is a character device with the device number of the null device: major 1, minor 3 on Linux, the st_rdev of /dev/null or \\.\NUL elsewhere. This covers a path, an fd and a symbolic link.
  • Verified: test/js/web/fetch/blob.test.ts on Linux and on a Windows x64 build. The 2 new tests fail on the head of Blob: read file-backed parts in multi-part new Blob([...]) #33600 and on bun 1.4.2.

Background

Downsides

  • A character device part costs one compare on Linux and one more stat elsewhere. No other part reaches that code.
  • /dev/zero and a /dev/stdin that is a pipe still throw. Node gives an empty part for both.
  • Binary size: bun +0 bytes, bun-profile -3,440 bytes.
Notes

Results (release builds, Linux x64)

Node takes the length of a part from the stat size, so /dev/zero and a pipe are an empty part there. bun throws for them so that no bytes are dropped without a signal.

part of new Blob(["x", part]) main head of #33600 this PR Node v26.3.0
Bun.file("/dev/null") size 1 TypeError size 1 size 1
Bun.file(fd) on /dev/null size 1 TypeError size 1
a symbolic link to /dev/null size 1 TypeError size 1
Bun.stdin with < /dev/null size 1 TypeError size 1
Bun.file("/dev/zero") size 1 TypeError TypeError size 1
/dev/full, /dev/urandom, /dev/tty size 1 TypeError TypeError
Bun.stdin as a pipe size 1 TypeError TypeError
a regular file of 3 bytes size 1 size 4 size 4 size 4

main gives size 1 in each row because it drops each file-backed part. That is the bug that #33600 corrects. For the null device the result was right by accident.

Windows (debug build of main plus this branch)

Bun.file("/dev/null"), NUL, nul, \\.\NUL, os.devNull and an fd on os.devNull are an empty part. CON, \\.\CON and CONIN$ throw the TypeError. Bun.stdin as a pipe throws the TypeError. libuv gives st_rdev = FILE_DEVICE_NULL << 16 for the null device and other values for a console and a pipe.

Linux

The kernel fixes the null device at major 1, minor 3 (Documentation/admin-guide/devices.txt). So the check needs no path. A process that has no /dev in its root, with an fd on the null device from its parent, gets an empty part too. I could not run that case: chroot is not permitted in the build container.

Tests

  • bun bd test test/js/web/fetch/blob.test.ts (debug, ASAN): 122 pass, 1 skip (the EACCES test does not run as root). As uid 65534 the block gives 13 pass.
  • With src/ of the head of Blob: read file-backed parts in multi-part new Blob([...]) #33600: "a null device part is an empty part" and "only the null device is an empty part" fail with the TypeError. The other tests pass.
  • With bun 1.4.2, which drops each file part: the same 2 tests fail, because each of them has a regular file as one more part.
  • bun scripts/rust-check-all.ts for aarch64-apple-darwin, x86_64-pc-windows-msvc, aarch64-linux-android and x86_64-unknown-freebsd: 4 ok. cargo clippy -p bun_runtime has no finding in Blob.rs.
  • Windows x64 debug build, the block of Blob: read file-backed parts in multi-part new Blob([...]) #33600: 9 pass, 4 skip.
  • The /dev/zero case runs in a child process. If the rule became too wide, a read of /dev/zero in the test process would not return.

In-memory parts

The two call sites in from_js_without_defer_gc are not changed. The new code is in push_file_part, which is #[cold], and in is_null_device.

When #33600 merges

I will rebase this branch on main and set the base of this PR to main.


no test proof · iteration 11 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/blob.test.ts

…FO writer still waits

The three callers that join an array of parts get one test: the bytes of
a file part, and the synchronous ENOENT for a part that is missing.

The FIFO test waits until the writer runs, and asserts after the
constructor that the writer has not exited. A constructor that opens the
FIFO now fails that assertion and does not run into the timeout.
A part that is not a regular file throws a TypeError, and that included
the null device. main gave an empty part for it, and Node does too. The
null device has no bytes and a read of it cannot block, so it is an empty
part again.

The part is the null device when it is a character device with the
st_rdev of the platform's null device. That needs one more stat, and only
for a character device. A path is still not opened for it. Each other
device, a pipe and a socket still throw.
@robobun

robobun commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. This PR is stacked on #33600 (base farm/a5b5509f/blob-file-part-bytes). When #33600 merges, I rebase this branch on main and set the base to main.

Reproduce on the head of #33600: new Blob([Bun.file("/dev/null"), "x"]) throws the TypeError for a pipe, socket or device. main and Node v26.3.0 give a Blob of size 1.

Proof: bun bd test test/js/web/fetch/blob.test.ts -t "file-backed Blob part". With src/ of the head of #33600, the tests "a null device part is an empty part" and "only the null device is an empty part" fail. With this branch they pass (Linux x64 debug with ASAN, and a Windows x64 build).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the is_ok_and -> match rewrite of the pre-open stat check: a stat error still falls through to open(2) exactly as before, and error polarity is unchanged. The early Ok(()) for a null-device part pushes nothing to the joiner, which is the same shape as the existing cap == 0 empty-part return, and both push_file_part call sites in from_js_without_defer_gc go through the shared helper, so no sibling site is missed.

Extended reasoning...

The change is confined to push_file_part in src/runtime/webcore/Blob.rs (a new is_null_device helper and a closure-to-Result rewrite of the refusal path) plus new tests in test/js/web/fetch/blob.test.ts; it touches no auth, crypto, or injection surface. Three inline findings were posted (a per-call re-stat of /dev/null that fails in sandboxes without /dev, a test that does not fail under USE_SYSTEM_BUN=1, and a FIFO assertion that can pass while the FIFO was opened), so a human look is already signalled; this note only records the refactor and empty-part paths that were checked and found behavior-preserving.

Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread test/js/web/fetch/blob.test.ts
Comment thread test/js/web/fetch/blob.test.ts
On Linux the null device is major 1, minor 3, so no stat of /dev/null is
needed. A process that has no /dev in its root, with an fd on the null
device that it got from its parent, now also gets an empty part.

The tests for the null device now have a regular file as one more part,
so they fail on a build that drops each file part.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed the new commit (dd1127f) and found no bugs; it addresses the Linux device-number and test-liveness points from my earlier pass. A human look is still worthwhile because the is_null_device cfg branches for Windows and macOS cannot be type-checked or exercised from this Linux machine, and the PR is stacked on an unmerged base.

What was reviewed:

  • is_null_device: Linux makedev(1, 3) matches the kernel encoding linux_syscall.rs writes into st_rdev (0x103); the non-Linux branch compares against a runtime stat of /dev/null / \\.\NUL, whose libuv-derived st_rdev is FILE_DEVICE_NULL << 16 on Windows.
  • Both push_file_part sites (pre-open path stat, post-open fstat) route through the same no_bytes_or_refused closure; the stat-error arm still falls through to open so the path-qualified error is unchanged, and the directory path still reaches EISDIR.
  • New tests now include a regular-file sibling so a build that drops file parts fails them; the /dev/zero refusal runs in a child so a too-wide rule cannot hang the test process.
Extended reasoning...

The diff touches src/runtime/webcore/Blob.rs (a new is_null_device helper with linux/android, windows and other-unix cfg branches, wired into the two non-regular-file checks of the cold push_file_part) and test/js/web/fetch/blob.test.ts (three new or changed tests). No security-sensitive surface: it widens one refusal to return an empty part only for a character device whose st_rdev matches the platform null device. The bug hunt ran dry with no findings, and the latest commit addressed two of three prior inline notes; the third (the FIFO writer exitCode snapshot) remains an open optional thread. I could not run cargo check here, so the Windows and macOS cfg branches are reviewed by reading only, and the PR is stacked on #33600, which is why this is a defer rather than an approve.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant