Skip to content
Open
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
41 changes: 33 additions & 8 deletions src/bun_core/string/immutable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2231,15 +2231,10 @@ pub fn percent_encode_write(
Ok(())
}

/// JavaScript's `encodeURIComponent` on bytes: every byte but `A-Z a-z 0-9 - _ . ! ~ * ' ( )` becomes `%XX`.
pub fn percent_encode_uri_component(input: &[u8], writer: &mut Vec<u8>) {
/// Appends `input` to `writer`, with every byte that `keep` rejects written as `%XX`.
fn percent_encode_except(input: &[u8], writer: &mut Vec<u8>, keep: impl Fn(u8) -> bool) {
for &byte in input {
if byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | b'\'' | b'(' | b')'
)
{
if keep(byte) {
writer.push(byte);
} else {
let h = crate::fmt::hex2_upper(byte);
Expand All @@ -2248,6 +2243,36 @@ pub fn percent_encode_uri_component(input: &[u8], writer: &mut Vec<u8>) {
}
}

/// JavaScript's `encodeURIComponent` on bytes: every byte but `A-Z a-z 0-9 - _ . ! ~ * ' ( )` becomes `%XX`.
pub fn percent_encode_uri_component(input: &[u8], writer: &mut Vec<u8>) {
percent_encode_except(input, writer, |byte| {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | b'\'' | b'(' | b')'
)
});
}

/// A file path as a URL path: escapes what a browser would (the WHATWG path percent-encode set), and `%` and `\`, which a file name means literally.
pub fn percent_encode_url_path(path: &[u8]) -> std::borrow::Cow<'_, [u8]> {
fn keep(byte: u8) -> bool {
byte.is_ascii_graphic()
&& !matches!(
byte,
b'"' | b'#' | b'%' | b'<' | b'>' | b'?' | b'\\' | b'^' | b'`' | b'{' | b'}'
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

let Some(first) = path.iter().position(|&byte| !keep(byte)) else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return std::borrow::Cow::Borrowed(path);
};
let mut out = Vec::with_capacity(path.len() + 2 * (path.len() - first));
out.extend_from_slice(&path[..first]);
percent_encode_except(&path[first..], &mut out, keep);
std::borrow::Cow::Owned(out)
}

/// Levenshtein's distance: how many elements have to be replaced, added or removed to make `b` of `a`.
pub fn edit_distance<T: PartialEq>(a: impl Iterator<Item = T>, b: &[T]) -> usize {
let mut row: Vec<usize> = (0..=b.len()).collect();
Expand Down
37 changes: 31 additions & 6 deletions src/bundler/Chunk.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::mal_prelude::*;
use core::cell::UnsafeCell;
use core::fmt;
use std::borrow::Cow;
use std::io::Write as _;

use bun_alloc::AllocError;
Expand Down Expand Up @@ -510,6 +511,16 @@ fn additional_output_file_index(f: &AdditionalFile) -> usize {
}
}

/// An output path as the value of a double-quoted `src`/`href`: its URL path, with `&` as a character reference.
fn html_attribute_url(path: &[u8]) -> Cow<'_, [u8]> {
let url_path = strings::percent_encode_url_path(path);
if strings::contains_char(&url_path, b'&') {
Cow::Owned(strings::replace_owned(&url_path, b"&", b"&amp;"))
} else {
url_path
}
}

impl IntermediateOutput {
pub(crate) fn allocator_for_size(_size: usize) -> &'static DynAlloc {
// mimalloc serves large allocations via mmap already, so the global
Expand Down Expand Up @@ -720,6 +731,14 @@ impl IntermediateOutput {
|| reference_path_style == ReferencePathStyle::OutdirRelative
|| !import_prefix.is_empty();

// HTML `src`/`href` values are URLs; JS and CSS strings stay raw.
let url_path: fn(&[u8]) -> Cow<'_, [u8]> = if matches!(chunk.content, Content::Html)
{
html_attribute_url
} else {
|path| Cow::Borrowed(path)
};
Comment thread
robobun marked this conversation as resolved.
Resolved

let urls_for_css: &[&[u8]] = if standalone_chunk_contents.is_some() {
graph.ast.items_url_for_css()
} else {
Expand Down Expand Up @@ -802,6 +821,11 @@ impl IntermediateOutput {
QueryKind::None | QueryKind::ChunkId => unreachable!(),
};

// The write pass normalizes separators; measure the same bytes.
let file_path = bun_paths::resolve_path::platform_to_posix_buf(
file_path,
&mut file_path_buf[..],
);
let cheap_normalizer = cheap_prefix_normalizer(
import_prefix,
if use_outdir_relative_path {
Expand All @@ -815,7 +839,8 @@ impl IntermediateOutput {
)
},
);
count += cheap_normalizer[0].len() + cheap_normalizer[1].len();
let path_part = url_path(cheap_normalizer[1]);
count += cheap_normalizer[0].len() + path_part.len();
Comment thread
robobun marked this conversation as resolved.
Resolved
}
QueryKind::None => {}
}
Expand Down Expand Up @@ -1013,6 +1038,7 @@ impl IntermediateOutput {
)
},
);
let path_part = url_path(cheap_normalizer[1]);

