From 441045581a40968d851cf8eeceae4c1d10310bdf Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 4 Sep 2026 10:43:31 +0200 Subject: [PATCH 1/3] fix(tui,coding-agent): survive lone surrogates in table cells and terminate the WebP EXIF scan encodeURIComponent throws URIError on a lone UTF-16 surrogate, so table cell markers sanitize content with toWellFormed before encoding. The WebP RIFF scan read chunk sizes with signed 32-bit arithmetic; a high-bit size walked the scan backward forever, hanging the process on a corrupt or crafted image. Chunk sizes (and the little-endian TIFF read32) are now unsigned. --- .../.changes/webp-exif-scan-termination.md | 1 + .../coding-agent/src/utils/exif-orientation.ts | 5 +++-- .../coding-agent/test/exif-orientation.test.ts | 18 ++++++++++++++++++ .../selection-marker-lone-surrogates.md | 1 + packages/tui/src/selection-metadata.ts | 5 ++++- packages/tui/test/selection-metadata.test.ts | 16 ++++++++++++++++ packages/tui/vitest.config.ts | 2 +- 7 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 packages/coding-agent/.changes/webp-exif-scan-termination.md create mode 100644 packages/coding-agent/test/exif-orientation.test.ts create mode 100644 packages/tui/.changes/selection-marker-lone-surrogates.md create mode 100644 packages/tui/test/selection-metadata.test.ts diff --git a/packages/coding-agent/.changes/webp-exif-scan-termination.md b/packages/coding-agent/.changes/webp-exif-scan-termination.md new file mode 100644 index 0000000000..8d3a068215 --- /dev/null +++ b/packages/coding-agent/.changes/webp-exif-scan-termination.md @@ -0,0 +1 @@ +- The WebP EXIF chunk scan reads chunk sizes as unsigned, so a crafted or corrupt image can no longer hang the process in an infinite scan loop. diff --git a/packages/coding-agent/src/utils/exif-orientation.ts b/packages/coding-agent/src/utils/exif-orientation.ts index ac34c790a4..1c9b6572f9 100644 --- a/packages/coding-agent/src/utils/exif-orientation.ts +++ b/packages/coding-agent/src/utils/exif-orientation.ts @@ -14,7 +14,7 @@ function readOrientationFromTiff(bytes: Uint8Array, tiffStart: number): number { }; const read32 = (pos: number): number => { - if (le) return bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24); + if (le) return (bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24)) >>> 0; return ((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0; }; @@ -66,8 +66,9 @@ function findWebpTiffOffset(bytes: Uint8Array): number { let offset = 12; while (offset + 8 <= bytes.length) { const chunkId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); + // Unsigned: a high-bit chunk size read as negative would walk the scan backward forever. const chunkSize = - bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24); + (bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24)) >>> 0; const dataStart = offset + 8; if (chunkId === "EXIF") { diff --git a/packages/coding-agent/test/exif-orientation.test.ts b/packages/coding-agent/test/exif-orientation.test.ts new file mode 100644 index 0000000000..d97350b4f8 --- /dev/null +++ b/packages/coding-agent/test/exif-orientation.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { applyExifOrientation } from "../src/utils/exif-orientation.js"; +import type { PhotonImageType } from "../src/utils/photon.js"; + +type Photon = typeof import("@silvia-odwyer/photon-node"); + +describe("exif orientation", () => { + it("terminates the WebP chunk scan when a chunk size has the high bit set", () => { + const bytes = new Uint8Array(20); + bytes.set([0x52, 0x49, 0x46, 0x46], 0); // RIFF + bytes.set([0x57, 0x45, 0x42, 0x50], 8); // WEBP + bytes.set([0x4a, 0x55, 0x4e, 0x4b], 12); // JUNK + bytes.set([0xf8, 0xff, 0xff, 0xff], 16); // chunk size 0xFFFFFFF8: read signed (-8), the scan re-visits this chunk forever + // Orientation resolves to 1, so neither photon nor the image is ever touched. + const image = {} as PhotonImageType; + expect(applyExifOrientation({} as Photon, image, bytes)).toBe(image); + }); +}); diff --git a/packages/tui/.changes/selection-marker-lone-surrogates.md b/packages/tui/.changes/selection-marker-lone-surrogates.md new file mode 100644 index 0000000000..66904d23b2 --- /dev/null +++ b/packages/tui/.changes/selection-marker-lone-surrogates.md @@ -0,0 +1 @@ +- Table cell selection markers no longer crash the renderer when cell content contains a lone UTF-16 surrogate; the surrogate is replaced before encoding. diff --git a/packages/tui/src/selection-metadata.ts b/packages/tui/src/selection-metadata.ts index 8922243da1..1bccc028af 100644 --- a/packages/tui/src/selection-metadata.ts +++ b/packages/tui/src/selection-metadata.ts @@ -35,7 +35,10 @@ interface TableBounds { } function cellMarker(kind: CellMarker["kind"], row: number, column: number, segment: number, content?: string): string { - const encodedContent = content === undefined ? "" : `:${encodeURIComponent(content)}`; + // Lone surrogates (split at cell boundaries) would make encodeURIComponent throw. + // toWellFormed exists on every supported runtime (engines node >=20); lib is still ES2022. + const wellFormed = content as undefined | (string & { toWellFormed(): string }); + const encodedContent = wellFormed === undefined ? "" : `:${encodeURIComponent(wellFormed.toWellFormed())}`; return `${TABLE_MARKER_PREFIX}${kind}:${row}:${column}:${segment}${encodedContent}\x07`; } diff --git a/packages/tui/test/selection-metadata.test.ts b/packages/tui/test/selection-metadata.test.ts new file mode 100644 index 0000000000..e530672d85 --- /dev/null +++ b/packages/tui/test/selection-metadata.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { + extractTableCellSelectionRegions, + markTableCell, + markTableEnd, + markTableStart, +} from "../src/selection-metadata.js"; + +describe("selection metadata", () => { + it("marks cells containing lone surrogates without throwing", () => { + // The lone surrogate is replaced with U+FFFD, not crashed on. + const line = markTableEnd(markTableStart("") + markTableCell("cell text", 0, 0, 0, "broken \uD800 surrogate")); + const { regions } = extractTableCellSelectionRegions([line], () => ({})); + expect(regions[0]?.content).toBe("broken \uFFFD surrogate"); + }); +}); diff --git a/packages/tui/vitest.config.ts b/packages/tui/vitest.config.ts index a90c176d92..217c428630 100644 --- a/packages/tui/vitest.config.ts +++ b/packages/tui/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["test/wrap-ansi.test.ts"], + include: ["test/selection-metadata.test.ts", "test/wrap-ansi.test.ts"], }, }); From 45cad18535e29280ab6771312a2b7325c8cce9d9 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 4 Sep 2026 11:37:01 +0200 Subject: [PATCH 2/3] test(tui): run the selection-metadata pin under the package's node --test runner packages/tui tests run via node --test, not vitest; the lone-surrogate pin now uses node:test and the vitest include-list change is reverted. --- packages/tui/test/selection-metadata.test.ts | 5 +++-- packages/tui/vitest.config.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/tui/test/selection-metadata.test.ts b/packages/tui/test/selection-metadata.test.ts index e530672d85..de02a21535 100644 --- a/packages/tui/test/selection-metadata.test.ts +++ b/packages/tui/test/selection-metadata.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import assert from "node:assert"; +import { describe, it } from "node:test"; import { extractTableCellSelectionRegions, markTableCell, @@ -11,6 +12,6 @@ describe("selection metadata", () => { // The lone surrogate is replaced with U+FFFD, not crashed on. const line = markTableEnd(markTableStart("") + markTableCell("cell text", 0, 0, 0, "broken \uD800 surrogate")); const { regions } = extractTableCellSelectionRegions([line], () => ({})); - expect(regions[0]?.content).toBe("broken \uFFFD surrogate"); + assert.equal(regions[0]?.content, "broken \uFFFD surrogate"); }); }); diff --git a/packages/tui/vitest.config.ts b/packages/tui/vitest.config.ts index 217c428630..a90c176d92 100644 --- a/packages/tui/vitest.config.ts +++ b/packages/tui/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["test/selection-metadata.test.ts", "test/wrap-ansi.test.ts"], + include: ["test/wrap-ansi.test.ts"], }, }); From a3d1139ee6e1f77337ab134b0f0c0cede8c942af Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 4 Sep 2026 18:51:50 +0200 Subject: [PATCH 3/3] refactor(tui): compress the surrogate-sanitizing comment --- packages/tui/src/selection-metadata.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/tui/src/selection-metadata.ts b/packages/tui/src/selection-metadata.ts index 1bccc028af..dbdb47b7dc 100644 --- a/packages/tui/src/selection-metadata.ts +++ b/packages/tui/src/selection-metadata.ts @@ -35,8 +35,7 @@ interface TableBounds { } function cellMarker(kind: CellMarker["kind"], row: number, column: number, segment: number, content?: string): string { - // Lone surrogates (split at cell boundaries) would make encodeURIComponent throw. - // toWellFormed exists on every supported runtime (engines node >=20); lib is still ES2022. + // Lone surrogates would throw in encodeURIComponent; toWellFormed exists on Node>=20, lib is ES2022. const wellFormed = content as undefined | (string & { toWellFormed(): string }); const encodedContent = wellFormed === undefined ? "" : `:${encodeURIComponent(wellFormed.toWellFormed())}`; return `${TABLE_MARKER_PREFIX}${kind}:${row}:${column}:${segment}${encodedContent}\x07`;