/** * peachScript.ts — PeachScript encoding/decoding. * * PeachScript is the device-to-device export format. Each byte of an * encrypted vault payload maps to one Unicode Private Use Area code point * in U+E000..U+E0FF. The bundled `PeachScript-Regular.otf` renders those * 256 code points as the visual script. Outside Peach the string renders * as boxes — that is the desired property: PeachScript is meant to be * read by Peach, not by humans. * * The encoding is trivially reversible: byte 0 → U+E000, byte 255 → * U+E0FF. No padding, no base conversion, no alignment fix-ups. The font * does the visual work; this module is the codec. */ const BASE = 0xe000; // PB-26-010: decode allocates one byte per code point, so bound the input at // the same 10 MiB ceiling parseImportSource applies to CSV/JSON — otherwise a // pasted or loaded string allocates without bound before any validation. import { IMPORT_LIMITS } from "../shared/import-limits"; /** * Every encoded code point lands in U+E000..U+E0FF, which UTF-8 encodes as * exactly three bytes. * * PB-26-113: the importer bounds the FILE, so the export ceiling has to be * measured in encoded bytes, not envelope bytes. Encoding an N-byte envelope * produced a 3N-byte .peach file, so a 4 MiB vault exported a ~12 MiB backup * that Peach's own importer then rejected outright — a backup that fails only * when it is needed, years later. */ const UTF8_BYTES_PER_CODE_POINT = 3; // PB-26-208: the decoded ceiling in code points. Matches the export ceiling — // floor(MAX_JSON_BYTES / 3) glyphs each cost 3 UTF-8 bytes. const MAX_PEACHSCRIPT_CODE_POINTS = Math.floor( IMPORT_LIMITS.MAX_JSON_BYTES / UTF8_BYTES_PER_CODE_POINT, ); // PB-26-208: a small explicit allowance for whitespace picked up by pasted // text (editor line-wrapping, trailing newlines). Peach-generated scripts // contain no bulk whitespace, so anything past this is hostile padding. const PEACHSCRIPT_WHITESPACE_ALLOWANCE = 64 * 1024; // Hard cap on the raw input length (glyphs + whitespace) checked before any // scan or allocation. A whitespace-only blob over this is rejected as too // large without touching the string beyond its length. const MAX_RAW_PEACHSCRIPT = MAX_PEACHSCRIPT_CODE_POINTS + PEACHSCRIPT_WHITESPACE_ALLOWANCE; /** * Whitespace the bounded scanner skips. Deliberately ASCII-only: BOM * (U+FEFF), ZWNJ/ZWJ, RTL/LTR marks and other Unicode space-class code points * stay INVALID so the fuzz-corpus rejection tests keep holding. Real pasted * text only carries spaces, tabs, CR, LF. */ function isPeachScriptWhitespace(cp: number): boolean { return cp === 0x20 || (cp >= 0x09 && cp <= 0x0d); } /** Encode a binary payload to a PeachScript string (one byte per code point). */ export function encodePeachScript(bytes: Uint8Array): string { if (bytes.length * UTF8_BYTES_PER_CODE_POINT > IMPORT_LIMITS.MAX_JSON_BYTES) { throw new Error("PEACHSCRIPT_TOO_LARGE"); } const out: string[] = []; for (let i = 0; i < bytes.length; i++) { out.push(String.fromCodePoint(BASE + bytes[i])); } return out.join(""); } /** * PB-26-208: the single bounded scanner shared by decode and validate. * * The raw input is capped at MAX_RAW_PEACHSCRIPT (glyphs + whitespace) by * length alone — a whitespace-padded blob is rejected before any per-code * point work. The scan then skips ASCII whitespace, validates every other * code point against the PUA glyph range, and throws PEACHSCRIPT_TOO_LARGE * as soon as either the whitespace allowance or the decoded glyph ceiling is * crossed. Only after the full pass does the decode mode allocate the * output — sized to the counted glyphs, never above the ceiling. * * `decode` selects throwing + byte output; `!decode` returns false on any * rejection (used by isPeachScript so the import UI's validity gate never * throws on hostile input). */ function scanPeachScript(str: string, decode: false): boolean; function scanPeachScript(str: string, decode: true): Uint8Array; function scanPeachScript(str: string, decode: boolean): Uint8Array | boolean { if (str.length > MAX_RAW_PEACHSCRIPT) { if (decode) throw new Error("PEACHSCRIPT_TOO_LARGE"); return false; } let glyphCount = 0; let wsCount = 0; // Pass 1: validate + count. No output allocation — the ceiling is enforced // here, so the output buffer is only ever allocated within it. for (let i = 0; i < str.length; ) { const cp = str.codePointAt(i)!; const step = cp > 0xffff ? 2 : 1; if (isPeachScriptWhitespace(cp)) { wsCount += 1; if (wsCount > PEACHSCRIPT_WHITESPACE_ALLOWANCE) { if (decode) throw new Error("PEACHSCRIPT_TOO_LARGE"); return false; } } else if (cp >= BASE && cp <= BASE + 0xff) { glyphCount += 1; if (glyphCount > MAX_PEACHSCRIPT_CODE_POINTS) { if (decode) throw new Error("PEACHSCRIPT_TOO_LARGE"); return false; } } else { if (decode) throw new Error("INVALID_PEACHSCRIPT_CHARACTER"); return false; } i += step; } if (!decode) return glyphCount > 0; // Pass 2: fill the now-bounded buffer. Every code point was validated in // pass 1, so this is a pure copy of PUA glyphs to bytes. const bytes = new Uint8Array(glyphCount); let bi = 0; for (let i = 0; i < str.length; ) { const cp = str.codePointAt(i)!; const step = cp > 0xffff ? 2 : 1; if (cp >= BASE && cp <= BASE + 0xff) { bytes[bi++] = cp - BASE; } i += step; } return bytes; } /** * Decode a PeachScript string back to bytes. Whitespace (ASCII space/tab/ * CR/LF/VT/FF) is skipped, so pasted text with editor line-wrapping decodes * without a separate normalisation step. Throws `PEACHSCRIPT_TOO_LARGE` * when the raw input exceeds the glyph ceiling plus the whitespace * allowance, and `INVALID_PEACHSCRIPT_CHARACTER` when any non-whitespace * code point is outside U+E000..U+E0FF — with no partial output. */ export function decodePeachScript(str: string): Uint8Array { return scanPeachScript(str, true); } /** Strip whitespace from a PeachScript string before decoding. * Kept for backward compatibility; decodePeachScript now skips whitespace * internally (PB-26-208), so callers no longer need to pre-normalise. */ export function normalisePeachScript(str: string): string { return str.replace(/\s+/g, ""); } /** * Cheap pre-flight: every non-whitespace char must be in the PUA range and * the string must be non-empty. Uses the same bounded scanner as decode so * the import UI's validity gate agrees with the decoder on oversized or * hostile input (returns false instead of throwing). */ export function isPeachScript(str: string): boolean { return scanPeachScript(str, false); }