Repository navigation
bundler: resolve a percent-encoded CSS url() or @import by its decoded path #46511
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Index>; | ||
|
|
||
|
|
@@ -6962,30 +6985,33 @@ 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, | ||
| import_record.kind, | ||
| ) { | ||
| 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::<bun_ast::Log>( | ||
| 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; | ||
|
Comment on lines
+7004
to
+7011
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Users serving a bundled HTML page with Bun.serve whose CSS has url(./my%20photo.png) to a copied asset now get a 404 for it where the base failed the build. The decoded hit adopted at src/bundler/bundle_v2.rs:7011 (and :2796) names the output Why this was flaggedA stylesheet in a Bun.serve HTML bundle (production, Verification: src/bundler/bundle_v2.rs:7005-7011 |
||
| } | ||
|
Comment on lines
+6998
to
+7012
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 pre-existing, not blocking: pre-existing (partial fix): a Why this was flaggedA JS API build passes Verification: pre-existing (partial fix: the base branch fails this by the same route). A |
||
| 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::<bun_ast::Log>( | ||
| 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; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| ) { | ||
|
Comment on lines
+4580
to
+4584
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Users of Why this was flaggedA JS file does Verification: The trigger is any importer whose specifier or resolved directory path makes openat/readdir return ENAMETOOLONG. src/resolver/resolver.rs:4580-4584 now reads |
||
| 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<options::ModuleType> { | |
| 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; | ||
| } | ||
|
Comment on lines
+6956
to
+6966
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Users running Why this was flaggedA stylesheet contains Verification: Acknowledged in the PR description's "Downsides" and a pinned test. |
||
| 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`: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 (optional) Dev-server users with a percent-encoded CSS url() whose file appears later keep an OS directory watch and a dead dependency entry for the rest of the session, where the base released both once the file appeared. Both spellings are tracked at src/bundler/bundle_v2.rs:2811-2820 (and bundle_v2.rs:7040-7048), but hot_reload_event.rs:140-152 frees only the dep that resolves and frees the watcher only when no dep remains; the raw
./my%20photo.pngdep never resolves, so the watcher on that directory is never freed and the raw spelling is re-resolved on every event in that directory. Fix: when one spelling of a record resolves, free the sibling deps registered for the same (source file, record), e.g. track the decoded spelling only, or key deps by record so a hit removes both.Why this was flagged
Trigger: Bake dev server, a stylesheet with
url(./my%20photo.png)whilemy photo.pngdoes not exist yet, then the user creates the file. Both spellings are passed to dev.track_resolution_failure at src/bundler/bundle_v2.rs:2811-2820 and src/bundler/bundle_v2.rs:7040-7048; directory_watch_store.rs:257-287 appends one Dep per spelling to the same directory watcher. On the create event, src/runtime/bake/dev_server/hot_reload_event.rs:129-147 resolves each Dep; only the decoded one resolves and is freed (line 147), the raw one is pushed back onto the chain (lines 149-151), so the watcher at line 155-161 is never freed. On the base branch there was only the raw dep, which stayed only while the import was genuinely unresolved; here the import now resolves but the directory watch and the stale dep persist for the session and every later change in that directory re-runs resolver.resolve on the raw spelling. Remedy: free sibling deps of the same record when any spelling resolves.Verification: Trigger: Bake dev server,
url(./my%20photo.png)missing at first build,my photo.pngcreated later. On the directory event, only a dep whose resolve succeeds is freed (src/runtime/bake/dev_server/hot_reload_event.rs:147), every other dep is re-linked (lines 150-151)../my%20photo.pngnever resolves oncemy photo.pngexists, so that dep and the directory watch persist.