diff --git a/src/native/apple/losslessJson.test.ts b/src/native/apple/losslessJson.test.ts index 7ad511ee9f..42c4b74588 100644 --- a/src/native/apple/losslessJson.test.ts +++ b/src/native/apple/losslessJson.test.ts @@ -126,3 +126,32 @@ it("refuses nesting beyond the selected depth in kept and skipped values", () => ).toThrow(expect.objectContaining({ reason: "depth" })); expect(parseLosslessJson(deep, 0, undefined, { maxDepth: 20 }).end).toBe(40); }); + +it("refuses JSON string escapes for unpaired surrogates", () => { + for (const text of ['{"a":"\\ud800"}', '{"a":"ok\\udfff"}']) + expect(() => parseLosslessJson(text, 0)).toThrow( + expect.objectContaining({ + message: "JSON string contains an unpaired surrogate", + }), + ); + expect(() => parseLosslessJson('{"a":"\\ud83d\\ude00"}', 0)).not.toThrow(); +}); + +it("applies the surrogate check to skipped strings and nested keys", () => { + const skipped: JsonShape = { kind: "object", members: {} }; + for (const text of [ + '{"extra":"\\ud800"}', + '{"extra":["\\ud800"]}', + '{"extra":{"\\ud800":1}}', + ]) + expect(() => parseLosslessJson(text, 0, skipped)).toThrow( + expect.objectContaining({ + message: "JSON string contains an unpaired surrogate", + }), + ); + expect(parseLosslessJson('{"extra":"ok"}', 0, skipped).value).toEqual({ + kind: "object", + members: {}, + unlisted: [], + }); +}); diff --git a/src/native/apple/losslessJson.ts b/src/native/apple/losslessJson.ts index f3777ad848..451a6a95fc 100644 --- a/src/native/apple/losslessJson.ts +++ b/src/native/apple/losslessJson.ts @@ -254,7 +254,7 @@ class Parser { throw new LosslessJsonFailure( `Expected a JSON object key at offset ${String(this.position)}`, ); - this.skipString(); + this.parseString(); this.skipWhitespace(); this.expect(":"); this.skipValue(depth + 1); @@ -295,7 +295,7 @@ class Parser { } } if (ch === '"') { - this.skipString(); + this.parseString(); return; } if (ch === "-" || (ch !== undefined && isDigit(ch.charCodeAt(0)))) { @@ -309,13 +309,40 @@ class Parser { private parseString(): string { const start = this.position; const escaped = this.skipString(); - if (!escaped) return this.text.slice(start + 1, this.position - 1); - // A decoded JSON string cannot contain more code units than its literal. - // The input's byte allowance bounds this allocation, including dense escapes. - const decoded: unknown = JSON.parse(this.text.slice(start, this.position)); + const decoded: unknown = escaped + ? // A decoded JSON string cannot contain more code units than its literal. + // The input's byte allowance bounds this allocation, including dense escapes. + JSON.parse(this.text.slice(start, this.position)) + : this.text.slice(start + 1, this.position - 1); if (typeof decoded !== "string") throw new LosslessJsonFailure("Expected a JSON string"); - return decoded; + return this.requirePairedSurrogates(decoded); + } + + /** + * JSON admits \uXXXX escapes for unpaired surrogates, but the decoded text is + * not valid Unicode. Evidence carrying one would report malformed text as + * complete, so the parser refuses the string like any other malformed input. + * A high surrogate followed by its low surrogate escape stays valid text. + */ + private requirePairedSurrogates(value: string): string { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0; + if (next < 0xdc00 || next > 0xdfff) + throw new LosslessJsonFailure( + "JSON string contains an unpaired surrogate", + ); + index += 1; + continue; + } + if (unit >= 0xdc00 && unit <= 0xdfff) + throw new LosslessJsonFailure( + "JSON string contains an unpaired surrogate", + ); + } + return value; } private skipString(): boolean {