import { createHash } from 'node:crypto'; export const APPDATA_ARCHIVE_VERSION = 1; export const APPDATA_ARCHIVE_CHECKSUM_ALGORITHM = 'sha256'; export function stableStringify(value) { return JSON.stringify(sortJsonValue(value)); } export function sha256Hex(value) { return createHash('sha256').update(String(value)).digest('hex'); } export function buildAppDataArchive(rows, options = {}) { const normalizedRows = [...rows] .sort((a, b) => String(a.key).localeCompare(String(b.key))) .map((row) => { const canonicalValue = stableStringify(row.value); return { key: String(row.key), value: row.value, version: toIsoString(row.updatedAt), valueChecksum: sha256Hex(canonicalValue), bytes: Buffer.byteLength(canonicalValue), }; }); const keys = normalizedRows.map((row) => row.key); const metadata = { archiveVersion: APPDATA_ARCHIVE_VERSION, exportedAt: options.exportedAt ?? new Date().toISOString(), appVersion: options.appVersion ?? 'unknown', appBuildTime: options.appBuildTime ?? null, sourceCommit: options.sourceCommit ?? 'unknown', rowCount: normalizedRows.length, keyCount: keys.length, checksumAlgorithm: APPDATA_ARCHIVE_CHECKSUM_ALGORITHM, keyListChecksum: sha256Hex(stableStringify(keys)), payloadChecksum: '', }; const archive = { metadata, keys, rows: normalizedRows }; archive.metadata.payloadChecksum = checksumPayload(archive); return archive; } export function verifyAppDataArchive(archive) { const errors = []; if (!archive || typeof archive !== 'object') { return { ok: false, errors: ['Archive must be a JSON object'], summary: emptySummary() }; } const metadata = archive.metadata; const keys = Array.isArray(archive.keys) ? archive.keys : []; const rows = Array.isArray(archive.rows) ? archive.rows : []; if (!metadata || typeof metadata !== 'object') { errors.push('Missing metadata object'); } else { if (metadata.archiveVersion !== APPDATA_ARCHIVE_VERSION) { errors.push(`Unsupported archiveVersion: ${metadata.archiveVersion}`); } if (metadata.checksumAlgorithm !== APPDATA_ARCHIVE_CHECKSUM_ALGORITHM) { errors.push(`Unsupported checksumAlgorithm: ${metadata.checksumAlgorithm}`); } if (metadata.rowCount !== rows.length) { errors.push(`rowCount mismatch: metadata=${metadata.rowCount}, actual=${rows.length}`); } if (metadata.keyCount !== keys.length) { errors.push(`keyCount mismatch: metadata=${metadata.keyCount}, actual=${keys.length}`); } } const rowKeys = rows.map((row) => row?.key); if (stableStringify(keys) !== stableStringify(rowKeys)) { errors.push('Key list does not match row keys'); } const keyListChecksum = sha256Hex(stableStringify(keys)); if (metadata?.keyListChecksum !== keyListChecksum) { errors.push('Key list checksum mismatch'); } for (const row of rows) { if (!row || typeof row !== 'object') { errors.push('Archive contains a non-object row'); continue; } if (typeof row.key !== 'string') { errors.push('Archive row is missing string key'); } if (typeof row.version !== 'string') { errors.push(`Archive row ${row.key ?? ''} is missing string version`); } const canonicalValue = stableStringify(row.value); const valueChecksum = sha256Hex(canonicalValue); if (row.valueChecksum !== valueChecksum) { errors.push(`Value checksum mismatch for ${row.key}`); } const bytes = Buffer.byteLength(canonicalValue); if (row.bytes !== bytes) { errors.push(`Byte size mismatch for ${row.key}`); } } const payloadChecksum = checksumPayload({ keys, rows }); if (metadata?.payloadChecksum !== payloadChecksum) { errors.push('Payload checksum mismatch'); } return { ok: errors.length === 0, errors, summary: { archiveVersion: metadata?.archiveVersion ?? null, exportedAt: metadata?.exportedAt ?? null, appVersion: metadata?.appVersion ?? null, sourceCommit: metadata?.sourceCommit ?? null, rowCount: rows.length, keyCount: keys.length, keys, payloadChecksum, }, }; } export function checksumPayload(archive) { return sha256Hex(stableStringify({ keys: archive.keys, rows: archive.rows, })); } function sortJsonValue(value) { if (Array.isArray(value)) return value.map(sortJsonValue); if (!value || typeof value !== 'object') return value; return Object.fromEntries( Object.entries(value) .filter(([, item]) => item !== undefined) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, item]) => [key, sortJsonValue(item)]), ); } function toIsoString(value) { if (value instanceof Date) return value.toISOString(); const date = new Date(value); if (Number.isFinite(date.getTime())) return date.toISOString(); throw new Error(`Invalid AppData updatedAt value: ${value}`); } function emptySummary() { return { archiveVersion: null, exportedAt: null, appVersion: null, sourceCommit: null, rowCount: 0, keyCount: 0, keys: [], payloadChecksum: null, }; }