peachScript.test.ts

9.4 KB · raw

1import { describe, expect, test } from "vitest";2import {3  decodePeachScript,4  encodePeachScript,5  isPeachScript,6} from "./peachScript";7import { IMPORT_LIMITS } from "../shared/import-limits";89describe("peachScript decode size bound (PB-26-010)", () => {10  test("decode rejects input above MAX_JSON_BYTES before allocating", () => {11    // Build an oversized valid PeachScript string without the spread-arg12    // limit: repeat a 1K-char chunk past the 10 MiB bound.13    const chunk = String.fromCodePoint(0xe000).repeat(1024);14    const tooLarge = chunk.repeat(15      Math.floor(IMPORT_LIMITS.MAX_JSON_BYTES / 1024) + 1,16    );17    expect(tooLarge.length).toBeGreaterThan(IMPORT_LIMITS.MAX_JSON_BYTES);18    expect(() => decodePeachScript(tooLarge)).toThrow(19      "PEACHSCRIPT_TOO_LARGE",20    );21  });2223  test("decode accepts input at the bound", () => {24    const atLimit = String.fromCodePoint(25      ...Array.from({ length: 64 }, () => 0xe041),26    );27    expect(decodePeachScript(atLimit).length).toBe(64);28  });29});3031describe("peachScript encode size bound (PB-26-113)", () => {32  // Every PeachScript code point is U+E000..U+E0FF, which UTF-8 encodes as33  // three bytes. The importer bounds the FILE, not the envelope, so exporting34  // an envelope of N bytes produces a 3N-byte file.35  const encodedFileBytes = (bytes: Uint8Array) =>36    new TextEncoder().encode(encodePeachScript(bytes)).length;3738  test("an envelope just above the import ceiling fails at export", () => {39    const overCeiling = Math.floor(IMPORT_LIMITS.MAX_JSON_BYTES / 3) + 1;40    const bytes = new Uint8Array(overCeiling);4142    // The old export guard measured envelope bytes and let this through.43    expect(bytes.length).toBeLessThan(IMPORT_LIMITS.MAX_JSON_BYTES);44    expect(() => encodePeachScript(bytes)).toThrow("PEACHSCRIPT_TOO_LARGE");45  });4647  test("the encoded file costs three bytes per envelope byte", () => {48    // Proves the factor the ceiling is computed from, on a payload small49    // enough to compare byte for byte.50    const probe = new Uint8Array(1024);51    for (let i = 0; i < probe.length; i++) probe[i] = (i * 37 + 13) % 256;52    expect(encodedFileBytes(probe)).toBe(probe.length * 3);53    expect(decodePeachScript(encodePeachScript(probe))).toEqual(probe);54  });5556  test("an envelope at the ceiling exports to a file the importer accepts", () => {57    const atCeiling = Math.floor(IMPORT_LIMITS.MAX_JSON_BYTES / 3);58    const bytes = new Uint8Array(atCeiling);59    for (let i = 0; i < atCeiling; i++) bytes[i] = (i * 37 + 13) % 256;6061    const encoded = encodePeachScript(bytes);62    // The .peach file's size is what the import guard checks (PB-26-112).63    expect(encodedFileBytes(bytes)).toBeLessThanOrEqual(IMPORT_LIMITS.MAX_JSON_BYTES);64    // Spot-check rather than deep-compare: 3.5M elements is not what this65    // assertion is about.66    expect(encoded.length).toBe(atCeiling);67    expect(decodePeachScript(encoded)[atCeiling - 1]).toBe(bytes[atCeiling - 1]);68  });6970  test("a 4 MiB envelope is refused — it would download as ~12 MiB", () => {71    const fourMiB = 4 * 1024 * 1024;72    expect(() => encodePeachScript(new Uint8Array(fourMiB))).toThrow(73      "PEACHSCRIPT_TOO_LARGE",74    );75  });76});7778describe("peachScript", () => {79  test("byte 0 maps to U+E000", () => {80    const out = encodePeachScript(new Uint8Array([0]));81    expect(out.codePointAt(0)).toBe(0xe000);82  });8384  test("byte 255 maps to U+E0FF", () => {85    const out = encodePeachScript(new Uint8Array([255]));86    expect(out.codePointAt(0)).toBe(0xe0ff);87  });8889  test("round-trip identity for all 256 byte values", () => {90    const bytes = new Uint8Array(256);91    for (let i = 0; i < 256; i++) bytes[i] = i;92    const encoded = encodePeachScript(bytes);93    const decoded = decodePeachScript(encoded);94    expect(decoded).toEqual(bytes);95  });9697  test("empty bytes round-trip to empty string and back", () => {98    expect(encodePeachScript(new Uint8Array())).toBe("");99    expect(decodePeachScript("")).toEqual(new Uint8Array());100  });101102  test("decode throws on character outside PUA range", () => {103    expect(() => decodePeachScript("hello")).toThrow("INVALID_PEACHSCRIPT_CHARACTER");104    expect(() => decodePeachScript(String.fromCodePoint(0xe100))).toThrow(105      "INVALID_PEACHSCRIPT_CHARACTER"106    );107    expect(() => decodePeachScript(String.fromCodePoint(0xdfff))).toThrow(108      "INVALID_PEACHSCRIPT_CHARACTER"109    );110  });111112  test("random-payload round-trip preserves bytes", () => {113    const payload = crypto.getRandomValues(new Uint8Array(2048));114    const decoded = decodePeachScript(encodePeachScript(payload));115    expect(decoded).toEqual(payload);116  });117118  test("isPeachScript accepts only PUA strings", () => {119    expect(isPeachScript(encodePeachScript(new Uint8Array([1, 2, 3])))).toBe(true);120    expect(isPeachScript("")).toBe(false);121    expect(isPeachScript("hello")).toBe(false);122    const mixed = encodePeachScript(new Uint8Array([1])) + "x";123    expect(isPeachScript(mixed)).toBe(false);124  });125});126127describe("peachScript fuzz corpus", () => {128  // Off-by-one boundary — U+E100 is one above the valid ceiling U+E0FF129  test("rejects U+E100 (off-by-one out of range)", () => {130    expect(() => decodePeachScript(String.fromCodePoint(0xe100))).toThrow(131      "INVALID_PEACHSCRIPT_CHARACTER"132    );133  });134135  test("rejects U+E200 (far out of range)", () => {136    expect(() => decodePeachScript(String.fromCodePoint(0xe200))).toThrow(137      "INVALID_PEACHSCRIPT_CHARACTER"138    );139  });140141  // Surrogate code points — below the valid PUA range U+E000..U+E0FF142  test("rejects lone low surrogate U+DFFF", () => {143    expect(() => decodePeachScript(String.fromCodePoint(0xdfff))).toThrow(144      "INVALID_PEACHSCRIPT_CHARACTER"145    );146  });147148  test("rejects lone high surrogate U+D800", () => {149    expect(() => decodePeachScript(String.fromCodePoint(0xd800))).toThrow(150      "INVALID_PEACHSCRIPT_CHARACTER"151    );152  });153154  // Real Mayan numerals are U+1D2E0..U+1D2F3 — outside PUA codec range155  test("rejects U+1D2E0 (real Mayan numeral — outside codec range)", () => {156    expect(() => decodePeachScript("\u{1D2E0}")).toThrow(157      "INVALID_PEACHSCRIPT_CHARACTER"158    );159  });160161  // BOM and Unicode direction/zero-width marks mid-stream162  test("rejects BOM (U+FEFF) mid-stream", () => {163    const encoded = encodePeachScript(new Uint8Array([0x41, 0x42]));164    // U+FEFF = BOM, inject between two valid runes165    const withBOM = encoded[0] + String.fromCodePoint(0xfeff) + encoded[1];166    expect(() => decodePeachScript(withBOM)).toThrow("INVALID_PEACHSCRIPT_CHARACTER");167  });168169  test("rejects RTL mark (U+200F) mid-stream", () => {170    const encoded = encodePeachScript(new Uint8Array([0x41]));171    // U+200F = RIGHT-TO-LEFT MARK172    expect(() => decodePeachScript(encoded + String.fromCodePoint(0x200f))).toThrow(173      "INVALID_PEACHSCRIPT_CHARACTER"174    );175  });176177  test("rejects zero-width non-joiner (U+200C)", () => {178    const encoded = encodePeachScript(new Uint8Array([0x41]));179    // U+200C = ZERO WIDTH NON-JOINER180    expect(() => decodePeachScript(encoded + String.fromCodePoint(0x200c))).toThrow(181      "INVALID_PEACHSCRIPT_CHARACTER"182    );183  });184185  // Whitespace — PB-26-208 merged normalisation into the bounded decoder, so186  // ASCII whitespace is skipped (not rejected) anywhere in the string. BOM,187  // ZWNJ, RTL marks and other Unicode space-class code points stay invalid188  // (see the rejection tests below).189  test("skips space between valid rune characters", () => {190    const encoded = encodePeachScript(new Uint8Array([0x41, 0x42]));191    const withSpace = encoded[0] + " " + encoded[1];192    expect(decodePeachScript(withSpace)).toEqual(new Uint8Array([0x41, 0x42]));193  });194195  test("skips newline between valid rune characters", () => {196    const encoded = encodePeachScript(new Uint8Array([0x41, 0x42]));197    const withNewline = encoded[0] + "\n" + encoded[1];198    expect(decodePeachScript(withNewline)).toEqual(new Uint8Array([0x41, 0x42]));199  });200201  // Boundary bytes must round-trip correctly202  test("byte 0x00 round-trips correctly", () => {203    const data = new Uint8Array([0x00]);204    expect(decodePeachScript(encodePeachScript(data))).toEqual(data);205  });206207  test("byte 0xFE round-trips correctly", () => {208    const data = new Uint8Array([0xfe]);209    expect(decodePeachScript(encodePeachScript(data))).toEqual(data);210  });211212  test("byte 0xFF round-trips correctly", () => {213    const data = new Uint8Array([0xff]);214    expect(decodePeachScript(encodePeachScript(data))).toEqual(data);215  });216217  // 1 KiB pseudo-random round-trip218  test("1 KiB deterministic payload round-trips", () => {219    const data = new Uint8Array(1024);220    for (let i = 0; i < 1024; i++) data[i] = (i * 37 + 13) % 256;221    expect(decodePeachScript(encodePeachScript(data))).toEqual(data);222  });223224  // DoS resistance — the decoder must fail on the first invalid character,225  // not after scanning the whole input. U+E100 > 0xE0FF fails on char 0.226  test("rejects large all-invalid input in well under 50 ms", () => {227    const invalid = String.fromCodePoint(0xe100).repeat(10_000);228    const start = Date.now();229    expect(() => decodePeachScript(invalid)).toThrow("INVALID_PEACHSCRIPT_CHARACTER");230    expect(Date.now() - start).toBeLessThan(50);231  });232233  // Copy-paste hostility — smart quote prefix (U+201C = LEFT DOUBLE QUOTATION MARK)234  test("rejects left double quotation mark (U+201C) prefix", () => {235    const encoded = encodePeachScript(new Uint8Array([0x41]));236    expect(() => decodePeachScript(String.fromCodePoint(0x201c) + encoded)).toThrow(237      "INVALID_PEACHSCRIPT_CHARACTER"238    );239  });240});