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,38 @@
|
||||
import CRC32 from 'crc-32';
|
||||
|
||||
/**
|
||||
* 8-char lowercase hex CRC-32 of the input string.
|
||||
* crc-32 returns a signed 32-bit integer; we mask to unsigned and pad.
|
||||
* Used by envelope.wrap/unwrap to detect save corruption (lossy storage,
|
||||
* partial writes, browser-eviction truncation).
|
||||
*/
|
||||
export function crc32hex(input: string): string {
|
||||
const signed = CRC32.str(input);
|
||||
const unsigned = signed >>> 0; // coerce to uint32
|
||||
return unsigned.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic JSON serialization with recursively-sorted object keys.
|
||||
* Required because checksum stability depends on stable key order across
|
||||
* V8 / SpiderMonkey / JavaScriptCore runs and across migration round-trips
|
||||
* (per .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md Pitfall 3).
|
||||
*
|
||||
* Arrays are NOT sorted — their order is meaningful (a garden tile list,
|
||||
* a timeline of harvested fragments). Only plain object keys are reordered.
|
||||
*
|
||||
* Hand-rolled rather than pulling in `json-stable-stringify` per RESEARCH
|
||||
* Open Question #1: ~10 LoC saves a dependency.
|
||||
*/
|
||||
export function canonicalJSON(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, val) => {
|
||||
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(val as Record<string, unknown>).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
),
|
||||
);
|
||||
}
|
||||
return val;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Forward-only save migration registry.
|
||||
*
|
||||
* Each entry `migrations[N]` is the function that migrates payload from
|
||||
* schema version N-1 to schema version N. Phase 1 ships migrations[1]
|
||||
* (the synthetic v0 → v1 demo per CONTEXT D-05); Phase 4 will land
|
||||
* migrations[2] when prestige / Roothold state lands.
|
||||
*
|
||||
* The v1 shape (from CONTEXT D-04) is intentionally minimal: only what
|
||||
* Phase 2's first feature commit will write. Authoring it now lets us
|
||||
* prove the migration chain end-to-end without speculating about future
|
||||
* Season 5+ structures.
|
||||
*/
|
||||
|
||||
type Migration = (payload: unknown) => unknown;
|
||||
|
||||
export const CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
interface V0Payload {
|
||||
garden?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimal v1 save shape per CONTEXT D-04: garden tiles, plant growth
|
||||
* data placeholder, harvested fragment IDs, last tick timestamp, settings.
|
||||
* Phase 2 fleshes the contents; Phase 1 just locks the field set.
|
||||
*/
|
||||
export interface V1Payload {
|
||||
garden: { tiles: unknown[] };
|
||||
plants: unknown[];
|
||||
harvestedFragmentIds: string[];
|
||||
lastTickAt: number;
|
||||
settings: {
|
||||
musicVolume: number;
|
||||
ambientVolume: number;
|
||||
sfxVolume: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward-only migration chain. Keys are TARGET versions; the function
|
||||
* at key N migrates FROM N-1 TO N.
|
||||
*
|
||||
* - `migrations[1]` = v0 → v1 (synthetic demo per CONTEXT D-05).
|
||||
* - `migrations[2]` = v1 → v2 will be added in Phase 4 when Roothold /
|
||||
* prestige state lands.
|
||||
*/
|
||||
export const migrations: Record<number, Migration> = {
|
||||
1: (s: unknown): V1Payload => {
|
||||
const v0 = (s ?? {}) as V0Payload;
|
||||
return {
|
||||
garden: { tiles: v0.garden ?? [] },
|
||||
plants: [],
|
||||
harvestedFragmentIds: [],
|
||||
lastTickAt: Date.now(),
|
||||
settings: {
|
||||
musicVolume: 0.7,
|
||||
ambientVolume: 0.5,
|
||||
sfxVolume: 0.8,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate `payload` from `fromVersion` up to `CURRENT_SCHEMA_VERSION`,
|
||||
* applying each registered migration in order. Returns both the migrated
|
||||
* payload and the schema version it now matches.
|
||||
*
|
||||
* Throws when:
|
||||
* - `fromVersion` is negative (invalid input)
|
||||
* - `fromVersion` is greater than `CURRENT_SCHEMA_VERSION` (future save
|
||||
* from a newer build of the game — refuse to silently downgrade)
|
||||
* - any required migration function is missing
|
||||
*/
|
||||
export function migrate(
|
||||
payload: unknown,
|
||||
fromVersion: number,
|
||||
): { payload: unknown; toVersion: number } {
|
||||
if (fromVersion < 0) {
|
||||
throw new Error(`Cannot migrate from negative version ${fromVersion}`);
|
||||
}
|
||||
if (fromVersion > CURRENT_SCHEMA_VERSION) {
|
||||
throw new Error(
|
||||
`Cannot migrate from future version ${fromVersion} (current: ${CURRENT_SCHEMA_VERSION})`,
|
||||
);
|
||||
}
|
||||
let current = payload;
|
||||
let v = fromVersion;
|
||||
while (v < CURRENT_SCHEMA_VERSION) {
|
||||
const next = v + 1;
|
||||
const fn = migrations[next];
|
||||
if (!fn) {
|
||||
throw new Error(`No migration registered for v${v} → v${next}`);
|
||||
}
|
||||
current = fn(current);
|
||||
v = next;
|
||||
}
|
||||
return { payload: current, toVersion: v };
|
||||
}
|
||||
Reference in New Issue
Block a user