From 2fec836e791546c0315e328b2fb3584da7b07f8f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:57:57 +0000 Subject: [PATCH 01/11] error: do not read a frame's source URL from disk unless the loader loaded it The error printer builds a code-frame excerpt for the top stack frame. For a frame parsed out of an `error.stack` string, it read the frame's source URL from disk with the module fetcher and printed the file contents. The running code chooses that source URL. A `//# sourceURL` directive, or a node:vm `filename`, sets it. So code run through node:vm could name any path and make the host print that file: vm.runInNewContext( 'throw new Error("x")\n//# sourceURL=/etc/hostname', {}, { filename: "sandbox.js" }); The contents of `/etc/hostname` then appear in `console.error`, the uncaught and unhandled-rejection printers, `Bun.inspect`, and the default Bun.serve 500 body. A whole file is read for a six-line excerpt, so a large named file drives RSS to the file size. A name with an interior NUL byte aborts the printer on a debug or ASan build. Only read the source from disk when the module loader loaded that URL. The stack-string branch now checks the source-map table, which records a path only when the loader transpiles it. The other branch already resolves through that table. A real module still shows its original source. An attacker-named path shows no excerpt. --- src/jsc/SavedSourceMap.rs | 13 ++++ src/jsc/VirtualMachine.rs | 16 +++++ .../__snapshots__/vm-sourceUrl.test.ts.snap | 6 +- test/js/node/vm/vm-sourceUrl.test.ts | 67 ++++++++++++++++++- 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/jsc/SavedSourceMap.rs b/src/jsc/SavedSourceMap.rs index 7edf2e3ad7d0..7069b354e7d8 100644 --- a/src/jsc/SavedSourceMap.rs +++ b/src/jsc/SavedSourceMap.rs @@ -252,6 +252,19 @@ impl SavedSourceMap { Ok(()) } + /// Returns `true` when a source map is registered for `path`. The module + /// loader registers a path here only when it loads and transpiles that + /// path. The error printer uses this to decide whether a stack frame's + /// source URL names a module the loader actually loaded, before it reads + /// that path from disk for a code-frame excerpt. + pub(crate) fn has_mapping(&mut self, path: &[u8]) -> bool { + let h = hash(path); + self.lock(); + let found = self.map.contains_key(&h); + self.unlock(); + found + } + /// You must call `sourcemap.map.deref()` or you will leak memory fn get_with_content( &mut self, diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index aa9cf30360e9..3875671d8bad 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5833,6 +5833,19 @@ impl VirtualMachine { } let already_remapped = frames[top].remapped; + // Only read a frame's source URL from disk for a code-frame excerpt + // when the module loader loaded that URL. The already-remapped branch + // parses frames out of an `error.stack` string, so the URL can be a + // name the running code chose (a `//# sourceURL` directive in node:vm + // or eval code), not a module the loader ever loaded. The non-remapped + // branch resolves through the source-map table, which is already the + // loaded-module check. + let allow_source_from_disk = if already_remapped { + let url = frames[top].source_url.to_utf8(); + self.source_mappings.has_mapping(url.slice()) + } else { + true + }; let resolved = { let top_source_url = frames[top].source_url.to_utf8(); let maybe_lookup: Option = if already_remapped { @@ -5900,6 +5913,9 @@ impl VirtualMachine { // Avoid printing "export default 'native'" break 'code bun_core::Utf8Bytes::EMPTY; } + if !allow_source_from_disk { + break 'code bun_core::Utf8Bytes::EMPTY; + } let mut log = bun_ast::Log::default(); let Ok(original_source) = Self::fetch_without_on_load_plugins( self, diff --git a/test/js/node/vm/__snapshots__/vm-sourceUrl.test.ts.snap b/test/js/node/vm/__snapshots__/vm-sourceUrl.test.ts.snap index 650537102d0a..e8fde2b376ca 100644 --- a/test/js/node/vm/__snapshots__/vm-sourceUrl.test.ts.snap +++ b/test/js/node/vm/__snapshots__/vm-sourceUrl.test.ts.snap @@ -8,7 +8,7 @@ throw new Error("hello"); Error: hello at hellohello.js:2:16 at runInNewContext (unknown) - at (:6:5)" + at (:8:5)" `; exports[`can get sourceURL inside node:vm 1`] = ` @@ -17,7 +17,7 @@ exports[`can get sourceURL inside node:vm 1`] = ` error: hello at hello (hellohello.js:4:24) at hellohello.js:7:6 - at (:21:15) + at (:23:15) " `; @@ -27,6 +27,6 @@ exports[`eval sourceURL is correct 1`] = ` error: hello at hello (hellohello.js:4:24) at eval (hellohello.js:7:6) - at (:39:15) + at (:41:15) " `; diff --git a/test/js/node/vm/vm-sourceUrl.test.ts b/test/js/node/vm/vm-sourceUrl.test.ts index b5b8d5dfb58e..79d882b1c1a1 100644 --- a/test/js/node/vm/vm-sourceUrl.test.ts +++ b/test/js/node/vm/vm-sourceUrl.test.ts @@ -1,5 +1,7 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; import { runInNewContext } from "node:vm"; +import path from "node:path"; test("can get sourceURL from eval inside node:vm", () => { try { @@ -50,3 +52,66 @@ hello(); ); expect(err.replaceAll(import.meta.path, "")).toMatchSnapshot(); }); + +const CANARY = "SECRET_CANARY_DO_NOT_LEAK_8f2a"; + +// The error printer reads a frame's source file from disk for a code-frame +// excerpt. A `//# sourceURL` directive, or a node:vm `filename`, lets the code +// that throws choose that source URL. The printer must not open a file the +// module loader never loaded. +describe.concurrent("error printer does not read attacker-named source files", () => { + for (const caught of [true, false]) { + for (const nul of [false, true]) { + const label = `${caught ? "caught" : "uncaught"}${nul ? " with interior NUL in the path" : ""}`; + test(`vm sourceURL does not leak a file's contents (${label})`, async () => { + using dir = tempDir("vm-sourceurl-leak", { + "secret.txt": CANARY + "\n", + "run.js": ` + const vm = require("node:vm"); + const target = process.env.CANARY_PATH + ${JSON.stringify(nul ? "\0.js" : "")}; + const code = 'function f(){ throw new Error("boom") }; f()\\n//# sourceURL=' + target; + ${ + caught + ? `try { vm.runInNewContext(code, {}, { filename: "sandbox.js" }); } catch (e) { console.error(e); }` + : `vm.runInNewContext(code, {}, { filename: "sandbox.js" });` + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(String(dir), "run.js")], + env: { ...bunEnv, CANARY_PATH: path.join(String(dir), "secret.txt") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const output = stdout + stderr; + + // The error is still reported. + expect(output).toContain("boom"); + // The file's contents are never shown. + expect(output).not.toContain(CANARY); + // An interior NUL in the name must not crash the printer. + if (caught) expect(exitCode).toBe(0); + else expect(exitCode).not.toBe(134); // SIGABRT + }); + } + } +}); + +test.concurrent("a real module still shows its source code frame", async () => { + using dir = tempDir("vm-sourceurl-real", { + "app.ts": `function doWork(): void {\n throw new Error("real module error");\n}\ndoWork();\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(String(dir), "app.ts")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("real module error"); + // The original source line is read from disk and shown as the code frame. + expect(stderr).toContain(`throw new Error("real module error");`); + expect(exitCode).not.toBe(0); +}); From c04c0845e0e10a9a2b175e73777247642e165126 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:00:44 +0000 Subject: [PATCH 02/11] [autofix.ci] apply automated fixes --- test/js/node/vm/vm-sourceUrl.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/vm/vm-sourceUrl.test.ts b/test/js/node/vm/vm-sourceUrl.test.ts index 79d882b1c1a1..0bcae32d2158 100644 --- a/test/js/node/vm/vm-sourceUrl.test.ts +++ b/test/js/node/vm/vm-sourceUrl.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; -import { runInNewContext } from "node:vm"; import path from "node:path"; +import { runInNewContext } from "node:vm"; test("can get sourceURL from eval inside node:vm", () => { try { From 77427424d148fa993766e31007ac304716086791 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:01:57 +0000 Subject: [PATCH 03/11] error printer: shorten the source-from-disk gate comments --- src/jsc/SavedSourceMap.rs | 6 +----- src/jsc/VirtualMachine.rs | 9 ++------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/jsc/SavedSourceMap.rs b/src/jsc/SavedSourceMap.rs index 7069b354e7d8..f5082efcd874 100644 --- a/src/jsc/SavedSourceMap.rs +++ b/src/jsc/SavedSourceMap.rs @@ -252,11 +252,7 @@ impl SavedSourceMap { Ok(()) } - /// Returns `true` when a source map is registered for `path`. The module - /// loader registers a path here only when it loads and transpiles that - /// path. The error printer uses this to decide whether a stack frame's - /// source URL names a module the loader actually loaded, before it reads - /// that path from disk for a code-frame excerpt. + /// Whether the module loader registered a source map for `path`. pub(crate) fn has_mapping(&mut self, path: &[u8]) -> bool { let h = hash(path); self.lock(); diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 3875671d8bad..efd9acd60f11 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5833,13 +5833,8 @@ impl VirtualMachine { } let already_remapped = frames[top].remapped; - // Only read a frame's source URL from disk for a code-frame excerpt - // when the module loader loaded that URL. The already-remapped branch - // parses frames out of an `error.stack` string, so the URL can be a - // name the running code chose (a `//# sourceURL` directive in node:vm - // or eval code), not a module the loader ever loaded. The non-remapped - // branch resolves through the source-map table, which is already the - // loaded-module check. + // A frame parsed from an `error.stack` string names whatever the thrown + // code chose (`//# sourceURL`). Read it from disk only if the loader loaded it. let allow_source_from_disk = if already_remapped { let url = frames[top].source_url.to_utf8(); self.source_mappings.has_mapping(url.slice()) From 0b1becd07380ca55242e79ca3eb115c8ba3e8880 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:05:59 +0000 Subject: [PATCH 04/11] error printer: compare the loaded path by bytes, not by hash The source-map table keys on a wyhash of the path. A membership test on the hash alone accepts any string that collides with a loaded module. The fetch normalizes `..` segments, so a colliding string can still name a real file. Keep the loaded paths by bytes and compare against those. Also cover the node:vm `filename` option in the tests, and require the exact exit code for the uncaught case. --- src/jsc/SavedSourceMap.rs | 14 +++-- test/js/node/vm/vm-sourceUrl.test.ts | 77 ++++++++++++++++------------ 2 files changed, 53 insertions(+), 38 deletions(-) diff --git a/src/jsc/SavedSourceMap.rs b/src/jsc/SavedSourceMap.rs index f5082efcd874..7825487614fa 100644 --- a/src/jsc/SavedSourceMap.rs +++ b/src/jsc/SavedSourceMap.rs @@ -3,7 +3,7 @@ use core::ffi::c_void; use std::sync::Arc; -use bun_collections::{HashMap, IdentityContext, TaggedPtrUnion}; +use bun_collections::{HashMap, IdentityContext, StringArrayHashMap, TaggedPtrUnion}; use bun_core::MutableString; use bun_core::Ordinal; use bun_ptr::tagged_pointer::TagType; @@ -16,6 +16,9 @@ use bun_wyhash::hash; pub struct SavedSourceMap { /// Only accessed between [`Self::lock`] and [`Self::unlock`]. map: HashTable, + /// Every path ever inserted into `map`, by bytes. `map` keys are hashes, + /// so a membership test for an untrusted path must not go through it. + paths: StringArrayHashMap<()>, mutex: Mutex, } @@ -141,6 +144,7 @@ impl SavedSourceMap { }; if refers_to_provider { self.map.remove(&key); + self.paths.swap_remove(path); // SAFETY: `old_value` was stored by us; the table's ownership of // it ends here. unsafe { Self::release_value(old_value) }; @@ -248,15 +252,17 @@ impl SavedSourceMap { v.insert(value.ptr()); } } + if !self.paths.contains(path) { + self.paths.insert(path, ()); + } self.unlock(); Ok(()) } - /// Whether the module loader registered a source map for `path`. + /// Whether the module loader registered a source map for exactly `path`. pub(crate) fn has_mapping(&mut self, path: &[u8]) -> bool { - let h = hash(path); self.lock(); - let found = self.map.contains_key(&h); + let found = self.paths.contains(path); self.unlock(); found } diff --git a/test/js/node/vm/vm-sourceUrl.test.ts b/test/js/node/vm/vm-sourceUrl.test.ts index 0bcae32d2158..9b42d8afbee5 100644 --- a/test/js/node/vm/vm-sourceUrl.test.ts +++ b/test/js/node/vm/vm-sourceUrl.test.ts @@ -60,41 +60,50 @@ const CANARY = "SECRET_CANARY_DO_NOT_LEAK_8f2a"; // that throws choose that source URL. The printer must not open a file the // module loader never loaded. describe.concurrent("error printer does not read attacker-named source files", () => { - for (const caught of [true, false]) { - for (const nul of [false, true]) { - const label = `${caught ? "caught" : "uncaught"}${nul ? " with interior NUL in the path" : ""}`; - test(`vm sourceURL does not leak a file's contents (${label})`, async () => { - using dir = tempDir("vm-sourceurl-leak", { - "secret.txt": CANARY + "\n", - "run.js": ` - const vm = require("node:vm"); - const target = process.env.CANARY_PATH + ${JSON.stringify(nul ? "\0.js" : "")}; - const code = 'function f(){ throw new Error("boom") }; f()\\n//# sourceURL=' + target; - ${ - caught - ? `try { vm.runInNewContext(code, {}, { filename: "sandbox.js" }); } catch (e) { console.error(e); }` - : `vm.runInNewContext(code, {}, { filename: "sandbox.js" });` - } - `, + for (const via of ["sourceURL", "filename"] as const) { + for (const caught of [true, false]) { + for (const nul of [false, true]) { + const label = `${via}, ${caught ? "caught" : "uncaught"}${nul ? ", interior NUL in the path" : ""}`; + test(`vm code does not leak a named file's contents (${label})`, async () => { + using dir = tempDir("vm-sourceurl-leak", { + "secret.txt": CANARY + "\n", + "run.js": ` + const vm = require("node:vm"); + const target = process.env.CANARY_PATH + ${JSON.stringify(nul ? "\0.js" : "")}; + const code = 'function f(){ throw new Error("boom") }; f()' ${ + via === "sourceURL" ? `+ '\\n//# sourceURL=' + target` : "" + }; + const options = { filename: ${via === "filename" ? "target" : '"sandbox.js"'} }; + ${ + caught + ? `try { vm.runInNewContext(code, {}, options); } catch (e) { console.error(e); }` + : `vm.runInNewContext(code, {}, options);` + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(String(dir), "run.js")], + env: { ...bunEnv, CANARY_PATH: path.join(String(dir), "secret.txt") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + const output = stdout + stderr; + + // The error is still reported. + expect(output).toContain("boom"); + // The file's contents are never shown. + expect(output).not.toContain(CANARY); + // An interior NUL in the name must not crash the printer. An + // uncaught error exits 1, a caught one exits 0. + expect(exitCode).toBe(caught ? 0 : 1); }); - - await using proc = Bun.spawn({ - cmd: [bunExe(), path.join(String(dir), "run.js")], - env: { ...bunEnv, CANARY_PATH: path.join(String(dir), "secret.txt") }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - const output = stdout + stderr; - - // The error is still reported. - expect(output).toContain("boom"); - // The file's contents are never shown. - expect(output).not.toContain(CANARY); - // An interior NUL in the name must not crash the printer. - if (caught) expect(exitCode).toBe(0); - else expect(exitCode).not.toBe(134); // SIGABRT - }); + } } } }); From 5ad91472b35bba01fe74d45887de14931629a979 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:06:51 +0000 Subject: [PATCH 05/11] error printer: one-line comments on the source-from-disk gate --- src/jsc/SavedSourceMap.rs | 3 +-- src/jsc/VirtualMachine.rs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/jsc/SavedSourceMap.rs b/src/jsc/SavedSourceMap.rs index 7825487614fa..6040216dd77c 100644 --- a/src/jsc/SavedSourceMap.rs +++ b/src/jsc/SavedSourceMap.rs @@ -16,8 +16,7 @@ use bun_wyhash::hash; pub struct SavedSourceMap { /// Only accessed between [`Self::lock`] and [`Self::unlock`]. map: HashTable, - /// Every path ever inserted into `map`, by bytes. `map` keys are hashes, - /// so a membership test for an untrusted path must not go through it. + /// Every path inserted into `map`, by bytes (`map` keys are only hashes). paths: StringArrayHashMap<()>, mutex: Mutex, } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index efd9acd60f11..14b2b2c58c1e 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5833,8 +5833,7 @@ impl VirtualMachine { } let already_remapped = frames[top].remapped; - // A frame parsed from an `error.stack` string names whatever the thrown - // code chose (`//# sourceURL`). Read it from disk only if the loader loaded it. + // A frame parsed from `error.stack` names whatever the thrown code chose. let allow_source_from_disk = if already_remapped { let url = frames[top].source_url.to_utf8(); self.source_mappings.has_mapping(url.slice()) From ef252230762191f802b908a730a74bd95f4b88f3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:09:26 +0000 Subject: [PATCH 06/11] [autofix.ci] apply automated fixes --- test/js/node/vm/vm-sourceUrl.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/js/node/vm/vm-sourceUrl.test.ts b/test/js/node/vm/vm-sourceUrl.test.ts index 9b42d8afbee5..5e9058f49b7d 100644 --- a/test/js/node/vm/vm-sourceUrl.test.ts +++ b/test/js/node/vm/vm-sourceUrl.test.ts @@ -88,11 +88,7 @@ describe.concurrent("error printer does not read attacker-named source files", ( stdout: "pipe", stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const output = stdout + stderr; // The error is still reported. From d7b25703bb52603967995fe6e6b744c86a4b0182 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:12:30 +0000 Subject: [PATCH 07/11] test: use a static import in the vm sourceURL fixture --- test/js/node/vm/vm-sourceUrl.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/js/node/vm/vm-sourceUrl.test.ts b/test/js/node/vm/vm-sourceUrl.test.ts index 5e9058f49b7d..40295c3573f4 100644 --- a/test/js/node/vm/vm-sourceUrl.test.ts +++ b/test/js/node/vm/vm-sourceUrl.test.ts @@ -67,8 +67,8 @@ describe.concurrent("error printer does not read attacker-named source files", ( test(`vm code does not leak a named file's contents (${label})`, async () => { using dir = tempDir("vm-sourceurl-leak", { "secret.txt": CANARY + "\n", - "run.js": ` - const vm = require("node:vm"); + "run.mjs": ` + import * as vm from "node:vm"; const target = process.env.CANARY_PATH + ${JSON.stringify(nul ? "\0.js" : "")}; const code = 'function f(){ throw new Error("boom") }; f()' ${ via === "sourceURL" ? `+ '\\n//# sourceURL=' + target` : "" @@ -83,7 +83,7 @@ describe.concurrent("error printer does not read attacker-named source files", ( }); await using proc = Bun.spawn({ - cmd: [bunExe(), path.join(String(dir), "run.js")], + cmd: [bunExe(), path.join(String(dir), "run.mjs")], env: { ...bunEnv, CANARY_PATH: path.join(String(dir), "secret.txt") }, stdout: "pipe", stderr: "pipe", From 1e0d693bef7ca55973dd06988217742bf926f7e1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:37:06 +0000 Subject: [PATCH 08/11] error printer: trust the paths a loaded module's source map names, and embedded files The gate on the stack-string branch compared the frame URL against the paths the loader registered. Two URLs the loader itself produces were missing from that set. A frame remapped through an external source map names the map's original source. A frame in a `bun build --compile` executable names an embedded file. Both lost their code frame once `error.stack` had been read. Record each URL the remap emits as trusted when it is emitted. Accept a path the standalone module graph serves, since that is not the filesystem. Cover all three loaded cases on the stack-string path in the tests. --- src/jsc/SavedSourceMap.rs | 13 +++++- src/jsc/VirtualMachine.rs | 36 +++++++++++----- test/js/node/vm/vm-sourceUrl.test.ts | 64 ++++++++++++++++++++++------ 3 files changed, 88 insertions(+), 25 deletions(-) diff --git a/src/jsc/SavedSourceMap.rs b/src/jsc/SavedSourceMap.rs index 6040216dd77c..ccfdd6fb1017 100644 --- a/src/jsc/SavedSourceMap.rs +++ b/src/jsc/SavedSourceMap.rs @@ -258,8 +258,17 @@ impl SavedSourceMap { Ok(()) } - /// Whether the module loader registered a source map for exactly `path`. - pub(crate) fn has_mapping(&mut self, path: &[u8]) -> bool { + /// Records a path that a loaded module's own source map names as an original source. + pub(crate) fn trust_path(&mut self, path: &[u8]) { + self.lock(); + if !self.paths.contains(path) { + self.paths.insert(path, ()); + } + self.unlock(); + } + + /// Whether `path` is exactly a loaded module, or an original source one of them maps to. + pub(crate) fn is_loaded_path(&mut self, path: &[u8]) -> bool { self.lock(); let found = self.paths.contains(path); self.unlock(); diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 14b2b2c58c1e..21226bd344f1 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5632,10 +5632,8 @@ impl VirtualMachine { bun_sourcemap::SourceContentHandling::NoSourceContents, ) .map(|lookup| { - ( - lookup.display_source_url_if_needed(source_url.slice()), - lookup, - ) + let display_url = self.remapped_source_url(&lookup, source_url.slice()); + (display_url, lookup) }) }; if let Some((display_url, lookup)) = resolved { @@ -5836,7 +5834,7 @@ impl VirtualMachine { // A frame parsed from `error.stack` names whatever the thrown code chose. let allow_source_from_disk = if already_remapped { let url = frames[top].source_url.to_utf8(); - self.source_mappings.has_mapping(url.slice()) + self.source_mappings.is_loaded_path(url.slice()) || self.is_embedded_module(url.slice()) } else { true }; @@ -5872,7 +5870,7 @@ impl VirtualMachine { maybe_lookup.map(|lookup| { let mapping = lookup.mapping; let display_url = if !already_remapped { - lookup.display_source_url_if_needed(top_source_url.slice()) + self.remapped_source_url(&lookup, top_source_url.slice()) } else { None }; @@ -5989,10 +5987,8 @@ impl VirtualMachine { bun_sourcemap::SourceContentHandling::NoSourceContents, ) .map(|lookup| { - ( - lookup.display_source_url_if_needed(source_url.slice()), - lookup, - ) + let display_url = self.remapped_source_url(&lookup, source_url.slice()); + (display_url, lookup) }) }; if let Some((display_url, lookup)) = resolved { @@ -6855,6 +6851,26 @@ impl VirtualMachine { let _ = writer.flush(); } + /// Whether `path` is a file embedded in this `bun build --compile` executable. + fn is_embedded_module(&self, path: &[u8]) -> bool { + bun_options_types::standalone_path::is_bun_standalone_file_path(path) + && self + .standalone_module_graph + .is_some_and(|graph| graph.find_assume_standalone_path(path).is_some()) + } + + /// The URL a frame at `source_url` displays after `lookup` remaps it, if it changes. + /// The new URL comes from a loaded module's own map, so the printer may read it later. + fn remapped_source_url( + &mut self, + lookup: &bun_sourcemap::mapping::Lookup, + source_url: &[u8], + ) -> Option { + let display_url = lookup.display_source_url_if_needed(source_url)?; + self.source_mappings.trust_path(display_url.to_utf8().slice()); + Some(display_url) + } + /// Looks up the source-map mapping for `path` at `line:column`. pub(crate) fn resolve_source_mapping( &mut self, diff --git a/test/js/node/vm/vm-sourceUrl.test.ts b/test/js/node/vm/vm-sourceUrl.test.ts index 40295c3573f4..0c6073dde515 100644 --- a/test/js/node/vm/vm-sourceUrl.test.ts +++ b/test/js/node/vm/vm-sourceUrl.test.ts @@ -104,19 +104,57 @@ describe.concurrent("error printer does not read attacker-named source files", ( } }); -test.concurrent("a real module still shows its source code frame", async () => { - using dir = tempDir("vm-sourceurl-real", { - "app.ts": `function doWork(): void {\n throw new Error("real module error");\n}\ndoWork();\n`, +// Frames parsed back out of an already-materialized `error.stack` are the +// gated path. A loaded module, an original source named by a loaded module's +// source map, and a file embedded in a compiled executable must keep their +// code frame there. +describe.concurrent("a loaded module still shows its source code frame", () => { + const app = `function doWork(): void { + throw new Error("real module error"); +} +try { + doWork(); +} catch (e) { + void (e as Error).stack; + console.error(e); +} +`; + + async function run(cmd: string[], cwd: string) { + await using proc = Bun.spawn({ cmd, env: bunEnv, cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { output: stdout + stderr, exitCode }; + } + + test("run from source", async () => { + using dir = tempDir("vm-sourceurl-real", { "app.ts": app }); + const { output, exitCode } = await run([bunExe(), "app.ts"], String(dir)); + expect(output).toContain(`throw new Error("real module error");`); + expect(output).toContain("app.ts:2:"); + expect(exitCode).toBe(0); }); - await using proc = Bun.spawn({ - cmd: [bunExe(), path.join(String(dir), "app.ts")], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", + + test("bundled with an external source map", async () => { + using dir = tempDir("vm-sourceurl-bundled", { "src/app.ts": app }); + const build = await run( + [bunExe(), "build", "--target=bun", "--sourcemap=external", "--outdir=dist", "src/app.ts"], + String(dir), + ); + expect(build.exitCode).toBe(0); + const { output, exitCode } = await run([bunExe(), "dist/app.js"], String(dir)); + // The frame names the original `src/app.ts`, which the map points at. + expect(output).toContain(`throw new Error("real module error");`); + expect(output).toContain(`${path.join("src", "app.ts")}:2:`); + expect(exitCode).toBe(0); + }); + + test("compiled executable", async () => { + using dir = tempDir("vm-sourceurl-compiled", { "app.ts": app }); + const exe = path.join(String(dir), process.platform === "win32" ? "app.exe" : "app"); + const build = await run([bunExe(), "build", "--compile", "app.ts", "--outfile", exe], String(dir)); + expect(build.exitCode).toBe(0); + const { output, exitCode } = await run([exe], String(dir)); + expect(output).toContain(`throw new Error("real module error");`); + expect(exitCode).toBe(0); }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(stderr).toContain("real module error"); - // The original source line is read from disk and shown as the code frame. - expect(stderr).toContain(`throw new Error("real module error");`); - expect(exitCode).not.toBe(0); }); From 073e85c1a337d466b259f6a94ba0dfcb001944e2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:37:54 +0000 Subject: [PATCH 09/11] error printer: one-line doc on remapped_source_url --- src/jsc/VirtualMachine.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 21226bd344f1..81044e33bdab 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6859,8 +6859,7 @@ impl VirtualMachine { .is_some_and(|graph| graph.find_assume_standalone_path(path).is_some()) } - /// The URL a frame at `source_url` displays after `lookup` remaps it, if it changes. - /// The new URL comes from a loaded module's own map, so the printer may read it later. + /// The URL `lookup` remaps `source_url` to, recorded as a path the printer may read. fn remapped_source_url( &mut self, lookup: &bun_sourcemap::mapping::Lookup, From 7fcbb2cb9ba6de463e5055d08088ead0bd69d85d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:40:25 +0000 Subject: [PATCH 10/11] [autofix.ci] apply automated fixes --- src/jsc/VirtualMachine.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 81044e33bdab..361748124a6a 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6866,7 +6866,8 @@ impl VirtualMachine { source_url: &[u8], ) -> Option { let display_url = lookup.display_source_url_if_needed(source_url)?; - self.source_mappings.trust_path(display_url.to_utf8().slice()); + self.source_mappings + .trust_path(display_url.to_utf8().slice()); Some(display_url) } From 6cfd4100017eae7b9f3dc4f188151b3289c7edba Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:30:01 +0000 Subject: [PATCH 11/11] ci: retrigger