fetch: reject file: URLs whose path cannot be read - #37425
Conversation
fetch("file:///missing") resolved with a 200 Response wrapping a lazily
opened file blob, so ENOENT (or EISDIR for a directory) only surfaced
once the body was read. Stat the path in the file: branch and reject
the fetch() promise with the system error instead. Byte-backed blobs
(files embedded in a standalone executable) are left alone.
The url string is now created after the check so the early return has
no +1 ref to release; the leak test for it fetches a file that exists
since a missing one no longer reaches that code.
WalkthroughChangesFile URL fetch behavior
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 8:44 AM PT - Aug 11th, 2026
✅ @robobun, your commit 0ca5f6383c68012a6cd976adee7b1e5aa181f364 passed in 🧪 To try this PR locally: bunx bun-pr 37425That installs a local version of the PR into your bun-37425 --bun |
|
Status: reproduced on the 1.4.0 release with |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Since it changes user-visible fetch("file://…") semantics (resolve→reject) and picks a specific error shape over alternatives like a 404 Response, a maintainer should confirm the API decision.
What was reviewed:
file_url_unreadable_error:Blobdrop releases theStoreRefon the reject path;path_buffer_pool::get()guard returns on drop;with_pathclones so no borrow of the scratch buffer escapes.url_stringreordering after the early return — no leak of the +1 ref, and the leak test still guards the path it was written for.- Embedded-file bypass: byte-backed blobs skip the stat via the
store::Data::Fileguard; covered by the new compile test. bun_sys::stataccepts theslice_zpath on both POSIX and Windows (libuv).
Extended reasoning...
Overview
The PR makes fetch() of a file: URL reject up front when the path cannot be read (does not stat, or is a directory), instead of resolving with a 200 Response whose body reader later throws. It adds a helper file_url_unreadable_error in src/runtime/webcore/fetch.rs, reorders the url_string +1-ref creation past the new early return, updates the fetch(file://) leak test to use an existing file, and adds coverage in fetch.test.ts (ENOENT via string/URL/Request, EISDIR, /dev/null still resolves, unhandled-rejection reporting) plus a bundler_compile test for embedded files.
Security risks
None. The change adds a stat of a local path the caller already controls; no new untrusted-input parsing, no auth/crypto/permissions surface.
Level of scrutiny
Medium-high. The native change is small and I checked it for the review guide's memory-safety rules: the Blob returned by find_or_create_file_from_path holds its store via StoreRef, whose Drop decrements the refcount, so the early-return path does not leak; the pooled path buffer is a Guard returned on drop; err.with_path(path.slice()) clones into a Box so nothing borrows the scratch buffer past the call. The url_string move is correctly justified — it is a bare +1 that only Response::init releases.
However, this is a user-facing behavior change to a public Web API. The author explicitly chose reject-with-system-error over a 404 Response (and over a bare TypeError), and switched to JSPromise::rejected_promise so an uncaught call now reports as an unhandled rejection. The PR description argues each choice well (Deno precedent, Fetch spec network error, symmetry with the blob: branch and the request-body-is-Bun.file(missing) path), but per the repo's review guidance API-shape decisions like this warrant maintainer sign-off rather than bot approval.
Other factors
Test coverage is thorough and follows harness conventions (tempDir, using, drained pipes, exit-code asserted last, skipIf(isWindows) for /dev/null). The leak-test rewrite keeps guarding the same leak (author re-verified by reintroducing .clone()). I also considered whether the synchronous stat on every fetch(file://) is acceptable — the author's rationale (matches Bun.file().size/.exists() and Bun.serve's file rendering) seems sound, but it is one more thing a maintainer may want to weigh.
|
On the API point above, for whoever reviews this: the shapes I considered and did not pick, so it is easy to ask for one of them instead.
Either of the first two is a small change to |
…ry in tests Materialize the rejection with to_type_error_instance so it has the same shape as every other fetch() rejection (ValueError::SystemTypeError), with the system error fields unchanged. Tests now assert the class on every rejection and cover the edges of the check: EACCES reported by stat on an unsearchable parent rejects, a mode 000 file still resolves and fails from the body read, and a FIFO with no writer resolves without being opened.
|
Two changes since the first revision, both from self-review:
The remaining pushes only shortened comments. Description updated to match. |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes the user-facing behavior of fetch() for file: URLs — and the author has explicitly laid out alternative API shapes (reject vs 404, sync stat vs async) for a reviewer to weigh in on — a maintainer look would still be worthwhile.
What was reviewed:
file_url_unreadable_error— checked that byte-store / fd-backed blobs and successfulstatfall through unchanged, and that the reportedpathis the blob's own path rather than the\\?\-prefixed scratch buffer.- The new early return —
url_string's +1 ref is now created only after the check passes, and the updatedfetch-leak.test.tsstill exercises the code path it was written to guard. - Test coverage — ENOENT/EISDIR/EACCES-from-stat reject; mode-000 file, FIFO,
/dev/null, and standalone-embedded files still resolve; unhandled rejection is reported.
Extended reasoning...
Overview
This PR makes fetch() of a file: URL reject up front (with a TypeError carrying code/syscall/path/errno) when the path cannot be stat'd or is a directory, instead of resolving with a 200 Response whose body read later throws. It touches src/runtime/webcore/fetch.rs (a ~25-line helper plus reordering of the url_string assignment), adds a new test block in test/js/web/fetch/fetch.test.ts, adds a compile test for embedded files in bundler_compile.test.ts, and updates fetch-leak.test.ts so its url_string leak guard still reaches the code it protects.
Security risks
None identified. The change adds a synchronous stat of a path the caller already controls; no new untrusted input is parsed and no security check is weakened. The path reported in the error is the same path that was already reported by the deferred .text() failure.
Level of scrutiny
This is a user-visible behavior change to a Web API (fetch). The implementation itself is small and well-tested, but the shape is an API design decision: reject-with-TypeError (chosen here, matching Deno and the blob: branch a few lines up) vs. a 404 Response; and a synchronous stat on the JS thread vs. deferring to the thread pool. The author has explicitly listed these alternatives in a PR comment and framed file_url_unreadable_error as easy to swap if a maintainer prefers a different shape. Per the repo's "API design" review guidance, a maintainer should sign off on that choice — it is not something an automated review should ratify on its own.
Other factors
The test coverage is thorough: it pins both sides of the boundary (what rejects, what still resolves), covers all three input forms (string/URL/Request), skips permission tests correctly on Windows and as root, and adds a compile test proving embedded standalone-executable files (byte-backed stores) are not broken by the new check. The comment-cop bot's inline notes on comment length have all been addressed and marked resolved. Using JSPromise::rejected_promise here (rather than the dangerously_... variant used by neighboring exits) is a deliberate divergence explained in the description and covered by the unhandled-rejection test.
…le fetch into the existing compile test The leak test kept a flat 20 MiB bound above a ~17 MiB raw signal, so on non-ASAN builds it only tripped thanks to noise. Warm up longer, collect more often, and branch the bound on isASAN like the other tests in the file (leaking vs fixed: 26-27 vs 8-10 MiB on debug+ASAN, 19-21 vs ~2 MiB on release). compile/EmbeddedFileOutfile already builds the fixture the new compile test needed, so assert the fetch() there instead of building a second executable per lane.
|
0ca5f63 is test-only: the #37501 is the same error-shape change for the upload direction (a |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes user-facing fetch("file://...") behavior (previously-resolving calls now reject) and the author explicitly flagged the API shape — TypeError-with-system-error-fields vs. 404 Response vs. plain TypeError — as an open question for a maintainer, a human sign-off on that choice would be worthwhile.
What was reviewed:
file_url_unreadable_errorcontrol flow: byte-store / fd-backed blobs and non-directory stats fall through to the existing 200 path; only stat failures and directories reject.- Resource lifetime on the new early return:
url_string's +1 ref is now created after the check, andfile_blob'sStoreRefis released viaDrop— no new leak. - Test coverage pins both edges (FIFO/char-device/mode-000 file still resolve; ENOENT/EISDIR/unsearchable-parent reject) and the embedded-file exemption.
- The updated leak test still exercises the code path it guards (file now exists so
url_stringis reached).
Extended reasoning...
Overview
The PR adds a synchronous stat pre-check to the file: branch of fetch_impl in src/runtime/webcore/fetch.rs. When the path does not stat or is a directory, fetch() now rejects with a TypeError carrying code/syscall/path/errno, instead of resolving to a 200 Response whose body read later fails. The url_string +1 ref creation is moved after the new early return so it cannot leak. Tests are added/updated in fetch.test.ts (7 new cases covering ENOENT/EISDIR/EACCES-from-stat rejection, FIFO/char-device/mode-000-file resolution, and unhandled-rejection reporting), fetch-leak.test.ts (updated to fetch an existing file so it still reaches url_string, with an isASAN-branched threshold), and bundler_compile.test.ts (embedded files in a standalone executable still resolve).
Security risks
None identified. The change adds a read-only stat of a path that the existing code already opens on body read; no new attack surface. The path comes from the user's own fetch() argument.
Level of scrutiny
This warrants human review because it is a user-facing behavioral change to a core Web API. Code that today does const r = await fetch("file:///maybe-missing"); if (r.ok) ... will now throw at the await. The author laid out three viable API shapes (reject-with-system-TypeError, 404 Response, plain TypeError à la Deno) and explicitly asked for a maintainer to pick one. That is the API-design category the review guidelines reserve for maintainers. The synchronous stat on the JS thread is also a design choice (well-justified against Bun.file().size/Bun.serve precedent, but still a choice).
Other factors
- The implementation itself is small, well-scoped, and thoroughly tested (both the rejecting and non-rejecting boundaries are pinned; the embedded-file exemption is covered).
- I checked that dropping
file_blobon the early return releases itsStoreref viaStoreRef'sDrop(src/jsc/webcore_types.rs:123-125), so the new return does not introduce a leak. - All comment-cop threads are resolved (comments reduced to single lines).
- No prior human reviews; CI build 92325 was in progress at the time of review.
What does this PR do?
fetch()of afile:URL resolved with a 200Responseno matter what the path pointed at. The error only showed up when the body was read:Same thing for a directory (
fetch("file:///tmp")is a 200,.text()throws EISDIR).Cause: the
file:branch offetch_impl(src/runtime/webcore/fetch.rs) builds theResponsearound aBun.file()-style blob, which does not touch the file until it is read, and hardcodesstatus_code: 200. Nothing in between looks at the disk.Fix: after the blob is created,
statthe path and reject thefetch()promise instead of building theResponse:code,syscall: "stat",path,errno)syscall: "read", the error the body read would have produced)/dev/null, keeps resolving as beforeThe rejection is the system error materialized as a
TypeError(SystemError::to_type_error_instance), which is the shape every otherfetch()rejection has had since #35855 (ValueError::SystemTypeError):err instanceof TypeErrorholds, anderr.code === "ENOENT"checks that today run against the.text()error keep working.The check only runs for path-backed file blobs. Files embedded in a standalone executable come back from
find_or_create_file_from_pathas byte-backed blobs and do not exist on disk, so they are left alone (covered by a new compile test).Why this shape
Responseis whatfetch()already does for an unresolvableblob:URL a few lines up, what Deno does for a missingfile:URL, and what the Fetch spec asks for when afile:fetch cannot be served (a network error, which is aTypeErrorat the JS level).fetch()has no status to map ENOTDIR, ELOOP or EISDIR onto anyway. This also mirrors the upload direction in the same function, wherefetch(url, { body: Bun.file(missing) })already rejects with the open error up front.statrather than an eageropen: nothing here needs the fd (the body reader opens the path itself, and a path-backed store is what keepsresponse.clone()working), and opening a FIFO or a device just to close it again has side effects, or blocks when a FIFO has no writer.stathas none of that. There is a FIFO test that blocks under an eager blocking open.access(2)can disagree withopen(2)(real vs effective ids, NFS), and a pre-flight must never reject a file the read would have succeeded on. That case still resolves and the EACCES still comes from the body read, exactly as today; a test pins that boundary from both sides.Bun.file().size/.exists()and theopen/fstatthatBun.servedoes when rendering a file-backedResponse. It is onestatof a local path.JSPromise::rejected_promise, so an uncaughtfetch("file:///missing")is reported as an unhandled rejection (tested). The older helper used by the other early exits in this function suppresses that reporting; switching those call sites is tracked separately.url_stringis a bare +1 ref that onlyResponse::initreleases, so it is now created after the check. The existing leak test for that ref (fetch-leak.test.ts) fetched a path that does not exist, which no longer reaches the code it guards, so it now fetches a file that does exist. While there, its flat 20 MiB bound (over a ~17 MiB raw signal) was replaced by a longer warm-up and anisASAN-branched bound like the other tests in that file. Measured withurl_string.clone()temporarily re-added vs fixed: 26-27 vs 8-10 MiB on debug+ASAN (bound 18); on the release build ~2 MiB fixed and 19-21 MiB with the leak approximated by retaining one equal-sized string per call (bound 12).fetch_impl) already does;HTTPRequestBodyhas noDrop, so a body passed to afile:fetch is retained today regardless of this change. That is a separate pre-existing leak and is being handled on its own.How did you verify your code works?
New tests in
test/js/web/fetch/fetch.test.ts(fetch() file:// that cannot be read), every rejection also asserted to be aTypeError:URLandRequestinput (a sibling that exists still resolves)statfor a file under a mode 000 directory (skipped on Windows and as root).text()rejects with the EACCES fromopen(skipped on Windows and as root)/dev/nullresolves with an empty body (skipped on Windows)TypeError: ENOENT ...and exits 1The ENOENT, EISDIR, EACCES-from-stat and uncaught cases fail on the current release (
fetch()resolves) and pass with this change; the other three pass on both and pin the boundary. I ran the block both as root and as an unprivileged user so the two permission tests executed.compile/EmbeddedFileOutfileintest/bundler/bundler_compile.test.ts(which already builds an executable with an embedded file) now also checks thatfetch(pathToFileURL(embeddedPath))returns the embedded contents; I confirmed by hand that the/$bunfs/...path involved does not exist on disk.Also run locally with the debug build: the pre-existing
fetch() file:// workstest, the updated leak test,cargo clippy -p bun_runtime. The rest offetch.test.tshas the same failures with and without this change in my environment (localhost connections refused, and(with gc)tests exceeding their timeout under the debug build).