feat(01-03): save envelope + canonical-JSON CRC32 + synthetic v0->v1 migration [GREEN]

- checksum.ts: crc32hex (8-char lowercase hex of CRC-32, signed->unsigned via >>>0)
  + canonicalJSON (recursive object-key sort, arrays preserved) per Pitfall 3
- envelope.ts: wrap/unwrap with SaveCorruptError on checksum mismatch + Zod
  SaveEnvelopeSchema accepting nonnegative schemaVersion (allows synthetic v0)
- migrations.ts: forward-only registry with migrations[1] producing the v1
  shape from CONTEXT D-04 (garden.tiles, plants, harvestedFragmentIds,
  lastTickAt, settings); throws on negative or future-version inputs

Removes src/save/.gitkeep firewall marker (real source files now live here).

Tests: 21/21 pass (npx vitest run src/save/checksum.test.ts
src/save/envelope.test.ts src/save/migrations.test.ts).
TypeScript-strict; no 'any' in production code (CLAUDE.md).
This commit is contained in:
2026-05-08 23:28:56 -04:00
parent 445a46139f
commit b6cc9000c3
3 changed files with 211 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
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<T = unknown> = {
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<T>(payload: T, schemaVersion: number): SaveEnvelope<T> {
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<T>(env: SaveEnvelope<unknown>): T {
const computed = crc32hex(canonicalJSON(env.payload));
if (computed !== env.checksum) {
throw new SaveCorruptError(env.checksum, computed);
}
return env.payload as T;
}