161 lines
5.0 KiB
TypeScript
161 lines
5.0 KiB
TypeScript
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 });
|
|
});
|
|
});
|