feat(appdata): 增加归档导出校验脚本
This commit is contained in:
161
scripts/lib/appdata-archive.mjs
Normal file
161
scripts/lib/appdata-archive.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
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 ?? '<unknown>'} 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user