if !cheap_normalizer[0].is_empty() {
remain[..cheap_normalizer[0].len()]
Expand All @@ -1023,12 +1049,11 @@ impl IntermediateOutput {
}
}

if !cheap_normalizer[1].is_empty() {
remain[..cheap_normalizer[1].len()]
.copy_from_slice(cheap_normalizer[1]);
remain = &mut remain[cheap_normalizer[1].len()..];
if !path_part.is_empty() {
remain[..path_part.len()].copy_from_slice(&path_part);
remain = &mut remain[path_part.len()..];
if ENABLE_SOURCE_MAP_SHIFTS {
shift.after.advance(cheap_normalizer[1]);
shift.after.advance(&path_part);
}
}

Expand Down
10 changes: 5 additions & 5 deletions src/runtime/server/HTMLBundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,7 @@ impl Route {
{
route_path = &route_path[1..];
}
headers.append(b"SourceMap", route_path);
headers.append(b"SourceMap", &strings::percent_encode_url_path(route_path));
}

let static_route = StaticRoute::new(blob, headers, Some(server), 200);
Expand All @@ -632,21 +632,21 @@ impl Route {
continue;
}

bun_core::handle_oom(server.append_static_route(
server.append_bundled_file_route(
route_path,
AnyRoute::Static(RefPtr::new(static_route)),
MethodOptional::Any,
));
);
}

let (mut html_route, html_route_path) =
this_html_route.expect("the loop above visited html_index");
let html_route_clone = html_route.clone(global_this);
bun_core::handle_oom(server.append_static_route(
server.append_bundled_file_route(
&html_route_path,
AnyRoute::Static(RefPtr::new(html_route)),
MethodOptional::Any,
));
);
self.state.set(State::Html(html_route_clone));

if !bun_core::handle_oom(server.reload_static_routes()) {
Expand Down
35 changes: 21 additions & 14 deletions src/runtime/server/ServerConfig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,27 @@ impl StaticRouteEntry {
fn memory_cost(&self) -> usize {
self.path.len() + self.route.memory_cost()
}

/// Adds the route of a file the bundler wrote under its URL path, and under its raw name for a client that sends that as is.
pub(crate) fn push_bundled_file(
routes: &mut Vec<StaticRouteEntry>,
path: &[u8],
route: AnyRoute,
method: MethodOptional,
) {
if let std::borrow::Cow::Owned(encoded) = strings::percent_encode_url_path(path) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
routes.push(StaticRouteEntry {
path: encoded.into_boxed_slice(),
route: route.clone(),
Comment thread
robobun marked this conversation as resolved.
Resolved
method,
});
}
routes.push(StaticRouteEntry {
path: Box::from(path),
route,
method,
});
}
Comment thread
robobun marked this conversation as resolved.
Resolved
}

impl ServerConfig {
Expand Down Expand Up @@ -313,20 +334,6 @@ impl ServerConfig {

Ok(that)
}

pub(crate) fn append_static_route(
&mut self,
path: &[u8],
route: AnyRoute,
method: MethodOptional,
) -> Result<(), crate::Error> {
self.static_routes.push(StaticRouteEntry {
path: Box::<[u8]>::from(path),
route,
method,
});
Ok(())
}
}

