mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-28 00:12:04 +00:00
- Snapshot model parity (kinds 30070/30071/30072): gzip chunking via platform CompressionStream, chunk-hash verification, reassembly of Python-produced chunks, manifest JSON schema, manifest id HMAC-SHA256(key_index, 'manifest|'+nonce) with the key_index HKDF chain. - Full merge_index_payloads port (modified_ts_hash_tombstone_v2): Python-compatible canonical JSON (sort_keys + ensure_ascii) for hash parity, equal-ts field-level union, tombstone create/prefer/apply/cap, _sync_meta bookkeeping. Populated _system.index0 content refuses to merge loudly (atlas-milestone work) instead of silently dropping data. - Delta replay: encrypted V3 deltas decrypt and merge in order with source_tag = sha256(payload)[:16]; final state matches Python. - New fixtures: nostr_snapshot.json, sync_merge.json (8 scenarios), delta_replay.json; generator remains byte-deterministic. - 117 parity tests green in Node and jsdom. Co-Authored-By: Claude Fable 5 <[email protected]>
124 lines
4.6 KiB
TypeScript
124 lines
4.6 KiB
TypeScript
/**
|
|
* Nostr sync parity: snapshot chunking/reassembly, manifest identity,
|
|
* deterministic conflict merge, tombstones, and full delta replay.
|
|
* Expected values are Python-generated fixtures.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { base64 } from "@scure/base";
|
|
import {
|
|
nostrSnapshot,
|
|
syncMergeCases,
|
|
deltaReplay,
|
|
mnemonics,
|
|
} from "@seedpass/test-vectors";
|
|
import {
|
|
parseManifest,
|
|
prepareSnapshot,
|
|
reassembleSnapshot,
|
|
gzipDecompress,
|
|
deriveKeyIndex,
|
|
manifestIdFromNonce,
|
|
mergeIndexPayloads,
|
|
decryptV3,
|
|
deriveIndexKeyBytes,
|
|
bytesToHex,
|
|
hexToBytes,
|
|
ChunkVerificationError,
|
|
KIND_MANIFEST,
|
|
KIND_SNAPSHOT_CHUNK,
|
|
KIND_DELTA,
|
|
} from "@seedpass/core";
|
|
|
|
describe("snapshot chunking", () => {
|
|
it("uses the agreed event kinds", () => {
|
|
expect(KIND_MANIFEST).toBe(nostrSnapshot.event_kinds.manifest);
|
|
expect(KIND_SNAPSHOT_CHUNK).toBe(nostrSnapshot.event_kinds.snapshot_chunk);
|
|
expect(KIND_DELTA).toBe(nostrSnapshot.event_kinds.delta);
|
|
});
|
|
|
|
it("parses the Python manifest JSON", () => {
|
|
const manifest = parseManifest(nostrSnapshot.manifest_json);
|
|
expect(manifest.ver).toBe(1);
|
|
expect(manifest.algo).toBe("gzip");
|
|
expect(manifest.chunks).toEqual(nostrSnapshot.chunk_metas);
|
|
expect(manifest.nonce).toBe(nostrSnapshot.manifest_nonce_b64);
|
|
});
|
|
|
|
it("reassembles and decompresses the Python-produced chunks", async () => {
|
|
const manifest = parseManifest(nostrSnapshot.manifest_json);
|
|
const chunks = nostrSnapshot.chunks_b64.map((c) => base64.decode(c));
|
|
const encrypted = await reassembleSnapshot(manifest, chunks);
|
|
expect(base64.encode(encrypted)).toBe(nostrSnapshot.encrypted_b64);
|
|
});
|
|
|
|
it("rejects a corrupted chunk", async () => {
|
|
const manifest = parseManifest(nostrSnapshot.manifest_json);
|
|
const chunks = nostrSnapshot.chunks_b64.map((c) => base64.decode(c));
|
|
chunks[0]![0]! ^= 0x01;
|
|
await expect(reassembleSnapshot(manifest, chunks)).rejects.toThrow(
|
|
ChunkVerificationError,
|
|
);
|
|
});
|
|
|
|
it("roundtrips its own compression and hashes chunks like Python", async () => {
|
|
const encrypted = base64.decode(nostrSnapshot.encrypted_b64);
|
|
const { manifest, chunks } = await prepareSnapshot(encrypted, nostrSnapshot.chunk_limit);
|
|
expect(manifest.chunks.length).toBe(chunks.length);
|
|
expect(manifest.chunks[0]!.id).toBe("seedpass-chunk-0000");
|
|
const restored = await reassembleSnapshot(manifest, chunks);
|
|
expect(base64.encode(restored)).toBe(nostrSnapshot.encrypted_b64);
|
|
});
|
|
|
|
it("decompresses Python gzip output directly", async () => {
|
|
const compressed = base64.decode(nostrSnapshot.compressed_b64);
|
|
const out = await gzipDecompress(compressed);
|
|
expect(base64.encode(out)).toBe(nostrSnapshot.encrypted_b64);
|
|
});
|
|
|
|
it("derives the manifest id from key_index and nonce", () => {
|
|
const keyIndex = deriveKeyIndex(mnemonics[nostrSnapshot.mnemonic_id]!);
|
|
expect(bytesToHex(keyIndex)).toBe(nostrSnapshot.key_index_hex);
|
|
const nonce = base64.decode(nostrSnapshot.manifest_nonce_b64);
|
|
expect(manifestIdFromNonce(keyIndex, nonce)).toBe(nostrSnapshot.manifest_id);
|
|
expect(manifestIdFromNonce(hexToBytes(nostrSnapshot.key_index_hex), nonce)).toBe(
|
|
nostrSnapshot.manifest_id,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("deterministic conflict merge", () => {
|
|
it.each(syncMergeCases)("$name", (c) => {
|
|
const merged = mergeIndexPayloads(c.current, c.incoming, c.source_tag);
|
|
expect(merged).toEqual(c.merged);
|
|
});
|
|
|
|
it("is order-insensitive for the newer-wins rule", () => {
|
|
const a = syncMergeCases.find((c) => c.name === "newer-incoming-wins")!;
|
|
const forward = mergeIndexPayloads(a.current, a.incoming, "t") as {
|
|
entries: Record<string, { label: string }>;
|
|
};
|
|
const backward = mergeIndexPayloads(a.incoming, a.current, "t") as {
|
|
entries: Record<string, { label: string }>;
|
|
};
|
|
expect(forward.entries["0"]!.label).toBe(backward.entries["0"]!.label);
|
|
});
|
|
});
|
|
|
|
describe("delta replay", () => {
|
|
it("replays encrypted deltas onto a snapshot to the Python final state", async () => {
|
|
const key = deriveIndexKeyBytes(mnemonics[deltaReplay.mnemonic_id]!);
|
|
let state: unknown = deltaReplay.snapshot_index;
|
|
for (const b64 of deltaReplay.delta_payloads_b64) {
|
|
const encrypted = base64.decode(b64);
|
|
const plaintext = await decryptV3(key, encrypted);
|
|
const incoming = JSON.parse(new TextDecoder().decode(plaintext)) as unknown;
|
|
const digest = bytesToHex(
|
|
(await import("@noble/hashes/sha2.js")).sha256(encrypted),
|
|
);
|
|
state = mergeIndexPayloads(state, incoming, digest.slice(0, 16));
|
|
}
|
|
expect(state).toEqual(deltaReplay.final_state);
|
|
});
|
|
});
|