From 2613436df23a6c8d05d4b8b02afba85e79140067 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:05:30 +0000 Subject: [PATCH] bundler: resolve a percent-encoded CSS url() or @import by its decoded path For a browser, `url(./my%20photo.png)` in a stylesheet names the file `my photo.png`. The bundler looked up the text as written and the build failed with "Could not resolve". When a `url()` or `@import` of a CSS file does not resolve as written, both bundler resolve sites now try the path with its percent escapes decoded (`percent_decoded_css_url`). A file with the literal name still wins. An escape stays as written when the resolver or a browser reads its byte another way: `%2F`, `%5C`, a decoded `#`, `?`, `%`, `"`, control byte or trailing space, bytes that are not UTF-8, and a `:` in the first path segment. HTML `src`/`href` and `composes` are not decoded. Under a watcher, the directory of the decoded path is dropped from the cache and watched like the directory of the literal text. The failure log is fetched after the retries, because the dev server marks the file stale when it hands out that log. The resolver treats ENAMETOOLONG as a missing file when it reads a directory. A long name written as escapes logged "Cannot read directory", and that error failed the build. The two resolve loops bind their result with `let mut` directly. With the second binding, the new `break` added one copy of the Result for each record that resolves. --- src/bundler/bundle_v2.rs | 136 +++++++---- src/resolver/lib.rs | 1 + src/resolver/package_json.rs | 2 +- src/resolver/resolver.rs | 55 ++++- test/bake/dev/css.test.ts | 154 ++++++++++++ test/bundler/esbuild/css.test.ts | 402 ++++++++++++++++++++++++++++++- 6 files changed, 700 insertions(+), 50 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 201b32587eeb..697826c928d2 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2766,7 +2766,7 @@ pub mod bv2_impl { } let mut had_busted_dir_cache = false; - let resolve_result: _resolver::Result = loop { + let mut resolve_result: _resolver::Result = loop { // SAFETY: see `transpiler` note above. match unsafe { &mut *transpiler }.resolver.resolve( source_dir, @@ -2777,31 +2777,55 @@ pub mod bv2_impl { Err(err) => { // Only perform directory busting when hot-reloading is enabled if err == _resolver::Error::ModuleNotFound { + let importer_loader = self.graph.input_files.items_loader() + [import_record.importer_source_index as usize]; + let decoded = _resolver::percent_decoded_css_url( + self.arena(), + importer_loader, + &import_record.specifier, + import_record.kind, + ); + if let Some(decoded) = decoded + // SAFETY: see `transpiler` note above. + && let Ok(result) = unsafe { &mut *transpiler }.resolver.resolve( + source_dir, + decoded, + import_record.kind, + ) + { + break result; + } + let spellings = + core::iter::once(&*import_record.specifier).chain(decoded); if let Some(dev) = &self.dev_server { if !had_busted_dir_cache { // Only re-query if we previously had something cached. - // SAFETY: see `transpiler` note above. - if unsafe { &mut *transpiler } - .resolver - .bust_dir_cache_from_specifier( - &import_record.source_file, - &import_record.specifier, - ) - { + let mut busted = false; + for spelling in spellings.clone() { + // SAFETY: see `transpiler` note above. + busted |= unsafe { &mut *transpiler } + .resolver + .bust_dir_cache_from_specifier( + &import_record.source_file, + spelling, + ); + } + if busted { had_busted_dir_cache = true; continue; } } // Tell Bake's Dev Server to wait for the file to be imported. - dev.track_resolution_failure( - &import_record.source_file, - &import_record.specifier, - target.bake_graph(), - self.graph.input_files.items_loader() - [import_record.importer_source_index as usize], - ) - .expect("oom"); + for spelling in spellings { + dev.track_resolution_failure( + &import_record.source_file, + spelling, + target.bake_graph(), + importer_loader, + ) + .expect("oom"); + } // Turn this into an invalid AST, so that incremental mode skips it when printing. // SAFETY: truncating to len 0 never exposes uninitialized elements. @@ -2916,7 +2940,6 @@ pub mod bv2_impl { } } }; - let mut resolve_result = resolve_result; let out_source_index: Option; @@ -6962,7 +6985,7 @@ pub mod bv2_impl { } let mut had_busted_dir_cache = false; - let resolve_result: _resolver::Result = 'inner: loop { + let mut resolve_result: _resolver::Result = 'inner: loop { match transpiler.resolver.resolve_with_framework( source_dir, import_record.path.text, @@ -6970,22 +6993,25 @@ pub mod bv2_impl { ) { Ok(r) => break r, Err(err) => { - // borrowck — `log_for_resolution_failures` returns - // `&mut Log` tied to `&mut self`, but it's always a raw-ptr - // deref (DevServer vtable or `transpiler.log`). Detach via - // `*mut` so later `self.*` reads don't conflict. - // SAFETY: log lives in DevServer/transpiler, disjoint from `self.graph`. - let log: &mut bun_ast::Log = unsafe { - &mut *std::ptr::from_mut::( - self.log_for_resolution_failures( - source.path.text, - ctx.target.bake_graph(), - ), - ) - }; - // Only perform directory busting when hot-reloading is enabled if err == _resolver::Error::ModuleNotFound { + let decoded = _resolver::percent_decoded_css_url( + self.arena(), + loader, + import_record.path.text, + import_record.kind, + ); + if let Some(decoded) = decoded + && let Ok(result) = transpiler.resolver.resolve( + source_dir, + decoded, + import_record.kind, + ) + { + break 'inner result; + } + let spellings = + core::iter::once(import_record.path.text).chain(decoded); if self.bun_watcher.is_some() { if !had_busted_dir_cache { bun_core::scoped_log!( @@ -6995,27 +7021,48 @@ pub mod bv2_impl { bstr::BStr::new(&import_record.path.text) ); // Only re-query if we previously had something cached. - if transpiler.resolver.bust_dir_cache_from_specifier( - source.path.text, - import_record.path.text, - ) { + let mut busted = false; + for spelling in spellings.clone() { + busted |= + transpiler.resolver.bust_dir_cache_from_specifier( + source.path.text, + spelling, + ); + } + if busted { had_busted_dir_cache = true; continue 'inner; } } if let Some(dev) = self.dev_server { // Tell DevServer about the resolution failure. - dev.track_resolution_failure( - source.path.text, - import_record.path.text, - ctx.target.bake_graph(), // use the source file target not the altered one - loader, - ) - .expect("oom"); + for spelling in spellings { + dev.track_resolution_failure( + source.path.text, + spelling, + ctx.target.bake_graph(), // use the source file target not the altered one + loader, + ) + .expect("oom"); + } } } } + // borrowck — `log_for_resolution_failures` returns + // `&mut Log` tied to `&mut self`, but it's always a raw-ptr + // deref (DevServer vtable or `transpiler.log`). Detach via + // `*mut` so later `self.*` reads don't conflict. + // SAFETY: log lives in DevServer/transpiler, disjoint from `self.graph`. + let log: &mut bun_ast::Log = unsafe { + &mut *std::ptr::from_mut::( + self.log_for_resolution_failures( + source.path.text, + ctx.target.bake_graph(), + ), + ) + }; + // Disable failing packages from being printed. // This may cause broken code to write. // However, doing this means we tell them all the resolve errors @@ -7155,7 +7202,6 @@ pub mod bv2_impl { } } }; - let mut resolve_result = resolve_result; // if there were errors, lets go ahead and collect them all if last_error.is_some() { continue; diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index a9872c36e891..83dc0e4e8a66 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -50,6 +50,7 @@ pub use tsconfig_json::TSConfigJSON; pub use ::bun_install_types::resolver_hooks as install_types; pub use resolver::{ AnyResolveWatcher, BrowserMapPathKind, Bufs, Dirname, Resolver, module_type_from_ext, + percent_decoded_css_url, }; pub use result::{ DebugLogs, DirEntryResolveQueueItem, ExternalKind, FlushMode, LoadResult, MatchResult, diff --git a/src/resolver/package_json.rs b/src/resolver/package_json.rs index 1aa43e1a9881..5dadab663522 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -1564,7 +1564,7 @@ impl<'a> Package<'a> { use bun_core::strings::{replace, replacement_size}; -const INVALID_PERCENT_CHARS: [&[u8]; 4] = [b"%2f", b"%2F", b"%5c", b"%5C"]; +pub(crate) const INVALID_PERCENT_CHARS: [&[u8]; 4] = [b"%2f", b"%2F", b"%5c", b"%5C"]; struct ModuleBufs { resolved_path_buf_percent: PathBuffer, diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 5e63425e68af..16b88df8c018 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -4577,7 +4577,11 @@ impl<'a> Resolver<'a> { // ... self.dir_cache_mut().mark_not_found(queue_top.result); rfs!().entries.mark_not_found(cached_dir_entry_result); - if err != crate::Error::Sys(bun_errno::SystemErrno::ENOENT) { + if !matches!( + err, + crate::Error::Sys(bun_errno::SystemErrno::ENOENT) + | crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG) + ) { if enable_logging { let pretty = queue_top_unsafe_path; let _ = self.log_mut().add_error_fmt( @@ -5905,7 +5909,8 @@ impl<'a> Resolver<'a> { if let Fs::file_system::real_fs::EntriesOption::Err(err) = dir_entry.get() { match err.original_err { crate::Error::Sys(bun_errno::SystemErrno::ENOENT) - | crate::Error::Sys(bun_errno::SystemErrno::ENOTDIR) => {} + | crate::Error::Sys(bun_errno::SystemErrno::ENOTDIR) + | crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG) => {} _ => { let _ = self.log_mut().add_error_fmt( None, @@ -6916,6 +6921,52 @@ pub fn module_type_from_ext(ext: &[u8]) -> Option { MODULE_TYPE_FROM_EXT.get(ext).copied() } +/// The percent-decoded path to try for a stylesheet `url()`/`@import` that is not a file as written. +#[cold] +pub fn percent_decoded_css_url<'a>( + arena: &'a bun_alloc::Arena, + importer: bun_ast::Loader, + url: &[u8], + kind: ast::ImportKind, +) -> Option<&'a [u8]> { + // HTML `src`/`href` records have these kinds too, and `composes .. from` names a module. + if !importer.is_css() + || !matches!( + kind, + ast::ImportKind::Url | ast::ImportKind::At | ast::ImportKind::AtConditional + ) + { + return None; + } + let path = &url[..strings::index_of_any(url, b"?#").unwrap_or(url.len())]; + if !strings::contains_char(path, b'%') + || crate::package_json::INVALID_PERCENT_CHARS + .iter() + .any(|escape| strings::contains(path, escape)) + { + return None; + } + // A Result can borrow its specifier, so the decoded bytes must outlive it. + let decoded = arena.alloc_slice_fill_copy(path.len(), 0u8); + let len = bun_url::PercentEncoding::decode_into(decoded, path).ok()? as usize; + let decoded: &[u8] = &decoded[..len]; + let first_segment = + &decoded[..strings::index_of_char_usize(decoded, b'/').unwrap_or(decoded.len())]; + // `resolve` and a browser would cut at a decoded `#` or `?`, and decode a `%` once more. + if strings::contains_any(decoded, b"#?%") + // The output is the raw file name, which cannot carry these to a browser. + || strings::contains_char(decoded, b'"') + || decoded.iter().any(u8::is_ascii_control) + || !strings::is_valid_utf8(decoded) + || strings::ends_with_char(decoded, b' ') + // A `:` here reads as a scheme or a drive letter. + || strings::contains_char(first_segment, b':') + { + return None; + } + Some(decoded) +} + /// TypeScript matches `./foo.js` with `./foo.ts` (microsoft/TypeScript#4595). This /// is esbuild's `rewrittenFileExtensions` table plus Bun's `.js` → `.mts` /// (oven-sh/bun#12580). `inside_node_modules` gates only `.mjs` and `.cjs`: diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index 9ce744ae8636..24a735336557 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -193,6 +193,160 @@ devTest("asset referenced in css", { await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun2); }, }); +devTest("asset referenced in css by a percent-encoded url", { + files: { + "index.html": emptyHtmlFile({ + styles: ["styles.css"], + }), + "styles.css": ` + body { + background-image: url(./my%20photo.png); + } + `, + "my photo.png": imageFixtures.bun, + "other photo.png": imageFixtures.bun2, + }, + async test(dev) { + await using c = await dev.client("/"); + let backgroundImage = await c.style("body").backgroundImage; + assert(backgroundImage); + await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun); + // A hot reload resolves the decoded path too. + await dev.write( + "styles.css", + ` + body { + background-image: url(./other%20photo.png); + } + `, + ); + backgroundImage = await c.style("body").backgroundImage; + assert(backgroundImage); + await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun2); + }, +}); +// The directory of the decoded path is the one to watch and to read again. +devTest("asset referenced in css by a percent-encoded url is created later", { + files: { + "index.html": emptyHtmlFile({ + styles: ["styles.css"], + }), + "styles.css": ` + body { + background-image: url(./my%20dir/photo.png); + } + `, + "my dir/other.png": imageFixtures.bun2, + }, + async test(dev) { + await using c = await dev.client("/", { + errors: ['styles.css:2:21: error: Could not resolve: "./my%20dir/photo.png"'], + }); + await c.expectReload(async () => { + await dev.write("my dir/photo.png", imageFixtures.bun); + }); + const backgroundImage = await c.style("body").backgroundImage; + assert(backgroundImage); + await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun); + }, +}); +devTest("imported css with a percent-encoded url hot reloads every time", { + files: { + "index.html": emptyHtmlFile({ + styles: ["styles.css"], + }), + "styles.css": ` + @import "./child.css"; + `, + "child.css": ` + body { + color: red; + background-image: url(./my%20photo.png); + } + `, + "my photo.png": imageFixtures.bun, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.style("body").color.expect.toBe("red"); + for (const color of ["green", "purple", "orange"]) { + await dev.write( + "child.css", + ` + body { + color: ${color}; + background-image: url(./my%20photo.png); + } + `, + ); + await c.style("body").color.expect.toBe(color); + } + await c.hardReload(); + await c.style("body").color.expect.toBe("orange"); + const backgroundImage = await c.style("body").backgroundImage; + assert(backgroundImage); + await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun); + }, +}); +// A record that an onResolve plugin declines is resolved at another call site. +devTest("asset referenced in css by a percent-encoded url that a plugin declines", { + files: { + "bunfig.toml": ` + [serve.static] + plugins = ["./css-plugin.ts"] + `, + "css-plugin.ts": ` + export default { + name: "declines", + setup(build) { + build.onResolve({ filter: /photo\\.png$/ }, () => undefined); + }, + }; + `, + "index.html": emptyHtmlFile({ + styles: ["styles.css"], + }), + "styles.css": ` + body { + background-image: url(./my%20photo.png); + } + `, + "my photo.png": imageFixtures.bun, + "other photo.png": imageFixtures.bun2, + }, + async test(dev) { + await using c = await dev.client("/"); + let backgroundImage = await c.style("body").backgroundImage; + assert(backgroundImage); + await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun); + await dev.write( + "styles.css", + ` + body { + background-image: url(./other%20photo.png); + } + `, + ); + backgroundImage = await c.style("body").backgroundImage; + assert(backgroundImage); + await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun2); + await dev.write( + "styles.css", + ` + body { + background-image: url(./missing%20photo.png); + } + `, + { + errors: ['styles.css:2:21: error: Could not resolve: "./missing%20photo.png"'], + }, + ); + await dev.write("missing photo.png", imageFixtures.bun); + backgroundImage = await c.style("body").backgroundImage; + assert(backgroundImage); + await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun); + }, +}); devTest("syntax error crash", { files: { "styles.css": ` diff --git a/test/bundler/esbuild/css.test.ts b/test/bundler/esbuild/css.test.ts index 0679a6644722..0ebe61ec8d2e 100644 --- a/test/bundler/esbuild/css.test.ts +++ b/test/bundler/esbuild/css.test.ts @@ -1,6 +1,8 @@ -import { describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import { isLinux, isWindows, tempDir } from "harness"; +import { readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { itBundled } from "../expectBundled"; +import { itBundled, type BundlerTestInput } from "../expectBundled"; // Tests ported from: // https://github.com/evanw/esbuild/blob/main/internal/bundler_tests/bundler_css_test.go @@ -1264,6 +1266,402 @@ b { }, }); + // A browser requests `url(./my%20photo.png)` as the file `my photo.png`. A path that is + // not a file as written is tried once more with its percent escapes decoded. + const percentEncoded: Pick = { + files: { + "/entry.css": /* css */ ` + @import "./my%20theme.css"; + @import url(./more%20themes/dark.css); + @import "./my%20grid.css" supports(display: grid); + .a { background: url(./my%20photo.png) } + .b { mask: url("./my%20photo.png#m") } + .c { --icon: url(./caf%C3%A9.png) } + .d { background: url(fonts/Geist%5Bwght%5D.woff2) } + .e { background: url(pkg/a%20b.png) } + .f { background: url(with-exports/a%20b.png) } + .g { background: url(./%6Dy%20photo.png) } + .h { background: url(./fonts/../my%20photo.png) } + .i { background: url(./fonts/%2e%2e/my%20photo.png) } + .j { background: url("./a%20b.png") } + `, + "/my theme.css": `.theme { color: red }`, + "/more themes/dark.css": `.dark { color: black }`, + "/my grid.css": `.grid { color: green }`, + "/my photo.png": "photo", + "/café.png": "cafe", + "/fonts/Geist[wght].woff2": "font", + "/node_modules/pkg/package.json": `{ "name": "pkg", "version": "1.0.0" }`, + "/node_modules/pkg/a b.png": "package", + // The target of a package `exports` map is decoded without this retry, as in esbuild. + "/node_modules/with-exports/package.json": `{ "name": "with-exports", "version": "1.0.0", "exports": { "./*": "./*" } }`, + "/node_modules/with-exports/a b.png": "exports", + // The file with the literal name wins. + "/a%20b.png": "literal", + "/a b.png": "decoded", + // `#m` is the fragment of the decoded path, not a part of this name. + "/my photo.png#m": "fragment", + }, + outdir: "/out", + metafile: true, + onAfterBundle(api) { + const inlined = (type: string, contents: string) => + `data:${type};base64,${Buffer.from(contents).toString("base64")}`; + expect(api.readFile("/out/entry.css")).toBe(`/* my theme.css */ +.theme { + color: red; +} + +/* more themes/dark.css */ +.dark { + color: #000; +} + +/* my grid.css */ +@supports (display: grid) { + .grid { + color: green; + } +} + +/* entry.css */ +.a { + background: url("${inlined("image/png", "photo")}"); +} + +.b { + mask: url("${inlined("image/png", "photo")}#m"); +} + +.c { + --icon: url("${inlined("image/png", "cafe")}"); +} + +.d { + background: url("${inlined("font/woff2", "font")}"); +} + +.e { + background: url("${inlined("image/png", "package")}"); +} + +.f { + background: url("${inlined("image/png", "exports")}"); +} + +.g { + background: url("${inlined("image/png", "photo")}"); +} + +.h { + background: url("${inlined("image/png", "photo")}"); +} + +.i { + background: url("${inlined("image/png", "photo")}"); +} + +.j { + background: url("${inlined("image/png", "literal")}"); +} +`); + // Each file is one input, under the name it has on disk. + const inputs = Object.keys(JSON.parse(api.readFile("/metafile.json")).inputs); + expect(inputs.map(input => input.replaceAll("\\", "/")).sort()).toEqual([ + "a%20b.png", + "café.png", + "entry.css", + "fonts/Geist[wght].woff2", + "more themes/dark.css", + "my grid.css", + "my photo.png", + "my theme.css", + "node_modules/pkg/a b.png", + "node_modules/with-exports/a b.png", + ]); + }, + }; + itBundled("css/URLPercentEncoded", { ...percentEncoded, backend: "api" }); + itBundled("css/URLPercentEncodedCLI", { ...percentEncoded, backend: "cli" }); + // A record that an onResolve plugin declines is resolved at another call site. + itBundled("css/URLPercentEncodedPluginDeclines", { + ...percentEncoded, + plugins(builder) { + builder.onResolve({ filter: /./ }, () => undefined); + }, + }); + // A copied asset keeps its decoded name, and the `?query#fragment` goes back on. + itBundled("css/URLPercentEncodedFileLoader", { + files: { + "/entry.css": `.a { background: url("./big%20asset.bin?v=2#frag") }`, + "/big asset.bin": Buffer.alloc(128 * 1024 + 1, "Z").toString(), + }, + loader: { + ".bin": "file", + }, + outdir: "/out", + onAfterBundle(api) { + expect(api.readFile("/out/entry.css")).toBe(`/* entry.css */ +.a { + background: url("./big asset-mrrzcz3w.bin?v=2#frag"); +} +`); + api.expectFile("/out/big asset-mrrzcz3w.bin").toBe(Buffer.alloc(128 * 1024 + 1, "Z").toString()); + }, + }); + // Minified output prints a copied asset unquoted, for `url("./big asset.bin")` too. + // A browser drops this rule until #41557 quotes it. + itBundled("css/URLPercentEncodedFileLoaderMinified", { + files: { + "/entry.css": `.a { background: url(./big%20asset.bin) }`, + "/big asset.bin": Buffer.alloc(128 * 1024 + 1, "Z").toString(), + }, + loader: { + ".bin": "file", + }, + minifyWhitespace: true, + outdir: "/out", + onAfterBundle(api) { + expect(api.readFile("/out/entry.css")).toBe(`.a{background:url(./big asset-mrrzcz3w.bin)}\n`); + }, + }); + + // The decoded path is matched against `external` as the raw spelling is. No file is read. + const percentEncodedExternal: Pick = { + files: { + "/entry.css": /* css */ ` + @import "./my%20theme.css"; + .a { background: url(./my%20photo.png) } + .b { background: url(./my%20fonts/a.woff2) } + `, + "/my theme.css": `.theme { color: red }`, + "/my photo.png": "photo", + }, + external: ["./my theme.css", "./my photo.png", "./my fonts/*"], + outdir: "/out", + onAfterBundle(api) { + expect(api.readFile("/out/entry.css")).toBe(`@import "./my%20theme.css"; + +/* entry.css */ +.a { + background: url("./my%20photo.png"); +} + +.b { + background: url("./my%20fonts/a.woff2"); +} +`); + }, + }; + itBundled("css/URLPercentEncodedExternal", { ...percentEncodedExternal, backend: "api" }); + itBundled("css/URLPercentEncodedExternalCLI", { ...percentEncodedExternal, backend: "cli" }); + itBundled("css/URLPercentEncodedExternalPluginDeclines", { + ...percentEncodedExternal, + plugins(builder) { + builder.onResolve({ filter: /./ }, () => undefined); + }, + }); + // Windows writes an absolute path with a drive letter, and a `:` there is refused. + describe.skipIf(isWindows)("an absolute path", () => { + itBundled("css/URLPercentEncodedAbsolute", ({ root }) => ({ + files: { + "/entry.css": `@import "${root}/my%20theme.css";\n.a { mask: url(${root}/my%20photo.png#m) }`, + "/my theme.css": `.theme { color: red }`, + "/my photo.png": "photo", + }, + outdir: "/out", + onAfterBundle(api) { + expect(api.readFile("/out/entry.css")).toBe(`/* my theme.css */ +.theme { + color: red; +} + +/* entry.css */ +.a { + mask: url("data:image/png;base64,${Buffer.from("photo").toString("base64")}#m"); +} +`); + }, + })); + }); + + // An escape stays as written when its byte is one that `resolve` and a browser cut at or + // decode once more (`#`, `?`, `%`), or one that the raw output name cannot carry to a + // browser. `%2F` and `%5C` are never a path separator. + itBundled("css/URLPercentEncodedRefused", { + files: { + "/entry.css": /* css */ ` + @import "./100%25.css"; + @import "./theme%23dark"; + @import "with-exports/c%2520d.css"; + .a { background: url(./dir%2Fslash.png) } + .b { background: url(./dir%5Cbackslash.png) } + .c { background: url(./hash%23tag.png) } + .d { background: url(./100%25.png) } + .e { background: url(data%3Ascheme.png) } + .f { background: url(./my%20100%.png) } + .g { background: url(./nul%00.png) } + .h { background: url(/my%20photo.png) } + `, + "/100%.css": `.percent { color: red }`, + "/theme.css": `.theme { color: red }`, + "/node_modules/with-exports/package.json": `{ "name": "with-exports", "version": "1.0.0", "exports": { "./*": "./*" } }`, + "/node_modules/with-exports/c d.css": `.twice { color: red }`, + "/dir/slash.png": "slash", + "/dir/backslash.png": "backslash", + "/hash#tag.png": "hash", + "/100%.png": "percent", + "/my 100%.png": "malformed", + // A leading `/` is the root of the file system, not of the project. + "/my photo.png": "photo", + }, + outdir: "/out", + bundleErrors: { + "/entry.css": [ + 'Could not resolve: "./100%25.css"', + 'Could not resolve: "./theme%23dark"', + 'Could not resolve: "with-exports/c%2520d.css"', + 'Could not resolve: "./dir%2Fslash.png"', + 'Could not resolve: "./dir%5Cbackslash.png"', + 'Could not resolve: "./hash%23tag.png"', + 'Could not resolve: "./100%25.png"', + 'Could not resolve: "data%3Ascheme.png"', + 'Could not resolve: "./my%20100%.png"', + 'Could not resolve: "./nul%00.png"', + 'Could not resolve: "/my%20photo.png"', + ], + }, + }); + // Windows cannot represent `?`, `"`, a control character or a trailing space in a file name. + (isWindows ? itBundled.skip : itBundled)("css/URLPercentEncodedRefusedPosixNames", { + files: { + "/entry.css": /* css */ ` + .a { background: url(./question%3Fmark.png) } + .b { background: url(./quo%22te.png) } + .c { background: url(./line%0Afeed.png) } + .d { background: url(./del%7Fete.png) } + .e { background: url(./trailing.png%20) } + `, + "/question?mark.png": "question", + '/quo"te.png': "quote", + "/line\nfeed.png": "line feed", + "/del\x7Fete.png": "delete", + "/trailing.png ": "trailing space", + }, + outdir: "/out", + bundleErrors: { + "/entry.css": [ + 'Could not resolve: "./question%3Fmark.png"', + 'Could not resolve: "./quo%22te.png"', + 'Could not resolve: "./line%0Afeed.png"', + 'Could not resolve: "./del%7Fete.png"', + 'Could not resolve: "./trailing.png%20"', + ], + }, + }); + // Only Linux file systems accept a name that is not UTF-8. + test.skipIf(!isLinux)("css/URLPercentEncodedEveryByte", async () => { + using dir = tempDir("css-url-percent-every-byte", { "x/y": "" }); + const bytes = Array.from({ length: 255 }, (_, i) => i + 1).filter(byte => byte !== 0x2f); + const hex = (byte: number) => byte.toString(16).padStart(2, "0"); + for (const byte of bytes) { + writeFileSync(Buffer.concat([Buffer.from(String(dir) + "/n"), Buffer.from([byte]), Buffer.from("n")]), ""); + } + expect(readdirSync(String(dir)).length).toBe(bytes.length + 1); + writeFileSync( + join(String(dir), "entry.css"), + [ + `.slash { background: url(./x%2Fy), url(./x%2fy) }`, + `.scheme { background: url(n%3An) }`, + ...bytes.map( + byte => `.b${hex(byte)} { background: url(./n%${hex(byte)}n), url(./n%${hex(byte).toUpperCase()}n) }`, + ), + ].join("\n"), + ); + const result = await Bun.build({ + entrypoints: [join(String(dir), "entry.css")], + outdir: join(String(dir), "out"), + throw: false, + }); + const refused = new Set(result.logs.map(log => log.message)); + const accepted = bytes.filter(byte => !refused.has(`Could not resolve: "./n%${hex(byte)}n"`)); + // Not a control character, `"`, `#`, `%`, `?`, `\` or a byte that is not ASCII. + expect(String.fromCharCode(...accepted)).toBe( + " !$&'()*+,-.0123456789:;<=>@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~", + ); + expect(bytes.filter(byte => !refused.has(`Could not resolve: "./n%${hex(byte).toUpperCase()}n"`))).toEqual( + accepted, + ); + expect([...refused].filter(message => !message.includes("./n%")).sort()).toEqual([ + 'Could not resolve: "./x%2Fy"', + 'Could not resolve: "./x%2fy"', + 'Could not resolve: "n%3An". Maybe you need to "bun install"?', + ]); + // The output is the raw name, so a URL parser has to give each accepted byte back. + const lost = accepted.filter(byte => { + const name = `n${String.fromCharCode(byte)}n`; + const url = new URL(`./${name}`, "http://localhost/dir/"); + return decodeURIComponent(url.pathname) !== `/dir/${name}` || url.search !== "" || url.hash !== ""; + }); + expect(lost).toEqual([]); + }); + // 29 letters of 3 bytes are a name of 261 bytes when written as escapes, too long to be a file. + const longName = Buffer.alloc(29 * 3, "漢").toString(); + (isWindows ? itBundled.skip : itBundled)("css/URLPercentEncodedLongName", { + files: { + "/entry.css": /* css */ ` + .a { background: url(./${encodeURIComponent(longName)}.png) } + .b { background: url(./${encodeURIComponent(longName)}/a.png) } + `, + [`/${longName}.png`]: "long name", + [`/${longName}/a.png`]: "long directory", + }, + outdir: "/out", + onAfterBundle(api) { + expect(api.readFile("/out/entry.css")).toBe(`/* entry.css */ +.a { + background: url("data:image/png;base64,${Buffer.from("long name").toString("base64")}"); +} + +.b { + background: url("data:image/png;base64,${Buffer.from("long directory").toString("base64")}"); +} +`); + }, + }); + // `composes .. from` names a module, as an import in JavaScript does. + itBundled("css/URLPercentEncodedNotInComposes", { + files: { + "/entry.module.css": `.x { composes: y from "./my%20mod.module.css"; }`, + "/my mod.module.css": `.y { color: red }`, + }, + entryPoints: ["/entry.module.css"], + outdir: "/out", + bundleErrors: { + "/entry.module.css": ['Could not resolve: "./my%20mod.module.css"'], + }, + }); + // HTML `src` and `href` have the same import kinds as `url()` and `@import`. + const percentEncodedHTML: Pick = { + files: { + "/index.html": ``, + "/my theme.css": `.theme { color: red }`, + "/my photo.png": "photo", + }, + entryPoints: ["/index.html"], + outdir: "/out", + bundleErrors: { + "/index.html": ['Could not resolve: "./my%20theme.css"', 'Could not resolve: "./my%20photo.png"'], + }, + }; + itBundled("css/URLPercentEncodedNotInHTML", percentEncodedHTML); + itBundled("css/URLPercentEncodedNotInHTMLPluginDeclines", { + ...percentEncodedHTML, + plugins(builder) { + builder.onResolve({ filter: /./ }, () => undefined); + }, + }); + itBundled("css/IgnoreURLsInAtRulePrelude", { // GENERATED files: {