From 572800451ed670b71fe8776b75daad5f1f9a4cc8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:40:47 +0000 Subject: [PATCH 1/2] sourcemap: don't index past a source ending in a truncated UTF-8 sequence LineOffsetTable::generate_in computed each codepoint's width from the lead byte's declared length. The decode already clamped that width to the bytes remaining, but the advance and the SIMD-skip offset did not, so a source whose final bytes are a truncated multi-byte sequence panicked: panic: range start index 4 out of range for slice of length 1 Compute the clamped width once and use it for the decode slice, the skip offset, and the advance, matching the clamped_width idiom already used by the string escapers. --- src/sourcemap/LineOffsetTable.rs | 12 +++--- .../internal-sourcemap-roundtrip.test.ts | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/sourcemap/LineOffsetTable.rs b/src/sourcemap/LineOffsetTable.rs index 6558fbb1c447..e246721a78c5 100644 --- a/src/sourcemap/LineOffsetTable.rs +++ b/src/sourcemap/LineOffsetTable.rs @@ -192,17 +192,19 @@ impl LineOffsetTable { let len_ = strings::wtf8_byte_sequence_length_with_invalid(b0); // After the SIMD skip below lands, the loop head // is overwhelmingly an ASCII '\r'/'\n' or a non-ASCII lead byte, so keep the - // 1-byte path branch-only and confine the zero+min+copy pad to the cold + // 1-byte path branch-only and confine the zero+copy pad to the cold // multibyte arm. + // `len_` is the lead byte's *declared* width; a source whose final bytes are + // a truncated multibyte sequence declares more bytes than remain, so every + // slice below (decode, SIMD-skip offset, advance) must use the clamped width. + let cp_len = (len_ as usize).min(remaining.len()); let c: i32 = if len_ == 1 { b0 as i32 } else { let mut cp_bytes = [0u8; 4]; - let take = (len_ as usize).min(remaining.len()); - cp_bytes[..take].copy_from_slice(&remaining[..take]); + cp_bytes[..cp_len].copy_from_slice(&remaining[..cp_len]); strings::decode_wtf8_rune_t::(cp_bytes, len_, 0) }; - let cp_len = len_ as usize; let offset = (remaining.as_ptr() as usize - base) as u32; @@ -233,7 +235,7 @@ impl LineOffsetTable { // skip ahead to the next newline or non-ascii character if let Some(j) = strings::index_of_newline_or_non_ascii_check_start::( remaining, - len_ as u32, + cp_len as u32, ) { column += i32::try_from(j).expect("int cast"); remaining = &remaining[j as usize..]; diff --git a/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts b/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts index 1f93f2dde36e..672cbd953c7f 100644 --- a/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts +++ b/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts @@ -408,3 +408,41 @@ console.log(go()); } }); }); + +// LineOffsetTable::generate builds the per-line column table directly from the +// raw source bytes, so it must tolerate a truncated trailing multi-byte UTF-8 +// sequence: a lead byte that declares more bytes than the file has left. +describe.concurrent("sourcemap of a source with a truncated trailing UTF-8 sequence", () => { + test.each([ + ["lone 4-byte lead 0xF0", [0xf0]], + ["lone 3-byte lead 0xE2", [0xe2]], + ["lone 2-byte lead 0xC3", [0xc3]], + ["overlong 2-byte lead 0xC1", [0xc1]], + ["2 of 3 bytes 0xE0 0x81", [0xe0, 0x81]], + ["3 of 4 bytes 0xF0 0x9F 0x92", [0xf0, 0x9f, 0x92]], + ] as [string, number[]][])("%s", async (_name, tail) => { + using dir = tempDir("sourcemap-truncated-utf8", { + "in.js": Buffer.concat([Buffer.from("console.log(1);//"), Buffer.from(tail)]), + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--sourcemap=external", "--outdir=out", "in.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ exitCode, stdout, stderr }).toEqual({ + exitCode: 0, + stdout: expect.any(String), + stderr: expect.any(String), + }); + // `console.log(1);` is identical in every variant and the truncated bytes + // live in a stripped comment, so the encoded mappings must be identical too. + const map = await Bun.file(path.join(String(dir), "out", "in.js.map")).json(); + expect(map).toMatchObject({ + sources: ["../in.js"], + mappings: ";AAAA,QAAQ,IAAI,CAAC;", + }); + }); +}); From 2485321c817c997024b9321043c06800c5ae6926 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:16:33 +0000 Subject: [PATCH 2/2] ci: retrigger