diff --git a/src/runtime/bake/FrameworkRouter.rs b/src/runtime/bake/FrameworkRouter.rs index aa6522009ff1..6786ea777e6d 100644 --- a/src/runtime/bake/FrameworkRouter.rs +++ b/src/runtime/bake/FrameworkRouter.rs @@ -1471,6 +1471,15 @@ impl bun_collections::zig_hash_map::HashContext> for ZigStringHashCont } } +/// The scanned entry's absolute path, or `None` when it is too long to open, in which case the scan skips the entry. +fn entry_abs_path<'b>( + fs: &bun_resolver::fs::FileSystem, + entry: &bun_resolver::fs::Entry, + buf: &'b mut PathBuffer, +) -> Option<&'b [u8]> { + fs.abs_buf_checked(&[entry.dir, entry.base()], &mut buf[..MAX_PATH_BYTES - 1]) +} + impl FrameworkRouter { pub(crate) fn scan( &mut self, @@ -1555,9 +1564,15 @@ impl FrameworkRouter { } } - if let Some(child_info) = - r.read_dir_info_ignore_error(fs_ref.abs(&[file.dir, file.base()])) - { + let child_info = { + let mut abs_path_buf = paths::path_buffer_pool::get(); + let Some(abs_path) = entry_abs_path(fs_ref, file, &mut abs_path_buf) + else { + continue 'outer; + }; + r.read_dir_info_ignore_error(abs_path) + }; + if let Some(child_info) = child_info { self.scan_inner(t_index, r, &child_info, arena_state, ctx)?; } } @@ -1580,15 +1595,18 @@ impl FrameworkRouter { } } + let mut abs_path_buf = paths::path_buffer_pool::get(); + let Some(abs_path) = entry_abs_path(fs_ref, file, &mut abs_path_buf) else { + continue 'outer; + }; + let mut rel_path_buf = PathBuffer::uninit(); let full_rel_path_len = { let full_rel_path = paths::resolve_path::relative_normalized_buf::< paths::platform::Auto, true, >( - &mut rel_path_buf[1..], - &self.root, - fs_ref.abs(&[file.dir, file.base()]), + &mut rel_path_buf[1..], &self.root, abs_path ); full_rel_path.len() }; @@ -1676,7 +1694,7 @@ impl FrameworkRouter { t_index, InsertPattern::Dynamic(pattern), file_kind, - fs_ref.abs(&[file.dir, file.base()]), + abs_path, ctx, &mut out_colliding_file_id, ) @@ -1707,7 +1725,7 @@ impl FrameworkRouter { t_index, InsertPattern::Static(pattern), file_kind, - fs_ref.abs(&[file.dir, file.base()]), + abs_path, ctx, &mut out_colliding_file_id, ) @@ -1797,14 +1815,12 @@ impl JSFrameworkRouter { // `Style` owns a `Strong` (Drop type), so `?` on any error path below // drops it automatically. - let abs_root: Box<[u8]> = strings::without_trailing_slash(paths::resolve_path::join_abs::< - paths::platform::Auto, - >( - // SAFETY: FileSystem::instance() returns the process-global singleton; live for the program. - bun_resolver::fs::FileSystem::get().top_level_dir, - root.slice(), - )) - .into(); + let Some(abs_root) = crate::bake::bake_body::resolve_dir_option(root.slice()) else { + return Err(global.throw_invalid_arguments(format_args!( + "options.root must resolve to a path shorter than {} bytes", + MAX_PATH_BYTES + ))); + }; let types: Box<[Type]> = Box::new([Type { abs_root: abs_root.clone(), diff --git a/src/runtime/bake/bake_body.rs b/src/runtime/bake/bake_body.rs index d51f80223766..83bae367b0b7 100644 --- a/src/runtime/bake/bake_body.rs +++ b/src/runtime/bake/bake_body.rs @@ -501,6 +501,33 @@ impl Default for Framework { } } +/// Resolves a directory from the app options; `None` once it is `MAX_PATH_BYTES` long, the length from which `Resolver::read_dir_info` rejects it too. +pub(crate) fn resolve_dir_option(dir: &[u8]) -> Option> { + let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; + let mut buf = paths::path_buffer_pool::get(); + let resolved = paths::resolve_path::join_abs_string_buf_checked::( + top_level_dir, + &mut buf[..paths::MAX_PATH_BYTES - 1], + &[dir], + )?; + Some(Box::from( + paths::string_paths::without_trailing_slash_windows_path(resolved), + )) +} + +/// `resolve_dir_option` for `fileSystemRouterTypes[index].root`; a too-long root is reported like an unresolvable entry point. +pub(crate) fn resolve_router_root(index: usize, root: &[u8]) -> Option> { + let resolved = resolve_dir_option(root); + if resolved.is_none() { + Output::err( + "ENAMETOOLONG", + "Failed to resolve 'fileSystemRouterTypes[{}].root' for framework: the resolved path must be shorter than {} bytes", + (index, paths::MAX_PATH_BYTES), + ); + } + resolved +} + impl Framework { /// Bun provides built-in support for using React as a framework. /// Depends on externally provided React @@ -681,11 +708,11 @@ impl Framework { // self.resolve_helper(client, &mut sc.client_runtime_import, &mut had_errors); } - for fsr in clone.file_system_router_types.iter_mut() { - let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; - fsr.root = arena_erase(arena.alloc_slice_copy(paths::resolve_path::join_abs::< - paths::platform::Auto, - >(top_level_dir, fsr.root))); + for (i, fsr) in clone.file_system_router_types.iter_mut().enumerate() { + match resolve_router_root(i, fsr.root) { + Some(root) => fsr.root = arena_erase(arena.alloc_slice_copy(&root)), + None => had_errors = true, + } if let Some(entry_client) = &mut fsr.entry_client { self.resolve_helper( client, diff --git a/src/runtime/bake/mod.rs b/src/runtime/bake/mod.rs index 12136842c27d..7de7939374f4 100644 --- a/src/runtime/bake/mod.rs +++ b/src/runtime/bake/mod.rs @@ -378,15 +378,11 @@ impl Framework { b"server components runtime", ); } - for fsr in self.file_system_router_types.iter_mut() { - let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; - fsr.root = Cow::Owned( - bun_paths::resolve_path::join_abs::( - top_level_dir, - &fsr.root, - ) - .to_vec(), - ); + for (i, fsr) in self.file_system_router_types.iter_mut().enumerate() { + match bake_body::resolve_router_root(i, &fsr.root) { + Some(root) => fsr.root = Cow::Owned(root.into_vec()), + None => had_errors = true, + } let _ = arena; if let Some(entry_client) = &mut fsr.entry_client { Self::resolve_helper( diff --git a/src/runtime/bake/production.rs b/src/runtime/bake/production.rs index de67f02ea5ee..785459d03917 100644 --- a/src/runtime/bake/production.rs +++ b/src/runtime/bake/production.rs @@ -545,7 +545,10 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result< }; for fsr in &framework.file_system_router_types { - let joined_root = resolve_path::join_abs::(cwd, fsr.root); + // `fsr.root` is absolute and fits in a `PathBuffer`: see `resolve_router_root`. + let mut buf = bun_paths::path_buffer_pool::get(); + let joined_root = + resolve_path::join_abs_string_buf::(cwd, &mut buf[..], &[fsr.root]); let Some(entry) = server_transpiler .resolver .read_dir_info_ignore_error(joined_root) diff --git a/test/bake/framework-router.test.ts b/test/bake/framework-router.test.ts index 7098049d2d53..738bf5d88c7f 100644 --- a/test/bake/framework-router.test.ts +++ b/test/bake/framework-router.test.ts @@ -1,6 +1,7 @@ import { frameworkRouterInternals } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; -import { tempDir } from "harness"; +import { mkdirSync, writeFileSync } from "fs"; +import { bunEnv, bunExe, isWindows, MAX_PATH_BYTES, tempDir } from "harness"; import path from "path"; const { parseRoutePattern, FrameworkRouter } = frameworkRouterInternals; @@ -133,3 +134,193 @@ test("discovers from filesystem paths", () => { ], }); }); + +describe.concurrent("fileSystemRouterTypes[n].root that does not fit in a path buffer", () => { + // Longer than MAX_PATH_BYTES on every platform. + const tooLongRoot = `Buffer.alloc(100_000, "a").toString()`; + // An absolute root resolves to itself, so this resolves to exactly `length` bytes + // ("/aaa..." on POSIX, "C:\\aaa..." on Windows). Evaluated inside the fixture. + const absoluteRootOfLength = (length: number) => + `(prefix => prefix + Buffer.alloc(${length} - prefix.length, "a").toString())(path.parse(process.cwd()).root)`; + const rootError = (index: number) => + `ENAMETOOLONG: Failed to resolve 'fileSystemRouterTypes[${index}].root' for framework: the resolved path must be shorter than ${MAX_PATH_BYTES} bytes`; + const rejected = "threw: Framework is missing required files!"; + + const serverEntryPoint = ` + export function render(req, meta) { + return meta.pageModule.default(req, meta); + } + `; + const appWithRoots = (...roots: string[]) => `{ + app: { + framework: { + fileSystemRouterTypes: [ + ${roots.map(root => `{ root: ${root}, style: "nextjs-pages", serverEntryPoint: "./server.ts" },`).join("\n")} + ], + }, + }, + }`; + // Prints one line per attempt: "started" if Bun.serve accepted the options, "threw: " otherwise. + const serveFixture = (...attempts: string[]) => ` + import path from "path"; + for (const options of [${attempts.join(", ")}]) { + try { + const server = Bun.serve({ port: 0, development: true, ...options, fetch: () => new Response("") }); + server.stop(true); + console.log("started"); + } catch (e) { + console.log("threw: " + e.message); + } + } + `; + + const buildFixture = (root: string) => ({ + "server.ts": serverEntryPoint, + "bun.app.ts": ` + import path from "path"; + export default ${appWithRoots(root)}; + `, + }); + + async function run(dir: string, args: string[], env = bunEnv) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: dir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + // Every `bun build --app` trips the exception check validator while loading its config (until #38949) and + // leaks its transpilers at exit (until #38233), which is why production.test.ts is exempt from both checks. + const build = (dir: string) => + run(dir, ["build", "--app"], { + ...bunEnv, + BUN_JSC_validateExceptionChecks: undefined, + BUN_JSC_dumpSimulatedThrows: undefined, + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":"), + }); + + test("the internal FrameworkRouter constructor throws instead of crashing", () => { + const prefix = path.parse(process.cwd()).root; + const root = prefix + Buffer.alloc(MAX_PATH_BYTES - prefix.length, "a").toString(); + expect(() => new FrameworkRouter({ root, style: "nextjs-pages" })).toThrow( + `options.root must resolve to a path shorter than ${MAX_PATH_BYTES} bytes`, + ); + }); + + // Windows' own path limit is below MAX_PATH_BYTES, so such a tree cannot be created there. + test.skipIf(isWindows)("scanning a root skips the entries whose paths do not fit instead of crashing", () => { + using dir = tempDir("fsr-scan-long-entries", {}); + // About 200 bytes below the limit: the root's own short files fit, entries with a maximum-length name do not. + // Every segment adds at least two bytes (separator included), so this terminates whatever length `dir` has. + let root = String(dir); + while (root.length < MAX_PATH_BYTES - 200) { + const part = Math.max(1, Math.min(200, MAX_PATH_BYTES - 200 - root.length - 1)); + root = path.join(root, Buffer.alloc(part, "d").toString()); + } + mkdirSync(root, { recursive: true }); + writeFileSync(path.join(root, "index.ts"), ""); + // The OS rejects their absolute paths too, so these can only be created relative to the root. + const longName = Buffer.alloc(252, "x").toString(); + const create = Bun.spawnSync({ + cmd: [ + bunExe(), + "-e", + ` + import { mkdirSync, writeFileSync } from "fs"; + writeFileSync(${JSON.stringify(`${longName}.ts`)}, ""); + mkdirSync(${JSON.stringify(`${longName}dir`)}); + writeFileSync(${JSON.stringify(`${longName}dir/index.ts`)}, ""); + `, + ], + cwd: root, + env: bunEnv, + }); + expect(create.stderr.toString()).toBe(""); + expect(create.exitCode).toBe(0); + + const router = new FrameworkRouter({ root, style: "nextjs-pages" }); + expect(router.toJSON()).toEqual({ part: "/", page: path.join(root, "index.ts"), layout: null, children: [] }); + }); + + test("Bun.serve({ app }) reports every root that does not fit instead of crashing", async () => { + using dir = tempDir("fsr-long-root-app", { + "server.ts": serverEntryPoint, + "start.ts": serveFixture(appWithRoots(tooLongRoot, `"/" + ${tooLongRoot}`)), + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["start.ts"]); + expect(stderr).toContain(rootError(0)); + expect(stderr).toContain(rootError(1)); + expect(stdout).toBe(`${rejected}\n`); + expect(exitCode).toBe(0); + }); + + test("Bun.serve({ routes: { '/*': { dir, style } } }) reports the root instead of crashing", async () => { + using dir = tempDir("fsr-long-root-routes", { + "start.ts": serveFixture(`{ routes: { "/*": { dir: ${tooLongRoot}, style: "nextjs-pages" } } }`), + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["start.ts"]); + expect(stderr).toContain(rootError(0)); + expect(stdout).toBe(`${rejected}\n`); + expect(exitCode).toBe(0); + }); + + test("Bun.serve({ app }) accepts a root one byte below the limit and rejects one at the limit", async () => { + using dir = tempDir("fsr-root-at-limit-app", { + "server.ts": serverEntryPoint, + "start.ts": serveFixture( + // The directory does not exist, so the accepted root is skipped like any other missing root. + appWithRoots(absoluteRootOfLength(MAX_PATH_BYTES - 1)), + appWithRoots(absoluteRootOfLength(MAX_PATH_BYTES)), + ), + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["start.ts"]); + expect(stdout).toBe(`started\n${rejected}\n`); + expect(stderr).toContain(rootError(0)); + expect(stderr.match(/ENAMETOOLONG/g) ?? []).toHaveLength(1); + expect(exitCode).toBe(0); + }); + + test("bun build --app fails on a root at the limit instead of crashing", async () => { + using dir = tempDir("fsr-root-at-limit-build", buildFixture(absoluteRootOfLength(MAX_PATH_BYTES))); + const { stderr, exitCode } = await build(String(dir)); + expect(stderr).toContain(rootError(0)); + expect(exitCode).toBe(1); + }); + + test("bun build --app looks up a root one byte below the limit like any other missing directory", async () => { + using dir = tempDir("fsr-root-below-limit-build", buildFixture(absoluteRootOfLength(MAX_PATH_BYTES - 1))); + const { stdout, stderr, exitCode } = await build(String(dir)); + expect(stderr).not.toContain("ENAMETOOLONG"); + expect(stderr).toContain("Bundling routes"); + expect(stdout).toContain("done"); + expect(exitCode).toBe(0); + }); + + test("a root that only normalizes down to a path that fits is served", async () => { + using dir = tempDir("fsr-long-root-normalizes", { + "server.ts": serverEntryPoint, + "routes/index.ts": `export default () => new Response("hello from routes");`, + "start.ts": ` + // 100 KB as written, "routes" once the ".." segments are resolved. + const root = "routes" + Buffer.alloc(100_000, "/../routes").toString(); + using server = Bun.serve({ + port: 0, + development: true, + ...${appWithRoots("root")}, + fetch: () => new Response("not routed", { status: 404 }), + }); + const res = await fetch(\`http://localhost:\${server.port}/\`); + console.log(res.status, await res.text()); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["start.ts"]); + expect(stderr).not.toContain("ENAMETOOLONG"); + expect(stdout).toBe("200 hello from routes\n"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/harness.ts b/test/harness.ts index b3167919ec84..6da74ac8fff6 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -24,6 +24,11 @@ export const isFreeBSD = process.platform === "freebsd"; export const isAndroid = process.platform === "android"; export const isPosix = isMacOS || isLinux || isFreeBSD || isAndroid; export const isWindows = process.platform === "win32"; +/** + * Size of bun's fixed path buffers (`MAX_PATH_BYTES` in `src/bun_core/util.rs`). A path longer than this does not + * fit in one, which code taking a path from the user has to report rather than overflow the buffer. + */ +export const MAX_PATH_BYTES = isWindows ? 32767 * 3 + 1 : isLinux || isAndroid ? 4096 : 1024; export const isIntelMacOS = isMacOS && process.arch === "x64"; export const isArm64 = process.arch === "arm64"; export const isDebug = Bun.version.includes("debug");