peachScript.test.ts

9.5 KB · raw

1import { describe, expect, test } from "vitest";2import {3  decodePeachScript,4  encodePeachScript,5  isPeachScript,6  normalisePeachScript,7} from "./peachScript";8import { IMPORT_LIMITS } from "../shared/import-limits";910describe("peachScript decode size bound (PB-26-010)", () => {11  test("decode rejects input above MAX_JSON_BYTES before allocating", () => {12    // Build an oversized valid PeachScript string without the spread-arg13    // limit: repeat a 1K-char chunk past the 10 MiB bound.14    const chunk = String.fromCodePoint(0xe000).repeat(1024);15    const tooLarge = chunk.repeat(16      Math.floor(IMPORT_LIMITS.MAX_JSON_BYTES / 1024) + 1,17    );18    expect(tooLarge.length).toBeGreaterThan(IMPORT_LIMITS.MAX_JSON_BYTES);19    expect(() => decodePeachScript(tooLarge)).toThrow(20      "PEACHSCRIPT_TOO_LARGE",21    );22  });2324  test("decode accepts input at the bound", () => {25    const atLimit = String.fromCodePoint(26      ...Array.from({ length: 64 }, () => 0xe041),27    );28    expect(decodePeachScript(atLimit).length).toBe(64);29  });30});3132describe("peachScript encode size bound (PB-26-113)", () => {33  // Every PeachScript code point is U+E000..U+E0FF, which UTF-8 encodes as34  // three bytes. The importer bounds the FILE, not the envelope, so exporting35  // an envelope of N bytes produces a 3N-byte file.36  const encodedFileBytes = (bytes: Uint8Array) =>37    new TextEncoder().encode(encodePeachScript(bytes)).length;3839  test("an envelope just above the import ceiling fails at export", () => {40    const overCeiling = Math.floor(IMPORT_LIMITS.MAX_JSON_BYTES / 3) + 1;41    const bytes = new Uint8Array(overCeiling);4243    // The old export guard measured envelope bytes and let this through.44    expect(bytes.length).toBeLessThan(IMPORT_LIMITS.MAX_JSON_BYTES);45    expect(() => encodePeachScript(bytes)).toThrow("PEACHSCRIPT_TOO_LARGE");46  });4748  test("the encoded file costs three bytes per envelope byte", () => {49    // Proves the factor the ceiling is computed from, on a payload small50    // enough to compare byte for byte.51    const probe = new Uint8Array(1024);52    for (let i = 0; i < probe.length; i++) probe[i] = (i * 37 + 13) % 256;53    expect(encodedFileBytes(probe)).toBe(probe.length * 3);54    expect(decodePeachScript(encodePeachScript(probe))).toEqual(probe);55  });5657  test("an envelope at the ceiling exports to a file the importer accepts", () => {58    const atCeiling = Math.floor(IMPORT_LIMITS.MAX_JSON_BYTES / 3);59    const bytes = new Uint8Array(atCeiling);60    for (let i = 0; i < atCeiling; i++) bytes[i] = (i * 37 + 13) % 256;6162    const encoded = encodePeachScript(bytes);63    // The .peach file's size is what the import guard checks (PB-26-112).64    expect(encodedFileBytes(bytes)).toBeLessThanOrEqual(IMPORT_LIMITS.MAX_JSON_BYTES);65    // Spot-check rather than deep-compare: 3.5M elements is not what this66    // assertion is about.67    expect(encoded.length).toBe(atCeiling);68    expect(decodePeachScript(encoded)[atCeiling - 1]).toBe(bytes[atCeiling - 1]);69  });7071  test("a 4 MiB envelope is refused — it would download as ~12 MiB", () => {72    const fourMiB = 4 * 1024 * 1024;73    expect(() => encodePeachScript(new Uint8Array(fourMiB))).toThrow(74      "PEACHSCRIPT_TOO_LARGE",75    );76  });77});7879describe("peachScript", () => {80  test("byte 0 maps to U+E000", () => {81    const out = encodePeachScript(new Uint8Array([0]));82    expect(out.codePointAt(0)).toBe(0xe000);83  });8485  test("byte 255 maps to U+E0FF", () => {86    const out = encodePeachScript(new Uint8Array([255]));87    expect(out.codePointAt(0)).toBe(0xe0ff);88  });8990  test("round-trip identity for all 256 byte values", () => {91    const bytes = new Uint8Array(256);92    for (let i = 0; i < 256; i++) bytes[i] = i;93    const encoded = encodePeachScript(bytes);94    const decoded = decodePeachScript(encoded);95    expect(decoded).toEqual(bytes);96  });9798  test("empty bytes round-trip to empty string and back", () => {99    expect(encodePeachScript(new Uint8Array())).toBe("");100    expect(decodePeachScript("")).toEqual(new Uint8Array());101  });102103  test("decode throws on character outside PUA range", () => {104    expect(() => decodePeachScript("hello")).toThrow("INVALID_PEACHSCRIPT_CHARACTER");105    expect(() => decodePeachScript(String.fromCodePoint(0xe100))).toThrow(106      "INVALID_PEACHSCRIPT_CHARACTER"107    );108    expect(() => decodePeachScript(String.fromCodePoint(0xdfff))).toThrow(109      "INVALID_PEACHSCRIPT_CHARACTER"110    );111  });112113  test("random-payload round-trip preserves bytes", () => {114    const payload = crypto.getRandomValues(new Uint8Array(2048));115    const decoded = decodePeachScript(encodePeachScript(payload));116    expect(decoded).toEqual(payload);117  });118119  test("normalisePeachScript strips whitespace", () => {120    const encoded = encodePeachScript(new Uint8Array([1, 2, 3, 4]));121    const wrapped = encoded.slice(0, 2) + "\n  " + encoded.slice(2);122    expect(normalisePeachScript(wrapped)).toBe(encoded);123  });124125  test("isPeachScript accepts only PUA strings", () => {126    expect(isPeachScript(encodePeachScript(new Uint8Array([1, 2, 3])))).toBe(true);127    expect(isPeachScript("")).toBe(false);128    expect(isPeachScript("hello")).toBe(false);129    const mixed = encodePeachScript(new Uint8Array([1])) + "x";130    expect(isPeachScript(mixed)).toBe(false);131  });132});133134describe("peachScript fuzz corpus", () => {135  // Off-by-one boundary — U+E100 is one above the valid ceiling U+E0FF136  test("rejects U+E100 (off-by-one out of range)", () => {137    expect(() => decodePeachScript(String.fromCodePoint(0xe100))).toThrow(138      "INVALID_PEACHSCRIPT_CHARACTER"139    );140  });141142  test("rejects U+E200 (far out of range)", () => {143    expect(() => decodePeachScript(String.fromCodePoint(0xe200))).toThrow(144      "INVALID_PEACHSCRIPT_CHARACTER"145    );146  });147148  // Surrogate code points — below the valid PUA range U+E000..U+E0FF149  test("rejects lone low surrogate U+DFFF", () => {150    expect(() => decodePeachScript(String.fromCodePoint(0xdfff))).toThrow(151      "INVALID_PEACHSCRIPT_CHARACTER"152    );153  });154155  test("rejects lone high surrogate U+D800", () => {156    expect(() => decodePeachScript(String.fromCodePoint(0xd800))).toThrow(157      "INVALID_PEACHSCRIPT_CHARACTER"158    );159  });160161  // Real Mayan numerals are U+1D2E0..U+1D2F3 — outside PUA codec range162  test("rejects U+1D2E0 (real Mayan numeral — outside codec range)", () => {163    expect(() => decodePeachScript("\u{1D2E0}")).toThrow(164      "INVALID_PEACHSCRIPT_CHARACTER"165    );166  });167168  // BOM and Unicode direction/zero-width marks mid-stream169  test("rejects BOM (U+FEFF) mid-stream", () => {170    const encoded = encodePeachScript(new Uint8Array([0x41, 0x42]));171    // U+FEFF = BOM, inject between two valid runes172    const withBOM = encoded[0] + String.fromCodePoint(0xfeff) + encoded[1];173    expect(() => decodePeachScript(withBOM)).toThrow("INVALID_PEACHSCRIPT_CHARACTER");174  });175176  test("rejects RTL mark (U+200F) mid-stream", () => {177    const encoded = encodePeachScript(new Uint8Array([0x41]));178    // U+200F = RIGHT-TO-LEFT MARK179    expect(() => decodePeachScript(encoded + String.fromCodePoint(0x200f))).toThrow(180      "INVALID_PEACHSCRIPT_CHARACTER"181    );182  });183184  test("rejects zero-width non-joiner (U+200C)", () => {185    const encoded = encodePeachScript(new Uint8Array([0x41]));186    // U+200C = ZERO WIDTH NON-JOINER187    expect(() => decodePeachScript(encoded + String.fromCodePoint(0x200c))).toThrow(188      "INVALID_PEACHSCRIPT_CHARACTER"189    );190  });191192  // Whitespace — the raw decoder is strict; normalisation is a separate step193  test("rejects space between valid rune characters", () => {194    const encoded = encodePeachScript(new Uint8Array([0x41, 0x42]));195    const withSpace = encoded[0] + " " + encoded[1];196    expect(() => decodePeachScript(withSpace)).toThrow("INVALID_PEACHSCRIPT_CHARACTER");197  });198199  test("rejects newline between valid rune characters", () => {200    const encoded = encodePeachScript(new Uint8Array([0x41, 0x42]));201    const withNewline = encoded[0] + "\n" + encoded[1];202    expect(() => decodePeachScript(withNewline)).toThrow("INVALID_PEACHSCRIPT_CHARACTER");203  });204205  // Boundary bytes must round-trip correctly206  test("byte 0x00 round-trips correctly", () => {207    const data = new Uint8Array([0x00]);208    expect(decodePeachScript(encodePeachScript(data))).toEqual(data);209  });210211  test("byte 0xFE round-trips correctly", () => {212    const data = new Uint8Array([0xfe]);213    expect(decodePeachScript(encodePeachScript(data))).toEqual(data);214  });215216  test("byte 0xFF round-trips correctly", () => {217    const data = new Uint8Array([0xff]);218    expect(decodePeachScript(encodePeachScript(data))).toEqual(data);219  });220221  // 1 KiB pseudo-random round-trip222  test("1 KiB deterministic payload round-trips", () => {223    const data = new Uint8Array(1024);224    for (let i = 0; i < 1024; i++) data[i] = (i * 37 + 13) % 256;225    expect(decodePeachScript(encodePeachScript(data))).toEqual(data);226  });227228  // DoS resistance — the decoder must fail on the first invalid character,229  // not after scanning the whole input. U+E100 > 0xE0FF fails on char 0.230  test("rejects large all-invalid input in well under 50 ms", () => {231    const invalid = String.fromCodePoint(0xe100).repeat(10_000);232    const start = Date.now();233    expect(() => decodePeachScript(invalid)).toThrow("INVALID_PEACHSCRIPT_CHARACTER");234    expect(Date.now() - start).toBeLessThan(50);235  });236237  // Copy-paste hostility — smart quote prefix (U+201C = LEFT DOUBLE QUOTATION MARK)238  test("rejects left double quotation mark (U+201C) prefix", () => {239    const encoded = encodePeachScript(new Uint8Array([0x41]));240    expect(() => decodePeachScript(String.fromCodePoint(0x201c) + encoded)).toThrow(241      "INVALID_PEACHSCRIPT_CHARACTER"242    );243  });244});