Skip to content

css: keep a url() in an at-rule prelude as written, and never print the resolved path of a url() - #45604

Open
robobun wants to merge 5 commits into
mainfrom
robobun/eb1e0d26/css-prelude-url-no-import
Open

robobun wants to merge 5 commits into
mainfrom
robobun/eb1e0d26/css-prelude-url-no-import

Conversation

@robobun

@robobun robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build writes the absolute build directory into CSS: @media print { @import url(sub/g.css); } prints @import url(/home/me/app/sub/g.css). So does background: url(sub/g.css). Found by a fuzzer.
  • parse_custom_at_rule_prelude (src/css/css_parser.rs:365) makes an import record for each url() in an unknown at-rule prelude, so the bundler loads the target or fails: Could not resolve: "sub/g.css".
  • Printer::get_import_record_url (src/css/printer.rs:380) prints the resolved path.text when the target has no data: URL or asset key.

Fix

  • A url() in an unknown at-rule prelude or in a media-query env() fallback stays a raw token with no import record. This also removes panic: index out of bounds: the len is 0 but the index is 0 for @import "./b.css" (min-width: env(x, url(y.css)));.
  • The printer returns original_path for a resolved record with no URL form.
  • Verified: test/bundler/esbuild/css.test.ts (17 new tests, 16 fail on main) and test/bake/dev/css.test.ts (1). Also test/bundler/css/, test/js/bun/css/.
  • Self-reviewed: 16 concerns raised, 13 addressed. Separate changes: the plain-CSS composes gate, the linker pairing, hoist tests.

