From 8178dca047eb71a46839c3e8a08dcf62cb1c60f8 Mon Sep 17 00:00:00 2001 From: samanpwbb Date: Wed, 27 May 2026 14:56:16 -0700 Subject: [PATCH] safely handle .puz files with malformed headers by throwing before trying to parse. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readString() in the .puz decoder had no bounds check: once ibyte walked past bytes.length, bytes[ibyte] was undefined, the `b !== 0` loop condition stayed true, and the loop appended String.fromCharCode of undefined (a NUL char) forever — Node OOM'd at ~4 GB instead of reporting a corrupt file. PUZtoJSON now rejects files where the header reports a zero-sized grid or where the file is shorter than 52 + 2*ncol*nrow (header + solution + progress), and readString throws a descriptive error with the grid-scan clue counts when it hits EOF mid-string. Adds a fixture (truncated-strings.puz — header says 10x10 / 34 clues but only 38 strings are present) and a regression test that asserts puzToXD throws instead of looping. --- .../xd-crossword-tools/src/vendor/puzjs.ts | 26 ++++++++++++++++-- .../tests/puz/truncated-strings.puz | Bin 0 -> 1658 bytes .../xd-crossword-tools/tests/puz2XD.test.ts | 6 ++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 packages/xd-crossword-tools/tests/puz/truncated-strings.puz diff --git a/packages/xd-crossword-tools/src/vendor/puzjs.ts b/packages/xd-crossword-tools/src/vendor/puzjs.ts index b3e74f6..80a3726 100644 --- a/packages/xd-crossword-tools/src/vendor/puzjs.ts +++ b/packages/xd-crossword-tools/src/vendor/puzjs.ts @@ -458,9 +458,21 @@ function PUZtoJSON(buffer: ArrayBuffer) { var ncol = bytes[44] var nrow = bytes[45] + if (!ncol || !nrow) { + throw new Error(`Invalid PUZ file: header reports grid size ${ncol}x${nrow}`) + } if (!(bytes[50] === 0 && bytes[51] === 0)) { throw new Error("Scrambled PUZ file") } + // Header must be followed by solution (ncol*nrow) and progress (ncol*nrow) bytes + // before the NUL-terminated string section starts. If the file is shorter than + // that the puzzle is truncated and we cannot safely decode it. + var minBytes = 52 + 2 * ncol * nrow + if (bytes.length < minBytes) { + throw new Error( + `Invalid PUZ file: header reports ${ncol}x${nrow} grid (needs at least ${minBytes} bytes) but file is only ${bytes.length} bytes` + ) + } for (var i = 0; i < nrow; i++) { grid[i] = [] @@ -475,8 +487,8 @@ function PUZtoJSON(buffer: ArrayBuffer) { return i < 0 || j < 0 || i >= nrow || j >= ncol || grid[i][j] === "." } - var isAcross = [] - var isDown = [] + var isAcross: boolean[] = [] + var isDown: boolean[] = [] var n = 0 for (var _i = 0; _i < nrow; _i++) { for (var _j = 0; _j < ncol; _j++) { @@ -496,8 +508,18 @@ function PUZtoJSON(buffer: ArrayBuffer) { var ibyte = 52 + ncol * nrow * 2 function readString() { var result = "" + if (ibyte >= bytes.length) { + throw new Error( + `Invalid PUZ file: reached end of file while reading strings. The grid scan found ${n} numbered cells (${ + isAcross.filter(Boolean).length + } across, ${isDown.filter(Boolean).length} down) which doesn't match the strings stored in the file — the puzzle is likely truncated or its header grid size (${ncol}x${nrow}) is wrong.` + ) + } var b = bytes[ibyte++] while (b !== 0) { + if (b === undefined) { + throw new Error("Invalid PUZ file: unterminated string at end of file") + } result += String.fromCharCode(b) b = bytes[ibyte++] } diff --git a/packages/xd-crossword-tools/tests/puz/truncated-strings.puz b/packages/xd-crossword-tools/tests/puz/truncated-strings.puz new file mode 100644 index 0000000000000000000000000000000000000000..3279cc742b787df3a32f281c1cd762925f46cf45 GIT binary patch literal 1658 zcmcgs%WmT~6y+|9baMB=L^hb)Ki&-Q9Y=&bLqLZl7)Q zhfOw57whe6oo4f!O_qJhR%v>dWt(*VoUNB%((S`znx?DWO*VgwYwP>n9X{qa*>17R z(%;wHEWh8a@-%y%Z=PoV!)xhShTbuKp1#FOnws4ur9W90>C+x)JGrTn&Y@%YlC`~L18w4pBgsg*t2wH14PtG$b-2G z+xMZt6s>%q%AU-`%`uCGwX?!nv4kSQ0k#5aN%vBEsmM2qbwzPN@O03!jt?A3<73u$ zfW>i!rLGTP&~gMy>|)^pe6hWwogUgwl~h|@ z`KavGFCRaNWW#}=^pUw6H#{r8opUx0cz=cW`{Z&Qq>K;Jlq~Y0Ev$y%UDQ0rsvdWM z0Ai=ZlcwQ)KlXSL7zb3Z>Wm=UsWMjsIAso)O1R+oAz%-R%(s2!prEObAP9fxf2!2OM@nDq+5f~bA=zUCCOlowQ zG|JY?8b6RW16F|9cVp*}WcYYeWituJf6BJgESB1OcnOW7Qg0@CYUJyfpv7WIkFru$ zBum>3mlT{d9uwQC%b-x8x(JUfOY5ppZ%cQ|6$1_dSNG`Pg_w?mxY$ZF0Lv>#cae%0 zW0MCuFQA&nT3?pT6(W;7AxqD*&q_mo0xuBHOJsErx3=SI7Ky{=&Kl1o579cLRCR3o zFg`u}Fm;W5r^1+puLs4{KQt9hNyKkG6sVComSscZMX?7q>{n`A9&tQOPebgO?@gAA zxo)iwGpY6L)t*=*ygs-deg^T-n { `) // expect(xd).toMatchFile(`./tests/output/${file}.xd`) }) + +it("throws a clear error for truncated/malformed puz files instead of looping forever", () => { + const path = __dirname + "/puz/truncated-strings.puz" + const puz = readFileSync(path) + expect(() => puzToXD(puz)).toThrowError(/Invalid PUZ file/) +})