diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 4a590b561b70..7cd84a9042f3 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -662,7 +662,6 @@ impl IntermediateOutput { let unique_key_for_additional_files = graph.input_files.items_unique_key_for_additional_file(); let mut relative_platform_buf = bun_paths::path_buffer_pool::get(); - let mut file_path_buf = bun_paths::path_buffer_pool::get(); match self { IntermediateOutput::Pieces(pieces) => { let entry_point_chunks_for_scb = linker_graph.files.items_entry_point_chunk_index(); @@ -943,18 +942,6 @@ impl IntermediateOutput { _ => unreachable!(), }; - // normalize windows paths to '/' - // The source slices are reachable only - // through `&Graph` / `&[Chunk]` here; materialising `&mut` from a - // shared-provenance pointer is UB regardless of whether the write - // happens. Copy into a pooled scratch buffer and normalise that. - let file_path: &[u8] = { - let n = file_path.len(); - let dst = &mut file_path_buf[..n]; - dst.copy_from_slice(file_path); - bun_paths::resolve_path::platform_to_posix_in_place::(dst); - dst - }; let cheap_normalizer = cheap_prefix_normalizer( import_prefix, if use_outdir_relative_path { diff --git a/src/bundler/HTMLImportManifest.rs b/src/bundler/HTMLImportManifest.rs index be23b7a1e15e..ab35e35f2ced 100644 --- a/src/bundler/HTMLImportManifest.rs +++ b/src/bundler/HTMLImportManifest.rs @@ -42,7 +42,8 @@ use bun_collections::VecExt; use bun_core::strings; use bun_io::{FmtAdapter, Write}; use bun_js_printer::Encoding; -use bun_paths::resolve_path::relative_normalized; +use bun_paths::path_buffer_pool; +use bun_paths::resolve_path::{platform, platform_to_posix_in_place}; use bun_resolver::fs::FileSystem; use crate::Graph::Graph; @@ -197,6 +198,7 @@ pub fn write( // Use the server-side public path here. let public_path: &[u8] = &options.public_path; let mut temp_buffer: Vec = Vec::new(); + let mut input_buf = path_buffer_pool::get(); for ch in chunks.iter() { if ch.entry_point.source_index() == browser_source_index && ch.entry_point.is_entry_point() @@ -253,11 +255,16 @@ pub fn write( let input: &[u8] = if !ch.entry_point.is_entry_point() { b"" } else { - let path_for_key = relative_normalized::( + // `root_dir` and `source.path.text` are native absolute paths, so + // compute the relative path with host semantics and emit it as posix. + let len = bun_paths::resolve_path::relative_platform_buf::( + &mut **input_buf, root_dir, sources[ch.entry_point.source_index() as usize].path.text, - ); - strings::remove_leading_dot_slash(path_for_key) + ) + .len(); + platform_to_posix_in_place::(&mut input_buf[..len]); + strings::remove_leading_dot_slash(&input_buf[..len]) }; let path: &[u8] = if inject_compiler_filesystem_prefix { @@ -308,11 +315,14 @@ pub fn write( } first = false; - let path_for_key = relative_normalized::( + let len = bun_paths::resolve_path::relative_platform_buf::( + &mut **input_buf, root_dir, sources[source_index.get() as usize].path.text, - ); - let path_for_key = strings::remove_leading_dot_slash(path_for_key); + ) + .len(); + platform_to_posix_in_place::(&mut input_buf[..len]); + let path_for_key = strings::remove_leading_dot_slash(&input_buf[..len]); let path: &[u8] = if inject_compiler_filesystem_prefix { temp_buffer.clear(); diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 69a5625b1974..95b12c5f2c57 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4155,6 +4155,10 @@ pub mod bv2_impl { template .print(&mut v, !self.transpiler.options.compile) .expect("oom"); + // Same invariant as `Chunk.final_rel_path`: every consumer + // (chunk assembly, manifest JSON, standalone graph, HTTP + // routes) wants `/`, and Windows openat normalises `/` itself. + bun_paths::resolve_path::platform_to_posix_in_place::(&mut v); v.into_boxed_slice() }; diff --git a/src/runtime/bake/production.rs b/src/runtime/bake/production.rs index facc9291a5d6..c25e02dbea24 100644 --- a/src/runtime/bake/production.rs +++ b/src/runtime/bake/production.rs @@ -270,13 +270,7 @@ pub(super) fn write_sourcemap_to_disk( let source_map_index = file.source_map_index; debug_assert!(bundled_outputs[source_map_index as usize].output_kind == OutputKind::Sourcemap); - let without_prefix = if strings::has_prefix(&file.dest_path, b"./") - || (cfg!(windows) && strings::has_prefix(&file.dest_path, b".\\")) - { - &file.dest_path[2..] - } else { - &file.dest_path[..] - }; + let without_prefix = strings::remove_leading_dot_slash(&file.dest_path); let mut key = Vec::with_capacity(6 + without_prefix.len()); write!(&mut key, "bake:/{}", BStr::new(without_prefix)).expect("infallible: in-memory write"); @@ -754,13 +748,7 @@ pub(super) fn build_with_vm( match file.output_kind { OutputKind::EntryPoint | OutputKind::Chunk => { - let without_prefix = if strings::has_prefix(&file.dest_path, b"./") - || (cfg!(windows) && strings::has_prefix(&file.dest_path, b".\\")) - { - &file.dest_path[2..] - } else { - &file.dest_path[..] - }; + let without_prefix = strings::remove_leading_dot_slash(&file.dest_path); if let Some(entry_point_index) = file.entry_point_index { if (entry_point_index as usize) < module_keys.len() { diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index 989650baf333..53f9268c889d 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -775,16 +775,6 @@ pub(crate) fn to_bytes( let dest_path = bun_core::strings::remove_leading_dot_slash(&output_file.dest_path); - // Windows: store the key with `/`. The template printer emits native - // `\` into `dest_path`, but `find_assume_standalone_path` normalizes - // lookups to `/`, so a `\` key would miss (ENOENT). `src/bundler/Chunk.rs` - // only normalizes a scratch copy, so we re-normalize here. - #[cfg(windows)] - let mut dest_path_buf = PathBuffer::uninit(); - #[cfg(windows)] - let dest_path: &[u8] = - path::resolve_path::platform_to_posix_buf::(dest_path, &mut dest_path_buf); - let bytecode: StringPointer = 'brk: { if output_file.bytecode_index != u32::MAX { // Bytecode alignment for JSC bytecode cache deserialization. diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index fbcf26e91598..04929da3a4c7 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { tempDirWithFiles } from "harness"; +import { tempDir, tempDirWithFiles } from "harness"; import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { itBundled } from "./expectBundled"; @@ -360,6 +360,51 @@ console.log("About manifest:", aboutHtml); }, }); + // The manifest is meant to be passed straight to a static-file server, so its + // `input` keys must be project-relative and every path must use `/` regardless + // of the host (no leaked build-machine absolute paths, no backslashes). + test("html-import/manifest-paths-are-posix-relative", async () => { + using cwd = tempDir("html-manifest-paths", { + "server.ts": `import m from "./page/index.html"; console.log(JSON.stringify(m));`, + "page/index.html": ``, + "page/a.ts": `import icon from "./icon.txt" with { type: "file" }; console.log(icon);`, + "page/s.css": `body { color: red }`, + "page/icon.txt": `hi`, + }); + const dir = String(cwd); + + const out = join(dir, "out"); + const r = await Bun.build({ entrypoints: [join(dir, "server.ts")], outdir: out, target: "bun", root: dir }); + expect(r.success).toBe(true); + const js = readFileSync(join(out, "server.js"), "utf8"); + const m = js.match(/__jsonParse\("(.+?)"\)/s)!; + const manifest = JSON.parse(JSON.parse('"' + m[1] + '"')) as { + index: string; + files: Array<{ input?: string; path: string; loader: string }>; + }; + + const absolute = /^(?:[A-Za-z]:|[\\/]|\.\.[\\/])/; + expect(manifest.index).not.toContain("\\"); + expect(manifest.index).not.toMatch(absolute); + + // Entry chunks are keyed by the entry HTML source, relative to `root`; + // the file-loader asset is keyed by its own source path. + expect(manifest.files.map(f => [f.loader, f.input])).toEqual([ + ["js", "page/index.html"], + ["html", "page/index.html"], + ["css", "page/index.html"], + ["file", "page/icon.txt"], + ]); + for (const f of manifest.files) { + expect(f.path).not.toContain("\\"); + expect(f.path).not.toMatch(absolute); + } + // file-loader asset path comes from `additional_output_files[].dest_path`, + // which was the one emitted with native `\` before the fix. + const asset = manifest.files.find(f => f.loader === "file")!; + expect(asset.path).toStartWith("./"); + }); + // The HTML chunk's etag must change when only a referenced JS/CSS chunk // changes; otherwise the browser 304s to a body that points at chunks the // server no longer has.