From f4b737e0bae62f236228bad0af69d721722936e8 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Wed, 21 Oct 2026 04:38:17 +0000 Subject: [PATCH 1/6] Fix FileSystemRouter panic when a directory listing is not under the router's dir --- src/router/lib.rs | 30 ++++++++++------------ test/js/bun/util/filesystem_router.test.ts | 20 +++++++++++++++ 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/router/lib.rs b/src/router/lib.rs index ceb2dfdf6168..6b49c7834c40 100644 --- a/src/router/lib.rs +++ b/src/router/lib.rs @@ -548,13 +548,24 @@ impl<'a> RouteLoader<'a> { // Snapshot the cached `DirEntry`'s entry pointers under `entries_mutex`: // another thread (e.g. the bundler's resolver) rewrites this map in place // under that lock, so iterating it live would walk freed buckets. - let entry_ptrs: Vec<*mut Fs::Entry> = { + let (entries_dir, entry_ptrs): (&'static [u8], Vec<*mut Fs::Entry>) = { let _entries_lock = fs.fs.entries_mutex.lock_guard(); match root_dir_info.get_entries_const() { - Some(entries) => entries.data.values().copied().collect(), + Some(entries) => (entries.dir, entries.data.values().copied().collect()), None => return, } }; + // The listing's directory is `base_dir` or a subdirectory of it, cached with or + // without a trailing slash depending on which resolver spelled it first + // (`base_dir` always has one). Both spellings trim to the same `public_dir`. + // The resolver's caches match a path by its hash alone, so a hit can be the + // listing of an unrelated directory, which has no routes of this router. + let public_dir = match entries_dir.strip_prefix(&base_dir[..base_dir.len() - 1]) { + Some(rest) if rest.first().is_none_or(|c| bun_paths::resolve_path::is_sep_any(*c)) => { + rest + } + _ => return, + }; // NOTE: the guard is dropped before this loop on purpose — the // `read_dir_info_ignore_error` recursion below re-acquires `entries_mutex`. 'outer: for entry_ptr in entry_ptrs { @@ -602,21 +613,6 @@ impl<'a> RouteLoader<'a> { for _extname in self.config.extensions.iter() { if &extname[1..] == _extname.as_ref() { - // `entry.dir()` is `base_dir` or a subdirectory of it, cached - // with or without a trailing slash depending on which resolver - // spelled it first (`base_dir` always has one). Both spellings - // trim to the same `public_dir`. - let entry_dir = entry.dir(); - debug_assert!(entry_dir.len() + 1 >= base_dir.len()); - if entry_dir.len() >= base_dir.len() { - debug_assert!(bun_paths::resolve_path::is_sep_any( - entry_dir[base_dir.len() - 1] - )); - } - - // SAFETY: entry.dir is at least base_dir.len()-1 bytes; verified above in debug - let public_dir = &entry_dir[base_dir.len() - 1..entry_dir.len()]; - // SAFETY: `entry_ptr` is EntryStore-owned (process // lifetime) with no other live `&mut` borrow here. let route = unsafe { diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 2e22b5eddfcd..2cd0499868b1 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -580,6 +580,26 @@ it("rejects a dir with a null byte", () => { ); }); +it.skipIf(isWindows)("has no routes from another directory that shares dir's resolver cache entry", async () => { + // The resolver's directory cache is keyed by the hash of the path without trailing slashes, so "/" is keyed as "". + const dir = "/ajgq" + Buffer.alloc(27, "a").toString() + "b\x05\x1db6@o\x084o.uNpJ0"; + expect(Bun.hash(dir)).toBe(Bun.hash("")); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `console.log(JSON.stringify(new Bun.FileSystemRouter({ dir: process.argv[1], style: "nextjs" }).routes))`, + dir, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "{}\n", stderr: "", exitCode: 0 }); +}); + it("origin should be validated", async () => { const { dir } = make(["posts.tsx"]); From cf371cad1ce80d89e0f2f403a0cff98172983c03 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:39:21 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- src/router/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/router/lib.rs b/src/router/lib.rs index 6b49c7834c40..5cd2642cbbd6 100644 --- a/src/router/lib.rs +++ b/src/router/lib.rs @@ -561,7 +561,11 @@ impl<'a> RouteLoader<'a> { // The resolver's caches match a path by its hash alone, so a hit can be the // listing of an unrelated directory, which has no routes of this router. let public_dir = match entries_dir.strip_prefix(&base_dir[..base_dir.len() - 1]) { - Some(rest) if rest.first().is_none_or(|c| bun_paths::resolve_path::is_sep_any(*c)) => { + Some(rest) + if rest + .first() + .is_none_or(|c| bun_paths::resolve_path::is_sep_any(*c)) => + { rest } _ => return, From 43556f534f7ccbe9833eff1e2a0873c7e814c933 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Wed, 21 Oct 2026 05:02:31 +0000 Subject: [PATCH 3/6] Use a directory listing in FileSystemRouter only if it is for the directory asked for --- src/router/lib.rs | 28 ++++++++++---------- src/runtime/api/filesystem_router.rs | 13 +++++++--- test/js/bun/util/filesystem_router.test.ts | 30 +++++++++++++++++++--- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/router/lib.rs b/src/router/lib.rs index 6b49c7834c40..9c2ad77ac038 100644 --- a/src/router/lib.rs +++ b/src/router/lib.rs @@ -476,7 +476,7 @@ impl<'a> RouteLoader<'a> { index: None, route_dirname_len, }; - this.load(resolver, root_dir_info, base_dir); + this.load(resolver, root_dir_info, base_dir, base_dir); if this.all_routes.is_empty() { return Routes { static_: this.static_list, @@ -541,6 +541,7 @@ impl<'a> RouteLoader<'a> { &mut self, resolver: &mut R, root_dir_info: &DirInfo, + dir: &[u8], base_dir: &[u8], ) { let fs = self.fs; @@ -555,16 +556,13 @@ impl<'a> RouteLoader<'a> { None => return, } }; - // The listing's directory is `base_dir` or a subdirectory of it, cached with or - // without a trailing slash depending on which resolver spelled it first - // (`base_dir` always has one). Both spellings trim to the same `public_dir`. - // The resolver's caches match a path by its hash alone, so a hit can be the - // listing of an unrelated directory, which has no routes of this router. - let public_dir = match entries_dir.strip_prefix(&base_dir[..base_dir.len() - 1]) { - Some(rest) if rest.first().is_none_or(|c| bun_paths::resolve_path::is_sep_any(*c)) => { - rest - } - _ => return, + // The resolver's caches match a path by its hash alone, so a hit can be another directory's listing. + if strings::without_trailing_slash(entries_dir) != strings::without_trailing_slash(dir) { + return; + } + let Some(public_dir) = entries_dir.strip_prefix(strings::without_trailing_slash(base_dir)) + else { + return; }; // NOTE: the guard is dropped before this loop on purpose — the // `read_dir_info_ignore_error` recursion below re-acquires `entries_mutex`. @@ -597,10 +595,10 @@ impl<'a> RouteLoader<'a> { } } - let abs_parts = [entry.dir(), entry.base()]; - if let Some(dir_info) = resolver.read_dir_info_ignore_error(fs.abs(&abs_parts)) - { - self.load(resolver, &dir_info, base_dir); + // Copied out of the buffer `fs.abs` shares with the resolver. + let abs_path = fs.abs(&[entries_dir, entry.base()]).to_vec(); + if let Some(dir_info) = resolver.read_dir_info_ignore_error(&abs_path) { + self.load(resolver, &dir_info, &abs_path, base_dir); } } diff --git a/src/runtime/api/filesystem_router.rs b/src/runtime/api/filesystem_router.rs index 4a2e55623f30..0d65115aa111 100644 --- a/src/runtime/api/filesystem_router.rs +++ b/src/runtime/api/filesystem_router.rs @@ -125,8 +125,14 @@ fn load_router( let resolver = &mut VirtualMachine::get().as_mut().transpiler.resolver; let root_dir_info = match resolver.read_dir_info(&config.dir) { - Ok(Some(info)) => info, - Ok(None) => return Ok(None), + // The resolver's cache matches a path by its hash alone, so a hit can be another directory. + Ok(Some(info)) + if strings::without_trailing_slash(info.abs_path) + == strings::without_trailing_slash(&config.dir) => + { + info + } + Ok(_) => return Ok(None), Err(_) => { let error = log.to_js(global, format_args!("reading root directory"))?; return Err(global.throw_value(error)); @@ -287,7 +293,7 @@ impl FileSystemRouterClass for FileSystemRouter { dir: strings::paths::clone_normalizing_separators(root_dir_path).into_boxed_slice(), extensions: extensions.into(), }; - let Some((mut router, root_dir_info)) = load_router(global, config, Load::Cached)? else { + let Some((router, root_dir_info)) = load_router(global, config, Load::Cached)? else { return Err(cx.heap.error(format_args!( "Unable to find directory: {}", bstr::BStr::new(root_dir_path) @@ -305,7 +311,6 @@ impl FileSystemRouterClass for FileSystemRouter { RefCountedString::new(vm, bytes).map_err(|_| global.throw_string_too_long()) }; let base_dir = ref_counted(base_dir_str)?; - router.config.dir = Box::from(base_dir.bytes()); let not_empty = |bytes: &&[u8]| !bytes.is_empty(); Ok(FileSystemRouter { origin: options diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 2cd0499868b1..d642d983d4c0 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -464,6 +464,21 @@ it("reload() works", () => { expect(router.match("/posts")!.name).toBe("/posts"); }); +it("reload() works when dir is reached through a symlink", () => { + const { dir } = make(["pages/index.tsx", "pages/posts/hey.tsx"]); + fs.symlinkSync(dir, dir + "-link", "junction"); + try { + const router = new FileSystemRouter({ dir: dir + "-link/pages", style: "nextjs" }); + const routes = { ...router.routes }; + expect(Object.keys(routes).sort()).toEqual(["/", "/posts/hey"]); + router.reload(); + expect(router.routes).toEqual(routes); + expect(router.match("/posts/hey")?.name).toBe("/posts/hey"); + } finally { + fs.unlinkSync(dir + "-link"); + } +}); + it("reload() works with new dirs/files", () => { const { dir } = make(["posts.tsx"]); @@ -580,7 +595,8 @@ it("rejects a dir with a null byte", () => { ); }); -it.skipIf(isWindows)("has no routes from another directory that shares dir's resolver cache entry", async () => { +// The colliding path below is an absolute POSIX path. +it.skipIf(isWindows)("does not use another directory that shares dir's resolver cache entry", async () => { // The resolver's directory cache is keyed by the hash of the path without trailing slashes, so "/" is keyed as "". const dir = "/ajgq" + Buffer.alloc(27, "a").toString() + "b\x05\x1db6@o\x084o.uNpJ0"; expect(Bun.hash(dir)).toBe(Bun.hash("")); @@ -589,7 +605,15 @@ it.skipIf(isWindows)("has no routes from another directory that shares dir's res cmd: [ bunExe(), "-e", - `console.log(JSON.stringify(new Bun.FileSystemRouter({ dir: process.argv[1], style: "nextjs" }).routes))`, + ` + try { Bun.resolveSync("./missing", "/"); } catch {} // caches "/" + try { + new Bun.FileSystemRouter({ dir: process.argv[1], style: "nextjs" }); + console.log("no error"); + } catch (error) { + console.log(error.message === "Unable to find directory: " + process.argv[1]); + } + `, dir, ], env: bunEnv, @@ -597,7 +621,7 @@ it.skipIf(isWindows)("has no routes from another directory that shares dir's res stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout, stderr, exitCode }).toEqual({ stdout: "{}\n", stderr: "", exitCode: 0 }); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "true\n", stderr: "", exitCode: 0 }); }); it("origin should be validated", async () => { From 31ec893e39e0d68637ea6558f9c44f3c6cbf8718 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Wed, 21 Oct 2026 05:18:49 +0000 Subject: [PATCH 4/6] Spell FileSystemRouter's dir as the resolver does when it is the current directory --- src/runtime/api/filesystem_router.rs | 30 ++++++++++------------ test/js/bun/util/filesystem_router.test.ts | 19 ++++++++++++++ 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/runtime/api/filesystem_router.rs b/src/runtime/api/filesystem_router.rs index 0d65115aa111..45600b7d1d35 100644 --- a/src/runtime/api/filesystem_router.rs +++ b/src/runtime/api/filesystem_router.rs @@ -259,23 +259,19 @@ impl FileSystemRouterClass for FileSystemRouter { Valid::no_null_bytes(options.dir, "dir", "a string", global)?; let mut out_buf = [0u8; MAX_PATH_BYTES * 2]; - let mut root_dir_path = global.bun_vm().top_level_dir(); - if !(options.dir.is_empty() || options.dir == b".") { - // An absolute path is joined too: a route's name is cut from the resolver's normalized spelling. - let Some(joined) = - path::resolve_path::join_abs_string_buf_checked::( - Fs::FileSystem::instance().top_level_dir(), - &mut out_buf, - &[options.dir], - ) - else { - return Err(cx.heap.error(format_args!( - "Unable to find directory: {}", - bstr::BStr::new(options.dir) - ))); - }; - root_dir_path = joined; - } + // Every path is joined, absolute or empty: the loader needs the resolver's normalized spelling. + let Some(root_dir_path) = + path::resolve_path::join_abs_string_buf_checked::( + Fs::FileSystem::instance().top_level_dir(), + &mut out_buf, + &[options.dir], + ) + else { + return Err(cx.heap.error(format_args!( + "Unable to find directory: {}", + bstr::BStr::new(options.dir) + ))); + }; let mut extensions: Vec> = options .file_extensions diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index d642d983d4c0..4056f6270a03 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -588,6 +588,25 @@ it("dir should be validated", async () => { }).toThrow("FileSystemRouterOptions.dir must be a string"); }); +it.each([".", ""])("dir %j is the current directory", async dir => { + const cwd = make(["index.tsx", "posts/hey.tsx"]).dir; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `console.log(JSON.stringify(Object.keys(new Bun.FileSystemRouter({ dir: process.argv[1], style: "nextjs" }).routes).sort()))`, + dir, + ], + env: bunEnv, + // The resolver caches a Windows path with its drive letter in uppercase. + cwd: cwd[0].toLowerCase() + cwd.slice(1), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: `["/","/posts/hey"]\n`, stderr: "", exitCode: 0 }); +}); + it("rejects a dir with a null byte", () => { const { dir } = make(["index.tsx"]); expect(() => new FileSystemRouter({ style: "nextjs", dir: `${dir}\0suffix` })).toThrow( From d7bd6dbec3651f4ec19846bce7f7325e1495fcdf Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Wed, 21 Oct 2026 05:27:25 +0000 Subject: [PATCH 5/6] Use tempDir in the FileSystemRouter current directory test --- test/js/bun/util/filesystem_router.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 4056f6270a03..c7004f71b6a3 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -589,7 +589,8 @@ it("dir should be validated", async () => { }); it.each([".", ""])("dir %j is the current directory", async dir => { - const cwd = make(["index.tsx", "posts/hey.tsx"]).dir; + using tmp = tempDir("filesystem-router-cwd", { "index.tsx": "", "posts/hey.tsx": "" }); + const cwd = String(tmp); await using proc = Bun.spawn({ cmd: [ bunExe(), From 3550e45b4cfe89af2c47d66bf708155d5d253b28 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Wed, 21 Oct 2026 05:49:53 +0000 Subject: [PATCH 6/6] Make the FileSystemRouter collision test reach the cached listing --- test/js/bun/util/filesystem_router.test.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index c7004f71b6a3..2f33b6a939d2 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -465,18 +465,14 @@ it("reload() works", () => { }); it("reload() works when dir is reached through a symlink", () => { - const { dir } = make(["pages/index.tsx", "pages/posts/hey.tsx"]); - fs.symlinkSync(dir, dir + "-link", "junction"); - try { - const router = new FileSystemRouter({ dir: dir + "-link/pages", style: "nextjs" }); - const routes = { ...router.routes }; - expect(Object.keys(routes).sort()).toEqual(["/", "/posts/hey"]); - router.reload(); - expect(router.routes).toEqual(routes); - expect(router.match("/posts/hey")?.name).toBe("/posts/hey"); - } finally { - fs.unlinkSync(dir + "-link"); - } + using tmp = tempDir("filesystem-router-symlink", { "real/pages/index.tsx": "", "real/pages/posts/hey.tsx": "" }); + fs.symlinkSync(path.join(String(tmp), "real"), path.join(String(tmp), "link"), "junction"); + const router = new FileSystemRouter({ dir: path.join(String(tmp), "link", "pages"), style: "nextjs" }); + const routes = { ...router.routes }; + expect(Object.keys(routes).sort()).toEqual(["/", "/posts/hey"]); + router.reload(); + expect(router.routes).toEqual(routes); + expect(router.match("/posts/hey")?.name).toBe("/posts/hey"); }); it("reload() works with new dirs/files", () => { @@ -626,7 +622,7 @@ it.skipIf(isWindows)("does not use another directory that shares dir's resolver bunExe(), "-e", ` - try { Bun.resolveSync("./missing", "/"); } catch {} // caches "/" + Bun.resolveSync(process.execPath, "/"); // caches "/" try { new Bun.FileSystemRouter({ dir: process.argv[1], style: "nextjs" }); console.log("no error");