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");18192021if (browserCoordinator.includes('helloIn.version === localVersion')) throw new Error("Browser still contains the forbidden equal-version fast path");22if (!browserCoordinator.includes("helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION")) throw new Error("Browser does not negotiate device manifests from SYNC_HELLO");23if (!androidCoordinator.includes("val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)")) throw new Error("Android does not negotiate device manifests from SYNC_HELLO");24if (!androidCoordinator.includes("peerVersion == DEVICE_MANIFEST_VERSION")) throw new Error("Android does not require an exact device-manifest version match");2526const manifestVersion = (source, declaration) => {27 const match = source.match(new RegExp(`${declaration}\\s*=\\s*(0x[0-9a-f]+|\\d+)`));28 if (!match) throw new Error(`Missing ${declaration} declaration`);29 return Number(match[1]);30};31if (manifestVersion(browserSyncTypes, "DEVICE_MANIFEST_VERSION") !== manifestVersion(androidManifest, "DEVICE_MANIFEST_VERSION")) {32 throw new Error("Browser and Android device-manifest versions differ");33}34if (!browserSyncTypes.includes("issuerSigningPublicKeyHex") || !browserSyncTypes.includes("signatureHex") ||35 !androidSyncMessage.includes("issuerSigningKeyHex") || !androidSyncMessage.includes("signatureHex")) {36 throw new Error("A client lacks signed revocation fields");37}38if (!browserPairedDevices.includes("MAX_REVOCATIONS = 500") || !androidPairedDevices.includes("MAX_REVOCATION_RECORDS = 500")) throw new Error("Browser and Android revocation ledger caps differ");39if (!browserPairedDevices.includes("enqueueDeviceMutation") || !androidPairedDevices.includes("RegistryMutationGate")) throw new Error("A paired-device registry lacks whole-list mutation serialization");40if (!browserPairedDevices.includes("function capRevocations") || !androidPairedDevices.includes("fun capRevocationRecords")) throw new Error("A client lacks largest-issuer-first revocation retention");41const browserEndpoint = String.raw`42import { createInterface } from "node:readline";43import { createServer, createConnection } from "node:net";44const root = process.env.PEACH_BROWSER_ROOT;45const coordinator = await import(root + "/src/background/sync-coordinator.ts");46const mode = process.env.PEACH_SYNC_HARNESS_MODE;47const portFile = process.env.PEACH_SYNC_HARNESS_PORT_FILE;48const transcriptFile = process.env.PEACH_SYNC_HARNESS_TRANSCRIPT_FILE;49const id = mode === "initiator" ? "browser" : "android";50const socket = await new Promise((resolve, reject) => {51 if (mode === "initiator") {52 const server = createServer((client) => {53 // Stop listening as soon as the one conformance peer connects. Leaving54 // this listener open keeps Bun alive after the coordinator completes.55 server.close();56 resolve(client);57 });58 server.once("error", reject);59 server.listen(0, "127.0.0.1", () => require("node:fs").writeFileSync(portFile, String(server.address().port)));60 } else {61 const client = createConnection({ host: "127.0.0.1", port: Number(process.env.PEACH_SYNC_HARNESS_PORT) });62 client.once("connect", () => resolve(client));63 client.once("error", reject);64 }65});66socket.setEncoding("utf8");67const lines = createInterface({ input: socket });68const iterator = lines[Symbol.asyncIterator]();69const events = [];70const typeOf = (message) => message.type;71const session = {72 remoteStaticPublicKey: new Uint8Array(32).fill(7),73 send(bytes) {74 const message = JSON.parse(new TextDecoder().decode(bytes));75 events.push("send:" + typeOf(message));76 socket.write(Buffer.from(bytes).toString("base64") + "\n");77 },78 async receive() {79 const next = await iterator.next();80 if (next.done) throw new Error("cross-client peer closed");81 const bytes = Uint8Array.from(Buffer.from(next.value.trim(), "base64"));82 events.push("recv:" + typeOf(JSON.parse(new TextDecoder().decode(bytes))));83 return bytes;84 },85 async close() { socket.end(); },86};87const entryId = id + "-entry";88const localMtime = id === "browser" ? 10 : 20;89const remoteMtime = id === "browser" ? 20 : 10;90const adapter = {91 async computeVaultId() { return "cross-client-vault"; },92 async getOurDeviceId() { return id; },93 async getLocalVersion() { return 7; },94 async buildEntryManifest() { return { [entryId]: localMtime }; },95 async buildTombstoneManifest() { return {}; },96 async getEncryptedEntries(ids) { return Object.fromEntries(ids.filter((value) => value === entryId).map((value) => [value, id + "-frame"])); },97 async applyEntries() {},98 async applyTombstones() {},99 async buildOutgoingDeviceManifest() { return { body: { manifestVersion: 4, issuerDeviceId: id, issuedAt: 1700000000000, devices: [], revoked: [] }, signatureHex: "11".repeat(64) }; },100 async applyIncomingDeviceManifest() {},101};102const outcome = mode === "initiator"103 ? await coordinator.runSyncInitiator(session, adapter)104 : await coordinator.runSyncResponder(session, adapter);105require("node:fs").writeFileSync(transcriptFile, JSON.stringify({ status: outcome.status, events }));106socket.end();107`.replace("const remoteMtime = id === \"browser\" ? 20 : 10;", "");108109const MAX_DIAGNOSTIC_BYTES = 12_000;110111function spawnProcess(command, args, env) {112 const child = spawn(command, args, { cwd: env.PEACH_PROCESS_ROOT ?? env.PEACH_BROWSER_ROOT ?? browserRoot, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });113 const output = { stdout: "", stderr: "" };114 for (const [name, stream] of [["stdout", child.stdout], ["stderr", child.stderr]]) {115 stream.setEncoding("utf8");116 stream.on("data", (chunk) => {117 output[name] = (output[name] + chunk).slice(-MAX_DIAGNOSTIC_BYTES);118 });119 }120 child.diagnostics = output;121 return child;122}123124function waitForExit(child) {125 return new Promise((resolveExit, reject) => {126 child.once("error", reject);127 child.once("exit", (code, signal) => resolveExit({ code, signal, diagnostics: child.diagnostics }));128 });129}130131function terminate(child) {132 if (!child || child.exitCode !== null || child.signalCode !== null) return;133 child.kill("SIGTERM");134 setTimeout(() => {135 if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");136 }, 1_000).unref();137}138139function formatResult(result) {140 const output = result.diagnostics;141 return `code=${result.code ?? "none"} signal=${result.signal ?? "none"}; stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`;142}143144async function waitForFile(path, child) {145 const deadline = Date.now() + 30_000;146 while (!existsSync(path)) {147 if (child.exitCode !== null) throw new Error(`endpoint exited before publishing ${path} (${child.exitCode})`);148 if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`);149 await new Promise((resolve) => setTimeout(resolve, 50));150 }151 return readFileSync(path, "utf8").trim();152}153154function readTranscript(path) {155 return JSON.parse(readFileSync(path, "utf8"));156}157158const androidTask = ["--no-daemon", "--no-configuration-cache", "--rerun-tasks", "testDebugUnitTest", "--tests", "com.peachpasswords.sync.CrossClientSyncProcessTest.exchange_production_frames_with_browser_process"];159const gradleProperty = (name, value) => `-D${name.toLowerCase().replaceAll("_", ".")}=${value}`;160161async function runRound({ initiator, mismatch = false }) {162 const dir = mkdtempSync(resolve(tmpdir(), "peach-cross-sync-"));163 const androidTranscript = resolve(dir, "android.json");164 const browserTranscript = resolve(dir, "browser.json");165 const androidPortFile = resolve(dir, "android.port");166 const browserPortFile = resolve(dir, "browser.port");167 const common = { PEACH_BROWSER_ROOT: browserRoot };168 const launchBrowser = (mode, port, transcript, portFile) => spawnProcess("bun", ["--eval", browserEndpoint], {169 ...common,170 PEACH_SYNC_HARNESS_MODE: mode,171 PEACH_SYNC_HARNESS_PORT: port ?? "",172 PEACH_SYNC_HARNESS_PORT_FILE: portFile ?? "",173 PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: transcript,174 });175 const launchAndroid = (mode, port) => spawnProcess(resolve(androidRoot, "gradlew"), [176 gradleProperty("PEACH_SYNC_HARNESS_MODE", mode),177 gradleProperty("PEACH_SYNC_HARNESS_PORT", port ?? ""),178 gradleProperty("PEACH_SYNC_HARNESS_PORT_FILE", androidPortFile),179 gradleProperty("PEACH_SYNC_HARNESS_TRANSCRIPT_FILE", androidTranscript),180 ...(mismatch ? [gradleProperty("PEACH_SYNC_HARNESS_MISMATCH", "message-order")] : []),181 ...androidTask,182 ], {183 ...common,184 PEACH_PROCESS_ROOT: androidRoot,185 PEACH_SYNC_HARNESS_MODE: mode,186 PEACH_SYNC_HARNESS_PORT: port ?? "",187 PEACH_SYNC_HARNESS_PORT_FILE: androidPortFile,188 PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: androidTranscript,189 ...(mismatch ? { PEACH_SYNC_HARNESS_MISMATCH: "message-order" } : {}),190 ...(javaHome ? { JAVA_HOME: javaHome } : {}),191 });192193 let first;194 let second;195 try {196 if (initiator === "android") {197 first = launchAndroid("initiator");198 const port = await waitForFile(androidPortFile, first);199 second = launchBrowser("responder", port, browserTranscript);200 } else {201 first = launchBrowser("initiator", undefined, browserTranscript, browserPortFile);202 const port = await waitForFile(browserPortFile, first);203 second = launchAndroid("responder", port);204 }205 const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error(`cross-client ${initiator} round timed out after 120s`)), 120_000));206 const results = await Promise.race([207 Promise.all([waitForExit(first), waitForExit(second)]),208 timeout,209 ]);210 const [firstResult, secondResult] = results;211 if (!mismatch && (firstResult.code !== 0 || secondResult.code !== 0)) {212 throw new Error(`cross-client ${initiator} round failed: first ${formatResult(firstResult)}; second ${formatResult(secondResult)}`);213 }214 return { android: readTranscript(androidTranscript), browser: readTranscript(browserTranscript) };215 } catch (error) {216 const details = [first, second].filter(Boolean).map((child) => child.diagnostics).map((output) => `stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`).join("; ");217 throw new Error(`${error.message}; child diagnostics: ${details}`, { cause: error });218 } finally {219 terminate(first);220 terminate(second);221 }222}223224function assertTranscript(actual, expected, label) {225 const got = JSON.stringify(actual.events);226 const want = JSON.stringify(expected);227 if (got !== want) throw new Error(`${label} transcript mismatch: expected ${want}, got ${got}`);228}229230const androidInitiator = ["send:SYNC_HELLO", "recv:SYNC_HELLO", "send:SYNC_MANIFEST", "recv:SYNC_MANIFEST", "send:SYNC_REQUEST", "recv:SYNC_REQUEST", "send:SYNC_ENTRIES", "send:SYNC_TOMBSTONES", "recv:SYNC_TOMBSTONES", "send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DONE", "recv:SYNC_DONE"];231const browserResponder = ["recv:SYNC_HELLO", "send:SYNC_HELLO", "recv:SYNC_MANIFEST", "send:SYNC_MANIFEST", "recv:SYNC_REQUEST", "send:SYNC_REQUEST", "recv:SYNC_ENTRIES", "recv:SYNC_TOMBSTONES", "send:SYNC_TOMBSTONES", "recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DONE", "send:SYNC_DONE"];232const browserInitiator = ["send:SYNC_HELLO", "recv:SYNC_HELLO", "send:SYNC_MANIFEST", "recv:SYNC_MANIFEST", "send:SYNC_REQUEST", "recv:SYNC_REQUEST", "send:SYNC_ENTRIES", "send:SYNC_TOMBSTONES", "recv:SYNC_TOMBSTONES", "send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DONE", "recv:SYNC_DONE"];233const androidResponder = ["recv:SYNC_HELLO", "send:SYNC_HELLO", "recv:SYNC_MANIFEST", "send:SYNC_MANIFEST", "recv:SYNC_REQUEST", "send:SYNC_REQUEST", "recv:SYNC_ENTRIES", "recv:SYNC_TOMBSTONES", "send:SYNC_TOMBSTONES", "recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DONE", "send:SYNC_DONE"];234235const mismatch = await runRound({ initiator: "browser", mismatch: true });236if (mismatch.android.status === "COMPLETED" || mismatch.browser.status === "completed") throw new Error("intentional message-order mismatch unexpectedly completed");237const androidRound = await runRound({ initiator: "android" });238if (androidRound.android.status !== "COMPLETED" || androidRound.browser.status !== "completed") throw new Error("Android-initiated cross-client round did not complete");239assertTranscript(androidRound.android, androidInitiator, "Android initiator");240assertTranscript(androidRound.browser, browserResponder, "Browser responder");241const browserRound = await runRound({ initiator: "browser" });242if (browserRound.android.status !== "COMPLETED" || browserRound.browser.status !== "completed") throw new Error("Browser-initiated cross-client round did not complete");243assertTranscript(browserRound.browser, browserInitiator, "Browser initiator");244assertTranscript(browserRound.android, androidResponder, "Android responder");245console.log("Cross-client sync process conformance passed (both initiators, manifest negotiation, mismatch rejection).");