Background

  • An import record is one url() or @import. Resolution overwrites its path. original_path keeps the written text.
  • A prelude is the at-rule text before { or ;. An unknown or misplaced at-rule (a nested @import) keeps it as tokens.
  • Considered the printer change alone: the target is still loaded. A flag in parse_at_rule: 7 instructions per at-rule.

Downsides

  • @foo url(x.png); is no longer inlined or copied.
  • A declaration url(x.css) prints as written, not copied. esbuild fails this build.
  • Cost: release .text +0 bytes. 15 stylesheets run the new code 18 times.
Notes

Report. No GitHub issue. An automated fuzz ledger found it (ledger #503290, row 42055). Repro:

mkdir -p sub
echo '.c0 { color: red }' > sub/g.css
echo '@media print { .c2 { z-index: 1 } @import url(sub/g.css); }' > entry.css
echo '.c0 { color: blue }' >> entry.css
bun build ./entry.css --minify --outdir out

Before: @media print{.c2{z-index:1}@import url(/abs/build/dir/sub/g.css);}.c0{color:#00f} and Bundled 2 modules. After: @media print{.c2{z-index:1}@import url(sub/g.css);}.c0{color:#00f} and Bundled 1 module. esbuild 0.21.5 prints the same bytes, with a warning that @import is only valid at the top level.

Reach is small. A scan of 16,977 stylesheets in 556 npm packages found one file with a local url() in an unknown at-rule prelude (a PostCSS source in maplibre-gl that uses @svg-load). It found no url() in an env() in a prelude and no declaration url() to a .css, .toml or .sh file.

Questions for a maintainer.

  1. A declaration url() to a file with no URL form (.css, .toml, .sh). This PR keeps exit 0 and prints the specifier as written. The target is still resolved and parsed, and nothing is emitted for it. esbuild fails the build (Cannot use "x.css" as a URL). Bun already fails for .json, .js and other loaders (Cannot import a ".json" file into a CSS file), and css/InvalidImportURLInCSS has a TODO FIX for the stylesheet case. The error is about 10 lines in scan_css_imports (src/bundler/LinkerContext.rs). It is a new build failure, so it is not in this PR. Say so if it must be. The tests for this case pin the written specifier. They become bundleErrors tests if the answer is the error.
  2. esbuild keeps the url() in @container style(<declaration>) as written. Then the query no longer matches the rewritten custom property. This PR does not change style(). Say so if it must follow esbuild.

Measured (base 5866b5f13d):

  • parse_at_rule: 8 of 8 instantiations identical, +0 instructions (objdump, bun_css object, base vs PR)
  • TokenList url arms: +2 and +2 instructions. add_import_record: +0 (objdump, base vs PR)
  • 15-fixture corpus (2.9 MB): 4 set/restore pairs, 14 url-arm flag tests, 0 hits of the new sink return (gdb)
  • release .text: +0 bytes (size -A, main 5866b5f vs PR)
  • reported input: modules 2 -> 1, metafile inputs 2 -> 1, import records 1 -> 0
  • 15 inputs: distinct outputs per input 1 (was 2), outputs containing the directory marker 0 (was 1800)
  • pinned outputs: 80 of 80 byte-identical, 0 new log lines

How: the bun_css object is the crate compiled alone with the release rustc flags and no LTO (llvm-objdump, llvm-nm -S). Its text grows 191 bytes (1,713,150 to 1,713,341). The two flag tests are testb $0x4, 0x19(%r15) and a jump. parse_custom_at_rule_prelude grows 6 instructions and get_import_record_url grows 5. In the LTO release binary the .text section is 81,071,045 bytes before and after. Four functions change size there, +189 bytes net (nm -S on bun-profile). The corpus is test/js/bun/css/files/*.css in one bun build, counted with gdb breakpoints on the debug build. The determinism run builds 15 inputs 60 times in each of two directories with --entry-naming '[name]-[hash].[ext]'. Before, each input gave one output per directory, and two files with a url() to the same stylesheet gave 4. The pinned outputs are the fixtures of css/MissingImportURLInCSS, ExternalImportURLInCSS, TextImportURLInCSSText, Png, FileImportURLInCSS, URLQueryFragmentPreserved*, PackageURLsInCSS, and the 15 stylesheets with and without --minify. The release builds and the last three lines are from 9cfe41342e. The head differs from it in comments, tests and the value of the flag bit.

What changes for users.

  • A url() in an unknown at-rule prelude (@foo url(x.png);, a nested @import url(), a nested @namespace url()) prints as written. Its target is not resolved, loaded, inlined or copied. A missing target no longer fails the build. A plugin onResolve does not see it. It is not a metafile import (esbuild lists it there as an external import).
  • The same applies to a url() in an env() fallback in @media, @container, @custom-media and the media list of @import.
  • Only the url() token is kept as written. The other prelude tokens still go through the value token list.
  • @foo url(a.css); @import "./b.css"; now inlines b.css. Before, the url() record took the place of the @import record and a.css was inlined.
  • A url() in a declaration, a custom property, image-set(), @font-face src or an unknown at-rule block still resolves. When its target has no URL form, the specifier prints as written. This includes a file with the dataurl or base64 loader, which still gets no data URL (plugin onLoad with dataurl loader bundles empty content "" broken and incompatible with esbuild #20917), and composes: a from "./x.css" in a file that is not a CSS module, which still bundles its target.
  • Two files with a url() to the same stylesheet print the same bytes in every build.
  • bun build --no-bundle no longer rejects a url() in an unknown at-rule prelude. A declaration url() still fails there (bun build --no-bundle: keep css url() references instead of failing to parse #38528).
  • Where Bun moves an invalid nested @import url() to the top level (a style rule with nesting lowered, @media all under --minify), the output now has a relative @import url(sub/g.css) that a browser loads. Before, it had the absolute path, or the build failed on a missing target. The string form @import "sub/g.css" already does this on main.

Not in this PR. Each has its own cause.

  • The esbuild error for a declaration url() with no URL form (question 1).
  • An invalid nested @import that becomes a live top-level @import (rules/style.rs, rules/media.rs).
  • An @import after an unknown at-rule statement is inlined and also left in the output (prepareCssAstsForChunk.rs strips only a leading run of imports).
  • findImportedFilesInCSSOrder.rs pairs an @import rule with its record by position, not by ImportRule.import_record_idx.
  • composes ... from in a plain stylesheet creates an import and bundles the target.
  • A url() whose declaration the minifier drops, or whose @font-face or @keyframes item fails to parse, is still resolved and copied. The debug_assert here covers only the two prelude scopes.
  • @property --x { initial-value: url(x) } fails with Unexpected token: url(x).
  • The prelude of an unknown at-rule is re-spaced (.dark prints as . dark).
  • The dataurl and base64 bundler loaders give an empty string (plugin onLoad with dataurl loader bundles empty content "" broken and incompatible with esbuild #20917).
  • The linker clones an import condition and never frees the token list of an env() fallback. LeakSanitizer reports it for @import "./b.css" (min-width: env(foo, 1px)); on main. The input that panicked before now reaches the same leak.
  • require.resolve() in JS prints the resolved absolute path (src/js_printer/lib.rs, bundler: print the require.resolve() specifier as written #42675).

Related open PRs.

Design. esbuild decides this in the CSS parser. convertTokens passes allowImports: false for an at-rule prelude, and the record is made with ast.IsUnused and never resolved. Declarations and unknown at-rule blocks pass true. Bun has the same flag (IS_UNUSED), but a record that exists is not safe here: the linker pairs @import rules with records by position, and an import condition is printed with a different record list (the panic above). So the prelude url() gets no record. The flag is set at the two places that parse a prelude as a token list (parse_custom_at_rule_prelude, and the env() attempt in MediaFeatureValue::parse_unknown). It is read only by the two url arms of TokenList::parse_into_impl. parse_at_rule and Parser::add_import_record are not changed. A debug_assert in the helper checks that no import record appears while the flag is set.

Suites run with the debug build: test/bundler/esbuild/css.test.ts (72 pass, 1 skip, 1 todo), test/bake/dev/css.test.ts (16 pass), and 36 more files (test/bundler/css/, bundler_html, bundler_plugin, bundler_loader, bundler_files, metafile, standalone, test/js/bun/http/bun-serve-html.test.ts, test/js/bun/css/): 2992 pass. Three css-fuzz tests hit their own 10 s limit on the debug build, with and without this change. They pass on a release build of 9cfe41342e.

css/URLInEnvFallbackOfAtImportMediaQuery is skipped under ASAN because of the leak above. With a release build of 9cfe41342e the file is 73 pass, and on main this test fails with the panic. No release build of the head was run here. Under ASAN, the --no-bundle test covers the same input: it builds only when the url() gets no import record.

… a resolved path

A url() in the token-list prelude of an unknown at-rule, or in an env()
fallback of a media query, is not an import. The parser now keeps it as a
raw token, so it gets no import record: the bundler does not resolve it,
does not load its target, and it prints as written.

The CSS printer no longer falls back to the resolved path of a url() whose
target has no data: URL and no asset key. It prints the specifier as
written, so the output does not carry the absolute path of the build
directory.
The tests for a declaration url() with no asset URL now check only that
the build directory is not printed. A plain-CSS composes has its own
test. New tests: a url() in a CSS module token list is still an import,
and a prelude url() builds with --no-bundle.
#45487 adds a ParserOpts flag at 0b10. Two flags with the same value
compile, and the prelude flag would then be set for every CSS module.
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 14 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 16ef49a8-7e41-4663-85e2-db2063294456
📥 Commits

Reviewing files that changed from the base of the PR and between f0e4f6b and b075ef3.

📒 Files selected for processing: 7
  • src/css/css_parser.rs
  • src/css/media_query.rs
  • src/css/printer.rs
  • src/css/properties/custom.rs
  • src/css/rules/container.rs
  • test/bake/dev/css.test.ts
  • test/bundler/esbuild/css.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 PM PT - Oct 16th, 2026

@robobun, your commit b075ef3 is building: #126968

@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the PR is open and waits for CI.

Reproduced with:

mkdir -p sub && echo '.c0 { color: red }' > sub/g.css
echo '@media print { .c2 { z-index: 1 } @import url(sub/g.css); }' > entry.css
bun build ./entry.css --minify --outdir out && cat out/entry.css
# bun 1.4.3-canary (367d939d9) and main (5866b5f13d): @import url(/abs/build/dir/sub/g.css), "Bundled 2 modules"
# this branch: @import url(sub/g.css), "Bundled 1 module"

USE_SYSTEM_BUN=1 bun test test/bundler/esbuild/css.test.ts   # 16 fail
bun bd test test/bundler/esbuild/css.test.ts                 # 72 pass, 1 skip, 1 todo
USE_SYSTEM_BUN=1 bun test test/bake/dev/css.test.ts          # 1 fail
bun bd test test/bake/dev/css.test.ts                        # 16 pass

PR: #45604

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline note, I also checked that the AT_RULE_PRELUDE_TOKENS flag cannot leak: parse_nested_block and try_parse reuse the same Parser, and parse_at_rule_prelude_tokens restores self.flags on both the Ok and Err path before try_parse rewinds, so a declaration url() later in the stylesheet still gets an import record. I also checked the printer fallthrough: original_path is only returned for records with a valid source_index, so external and unresolved url()s still print path.text as before. The user-visible semantics change (prelude url() no longer resolved, not in the metafile, not seen by plugin onResolve) and the two questions the description poses are design calls for a maintainer.

Extended reasoning...

The change touches the Rust CSS parser and printer in src/css/ (a new ParserOpts flag, a scoped wrapper, two TokenList::parse guards, a printer fallthrough) plus ~540 lines of bundler and dev-server tests; it touches no auth, crypto or injection surface. The flag scoping and the printer fallthrough were traced and hold up, but the change alters bundler behavior for url() in at-rule preludes and for url() to non-asset files, and the description itself asks a maintainer to rule on whether to follow esbuild's build error, so a human decision is still needed.

Comment thread test/bundler/esbuild/css.test.ts Outdated
The linker clones an import condition and never frees the token list of
an env() fallback. LeakSanitizer reports it for a spawned bun build, also
for env(foo, 1px) on main. The bundled test for that input is skipped
under ASAN. The --no-bundle test covers the same input there.

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

The exact url() assertions in b075ef3 address my earlier comment. Beyond the inline ASAN-skip finding, I also checked the new original_path fallback in src/css/printer.rs for ?query#fragment loss — the suffix is a slice of original_path itself, so it is preserved (the ./sub/g.css?x=1#y case in the test covers it) — and the flag save/restore in parse_at_rule_prelude_tokens: ParserOpts is not part of ParserState, nested blocks reuse the same Parser, so the flag reaches the env() fallback token list and is restored even when the enclosing try_parse fails.

Extended reasoning...

The PR adds a parser flag so url() tokens in unknown at-rule preludes and media-query env() fallbacks stay raw tokens without import records, and changes the CSS printer to emit the written specifier instead of the resolver's absolute path for records with no URL form. It touches no security-sensitive surface. A human look remains worthwhile because the change alters user-visible bundler behavior (at-rule prelude url() targets are no longer loaded or copied, and declaration url() to stylesheets now prints as written where esbuild errors), and the description itself poses two open questions for a maintainer.

Comment on lines +1430 to +1436
// Before: `panic: index out of bounds: the len is 0 but the index is 0`.
// The url() record index pointed into the importing file's records, and
// the condition was printed with the records of the import chain.
// Skipped under ASAN: the linker clones an import condition and never frees
// the token list of an env() fallback (`Direct leak of 72 byte(s)` in
// `TokenList::deep_clone`), also for `env(foo, 1px)` on main.
(isASAN ? itBundled.skip : itBundled)("css/URLInEnvFallbackOfAtImportMediaQuery", {

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): maintainers get the only regression test for the @ import ... env(foo, url(...)) panic skipped on the ASAN lane, so that lane never exercises the fixed path. The skip at test/bundler/esbuild/css.test.ts:1436 routes around a leak instead of fixing it. The leak is pre-existing: MediaFeatureValue::deep_clone at src/css/media_query.rs:1379 clones the env fallback into a heap Vec inside an arena-allocated value that never runs Drop. Fix: free or arena-allocate the fallback TokenList in the linker's import-condition clone so the test runs under ASAN, then drop the isASAN ? itBundled.skip : itBundled gate.

Why this was flagged

The test css/URLInEnvFallbackOfAtImportMediaQuery at test/bundler/esbuild/css.test.ts:1436 is wrapped in (isASAN ? itBundled.skip : itBundled). On the ASAN CI lane it is skipped, so the input @ import "./b.css" (min-width: env(foo, url(sub/g.css))) that used to panic with index out of bounds: the len is 0 but the index is 0 is never built there. The comment at test/bundler/esbuild/css.test.ts:1433-1435 names the observed failure, Direct leak of 72 byte(s) in TokenList::deep_clone, which comes from MediaFeatureValue::deep_clone at src/css/media_query.rs:1379 cloning the env fallback; the token list is a heap Vec held by an arena value that never runs Drop. REVIEW.md says a test must not be edited to route around a runtime bug it exposed. On the base branch there is no test at all here; after this change the non-ASAN lanes cover the panic but the ASAN lane does not, and the leak stays.

Verification: test/bundler/esbuild/css.test.ts:1436 reads (isASAN ? itBundled.skip : itBundled), so under ASAN the only bundled fixture for @ import "./b.css" (min-width: env(foo, url(sub/g.css))) is never built. The leak is pre-existing: MediaFeatureValue::deep_clone at src/css/media_query.rs:1377-1380 clones the env fallback TokenList heap Vec into a slab that is never dropped.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed: the skip goes around the leak, and the ASAN lane does not build the bundled path for this input.

The leak is on main without this PR. On a debug build of 5866b5f, @import "./b.css" (min-width: env(foo, 1px)); reports Direct leak of 144 byte(s) in 2 object(s) from the same TokenList::deep_clone frames. The fix must change who owns the cloned import conditions in the linker. There are three clone sites (conditions_with_import_records, deep_clone_conditions, wrap_rules_with_conditions), and none of the clones is dropped, because the Vec headers are shared bitwise. That is a separate change, and it is in progress outside this PR. It removes this skip.

Until then, the --no-bundle test builds the same input under ASAN. It passes only when the url() gets no import record, but it does not run the linker. I leave this thread open.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants