1import { existsSync, readFileSync, mkdtempSync } from "node:fs";2import { dirname, resolve } from "node:path";3import { fileURLToPath } from "node:url";4import { spawn } from "node:child_process";5import { tmpdir } from "node:os";67const browserRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");8const androidRoot = resolve(browserRoot, "../peach-android");9const localJavaHome = "/opt/homebrew/opt/openjdk@17";10const javaHome = process.env.JAVA_HOME ?? (existsSync(localJavaHome) ? localJavaHome : undefined);11const browserCoordinator = readFileSync(resolve(browserRoot, "src/background/sync-coordinator.ts"), "utf8");12const androidCoordinator = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt"), "utf8");13const browserSyncTypes = readFileSync(resolve(browserRoot, "src/shared/sync-types.ts"), "utf8");14const androidManifest = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt"), "utf8");15const androidSyncMessage = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncMessage.kt"), "utf8");16const browserPairedDevices = readFileSync(resolve(browserRoot, "src/background/paired-devices.ts"), "utf8");17const androidPairedDevices = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt"), "utf8");18const browserAdoption = readFileSync(resolve(browserRoot, "src/shared/adoption-protocol.ts"), "utf8");19const androidAdoption = [20 "AdoptionProtocol.kt",21 "VaultAdopt.kt",22].map((file) => readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync", file), "utf8")).join("\n");23242526if (browserCoordinator.includes('helloIn.version === localVersion')) throw new Error("Browser still contains the forbidden equal-version fast path");27if (!browserCoordinator.includes("helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION")) throw new Error("Browser does not negotiate device manifests from SYNC_HELLO");28if (!androidCoordinator.includes("val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)")) throw new Error("Android does not negotiate device manifests from SYNC_HELLO");29if (!androidCoordinator.includes("peerVersion == DEVICE_MANIFEST_VERSION")) throw new Error("Android does not require an exact device-manifest version match");3031const numericConstant = (source, declaration) => {32 const match = source.match(new RegExp(`${declaration}\\s*=\\s*(0x[0-9a-f]+|\\d+)`));33 if (!match) throw new Error(`Missing ${declaration} declaration`);34 return Number(match[1]);35};36if (numericConstant(browserSyncTypes, "DEVICE_MANIFEST_VERSION") !== numericConstant(androidManifest, "DEVICE_MANIFEST_VERSION")) {37 throw new Error("Browser and Android device-manifest versions differ");38}39if (numericConstant(browserSyncTypes, "DEVICE_REVOCATION_RECORD_VERSION") !== numericConstant(androidSyncMessage, "DEVICE_REVOCATION_RECORD_VERSION")) {40 throw new Error("Browser and Android revocation record versions differ");41}424344for (const field of ["recordVersion", "deviceId", "longTermPublicKeyHex", "revokedByDeviceId"]) {45 if (!browserSyncTypes.includes(field) || !androidSyncMessage.includes(field)) {46 throw new Error(`A client lacks revocation field ${field}`);47 }48}49if (browserSyncTypes.includes("issuerSigningPublicKeyHex") || androidSyncMessage.includes("issuerSigningKeyHex")) {50 throw new Error("A client still carries an issuer key inside the revocation record");51}52const syncProtocolVersion = (source, label) => {53 const match = source.match(/SYNC_PROTOCOL_VERSION\s*=\s*(\d+)/);54 if (!match) throw new Error(`Missing SYNC_PROTOCOL_VERSION declaration in ${label}`);55 return Number(match[1]);56};57const browserProtocolVersion = syncProtocolVersion(browserSyncTypes, "Browser");58const androidProtocolVersion = syncProtocolVersion(androidCoordinator, "Android");59if (browserProtocolVersion !== androidProtocolVersion) {60 throw new Error(`Browser and Android Sync protocol versions differ (${browserProtocolVersion} vs ${androidProtocolVersion})`);61}62if (numericConstant(browserAdoption, "ADOPTION_VERSION") !== numericConstant(androidAdoption, "ADOPTION_VERSION")) {63 throw new Error("Browser and Android adoption transcript versions differ");64}65for (const frame of [66 "ADOPT_REQUEST_V2",67 "ADOPT_CONFIRM_REQUIRED",68 "ADOPT_CONFIRM",69 "ADOPT_PREPARE",70 "ADOPT_READY",71 "ADOPT_GRANT",72 "ADOPT_APPLIED",73 "ADOPT_COMPLETE",74 "ADOPT_REJECT",75]) {76 if (!browserAdoption.includes(frame) || !androidAdoption.includes(frame)) {77 throw new Error(`A client lacks adoption v2 frame ${frame}`);78 }79}8081if (browserCoordinator.includes("protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION") ||82 androidCoordinator.includes("< SYNC_PROTOCOL_VERSION")) {83 throw new Error("A client still accepts a peer protocol version below its own");84}85if (!browserSyncTypes.includes("signatureHex") || !androidSyncMessage.includes("signatureHex")) {86 throw new Error("A client lacks the revocation signature field");87}88if (!browserPairedDevices.includes("MAX_REVOCATIONS = 500") || !androidPairedDevices.includes("MAX_REVOCATION_RECORDS = 500")) throw new Error("Browser and Android revocation ledger caps differ");89if (!browserPairedDevices.includes("enqueueDeviceMutation") || !androidPairedDevices.includes("RegistryMutationGate")) throw new Error("A paired-device registry lacks whole-list mutation serialization");90if (!browserPairedDevices.includes("function capRevocations") || !androidPairedDevices.includes("fun capRevocationRecords")) throw new Error("A client lacks largest-issuer-first revocation retention");91const browserEndpoint = String.raw`92import { createInterface } from "node:readline";93import { createServer, createConnection } from "node:net";94const root = process.env.PEACH_BROWSER_ROOT;95const coordinator = await import(root + "/src/background/sync-coordinator.ts");96const syncTypes = await import(root + "/src/shared/sync-types.ts");97const padding = await import(root + "/src/shared/sync-padding.ts");98const mode = process.env.PEACH_SYNC_HARNESS_MODE;99const portFile = process.env.PEACH_SYNC_HARNESS_PORT_FILE;100const transcriptFile = process.env.PEACH_SYNC_HARNESS_TRANSCRIPT_FILE;101102// Scenario shape arrives entirely from the parent; this endpoint does not know103// what a scenario is. Both clients read the same knobs, so the matrix lives in104// one readable place rather than half here and half in the harness.105const knobs = {106 parity: process.env.PEACH_SYNC_HARNESS_MTIME_PARITY ?? "",107 localVersion: Number(process.env.PEACH_SYNC_HARNESS_LOCAL_VERSION ?? "7"),108 vaultId: process.env.PEACH_SYNC_HARNESS_VAULT_ID ?? "cross-client-vault",109 manifestSupport: process.env.PEACH_SYNC_HARNESS_MANIFEST_VERSION !== "3",110 entryCount: Number(process.env.PEACH_SYNC_HARNESS_ENTRY_COUNT ?? "1"),111 expectStatus: process.env.PEACH_SYNC_HARNESS_EXPECT_STATUS ?? "completed",112 drain: process.env.PEACH_SYNC_HARNESS_DRAIN === "1",113 // A locked session may hold the transport key and stage ciphertext, but it114 // never authors or applies a device manifest and never merges entries into115 // the live vault (PB-26-112 C).116 locked: process.env.PEACH_SYNC_HARNESS_LOCKED === "1",117 stopAfter: process.env.PEACH_SYNC_HARNESS_STOP_AFTER ?? "",118};119120const id = mode === "initiator" ? "browser" : "android";121const socket = await new Promise((resolve, reject) => {122 if (mode === "initiator") {123 const server = createServer((client) => {124 // Stop listening as soon as the one conformance peer connects. Leaving125 // this listener open keeps Bun alive after the coordinator completes.126 server.close();127 resolve(client);128 });129 server.once("error", reject);130 server.listen(0, "127.0.0.1", () => require("node:fs").writeFileSync(portFile, String(server.address().port)));131 } else {132 const client = createConnection({ host: "127.0.0.1", port: Number(process.env.PEACH_SYNC_HARNESS_PORT) });133 client.once("connect", () => resolve(client));134 client.once("error", reject);135 }136});137socket.setEncoding("utf8");138const lines = createInterface({ input: socket });139const iterator = lines[Symbol.asyncIterator]();140const events = [];141const typeOf = (message) => message.type;142let outcome = null;143let counted = { sent: 0, received: 0, applied: 0, staged: 0 };144const fs = require("node:fs");145const writeTranscript = (status) => fs.writeFileSync(transcriptFile, JSON.stringify({146 status,147 error: outcome?.error,148 entriesSent: counted.sent,149 entriesReceived: counted.received,150 applied: counted.applied,151 staged: counted.staged,152 events,153}));154const session = {155 remoteStaticPublicKey: new Uint8Array(32).fill(7),156 send(bytes) {157 const message = JSON.parse(new TextDecoder().decode(bytes));158 events.push("send:" + typeOf(message));159 // Simulated process death: the transcript is on disk before the process160 // goes, so the parent can still say what the peer had seen.161 if (knobs.stopAfter !== "" && knobs.stopAfter === typeOf(message)) {162 writeTranscript("DIED");163 process.exit(7);164 }165 socket.write(Buffer.from(padding.padSyncFrame(bytes)).toString("base64") + "\n");166 },167 async receive() {168 const next = await iterator.next();169 if (next.done) throw new Error("cross-client peer closed");170 const payload = padding.unpadSyncFrame(Uint8Array.from(Buffer.from(next.value.trim(), "base64")));171 events.push("recv:" + typeOf(JSON.parse(new TextDecoder().decode(payload))));172 return payload;173 },174 async close() { socket.end(); },175};176177// Entries are shared ids so a diff can actually flow. Parity decides which side178// is newer for a given index: the same parity on both sides means equal data179// versions (nothing to send), opposite parities means each side is newer for180// half the vault (entries flow both ways).181const ids = Array.from({ length: knobs.entryCount }, (_, index) => "shared-entry-" + index);182const mtimeFor = (index) =>183 knobs.parity === "" ? 10 : (index % 2 === Number(knobs.parity) ? 15 : 10);184const entries = Object.fromEntries(ids.map((entryId, index) => [entryId, mtimeFor(index)]));185const blobs = Object.fromEntries(ids.map((entryId, index) => [entryId, id + "-frame-" + (index % 8)]));186187const adapter = {188 async computeVaultId() { return knobs.vaultId; },189 async getOurDeviceId() { return id; },190 async getLocalVersion() { return knobs.localVersion; },191 async buildEntryManifest() { return { ...entries }; },192 async buildTombstoneManifest() { return {}; },193 async getEncryptedEntries(requested) {194 return Object.fromEntries(requested.filter((value) => value in blobs).map((value) => [value, blobs[value]]));195 },196 async applyEntries(entries) {197 if (knobs.locked) {198 counted.staged += Object.keys(entries).length;199 return;200 }201 counted.applied += Object.keys(entries).length;202 },203 async applyTombstones() {},204 // The coordinator reads capability off the adapter's shape, not a flag: a205 // manifest-v3 peer is one that does not expose the exchange at all. Modelling206 // it any other way would let this fixture advertise a version it cannot speak207 // — the same class of mistake the scenario is here to catch.208 ...(knobs.manifestSupport && !knobs.locked209 ? {210 async buildOutgoingDeviceManifest() {211 return { body: { manifestVersion: syncTypes.DEVICE_MANIFEST_VERSION, issuerDeviceId: id, issuedAt: 1700000000000, devices: [], revoked: [] }, signatureHex: "11".repeat(64) };212 },213 async applyIncomingDeviceManifest() {},214 }215 : {}),216};217218outcome = mode === "initiator"219 ? await coordinator.runSyncInitiator(session, adapter)220 : await coordinator.runSyncResponder(session, adapter);221counted.sent = outcome.entriesSent ?? 0;222counted.received = outcome.entriesReceived ?? 0;223224// A clean stop is only deterministic if our socket is still up when the peer225// writes its last frame, so hold it open until the peer goes away rather than226// exiting the instant the round ends.227if (knobs.drain) {228 await Promise.race([229 (async () => { for await (const _ of lines) { /* drain */ } })(),230 new Promise((resolve) => setTimeout(resolve, 2000)),231 ]);232}233234writeTranscript(outcome.status);235socket.end();236if (outcome.status !== knobs.expectStatus) {237 console.error("expected " + knobs.expectStatus + ", got " + outcome.status);238 process.exit(1);239}240`;241242const MAX_DIAGNOSTIC_BYTES = 12_000;243244function spawnProcess(command, args, env) {245 const child = spawn(command, args, { cwd: env.PEACH_PROCESS_ROOT ?? env.PEACH_BROWSER_ROOT ?? browserRoot, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });246 const output = { stdout: "", stderr: "" };247 for (const [name, stream] of [["stdout", child.stdout], ["stderr", child.stderr]]) {248 stream.setEncoding("utf8");249 stream.on("data", (chunk) => {250 output[name] = (output[name] + chunk).slice(-MAX_DIAGNOSTIC_BYTES);251 });252 }253 child.diagnostics = output;254 return child;255}256257function waitForExit(child) {258 return new Promise((resolveExit, reject) => {259 child.once("error", reject);260 child.once("exit", (code, signal) => resolveExit({ code, signal, diagnostics: child.diagnostics }));261 });262}263264function terminate(child) {265 if (!child || child.exitCode !== null || child.signalCode !== null) return;266 child.kill("SIGTERM");267 setTimeout(() => {268 if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");269 }, 1_000).unref();270}271272function formatResult(result) {273 const output = result.diagnostics;274 return `code=${result.code ?? "none"} signal=${result.signal ?? "none"}; stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`;275}276277async function waitForFile(path, child) {278 const deadline = Date.now() + 30_000;279 while (!existsSync(path)) {280 if (child.exitCode !== null) throw new Error(`endpoint exited before publishing ${path} (${child.exitCode})`);281 if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`);282 await new Promise((resolve) => setTimeout(resolve, 50));283 }284 return readFileSync(path, "utf8").trim();285}286287function readTranscript(path) {288 return JSON.parse(readFileSync(path, "utf8"));289}290291const androidTask = ["--no-daemon", "--no-configuration-cache", "--rerun-tasks", "testDebugUnitTest", "--tests", "com.peachpasswords.sync.CrossClientSyncProcessTest.exchange_production_frames_with_browser_process"];292const gradleProperty = (name, value) => `-D${name.toLowerCase().replaceAll("_", ".")}=${value}`;293const KNOB_PREFIX = "PEACH_SYNC_HARNESS_";294const knobEnv = (knobs) =>295 Object.fromEntries(Object.entries(knobs).map(([name, value]) => [KNOB_PREFIX + name, String(value)]));296297298299300301302async function runRound({ initiator, android = {}, browser = {}, timeoutMs = 180_000, label = "", expectFailure = [] }) {303 const dir = mkdtempSync(resolve(tmpdir(), "peach-cross-sync-"));304 const androidTranscript = resolve(dir, "android.json");305 const browserTranscript = resolve(dir, "browser.json");306 const androidPortFile = resolve(dir, "android.port");307 const browserPortFile = resolve(dir, "browser.port");308 const common = { PEACH_BROWSER_ROOT: browserRoot };309 const launchBrowser = (mode, port, transcript, portFile) => spawnProcess("bun", ["--eval", browserEndpoint], {310 ...common,311 ...knobEnv(browser),312 PEACH_SYNC_HARNESS_MODE: mode,313 PEACH_SYNC_HARNESS_PORT: port ?? "",314 PEACH_SYNC_HARNESS_PORT_FILE: portFile ?? "",315 PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: transcript,316 });317 const launchAndroid = (mode, port) => spawnProcess(resolve(androidRoot, "gradlew"), [318 gradleProperty("PEACH_SYNC_HARNESS_MODE", mode),319 ...androidTask,320 ], {321 ...common,322 ...knobEnv(android),323 PEACH_PROCESS_ROOT: androidRoot,324 PEACH_SYNC_HARNESS_MODE: mode,325 PEACH_SYNC_HARNESS_PORT: port ?? "",326 PEACH_SYNC_HARNESS_PORT_FILE: androidPortFile,327 PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: androidTranscript,328 ...(javaHome ? { JAVA_HOME: javaHome } : {}),329 });330331 let first;332 let second;333 let timeoutId;334 try {335 if (initiator === "android") {336 first = launchAndroid("initiator");337 const port = await waitForFile(androidPortFile, first);338 second = launchBrowser("responder", port, browserTranscript);339 } else {340 first = launchBrowser("initiator", undefined, browserTranscript, browserPortFile);341 const port = await waitForFile(browserPortFile, first);342 second = launchAndroid("responder", port);343 }344 const timeout = new Promise((_, reject) => {345 timeoutId = setTimeout(346 () => reject(new Error(`${label} timed out after ${Math.round(timeoutMs / 1000)}s`)),347 timeoutMs,348 );349 });350 const results = await Promise.race([351 Promise.all([waitForExit(first), waitForExit(second)]),352 timeout,353 ]);354 const [firstResult, secondResult] = results;355 356 357 const initiatorSide = initiator;358 const responderSide = initiator === "android" ? "browser" : "android";359 const tolerate = (side, result) => expectFailure.includes(side) || result.code === 0;360 if (!tolerate(initiatorSide, firstResult) || !tolerate(responderSide, secondResult)) {361 throw new Error(`${label} failed: initiator(${initiatorSide}) ${formatResult(firstResult)}; responder(${responderSide}) ${formatResult(secondResult)}`);362 }363 return { android: readTranscript(androidTranscript), browser: readTranscript(browserTranscript) };364 } catch (error) {365 const details = [first, second].filter(Boolean).map((child) => child.diagnostics)366 .map((output) => `stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`).join("; ");367 throw new Error(`${error.message}; child diagnostics: ${details}`, { cause: error });368 } finally {369 clearTimeout(timeoutId);370 terminate(first);371 terminate(second);372 }373}374375376function expectedRound({ flow, manifests }) {377 return {378 initiator: [379 "send:SYNC_HELLO", "recv:SYNC_HELLO",380 "send:SYNC_MANIFEST", "recv:SYNC_MANIFEST",381 "send:SYNC_REQUEST", "recv:SYNC_REQUEST",382 "send:SYNC_ENTRIES", ...(flow ? ["recv:SYNC_ENTRIES"] : []),383 "send:SYNC_TOMBSTONES", "recv:SYNC_TOMBSTONES",384 ...(manifests ? ["send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DEVICE_MANIFEST"] : []),385 "send:SYNC_DONE", "recv:SYNC_DONE",386 ],387 responder: [388 "recv:SYNC_HELLO", "send:SYNC_HELLO",389 "recv:SYNC_MANIFEST", "send:SYNC_MANIFEST",390 "recv:SYNC_REQUEST", "send:SYNC_REQUEST",391 "recv:SYNC_ENTRIES", ...(flow ? ["send:SYNC_ENTRIES"] : []),392 "recv:SYNC_TOMBSTONES", "send:SYNC_TOMBSTONES",393 ...(manifests ? ["recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DEVICE_MANIFEST"] : []),394 "recv:SYNC_DONE", "send:SYNC_DONE",395 ],396 };397}398399const normalise = (status) => String(status).toLowerCase();400const allEvents = (round) => [...round.android.events, ...round.browser.events];401402function assertTranscript(actual, expected, label) {403 const got = JSON.stringify(actual.events);404 const want = JSON.stringify(expected);405 if (got !== want) throw new Error(`${label} transcript mismatch: expected ${want}, got ${got}`);406}407408function assertCompleted(round, { flow, manifests, label }) {409 const expected = expectedRound({ flow, manifests });410 for (const side of ["android", "browser"]) {411 const status = normalise(round[side].status);412 if (status !== "completed") throw new Error(`${label}: ${side} ended ${round[side].status} (${round[side].error ?? "no error"})`);413 const role = side === (round.initiatedBy === "android" ? "android" : "browser");414 assertTranscript(round[side], expected[role], `${label}: ${side}`);415 }416}417418419420421422const SCENARIOS = [423 {424 id: 1,425 name: "equal versions, both unlocked, both manifest-v5",426 initiator: "android",427 428 android: { MTIME_PARITY: "0", EXPECT_STATUS: "COMPLETED" },429 browser: { MTIME_PARITY: "0", EXPECT_STATUS: "completed" },430 check(round, { completed, transcript }) {431 completed(round, { flow: false, manifests: true, label: "scenario 1" });432 433 434 transcript(round, "scenario 1", (t) => t.events.includes("send:SYNC_MANIFEST"));435 },436 },437 {438 id: 2,439 name: "unequal data versions, both unlocked, both manifest-v5",440 initiator: "android",441 442 443 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },444 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },445 check(round, { completed, expect }) {446 completed(round, { flow: true, manifests: true, label: "scenario 2" });447 expect(round.android.entriesSent > 0 && round.android.entriesReceived > 0, "scenario 2: entries must flow both ways");448 expect(round.browser.entriesSent > 0 && round.browser.entriesReceived > 0, "scenario 2: entries must flow both ways");449 },450 },451 {452 id: 3,453 name: "equal versions, one manifest-v5 and one manifest-v3",454 initiator: "android",455 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },456 browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "completed" },457 check(round, { completed, expect }) {458 completed(round, { flow: false, manifests: false, label: "scenario 3" });459 expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 3: the manifest step must be skipped");460 },461 },462 {463 id: 4,464 name: "unequal data versions, one manifest-v5 and one manifest-v3",465 initiator: "browser",466 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "COMPLETED" },467 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },468 check(round, { completed, expect }) {469 completed(round, { flow: true, manifests: false, label: "scenario 4" });470 expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 4: the manifest step must be skipped");471 },472 },473 {474 id: 9,475 name: "manifest negotiation: no peer sends SYNC_DEVICE_MANIFEST unilaterally",476 initiator: "android",477 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },478 browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "completed" },479 check(round, { expect }) {480 481 482 const events = allEvents(round);483 expect(!events.some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 9: a peer sent SYNC_DEVICE_MANIFEST while the other expected SYNC_DONE");484 expect(events.filter((event) => event.endsWith("SYNC_DONE")).length === 4, "scenario 9: both sides must still reach SYNC_DONE");485 },486 },487 {488 id: 13,489 name: "vault id mismatch stops cleanly",490 initiator: "browser",491 android: { MTIME_PARITY: "0", VAULT_ID: "vault-android", EXPECT_STATUS: "VAULT_MISMATCH", DRAIN: "1" },492 browser: { MTIME_PARITY: "0", VAULT_ID: "vault-browser", EXPECT_STATUS: "vault_mismatch", DRAIN: "1" },493 check(round, { expect, side }) {494 expect(normalise(round.android.status) === "vault_mismatch", `scenario 13: android ${round.android.status}`);495 expect(normalise(round.browser.status) === "vault_mismatch", `scenario 13: browser ${round.browser.status}`);496 497 498 side(round, "browser", "scenario 13: browser initiator", ["send:SYNC_HELLO", "recv:SYNC_HELLO", "send:SYNC_ERROR"]);499 side(round, "android", "scenario 13: android responder", ["recv:SYNC_HELLO", "send:SYNC_HELLO"]);500 expect(!allEvents(round).some((event) => event.endsWith("SYNC_MANIFEST")), "scenario 13: no data frames may follow a vault-id mismatch");501 },502 },503 {504 id: 5,505 name: "browser locked (spool active), android unlocked, unequal versions",506 initiator: "android",507 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },508 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", LOCKED: "1", EXPECT_STATUS: "completed" },509 check(round, { completed, expect }) {510 completed(round, { flow: true, manifests: false, label: "scenario 5" });511 512 expect(round.browser.staged > 0, "scenario 5: the locked side must stage what it receives");513 expect(round.browser.applied === 0, "scenario 5: a locked session must not merge into the live vault");514 expect(round.browser.entriesReceived === round.browser.staged, "scenario 5: staged must account for everything received");515 expect(round.android.applied > 0, "scenario 5: the unlocked side applies normally");516 expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 5: a locked session must not exchange a manifest");517 },518 },519 {520 id: 6,521 name: "android locked (listener running), browser unlocked",522 initiator: "browser",523 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "COMPLETED" },524 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", EXPECT_STATUS: "completed" },525 check(round, { completed, expect }) {526 completed(round, { flow: true, manifests: false, label: "scenario 6" });527 expect(round.android.staged > 0, "scenario 6: the locked side must stage what it receives");528 expect(round.android.applied === 0, "scenario 6: a locked session must not merge into the live vault");529 expect(round.browser.applied > 0, "scenario 6: the unlocked side applies normally");530 expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 6: a locked session must not exchange a manifest");531 },532 },533 {534 id: 8,535 name: "both sides locked",536 initiator: "android",537 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "COMPLETED" },538 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "completed" },539 check(round, { completed, expect }) {540 completed(round, { flow: true, manifests: false, label: "scenario 8" });541 for (const side of ["android", "browser"]) {542 expect(round[side].staged > 0, `scenario 8: ${side} must stage`);543 expect(round[side].applied === 0, `scenario 8: ${side} must not merge while locked`);544 }545 expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 8: neither locked side may exchange a manifest");546 },547 },548 {549 id: 10,550 name: "process death mid-session, next round completes cleanly",551 initiator: "android",552 553 554 555 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", STOP_AFTER: "SYNC_HELLO", EXPECT_STATUS: "COMPLETED" },556 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", EXPECT_STATUS: "errored", DRAIN: "1" },557 expectFailure: ["android"],558 check(round, { expect, side }) {559 expect(normalise(round.android.status) === "died", `scenario 10: android ended ${round.android.status}`);560 expect(normalise(round.browser.status) === "errored", `scenario 10: browser ended ${round.browser.status}`);561 side(round, "android", "scenario 10: the dead initiator", ["send:SYNC_HELLO"]);562 },563 async afterwards({ completed }, { runRound: run }) {564 const restarted = await run({565 initiator: "android",566 label: "scenario 10 (restarted)",567 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },568 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },569 });570 restarted.initiatedBy = "android";571 completed(restarted, { flow: true, manifests: true, label: "scenario 10 (restarted)" });572 },573 },574 {575 id: 15,576 name: "10,000-entry vault with padded frames",577 initiator: "android",578 android: { MTIME_PARITY: "0", ENTRY_COUNT: "10000", EXPECT_STATUS: "COMPLETED" },579 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "10000", EXPECT_STATUS: "completed" },580 timeoutMs: 300_000,581 check(round, { completed, expect }) {582 completed(round, { flow: true, manifests: true, label: "scenario 15" });583 584 585 for (const side of ["android", "browser"]) {586 const sent = round[side].entriesSent;587 const received = round[side].entriesReceived;588 expect(sent === 5000, `scenario 15: ${side} sent ${sent}`);589 expect(received === 5000, `scenario 15: ${side} received ${received}`);590 }591 },592 },593];594595const helpers = {596 expect(condition, message) {597 if (!condition) throw new Error(message);598 },599 completed(round, options) {600 if (options.flow === undefined) throw new Error("completed() needs an explicit flow expectation");601 const expected = expectedRound(options);602 for (const [side, role] of [["android", round.initiatedBy === "android" ? "initiator" : "responder"], ["browser", round.initiatedBy === "browser" ? "initiator" : "responder"]]) {603 const status = normalise(round[side].status);604 if (status !== "completed") throw new Error(`${options.label}: ${side} ended ${round[side].status} (${round[side].error ?? "no error"})`);605 assertTranscript(round[side], expected[role], `${options.label}: ${side} ${role}`);606 }607 },608 transcript(round, label, predicate) {609 for (const side of ["android", "browser"]) {610 if (!predicate(round[side])) throw new Error(`${label}: unexpected transcript for ${side}: ${JSON.stringify(round[side].events)}`);611 }612 },613 side(round, side, label, expected) {614 const got = JSON.stringify(round[side].events);615 const want = JSON.stringify(expected);616 if (got !== want) throw new Error(`${label}: expected ${want}, got ${got}`);617 },618};619620621622const only = (process.env.PEACH_SYNC_HARNESS_ONLY ?? "").split(",").filter(Boolean).map(Number);623const selected = only.length > 0 ? SCENARIOS.filter((scenario) => only.includes(scenario.id)) : SCENARIOS;624625const results = [];626for (const scenario of selected) {627 const started = Date.now();628 const round = await runRound({ ...scenario, label: `scenario ${scenario.id}` });629 round.initiatedBy = scenario.initiator;630 scenario.check(round, helpers);631 if (scenario.afterwards) await scenario.afterwards(helpers, { runRound });632 const elapsed = Date.now() - started;633 results.push({ ...scenario, elapsed });634 console.log(` ✓ ${String(scenario.id).padStart(2)} ${scenario.name} (${(elapsed / 1000).toFixed(1)}s)`);635}636637console.log("");638console.log(`Cross-client sync process conformance passed: ${results.length} of 15 §7.1 scenarios.`);639for (const result of results) {640 const timing = result.id === 15 ? ` — 10,000-entry round ${(result.elapsed / 1000).toFixed(1)}s wall clock` : "";641 console.log(` #${result.id} ${result.name}${timing}`);642}643console.log("");644console.log("Not yet covered by this gate (see the campaign §7.1 table):");645console.log(" #7 android cold-locked — evidence class is unit + real-device, not a process transcript");646console.log(" #11 revocation then re-vouch, #12 excessive vouch depth, #14 freshness window —");647console.log(" these need a correctly signed adversarial manifest on the wire; the");648console.log(" signed-manifest fixture is not built yet");649console.log(" the adoption-v2 group, and the eight §16.3 manifest-v5 adversarial cases");