peachScript.ts

6.3 KB · raw

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/**153 * Cheap pre-flight: every non-whitespace char must be in the PUA range and154 * the string must be non-empty. Uses the same bounded scanner as decode so155 * the import UI's validity gate agrees with the decoder on oversized or156 * hostile input (returns false instead of throwing).157 */158export function isPeachScript(str: string): boolean {159  return scanPeachScript(str, false);160}