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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user