From 19ae28c382116a63d680fd3316e094d34dae6b07 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:16:15 +0800 Subject: [PATCH] fix(wallet): block coco-core while legacy cocod is running Check the legacy cocod PID file and Unix socket before opening coco.db. Refuse startup when cocod is active or its state cannot be verified, while permitting confirmed stale socket files. Add focused coverage for PID, live socket, stale socket, and fail-closed behavior. --- src/daemon/wallet/coco-client.test.ts | 131 ++++++++++++++++++++++++ src/daemon/wallet/coco-client.ts | 137 ++++++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 src/daemon/wallet/coco-client.test.ts diff --git a/src/daemon/wallet/coco-client.test.ts b/src/daemon/wallet/coco-client.test.ts new file mode 100644 index 0000000..2124e65 --- /dev/null +++ b/src/daemon/wallet/coco-client.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, mock } from "bun:test"; +import { assertLegacyCocodNotRunning } from "./coco-client"; + +type GuardOptions = NonNullable< + Parameters[0] +>; +type LegacyFetch = NonNullable; + +const SOCKET_PATH = "/tmp/routstrd-test/cocod.sock"; +const PID_FILE_PATH = "/tmp/routstrd-test/cocod.pid"; + +function socketOnly(path: string): boolean { + return path === SOCKET_PATH; +} + +describe("assertLegacyCocodNotRunning", () => { + it("does not probe when the legacy socket and PID file do not exist", async () => { + const fetchImpl = mock(async () => new Response("pong")); + + await assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: () => false, + fetchImpl, + }); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("refuses to continue when cocod responds on the legacy socket", async () => { + const fetchImpl = mock(async () => + Response.json({ output: "pong" }), + ); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: socketOnly, + fetchImpl, + }), + ).rejects.toThrow("Legacy cocod daemon is still running"); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://localhost/ping"); + expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({ unix: SOCKET_PATH }); + }); + + it.each(["ENOENT", "ECONNREFUSED", "FailedToOpenSocket"])( + "allows startup for a stale socket that fails with %s", + async (code) => { + const fetchImpl = mock(async () => { + throw Object.assign(new Error("socket unavailable"), { code }); + }); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: socketOnly, + fetchImpl, + }), + ).resolves.toBeUndefined(); + }, + ); + + it("recognizes stale socket errors nested under cause", async () => { + const fetchImpl = mock(async () => { + throw new TypeError("fetch failed", { + cause: Object.assign(new Error("connection refused"), { + code: "ECONNREFUSED", + }), + }); + }); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: socketOnly, + fetchImpl, + }), + ).resolves.toBeUndefined(); + }); + + it("refuses to continue when the legacy PID is still running", async () => { + const fetchImpl = mock(async () => + Response.json({ output: "pong" }), + ); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: (path) => path === PID_FILE_PATH, + readFile: () => "4242\n", + isProcessRunning: (pid) => pid === 4242, + fetchImpl, + }), + ).rejects.toThrow("Legacy cocod daemon is still running with PID 4242"); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("ignores a stale PID file when no socket exists", async () => { + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: (path) => path === PID_FILE_PATH, + readFile: () => "4242\n", + isProcessRunning: () => false, + }), + ).resolves.toBeUndefined(); + }); + + it("fails closed when the socket cannot be probed safely", async () => { + const fetchImpl = mock(async () => { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + }); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: socketOnly, + fetchImpl, + }), + ).rejects.toThrow("Cannot verify whether the legacy cocod daemon has stopped"); + }); +}); diff --git a/src/daemon/wallet/coco-client.ts b/src/daemon/wallet/coco-client.ts index 302bfab..122e528 100644 --- a/src/daemon/wallet/coco-client.ts +++ b/src/daemon/wallet/coco-client.ts @@ -13,6 +13,32 @@ const CONFIG_DIR = const CONFIG_FILE = join(CONFIG_DIR, "config.json"); const DB_PATH = join(CONFIG_DIR, "coco.db"); +const LEGACY_COCOD_SOCKET = + process.env.COCOD_SOCKET || join(CONFIG_DIR, "cocod.sock"); +const LEGACY_COCOD_PID_FILE = join(CONFIG_DIR, "cocod.pid"); + +const STALE_SOCKET_ERROR_CODES = new Set([ + "ECONNREFUSED", + "ENOENT", + // Bun's Unix-socket fetch error for an abandoned socket inode. + "FailedToOpenSocket", +]); + +type UnixRequestInit = RequestInit & { unix: string }; +type LegacyCocodFetch = ( + input: string | URL | Request, + init: UnixRequestInit, +) => Promise; + +export interface LegacyCocodGuardOptions { + socketPath?: string; + pidFilePath?: string; + pathExists?: (path: string) => boolean; + readFile?: (path: string) => string; + isProcessRunning?: (pid: number) => boolean; + fetchImpl?: LegacyCocodFetch; + timeoutMs?: number; +} interface CocodConfig { mnemonic: string; @@ -39,7 +65,118 @@ async function seedGetter(): Promise { return mnemonicToSeedSync(mnemonic); } +function defaultIsProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function hasErrorCode(error: unknown, codes: Set): boolean { + let current: unknown = error; + const visited = new Set(); + + while (current && typeof current === "object" && !visited.has(current)) { + visited.add(current); + const candidate = current as { code?: unknown; cause?: unknown }; + if (typeof candidate.code === "string" && codes.has(candidate.code)) { + return true; + } + current = candidate.cause; + } + + return false; +} + +/** + * Refuse to open coco.db while the legacy cocod daemon owns its Unix socket. + * Two independent wallet engines must never operate on the same proof database. + * + * A socket left behind after a crash is safe to ignore only when connecting + * fails with ENOENT or ECONNREFUSED. Other probe failures are treated as unsafe + * because they do not prove that cocod has stopped. + */ +export async function assertLegacyCocodNotRunning( + options: LegacyCocodGuardOptions = {}, +): Promise { + const socketPath = options.socketPath || LEGACY_COCOD_SOCKET; + const pidFilePath = options.pidFilePath || LEGACY_COCOD_PID_FILE; + const pathExists = options.pathExists || existsSync; + const readFile = options.readFile || ((path) => readFileSync(path, "utf-8")); + const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning; + + const getRunningLegacyPid = (): number | null => { + if (!pathExists(pidFilePath)) return null; + + try { + const pid = Number.parseInt(readFile(pidFilePath).trim(), 10); + return Number.isInteger(pid) && pid > 0 && isProcessRunning(pid) + ? pid + : null; + } catch { + // An unreadable or malformed PID file does not prove that cocod is alive; + // the socket probe below remains the authoritative fallback. + return null; + } + }; + + const runningPid = getRunningLegacyPid(); + if (runningPid !== null) { + throw new Error( + `Legacy cocod daemon is still running with PID ${runningPid}. ` + + "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " + + "Run 'cocod stop' and try again.", + ); + } + + if (!pathExists(socketPath)) return; + + const fetchImpl = options.fetchImpl || (fetch as LegacyCocodFetch); + const timeoutMs = options.timeoutMs ?? 1_000; + + try { + const response = await fetchImpl("http://localhost/ping", { + unix: socketPath, + signal: AbortSignal.timeout(timeoutMs), + }); + await response.body?.cancel(); + } catch (error) { + if (hasErrorCode(error, STALE_SOCKET_ERROR_CODES)) { + // Recheck after the failed probe in case cocod started concurrently. + const newlyRunningPid = getRunningLegacyPid(); + if (newlyRunningPid === null) { + logger.debug(`Ignoring stale legacy cocod socket at ${socketPath}`); + return; + } + + throw new Error( + `Legacy cocod daemon is still running with PID ${newlyRunningPid}. ` + + "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " + + "Run 'cocod stop' and try again.", + { cause: error }, + ); + } + + throw new Error( + `Cannot verify whether the legacy cocod daemon has stopped at ${socketPath}. ` + + "Refusing to open the wallet database to prevent concurrent access. " + + "Run 'cocod stop', verify the daemon has exited, and try again.", + { cause: error }, + ); + } + + throw new Error( + `Legacy cocod daemon is still running at ${socketPath}. ` + + "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " + + "Run 'cocod stop' and try again.", + ); +} + export async function createCocoClient(): Promise { + await assertLegacyCocodNotRunning(); + const database = new Database(DB_PATH); const repo = new SqliteRepositories({ database }); await repo.init();