peachScript.ts
1/**2 * peachScript.ts — PeachScript encoding/decoding.3 *4 * PeachScript is the device-to-device export format. Each byte of an5 * encrypted vault payload maps to one Unicode Private Use Area code point6 * in U+E000..U+E0FF. The bundled `PeachScript-Regular.otf` renders those7 * 256 code points as the visual script. Outside Peach the string renders8 * as boxes — that is the desired property: PeachScript is meant to be9 * read by Peach, not by humans.10 *11 * The encoding is trivially reversible: byte 0 → U+E000, byte 255 →12 * U+E0FF. No padding, no base conversion, no alignment fix-ups. The font13 * does the visual work; this module is the codec.14 */1516const BASE = 0xe000;1718// PB-26-010: decode allocates one byte per code point, so bound the input at19// the same 10 MiB ceiling parseImportSource applies to CSV/JSON — otherwise a20// pasted or loaded string allocates without bound before any validation.21import { IMPORT_LIMITS } from "../shared/import-limits";2223/**24 * Every encoded code point lands in U+E000..U+E0FF, which UTF-8 encodes as25 * exactly three bytes.26 *27 * PB-26-113: the importer bounds the FILE, so the export ceiling has to be28 * measured in encoded bytes, not envelope bytes. Encoding an N-byte envelope29 * produced a 3N-byte .peach file, so a 4 MiB vault exported a ~12 MiB backup30 * that Peach's own importer then rejected outright — a backup that fails only31 * when it is needed, years later.32 */33const UTF8_BYTES_PER_CODE_POINT = 3;3435// PB-26-208: the decoded ceiling in code points. Matches the export ceiling —36// floor(MAX_JSON_BYTES / 3) glyphs each cost 3 UTF-8 bytes.37const MAX_PEACHSCRIPT_CODE_POINTS = Math.floor(38 IMPORT_LIMITS.MAX_JSON_BYTES / UTF8_BYTES_PER_CODE_POINT,39);4041// PB-26-208: a small explicit allowance for whitespace picked up by pasted42// text (editor line-wrapping, trailing newlines). Peach-generated scripts43// contain no bulk whitespace, so anything past this is hostile padding.44const PEACHSCRIPT_WHITESPACE_ALLOWANCE = 64 * 1024;4546// Hard cap on the raw input length (glyphs + whitespace) checked before any47// scan or allocation. A whitespace-only blob over this is rejected as too48// large without touching the string beyond its length.49const MAX_RAW_PEACHSCRIPT =50 MAX_PEACHSCRIPT_CODE_POINTS + PEACHSCRIPT_WHITESPACE_ALLOWANCE;5152/**53 * Whitespace the bounded scanner skips. Deliberately ASCII-only: BOM54 * (U+FEFF), ZWNJ/ZWJ, RTL/LTR marks and other Unicode space-class code points55 * stay INVALID so the fuzz-corpus rejection tests keep holding. Real pasted56 * text only carries spaces, tabs, CR, LF.57 */58function isPeachScriptWhitespace(cp: number): boolean {59 return cp === 0x20 || (cp >= 0x09 && cp <= 0x0d);60}6162/** Encode a binary payload to a PeachScript string (one byte per code point). */63export function encodePeachScript(bytes: Uint8Array): string {64 if (bytes.length * UTF8_BYTES_PER_CODE_POINT > IMPORT_LIMITS.MAX_JSON_BYTES) {65 throw new Error("PEACHSCRIPT_TOO_LARGE");66 }67 const out: string[] = [];68 for (let i = 0; i < bytes.length; i++) {69 out.push(String.fromCodePoint(BASE + bytes[i]));70 }71 return out.join("");72}7374/**75 * PB-26-208: the single bounded scanner shared by decode and validate.76 *77 * The raw input is capped at MAX_RAW_PEACHSCRIPT (glyphs + whitespace) by78 * length alone — a whitespace-padded blob is rejected before any per-code79 * point work. The scan then skips ASCII whitespace, validates every other80 * code point against the PUA glyph range, and throws PEACHSCRIPT_TOO_LARGE81 * as soon as either the whitespace allowance or the decoded glyph ceiling is82 * crossed. Only after the full pass does the decode mode allocate the83 * output — sized to the counted glyphs, never above the ceiling.84 *85 * `decode` selects throwing + byte output; `!decode` returns false on any86 * rejection (used by isPeachScript so the import UI's validity gate never87 * throws on hostile input).88 */89function scanPeachScript(str: string, decode: false): boolean;90function scanPeachScript(str: string, decode: true): Uint8Array;91function scanPeachScript(str: string, decode: boolean): Uint8Array | boolean {92 if (str.length > MAX_RAW_PEACHSCRIPT) {93 if (decode) throw new Error("PEACHSCRIPT_TOO_LARGE");94 return false;95 }9697 let glyphCount = 0;98 let wsCount = 0;99 // Pass 1: validate + count. No output allocation — the ceiling is enforced100 // here, so the output buffer is only ever allocated within it.101 for (let i = 0; i < str.length; ) {102 const cp = str.codePointAt(i)!;103 const step = cp > 0xffff ? 2 : 1;104 if (isPeachScriptWhitespace(cp)) {105 wsCount += 1;106 if (wsCount > PEACHSCRIPT_WHITESPACE_ALLOWANCE) {107 if (decode) throw new Error("PEACHSCRIPT_TOO_LARGE");108 return false;109 }110 } else if (cp >= BASE && cp <= BASE + 0xff) {111 glyphCount += 1;112 if (glyphCount > MAX_PEACHSCRIPT_CODE_POINTS) {113 if (decode) throw new Error("PEACHSCRIPT_TOO_LARGE");114 return false;115 }116 } else {117 if (decode) throw new Error("INVALID_PEACHSCRIPT_CHARACTER");118 return false;119 }120 i += step;121 }122123 if (!decode) return glyphCount > 0;124125 // Pass 2: fill the now-bounded buffer. Every code point was validated in126 // pass 1, so this is a pure copy of PUA glyphs to bytes.127 const bytes = new Uint8Array(glyphCount);128 let bi = 0;129 for (let i = 0; i < str.length; ) {130 const cp = str.codePointAt(i)!;131 const step = cp > 0xffff ? 2 : 1;132 if (cp >= BASE && cp <= BASE + 0xff) {133 bytes[bi++] = cp - BASE;134 }135 i += step;136 }137 return bytes;138}139140/**141 * Decode a PeachScript string back to bytes. Whitespace (ASCII space/tab/142 * CR/LF/VT/FF) is skipped, so pasted text with editor line-wrapping decodes143 * without a separate normalisation step. Throws `PEACHSCRIPT_TOO_LARGE`144 * when the raw input exceeds the glyph ceiling plus the whitespace145 * allowance, and `INVALID_PEACHSCRIPT_CHARACTER` when any non-whitespace146 * code point is outside U+E000..U+E0FF — with no partial output.147 */148export function decodePeachScript(str: string): Uint8Array {149 return scanPeachScript(str, true);150}151152/** Strip whitespace from a PeachScript string before decoding.153 * Kept for backward compatibility; decodePeachScript now skips whitespace154 * internally (PB-26-208), so callers no longer need to pre-normalise. */155export function normalisePeachScript(str: string): string {156 return str.replace(/\s+/g, "");157}158159/**160 * Cheap pre-flight: every non-whitespace char must be in the PUA range and161 * the string must be non-empty. Uses the same bounded scanner as decode so162 * the import UI's validity gate agrees with the decoder on oversized or163 * hostile input (returns false instead of throwing).164 */165export function isPeachScript(str: string): boolean {166 return scanPeachScript(str, false);167}