Skip to content

JSON.stringify emits invalid JSON for record property names containing \ or " (and JSON.parse of it aborts) #312

Description

@xia-chao

TL;DR

JSON.stringify produces invalid JSON when a record's property name contains a backslash or a double quote: the backslash loses its escape, and a quote closes the key and breaks the document. Worse, JSON.parse-ing that string aborts the process with SIGABRT instead of throwing a catchable error.

Environment

  • scriptc 0.1.1 (npm i -g scriptc)
  • macOS 15.6.1 arm64
  • Oracle: Node v24.15.0 (the release pinned by .node-version in this repo)
  • Upstream commit: a8a52045 (main, after Implement typed async generators #310)

Minimal reproduction

const payload = { "a\\nb": 1, 'q"q': 2, note: "ok" };
console.log(JSON.stringify(payload));

const parsed = JSON.parse(JSON.stringify({ "a\\nb": 1 })) as Record<string, number>;
console.log(parsed["a\\nb"]);

Under Node (the expected output):

$ node repro.ts
{"a\\nb":1,"q\"q":2,"note":"ok"}
1
exit=0

Compiled by scriptc:

$ scriptc run repro.ts
{"a\nb":1,"q"q":2,"note":"ok"}
scriptc: TypeError: record has no key (typed slot — no undefined is representable)
scriptc: program killed by SIGABRT
exit=1

Three separate failures:

  1. "a\\nb" comes out as "a\nb" — the backslash escape is gone, so the key silently changes from backslash + n to a newline character.
  2. 'q"q' comes out as "q"q" — the quote is not escaped, so the JSON document is structurally broken.
  3. Because of that, JSON.parse(JSON.stringify(x)) aborts the process and the exit code flips from 0 to 1.

Debugger walkthrough

Shot 1 — the defective line: the key is interpolated raw into JSON

LLVM backend, all-required branch

Paused at packages/compiler/src/backend/llvm/walkers.ts:272 (conditional breakpoint f.name.length !== 2, so it stops only on the field that triggers the bug):

this.puts(B, "%b", `${i > 0 ? "," : ""}"${f.name}":`);

The Locals/Watch panels show the key facts:

  • f.name.length = 4
  • f.name.split("").map(c=>c.charCodeAt(0)) = [97, 92, 110, 98], i.e. a, \ (92), n, b

So the runtime key string itself is correct — identical to Node. The problem is this line: it interpolates f.name straight into a "...": label and only applies the LLVM string-literal escaper, never JSON escaping. Byte 92 lands verbatim in the JSON text.

Shot 2 — contrast: index-signature keys go through the runtime escaper, and are correct

C backend, index-signature path

The same data typed as Record<string, number> (index-signature / overflow branch) serializes correctly, because that path calls the runtime JSON escaper:

// packages/compiler/src/backend/c/walkers.ts
591:      scr_jb_put_json_str(b, k);      // correct: the key is really JSON-escaped here
592:      scr_jb_putc(b, ':');

The breakpoint confirms why one branch is skipped and the other is taken: droppable = true, shape.indexValue.kind = 'f64', byName.size = 0.

This is not a missing feature — it is the same contract implemented asymmetrically: index-signature keys go through the runtime escaper (correct), while statically-known field names are string-concatenated (wrong), and both backends get it wrong independently.

Shot 3 — the LLVM backend's other branch has the same defect

LLVM backend, droppable branch

A record with an optional property takes the droppable branch, and packages/compiler/src/backend/llvm/walkers.ts:308 interpolates raw as well:

this.puts(B, "%b", `"${f.name}":`);

Blast radius

Every record with statically-known literal property names is affected:

Property name Node scriptc
"a\\nb" {"a\\nb":1} {"a\nb":1}
'q"q' {"q\"q":1} {"q"q":1} (invalid JSON)
"a\tb" {"a\tb":1} raw TAB byte
"a\u0001b" {"a\u0001b":1} raw 0x01 byte
"aéb" {"aéb":1} matches (no escaping needed)
index signature Record<string, number> correct correct

Four sites, two per backend:

  • packages/compiler/src/backend/c/walkers.ts:532 (all-required branch) and :542 (droppable branch)
  • packages/compiler/src/backend/llvm/walkers.ts:272 (all-required branch) and :308 (droppable branch)

JSON.stringify(v, null, space), the %j format path, and island boundary serialization reuse the same record writer, so they inherit the bug. --backend c is not a workaround — both backends are wrong.

How it is fixed

At the four label-construction sites, JSON-encode the key before interpolation:

-const label = cStringLiteral(Buffer.from(`${i > 0 ? "," : ""}"${f.name}":`, "utf8"));
+const label = cStringLiteral(Buffer.from(`${i > 0 ? "," : ""}${JSON.stringify(f.name)}:`, "utf8"));

Compile-time JSON.stringify(f.name) returns an already-quoted, correctly escaped JSON token; the existing cStringLiteral / LLVM literal layer then re-escapes it for the C/LLVM string literal, so the runtime buffer holds valid JSON.

Verified with the same reproduction on both backends:

$ node repro.ts                      → {"a\\nb":1,"q\"q":2,"note":"ok"} / exit 0
$ scriptc run repro.ts               → {"a\\nb":1,"q\"q":2,"note":"ok"} / exit 0
$ scriptc run repro.ts --backend c   → {"a\\nb":1,"q\"q":2,"note":"ok"} / exit 0

A differential corpus program was added at tests/corpus/2853-json-stringify-record-key-escapes.ts (backslash, double quote, control bytes, optional-field record, index-signature record, nested record, space pretty printing, and a JSON.parse round-trip). The official differential harness treats Node as the expected output, so the case pins the behavior:

$ pnpm vitest run tests/harness/differential.test.ts -t "2853-json-stringify-record-key-escapes"
✓ tests/harness/differential.test.ts (1141 tests | 1140 skipped)
   ✓ differential corpus (1141 programs) > 2853-json-stringify-record-key-escapes.ts

The PR is up: #313.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions