Files
bun-src/test/regression/issue/invalid-escape-sequences.test.ts
2026-08-27 21:09:14 +00:00

458 lines
16 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
test("Invalid escape sequence \\x in identifier shows helpful error message", async () => {
using dir = tempDir("escape-test", {
"test.js": `const \\x41 = 1;`,
});
const { stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "test.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
expect(exitCode).toBe(1);
const err = stderr.toString();
expect(err).toContain("const \\x41 = 1;");
expect(err).toContain("error: Unexpected escape sequence");
expect(err).toContain(":1:7");
});
test("Invalid escaped double quote in identifier shows helpful error message", async () => {
using dir = tempDir("escape-test", {
"test.js": `const \\" = 1;`,
});
const { stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "test.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
expect(exitCode).toBe(1);
const err = stderr.toString();
expect(err).toContain('const \\" = 1;');
expect(err).toContain("error: Unexpected escaped double quote");
expect(err).toContain(":1:7");
});
test("Invalid escaped single quote in identifier shows helpful error message", async () => {
using dir = tempDir("escape-test", {
"test.js": `const \\' = 1;`,
});
const { stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "test.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
expect(exitCode).toBe(1);
const err = stderr.toString();
expect(err).toContain("const \\' = 1;");
expect(err).toContain("error: Unexpected escaped single quote");
expect(err).toContain(":1:7");
});
test("Invalid escaped backtick in identifier shows helpful error message", async () => {
using dir = tempDir("escape-test", {
"test.js": `const \\\` = 1;`,
});
const { stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "test.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
expect(exitCode).toBe(1);
const err = stderr.toString();
expect(err).toContain("const \\\` = 1;");
expect(err).toContain("error: Unexpected escaped backtick");
expect(err).toContain(":1:7");
});
test("Invalid escaped backslash in identifier shows helpful error message", async () => {
using dir = tempDir("escape-test", {
"test.js": `const \\\\ = 1;`,
});
const { stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "test.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
expect(exitCode).toBe(1);
const err = stderr.toString();
expect(err).toContain("const \\\\ = 1;");
expect(err).toContain("error: Unexpected escaped backslash");
expect(err).toContain(":1:7");
});
test("Invalid escaped z in identifier shows helpful error message", async () => {
using dir = tempDir("escape-test", {
"test.js": `const \\z = 1;`,
});
const { stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "test.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
expect(exitCode).toBe(1);
const err = stderr.toString();
expect(err).toContain("const \\z = 1;");
expect(err).toContain("error: Unexpected escape sequence");
expect(err).toContain(":1:7");
});
// https://github.com/oven-sh/bun/issues/30893
// Invalid escape sequence where the first char after `\x`, `\u`, or `\u{` is a
// multi-byte codepoint underflowed `start + iter.i - width` in the lexer's error
// path — panic in debug, silent wrap in release. The wrapped value is stored in
// `self.end`, but `syntax_error()` reports at `self.start`, so release output is
// identical pre/post-fix and nothing here can distinguish them under
// USE_SYSTEM_BUN=1. These tests exist to guard `bun bd` (ASAN/debug) CI.
test("invalid \\x followed by multi-byte codepoint does not panic (#30893)", async () => {
// Input bytes: 0x27 0x5C 0x78 0xF0 0xB9 0x91 0x9C 0x27 0xFF
// ' \ x <── U+3945C ──> ' (trailing junk)
using dir = tempDir("escape-overflow", {
"test.js": Buffer.from([0x27, 0x5c, 0x78, 0xf0, 0xb9, 0x91, 0x9c, 0x27, 0xff]),
});
const { stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "test.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
const err = stderr.toString();
// Must be a clean SyntaxError, not a panic.
expect(err).toContain("Syntax Error");
expect(exitCode).toBe(1);
});
test("invalid \\x with second hex digit being multi-byte codepoint does not panic (#30893)", async () => {
// '\x2' followed by a 4-byte codepoint: exercises the second-hex-digit branch.
using dir = tempDir("escape-overflow-2", {
"test.js": Buffer.from([0x27, 0x5c, 0x78, 0x32, 0xf0, 0xb9, 0x91, 0x9c, 0x27]),
});
const { stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "test.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
expect(stderr.toString()).toContain("Syntax Error");
expect(exitCode).toBe(1);
});
test("invalid \\u followed by multi-byte codepoint does not panic (#30893)", async () => {
// '\u' followed by a 4-byte codepoint: exercises the fixed-length \u branch.
using dir = tempDir("escape-overflow-u", {
"test.js": Buffer.from([0x27, 0x5c, 0x75, 0xf0, 0xb9, 0x91, 0x9c, 0x27]),
});
const { stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "test.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
expect(stderr.toString()).toContain("Syntax Error");
expect(exitCode).toBe(1);
});
test("invalid \\u{ followed by multi-byte codepoint does not panic (#30893)", async () => {
// '\u{' followed by a 4-byte codepoint then '}': exercises the variable-length branch.
using dir = tempDir("escape-overflow-u-brace", {
"test.js": Buffer.from([0x27, 0x5c, 0x75, 0x7b, 0xf0, 0xb9, 0x91, 0x9c, 0x7d, 0x27]),
});
const { stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "test.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
expect(stderr.toString()).toContain("Syntax Error");
expect(exitCode).toBe(1);
});
test("Valid unicode escapes in identifiers should work", async () => {
// Test valid \u escape with 4 hex digits
{
using dir = tempDir("escape-test", {
"valid1.js": `const \\u0041 = 1; console.log(A);`,
});
const { stdout, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "valid1.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
expect(exitCode).toBe(0);
expect(stdout.toString()).toBe("1\n");
}
// Test valid \u{} escape with variable length
{
using dir = tempDir("escape-test", {
"valid2.js": `const \\u{41} = 2; console.log(A);`,
});
const { stdout, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "valid2.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
expect(exitCode).toBe(0);
expect(stdout.toString()).toBe("2\n");
}
});
// https://github.com/oven-sh/bun/issues/31134
// Invalid escape-sequence diagnostics in `decode_escape_sequences` reported
// caret locations at text-relative offsets instead of absolute source
// positions, landing the caret several columns left of the backslash (or on
// an unrelated earlier line/character). Two kinds of bugs fed into this:
// 1. `hex_start` in the `\u{...}` branch over-subtracted by the width of
// `{`, and the string-path caller passed the opening-quote offset
// where the decoder expects the content-start offset.
// 2. The legacy-octal `\0`..`\7` + `8`/`9` branch built `Range.loc.start`
// from `octal_start` alone, ignoring the absolute `start` argument that
// every sibling error path adds.
// Pin the caret under the `\` for every affected escape and quote style.
describe("invalid-escape caret points at the backslash (#31134)", () => {
const cases = [
// `\u{...}` out-of-range:
{
name: "\\u{} backtick with prefix",
source: "`aaaaa\\u{110000}`",
msg: "Unicode escape sequence is out of range",
pattern: "\\u{110000}",
expectCol: 7,
},
{
name: "\\u{} backtick without prefix",
source: "`\\u{110000}`",
msg: "Unicode escape sequence is out of range",
pattern: "\\u{110000}",
expectCol: 2,
},
{
name: "\\u{} double-quote with prefix",
source: 'var a = "aaaaa\\u{110000}";',
msg: "Unicode escape sequence is out of range",
pattern: "\\u{110000}",
expectCol: 15,
},
{
name: "\\u{} double-quote without prefix",
source: 'var a = "\\u{110000}";',
msg: "Unicode escape sequence is out of range",
pattern: "\\u{110000}",
expectCol: 10,
},
{
name: "\\u{} single-quote with prefix",
source: "var a = 'aaaaa\\u{110000}';",
msg: "Unicode escape sequence is out of range",
pattern: "\\u{110000}",
expectCol: 15,
},
{
name: "\\u{} identifier with prefix",
source: "var ab\\u{110000} = 1;",
msg: "Unicode escape sequence is out of range",
pattern: "\\u{110000}",
expectCol: 7,
},
// Legacy-octal `\08`/`\09`:
{
name: "\\08 double-quote with prefix",
source: 'var a = "aaaaa\\08";',
msg: "Invalid legacy octal literal",
pattern: "\\08",
expectCol: 15,
},
{
name: "\\08 double-quote without prefix",
source: 'var a = "\\08";',
msg: "Invalid legacy octal literal",
pattern: "\\08",
expectCol: 10,
},
{
name: "\\09 single-quote with prefix",
source: "var a = 'aaaaa\\09';",
msg: "Invalid legacy octal literal",
pattern: "\\09",
expectCol: 15,
},
];
test.each(cases)("$name → column $expectCol", ({ source, msg, pattern, expectCol }) => {
using dir = tempDir("caret-pos", { "test.js": source });
const { stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "test.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
// Check stderr first so a location/caret mismatch surfaces directly
// instead of getting hidden behind a bare exitCode assertion.
const err = stderr.toString();
expect(err).toContain(msg);
// Reported error location: `:1:<col>` — 1-indexed byte column of the `\`.
expect(err).toContain(`:1:${expectCol}`);
// The caret line (second printed line) should place `^` directly under
// the backslash in the source line above it.
const lines = err.split("\n");
const sourceLineIdx = lines.findIndex(l => l.includes(source));
expect(sourceLineIdx).toBeGreaterThanOrEqual(0);
const sourceLine = lines[sourceLineIdx];
const caretLine = lines[sourceLineIdx + 1];
// `1 | ` (or similar) prefix is identical on both lines, so column
// alignment carries through. The backslash in `sourceLine` must sit
// above the `^` in `caretLine`.
const backslashIdx = sourceLine.indexOf(pattern);
expect(backslashIdx).toBeGreaterThanOrEqual(0);
expect(caretLine[backslashIdx]).toBe("^");
expect(exitCode).toBe(1);
});
});
// https://github.com/oven-sh/bun/issues/30825
// Two bugs in the variable-length `\u{...}` loop of `decode_escape_sequences`:
// 1. `value = value * 16 | d` overflowed `i64` once the escape carried enough
// hex digits, trapping in debug builds. `is_out_of_range` is sticky and the
// value only grows, so saturating the multiply keeps the range error intact.
// 2. Running out of literal before the closing `}` broke out of the loop and
// used the half-parsed value: `"\u{41"` decoded to `"A"` and `"\u{"` to NUL.
// esbuild and JSC both reject these.
describe("pathological `\\u{...}` escapes (#30825)", () => {
const run = (source: string) => {
using dir = tempDir("u-brace", { "test.js": source });
const { stdout, stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), join(dir, "test.js")],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
cwd: String(dir),
});
return { stdout: stdout.toString(), stderr: stderr.toString(), exitCode };
};
// Any run of ~16+ significant hex digits pushes the accumulator past `i64::MAX`.
const overflowing = Buffer.alloc(64, "f").toString();
const outOfRange = [
{ name: "identifier (issue repro)", source: "\\u{3333333316aaaaaaa}a" },
{ name: "double-quoted string", source: 'var a = "\\u{3333333316aaaaaaa}";' },
{ name: "single-quoted string", source: "var a = '\\u{3333333316aaaaaaa}';" },
{ name: "template literal", source: "var a = `\\u{3333333316aaaaaaa}`;" },
{ name: "64 hex digits", source: `var a = "\\u{${overflowing}}";` },
{ name: "leading zeros then overflow", source: `var a = "\\u{0000${overflowing}}";` },
{ name: "one past U+10FFFF", source: 'var a = "\\u{110000}";' },
// A template head's text ends at `${`, not at a quote.
{ name: "template head before a substitution", source: "var x = 1; var a = `\\u{110000}${x}`;" },
];
test.each(outOfRange)("$name → out-of-range error", ({ source }) => {
const { stderr, exitCode } = run(source);
expect(stderr).toContain("error: Unicode escape sequence is out of range");
expect(exitCode).toBe(1);
});
// The literal ends before `}`. esbuild and JSC both reject; the out-of-range
// case is a syntax error too, because the missing brace is found first.
const unterminated = [
{ name: "double-quoted, digits", source: 'var a = "\\u{41";' },
{ name: "double-quoted, no digits", source: 'var a = "\\u{";' },
{ name: "single-quoted, digits", source: "var a = '\\u{41';" },
{ name: "template literal, digits", source: "var a = `\\u{41`;" },
{ name: "digits are out of range", source: 'var a = "\\u{110000";' },
{ name: "digits overflow the accumulator", source: `var a = "\\u{${overflowing}";` },
// The head's text ends at `${`, so the brace is missing there too. Before the
// fix this cooked to "A" + x + "}".
{ name: "template head before a substitution", source: "var x = 1; var a = `\\u{41${x}}`;" },
];
test.each(unterminated)("$name → syntax error", ({ source }) => {
const { stderr, exitCode } = run(source);
expect(stderr).toContain("error: Syntax Error");
// The missing brace is reported, not the value it would have produced.
expect(stderr).not.toContain("out of range");
expect(exitCode).toBe(1);
});
test("in-range escapes still decode, however many leading zeros", () => {
const { stdout, exitCode } = run(
[
`console.log("\\u{41}");`,
// Long enough to overflow if the leading zeros were counted as digits.
`console.log("\\u{${Buffer.alloc(64, "0").toString()}41}");`,
`console.log("\\u{10FFFF}".codePointAt(0).toString(16));`,
`console.log("\\u{1F600}".length);`,
"console.log(`\\u{42}`);",
"const \\u{43} = 3; console.log(C);",
].join("\n"),
);
expect(stdout).toBe("A\nA\n10ffff\n2\nB\n3\n");
expect(exitCode).toBe(0);
});
// Tagged templates keep their raw text and never reach the escape decoder, so
// neither new error path may leak into them: the cooked value stays undefined.
test("tagged templates still expose raw text for invalid escapes", () => {
const { stdout, exitCode } = run(
[
"const x = 1;",
"function tag(s) { return `${s[0]} ${s.raw[0]}`; }",
"console.log(tag`\\u{110000}`);",
"console.log(tag`\\u{41`);",
"console.log(tag`\\u{3333333316aaaaaaa}`);",
"console.log(tag`\\u{41${x}}`);",
].join("\n"),
);
expect(stdout).toBe(
"undefined \\u{110000}\nundefined \\u{41\nundefined \\u{3333333316aaaaaaa}\nundefined \\u{41\n",
);
expect(exitCode).toBe(0);
});
});