import { z } from 'zod'; import { crc32hex, canonicalJSON } from './checksum'; /** * The save envelope shape, locked by CLAUDE.md "Code Style": * `{schemaVersion, payload, checksum}` * * `schemaVersion` is `nonnegative` (NOT `positive`) because CONTEXT D-05 * declares the synthetic v0 era — see migrations.ts. RESEARCH Pattern 1's * example uses `positive` but that conflicts with D-05's requirement. */ export const SaveEnvelopeSchema = z.object({ schemaVersion: z.number().int().nonnegative(), payload: z.unknown(), checksum: z.string().regex(/^[0-9a-f]{8}$/), }); export type SaveEnvelope = { schemaVersion: number; payload: T; checksum: string; }; /** * Thrown by `unwrap` when the envelope's stored checksum disagrees with * the recomputed checksum of the payload. Phase 2's settings UI surfaces * this with the recovery option (load from `save_snapshots` per CORE-08). * * NOT a cryptographic guarantee — see threat-model T-01-03 in the plan. * A player editing their own save is acceptable in single-player; this * detects lossy-storage corruption, not adversarial editing. */ export class SaveCorruptError extends Error { override readonly name = 'SaveCorruptError'; constructor( public readonly expected: string, public readonly actual: string, ) { super(`Save checksum mismatch: expected ${expected}, got ${actual}`); } } /** * Wrap a payload in an envelope at the given schema version. Computes the * checksum over the canonical-JSON serialization of the payload so that * key order does not affect the checksum (per RESEARCH Pitfall 3). */ export function wrap(payload: T, schemaVersion: number): SaveEnvelope { return { schemaVersion, payload, checksum: crc32hex(canonicalJSON(payload)), }; } /** * Unwrap an envelope, verifying the checksum. Throws `SaveCorruptError` * when the payload's recomputed checksum does not match the envelope's * stored checksum. * * The `expected` field on the error is the value the envelope ARRIVED with * (what the storage layer expected to be authoritative); `actual` is the * value computed from the payload as decoded. Phase 2's recovery UI shows * this delta so the user can choose between rolling back to a snapshot * or accepting the (presumably-tampered) payload as-is. */ export function unwrap(env: SaveEnvelope): T { const computed = crc32hex(canonicalJSON(env.payload)); if (computed !== env.checksum) { throw new SaveCorruptError(env.checksum, computed); } return env.payload as T; }