pub(crate) fn apply_static_route<const SSL: bool, T: StaticRouteLike>(
Expand Down
16 changes: 12 additions & 4 deletions src/runtime/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ pub(crate) fn write_status<const SSL: bool>(resp: *mut uws_sys::NewAppResponse<S
}

// ─── AnyRoute ────────────────────────────────────────────────────────────────
/// The route table's ref on each route.
/// The route table's ref on each route. A clone is another ref on the same route.
#[derive(Clone)]
pub(crate) enum AnyRoute {
/// Serve a static file — `"/robots.txt": new Response(...)`
Static(bun_ptr::RefPtr<StaticRoute>),
Expand Down Expand Up @@ -4139,13 +4140,20 @@ impl AnyServer {
any_server_dispatch_mut!(self, |s| s.get_or_load_plugins(callback))
}

pub(crate) fn append_static_route(
pub(crate) fn append_bundled_file_route(
&self,
path: &[u8],
route: AnyRoute,
method: server_config::MethodOptional,
) -> Result<(), crate::Error> {
any_server_dispatch_mut!(self, |s| s.config.append_static_route(path, route, method))
) {
any_server_dispatch_mut!(self, |s| {
server_config::StaticRouteEntry::push_bundled_file(
&mut s.config.static_routes,
path,
route,
method,
)
})
}

pub(crate) fn reload_static_routes(&self) -> Result<bool, crate::Error> {
Expand Down
9 changes: 5 additions & 4 deletions src/runtime/server/server_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -652,11 +652,12 @@ impl AnyRoute {
methods.insert(Method::GET);
methods.insert(Method::HEAD);

init_ctx.user_routes.push(server_config::StaticRouteEntry {
path: builder.into_boxed_slice(),
server_config::StaticRouteEntry::push_bundled_file(
init_ctx.user_routes,
&builder,
route,
method: methods,
});
methods,
);
Ok(None)
}

Expand Down
54 changes: 54 additions & 0 deletions test/bundler/bundler_html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1078,4 +1078,58 @@ body {
expect(htmlContent).toMatch(/href=".*\.webmanifest"/);
},
});

// A rewritten `src`/`href` is a URL in an HTML attribute, so an output file
// name with a space, `#`, `%` or a non-ASCII byte in it is percent-encoded,
// and `&` is written as `&amp;`. A browser then requests the file that exists
// instead of cutting the name at `#` or reading `&copy` as a character.
itBundled("html/percent-encodes-rewritten-urls", {
outdir: "out/",
files: {
"/my page.html": /* html */ `
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="./styles.css">
<script type="module" src="./app.js"></script>
<script src="https://cdn.example.com/lib%20v1.js?a=b#c"></script>
</head>
<body>
<img src="./my photo.png">
<img src="./shot #1.png">
<img src="./100%.png">
<img src="./ünï (1)+@2x.png">
<img src="./cut&copy.png">
</body>
</html>`,
"/styles.css": "body { color: red }",
"/app.js": "console.log('app')",
"/my photo.png": "photo",
"/shot #1.png": "shot",
"/100%.png": "percent",
"/ünï (1)+@2x.png": "unicode",
"/cut&copy.png": "ampersand",
},
entryPoints: ["/my page.html"],
onAfterBundle(api) {
const html = api.readFile("out/my page.html");
const attributes = [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map(m => m[1]);
expect(attributes).toEqual([
"https://cdn.example.com/lib%20v1.js?a=b#c",
expect.stringMatching(/^\.\/my%20page-[a-z0-9]+\.css$/),
expect.stringMatching(/^\.\/my%20page-[a-z0-9]+\.js$/),
expect.stringMatching(/^\.\/my%20photo-[a-z0-9]+\.png$/),
expect.stringMatching(/^\.\/shot%20%231-[a-z0-9]+\.png$/),
expect.stringMatching(/^\.\/100%25-[a-z0-9]+\.png$/),
expect.stringMatching(/^\.\/%C3%BCn%C3%AF%20\(1\)\+@2x-[a-z0-9]+\.png$/),
expect.stringMatching(/^\.\/cut&amp;copy-[a-z0-9]+\.png$/),
]);
// The URL each attribute holds is its own parsed form, and names a file that the build wrote.
for (const attribute of attributes.slice(1)) {
const url = attribute.replaceAll("&amp;", "&");
expect(new URL(url, "http://example.test/").pathname).toBe(url.slice(1));
api.assertFileExists("out/" + decodeURIComponent(url.slice(2)));
}
},
});
});
54 changes: 54 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,59 @@ describe.concurrent("bundler", () => {
stdout: "Home status: 200\nHome has content: true\nAbout status: 200\nAbout has content: true",
},
});

// The embedded page references each asset by its URL path (percent-encoded,
// with `&` as `&amp;`) and the compiled server registers the file under that
// path, so the request a browser makes for each `<img src>` is a 200.
itBundled(`compile/${backend}/HTMLServerPercentEncodedAssets`, {
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 [, attribute] of html.matchAll(/<img src="([^"]+)"/g)) {
const src = attribute.replaceAll("&amp;", "&");
const url = new URL(src, server.url);
const res = await fetch(url);
console.log(attribute.replace(/-[a-z0-9]+\\.png$/, ".png"), url.pathname === src, res.status, await res.text());
}
`,
"/index.html": /* html */ `
<!DOCTYPE html>
<html>
<body>
<img src="./my photo.png">
<img src="./shot #1.png">
<img src="./100%.png">
<img src="./ünï.png">
<img src="./cut&copy.png">
</body>
</html>
`,
"/my photo.png": "photo",
"/shot #1.png": "shot",
"/100%.png": "percent",
"/ünï.png": "unicode",
"/cut&copy.png": "ampersand",
},
run: {
stdout: [
"/my%20photo.png true 200 photo",
"/shot%20%231.png true 200 shot",
"/100%25.png true 200 percent",
"/%C3%BCn%C3%AF.png true 200 unicode",
"/cut&amp;copy.png true 200 ampersand",
].join("\n"),
},
});
}
});
Loading
Loading