Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/runtime/server/HTMLBundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -617,14 +617,15 @@ impl Route {
{
route_path = &route_path[1..];
}
let route_path = crate::server::percent_encode_route_path(route_path);

if i == html_index {
this_html_route = Some((static_route, Box::<[u8]>::from(route_path)));
this_html_route = Some((static_route, Box::<[u8]>::from(&*route_path)));
continue;
}

bun_core::handle_oom(server.append_static_route(
route_path,
&route_path,
AnyRoute::Static(RefPtr::new(static_route)),
MethodOptional::Any,
));
Expand Down
29 changes: 29 additions & 0 deletions src/runtime/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,35 @@ pub(crate) fn write_status<const SSL: bool>(resp: *mut uws_sys::NewAppResponse<S
}

// ─── AnyRoute ────────────────────────────────────────────────────────────────

/// An output file's path as a browser puts it on the request line: every
/// byte in the WHATWG URL path percent-encode set is escaped. The URL written
/// into the page is the raw name, and the browser encodes it before the
/// request, so the route must be registered under the encoded form.
pub(crate) fn percent_encode_route_path(path: &[u8]) -> std::borrow::Cow<'_, [u8]> {
fn needs_escape(byte: u8) -> bool {
byte < 0x20
|| byte >= 0x7F
|| matches!(
byte,
b' ' | b'"' | b'#' | b'<' | b'>' | b'?' | b'`' | b'{' | b'}'
)
}
if !path.iter().copied().any(needs_escape) {
return std::borrow::Cow::Borrowed(path);
}
let mut out = Vec::with_capacity(path.len() + 16);
for &byte in path {
if needs_escape(byte) {
let hex = bun_core::fmt::hex2_upper(byte);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): bun_core::fmt::hex2_upper is a #[doc(hidden)] compat alias (fmt.rs:2858 "compat aliases (pre-dedup names)"); the canonical helper is hex_byte_upper. Fix: call bun_core::fmt::hex_byte_upper(byte) so new code doesn't add a caller to the alias being phased out.

Extended reasoning...

src/bun_core/fmt.rs:2858-2863 marks hex2_upper as #[doc(hidden)] under a "compat aliases (pre-dedup names)" banner and delegates to hex_byte_upper at line 2851 (whose doc even says "Used by percent-encoders"). New percent-encoders in the tree (src/md/html_renderer.rs:500, src/resolver/data_url.rs:274) already use hex_byte_upper; the remaining hex2_upper sites carry comments like js_printer/lib.rs:877 "remaining \xHH site below" indicating they are leftovers, not the name to reach for. No behaviour difference — purely which name new code should use.

Verification: nit — The new code at src/runtime/server/mod.rs:184 calls bun_core::fmt::hex2_upper(byte). In src/bun_core/fmt.rs:2858-2863, hex2_upper sits under the banner // ── compat aliases (pre-dedup names) ──, is #[doc(hidden)], and just delegates to hex_byte_upper(b) (line 2862). The canonical helper is hex_byte_upper at fmt.rs:2851, whose doc comment explicitly says "Used by… | nit — the…

out.extend_from_slice(&[b'%', hex[0], hex[1]]);
} else {
out.push(byte);
}
}
std::borrow::Cow::Owned(out)
}

/// The route table's ref on each route.
pub enum AnyRoute {
/// Serve a static file — `"/robots.txt": new Response(...)`
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/server/server_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,7 @@ impl AnyRoute {
if !relative_path.starts_with(b"/") {
builder.push(b'/');
}
builder.extend_from_slice(relative_path);
builder.extend_from_slice(&super::percent_encode_route_path(relative_path));

let Some(headers_js) = argument.get(init_ctx.global, b"headers")? else {
return Ok(None);
Expand Down
39 changes: 39 additions & 0 deletions test/bundler/bundler_html_server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,5 +121,44 @@ describe.concurrent("bundler", () => {
stdout: "Home status: 200\nHome has content: true\nAbout status: 200\nAbout has content: true",
},
});

// The page carries the raw asset name. A browser percent-encodes it
// before the request, so the route is registered under that form.
itBundled(`compile/${backend}/HTMLServerEncodedAssetRoute`, {
compile: true,
backend: backend,
files: {
"/entry.ts": /* js */ `
import index from "./index.html";

using server = Bun.serve({
port: 0,
routes: {
"/": index,
},
});

const html = await (await fetch(server.url)).text();
for (const [, src] of html.matchAll(/<img src="([^"]+)"/g)) {
const url = new URL(src, server.url);
console.log(url.pathname.replace(/-[a-z0-9]+\.png$/, ".png"), (await fetch(url)).status);
}
`,
"/index.html": /* html */ `
<!DOCTYPE html>
<html>
<body>
<img src="./my img.png">
<img src="./ünï.png">
</body>
</html>
`,
"/my img.png": "fake png",
"/ünï.png": "fake png",
},
run: {
stdout: "/my%20img.png 200\n/%C3%BCn%C3%AF.png 200",
},
});
}
});
30 changes: 30 additions & 0 deletions test/js/bun/http/bun-serve-html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1606,3 +1606,33 @@ describe("production headers and import.meta.env", () => {
expect(results).toEqual(cases.map(([, , expected]) => expected));
});
});

// The page carries the raw asset name. A browser percent-encodes it before
// the request, so the production build registers the route under that form.
test.concurrent("production build serves an asset whose name has a space or non-ASCII", async () => {
using dir = tempDir("bun-serve-html-encoded-asset-route", {
"index.html": `<!DOCTYPE html><html><body><img src="./my img.png"><img src="./ünï.png"><script type="module" src="./app.ts"></script></body></html>`,
"my img.png": "fake png",
"ünï.png": "fake png",
"app.ts": `console.log("app");`,
"serve.ts": /*ts*/ `
import page from "./index.html";
using server = Bun.serve({ port: 0, development: false, routes: { "/": page } });
const html = await (await fetch(server.url)).text();
const result = [];
for (const [, src] of html.matchAll(/<img src="([^"]+)"/g)) {
const url = new URL(src, server.url);
result.push([src, url.pathname, (await fetch(url)).status]);
}
console.log(JSON.stringify(result));
`,
});
const { stdout, stderr, exitCode } = await runServeFixture(dir);
expect({ result: stdout === "" ? null : JSON.parse(stdout), exitCode }, stderr).toEqual({
result: [
[expect.stringMatching(/^\/my img-[a-z0-9]+\.png$/), expect.stringMatching(/^\/my%20img-[a-z0-9]+\.png$/), 200],
[expect.stringMatching(/^\/ünï-[a-z0-9]+\.png$/), expect.stringMatching(/^\/%C3%BCn%C3%AF-[a-z0-9]+\.png$/), 200],
],
exitCode: 0,
});
});
Loading