Skip to content
Open
48 changes: 32 additions & 16 deletions src/runtime/bake/FrameworkRouter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1471,6 +1471,15 @@ impl bun_collections::zig_hash_map::HashContext<Box<[u8]>> 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,
Expand Down Expand Up @@ -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)?;
}
}
Expand All @@ -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()
};
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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(),
Expand Down
37 changes: 32 additions & 5 deletions src/runtime/bake/bake_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Box<[u8]>> {
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::<paths::platform::Auto>(
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<Box<[u8]>> {
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
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 5 additions & 9 deletions src/runtime/bake/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<bun_paths::platform::Auto>(
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(
Expand Down
5 changes: 4 additions & 1 deletion src/runtime/bake/production.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<platform::Auto>(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::<platform::Auto>(cwd, &mut buf[..], &[fsr.root]);
let Some(entry) = server_transpiler
.resolver
.read_dir_info_ignore_error(joined_root)
Expand Down
193 changes: 192 additions & 1 deletion test/bake/framework-router.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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: <message>" 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);
});
});
5 changes: 5 additions & 0 deletions test/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading