import fc from "fast-check"; import { compressToBase64, compressToEncodedURIComponent } from "lz-string"; import { describe, expect, it } from "vitest"; import { LzStringOutputLimitError, decompressFromBase64Bounded, decompressFromBase64OrUriComponentBounded, decompressFromEncodedURIComponentBounded, } from "../../src/formats/boundedLz"; import { MAX_DOCUMENT_BYTES, PUZZLE_HASH_PREFIX, SudokuFormatError, decodePuzzleHash, importFpuzzles, importPuzzle, importSudokuPad, parseFpuzzles, } from "../../src/formats"; function emptyGrid(size = 4) { return Array.from({ length: size }, () => Array.from({ length: size }, () => ({})), ); } function expectLimitExceeded(action: () => unknown): void { try { action(); } catch (error) { expect(error).toBeInstanceOf(SudokuFormatError); expect(error).toMatchObject({ code: "LIMIT_EXCEEDED" }); return; } throw new Error("Expected the import to reject an oversized payload."); } describe("bounded puzzle imports", () => { it("keeps both LZ-String wire formats compatible for arbitrary text", () => { fc.assert( fc.property(fc.string({ maxLength: 512 }), (source) => { const byteLength = new TextEncoder().encode(source).byteLength; const limit = Math.max(1, byteLength); expect( decompressFromBase64Bounded(compressToBase64(source), limit), ).toBe(source); expect( decompressFromEncodedURIComponentBounded( compressToEncodedURIComponent(source), limit, ), ).toBe(source); }), { numRuns: 100 }, ); }); it("counts UTF-8 bytes while expanding and stops before oversized output", () => { const source = "🧩".repeat(1_024); const compressed = compressToEncodedURIComponent(source); const exactBytes = new TextEncoder().encode(source).byteLength; expect( decompressFromEncodedURIComponentBounded(compressed, exactBytes), ).toBe(source); expect(() => decompressFromEncodedURIComponentBounded(compressed, exactBytes - 1), ).toThrow(LzStringOutputLimitError); }); it("selects URI-safe payloads before an invalid Base64 interpretation", () => { const title = "AE(a^'XS2pclC*+Q: ?8IJnG(Fe-nR"; const grid = emptyGrid(); const fpuzzlesJson = JSON.stringify({ size: 4, grid, title }); const fpuzzlesPayload = compressToEncodedURIComponent(fpuzzlesJson); const sudokuPadJson = JSON.stringify({ id: "local-scl", cells: grid, metadata: { title, author: "Tester", rules: "Normal rules apply.", antiknight: true, }, cages: [ { cells: [ [0, 0], [0, 1], ], value: "3", unique: true, }, ], }); const sudokuPadPayload = compressToEncodedURIComponent(sudokuPadJson); expect(fpuzzlesPayload).toMatch(/[-$]/u); expect(sudokuPadPayload).toMatch(/[-$]/u); expect( decompressFromBase64Bounded(fpuzzlesPayload, MAX_DOCUMENT_BYTES), ).not.toBe(fpuzzlesJson); expect( decompressFromBase64Bounded(sudokuPadPayload, MAX_DOCUMENT_BYTES), ).not.toBe(sudokuPadJson); expect( decompressFromBase64OrUriComponentBounded( fpuzzlesPayload, MAX_DOCUMENT_BYTES, ), ).toBe(fpuzzlesJson); expect(importFpuzzles(`fpuzzles${fpuzzlesPayload}`).title).toBe(title); expect(importSudokuPad(`ctc${sudokuPadPayload}`).title).toBe(title); }); it("rejects compressed bombs in every LZ-backed import format", () => { const oversized = " ".repeat(MAX_DOCUMENT_BYTES + 1); const base64 = compressToBase64(oversized); const uri = compressToEncodedURIComponent(oversized); expectLimitExceeded(() => importFpuzzles(`fpuzzles${base64}`)); expectLimitExceeded(() => importSudokuPad(`ctc${base64}`)); expectLimitExceeded(() => decodePuzzleHash(`${PUZZLE_HASH_PREFIX}${uri}`)); }); it("checks raw JSON character and byte limits before parsing", async () => { const tooManyCharacters = `{${" ".repeat(MAX_DOCUMENT_BYTES)}}`; await expect(importPuzzle(tooManyCharacters)).rejects.toMatchObject({ code: "LIMIT_EXCEEDED", }); const tooManyUtf8Bytes = `{"future":"${"é".repeat( Math.floor(MAX_DOCUMENT_BYTES / 2), )}"}`; expect(tooManyUtf8Bytes.length).toBeLessThan(MAX_DOCUMENT_BYTES); await expect(importPuzzle(tooManyUtf8Bytes)).rejects.toMatchObject({ code: "LIMIT_EXCEEDED", }); }); it("rejects unknown fpuzzles inequality markers", () => { for (const value of [undefined, "≤", "left", 1]) { expect(() => parseFpuzzles({ size: 4, grid: emptyGrid(), inequality: [{ cells: ["R1C1", "R1C2"], value }], }), ).toThrow(/inequality\.value must be either/u); } expect( parseFpuzzles({ size: 4, grid: emptyGrid(), inequality: [{ cells: ["R1C1", "R1C2"], value: "<" }], }).constraints, ).toContainEqual({ type: "inequality", lesser: 0, greater: 1 }); }); });