diff --git a/src/config.ts b/src/config.ts index 703a444d8..65a553297 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmdirSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { Database } from "bun:sqlite"; @@ -382,7 +382,7 @@ export class OpenAiTierBackupCollisionError extends Error { } export class OpenAiTierRollbackPreserveError extends Error { - readonly code?: "missing" | "not-rollback" | "mismatch" | "exhausted"; + readonly code?: "missing" | "not-rollback" | "mismatch" | "exhausted" | "changed"; constructor(message: string, options?: ErrorOptions & { code?: OpenAiTierRollbackPreserveError["code"] }) { super(message, options); this.name = "OpenAiTierRollbackPreserveError"; @@ -390,6 +390,27 @@ export class OpenAiTierRollbackPreserveError extends Error { } } +export class OpenAiTierRollbackPreserveSecretResidualError extends Error { + constructor(readonly preservedPath: string, options?: ErrorOptions) { + super("OpenAI tier rollback preserve could not scrub or remove an unverified snapshot", options); + this.name = "OpenAiTierRollbackPreserveSecretResidualError"; + } +} + +export class OpenAiTierRollbackPreserveCleanupError extends Error { + constructor(readonly preservedPath: string, readonly restricted = false, options?: ErrorOptions) { + super("OpenAI tier rollback preserve could not remove a scrubbed unverified snapshot", options); + this.name = "OpenAiTierRollbackPreserveCleanupError"; + } +} + +export class OpenAiTierRollbackPreserveClaimError extends Error { + constructor(readonly claimedPath: string, options?: ErrorOptions) { + super("OpenAI tier rollback backup was replaced during preserve; the claimed snapshot was kept", options); + this.name = "OpenAiTierRollbackPreserveClaimError"; + } +} + export class OpenAiTierBackupSecretResidualError extends Error { constructor(readonly tempPath: string, options?: ErrorOptions) { super("OpenAI tier backup could not scrub or remove a secret-bearing temporary file", options); @@ -570,7 +591,14 @@ export interface OpenAiTierRollbackPreserveIO { exists(path: string): boolean; read(path: string): Uint8Array; copyExclusive(source: string, destination: string): void; + harden(path: string): void; + truncate(path: string): void; + write(path: string, bytes: Uint8Array): void; unlink(path: string): void; + mkdirExclusive(path: string): void; + claimExclusive(source: string, destination: string): void; + linkExclusive(source: string, destination: string): void; + rmdir(path: string): void; } const DEFAULT_ROLLBACK_PRESERVE_IO: OpenAiTierRollbackPreserveIO = { @@ -579,17 +607,38 @@ const DEFAULT_ROLLBACK_PRESERVE_IO: OpenAiTierRollbackPreserveIO = { copyExclusive: (source, destination) => { copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); }, + harden: target => { + // Fail closed: chmod errors propagate. Windows ACL uses required:true so a + // failed icacls cannot continue into source unlink. The v2 migration backup + // path keeps required:false; this callback is only for preserved rollback + // snapshots. CopyFile does not copy the source DACL. + chmodSync(target, 0o600); + if (process.platform === "win32") hardenSecretPath(target, { required: true }); + }, + truncate: target => truncateSync(target, 0), + write: (target, bytes) => writeFileSync(target, bytes), unlink: unlinkSync, + mkdirExclusive: target => { mkdirSync(target, { mode: 0o700 }); }, + claimExclusive: (source, destination) => { renameSync(source, destination); }, + linkExclusive: (source, destination) => { linkSync(source, destination); }, + rmdir: target => { rmdirSync(target); }, }; const OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS = 16; /** * Copy a rollback-classified `.pre-openai-tiers-v2.bak` to a unique - * `.pre-openai-tiers-v1-rollback.[suffix].bak` path, then unlink the - * blocking v2 name. The original bytes are copied with no-replace publication; - * the v2 path is removed only after the copy is verified. Shared by startup - * migration recovery and `ocx init` cleanup so the two paths cannot drift. + * `.pre-openai-tiers-v1-rollback.[suffix].bak` path, then atomically + * claim the blocking v2 name into a private directory. Order is copy → verify + * bytes → harden preserved → mkdir claim dir → rename source onto the unique + * claim path → read the claimed inode → unlink only the claim. The original + * backup path is never unlinked, so a replacement that appears after the claim + * stays. After a successful claim, a claimed-read or claimed-byte failure never + * unlinks the claimed path: it independently hardens that leftover, independently + * restores the original directory entry when vacant (EEXIST keeps a replacement), + * and throws `OpenAiTierRollbackPreserveClaimError` with `claimedPath`. Pre-harden + * failures scrub the unverified destination and never claim the source. Shared by + * startup migration recovery and `ocx init` cleanup. */ export function preserveOpenAiTierRollbackSnapshot( configPath = getConfigPath(), @@ -603,6 +652,57 @@ export function preserveOpenAiTierRollbackSnapshot( if (classifyOpenAiTierBackup(original) !== "rollback") { throw new OpenAiTierRollbackPreserveError("OpenAI tier backup is not a rollback snapshot", { code: "not-rollback" }); } + + const failUnverifiedCopy = (preserved: string, cause: unknown): never => { + let scrubbed = false; + try { + io.truncate(preserved); + scrubbed = true; + } catch (error) { + if (isMissingPathError(error)) scrubbed = true; + else { + try { io.write(preserved, new Uint8Array()); scrubbed = true; } catch { /* removal may still succeed */ } + } + } + let removed = false; + try { + io.unlink(preserved); + removed = true; + } catch (error) { + if (isMissingPathError(error)) removed = true; + else { + try { io.unlink(preserved); removed = true; } + catch (retryError) { if (isMissingPathError(retryError)) removed = true; } + } + } + let restricted = false; + if (!removed) { + try { io.harden(preserved); restricted = true; } catch { /* leftover restriction is best-effort */ } + } + if (!removed && !scrubbed) { + throw new OpenAiTierRollbackPreserveSecretResidualError(preserved, { cause }); + } + if (!removed) { + throw new OpenAiTierRollbackPreserveCleanupError(preserved, restricted, { cause }); + } + throw cause; + }; + + const restoreClaimedIfVacant = (claimedPath: string): void => { + try { + io.linkExclusive(claimedPath, backup); + } catch (error) { + if (isAlreadyExistsError(error)) return; + throw error; + } + }; + + const failClaimedSnapshot = (claimedPath: string, cause?: unknown): never => { + try { io.harden(claimedPath); } catch { /* claimed leftover must remain inspectable; restore still runs */ } + try { restoreClaimedIfVacant(claimedPath); } catch { /* EEXIST keeps B; other restore failures must not hide claimedPath */ } + throw new OpenAiTierRollbackPreserveClaimError(claimedPath, cause === undefined ? undefined : { cause }); + }; + for (let attempt = 0; attempt < OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS; attempt++) { const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`; try { @@ -611,17 +711,70 @@ export function preserveOpenAiTierRollbackSnapshot( if (isAlreadyExistsError(error)) continue; throw error; } - let copied: Uint8Array; + const copied = (() => { + try { + return io.read(preserved); + } catch (error) { + return failUnverifiedCopy(preserved, new OpenAiTierRollbackPreserveError("Failed to read preserved rollback snapshot", { cause: error, code: "mismatch" })); + } + })(); + if (!sameBytes(original, copied)) { + failUnverifiedCopy(preserved, new OpenAiTierRollbackPreserveError("Preserved rollback snapshot does not match source bytes", { code: "mismatch" })); + } try { - copied = io.read(preserved); + io.harden(preserved); } catch (error) { - throw new OpenAiTierRollbackPreserveError("Failed to read preserved rollback snapshot", { cause: error, code: "mismatch" }); + failUnverifiedCopy(preserved, error); } - if (!sameBytes(original, copied)) { - try { io.unlink(preserved); } catch { /* keep the original backup; incomplete copy is best-effort */ } - throw new OpenAiTierRollbackPreserveError("Preserved rollback snapshot does not match source bytes", { code: "mismatch" }); + + let claimedDir = ""; + let claimedPath = ""; + for (let claimAttempt = 0; claimAttempt < OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS; claimAttempt++) { + const candidateDir = `${configPath}.pre-openai-tiers-v2-claim.${Date.now()}${claimAttempt ? `-${claimAttempt}` : ""}`; + try { + io.mkdirExclusive(candidateDir); + } catch (error) { + if (isAlreadyExistsError(error)) continue; + throw error; + } + const candidatePath = join(candidateDir, "claimed.bak"); + try { + io.claimExclusive(backup, candidatePath); + } catch (error) { + try { io.rmdir(candidateDir); } catch { /* empty claim dir leftover is not secret-bearing */ } + throw error; + } + claimedDir = candidateDir; + claimedPath = candidatePath; + break; + } + if (!claimedPath) { + throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback claim path", { code: "exhausted" }); + } + + const claimedBytes = (() => { + try { + return io.read(claimedPath); + } catch (error) { + return failClaimedSnapshot(claimedPath, error); + } + })(); + if (!sameBytes(copied, claimedBytes)) { + failClaimedSnapshot(claimedPath); + } + + try { + io.unlink(claimedPath); + } catch (error) { + if (!isMissingPathError(error)) { + throw new OpenAiTierRollbackPreserveCleanupError(claimedPath, false, { cause: error }); + } + } + try { + io.rmdir(claimedDir); + } catch (error) { + throw new OpenAiTierRollbackPreserveCleanupError(claimedDir, true, { cause: error }); } - io.unlink(backup); return preserved; } throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback snapshot path", { code: "exhausted" }); diff --git a/tests/init-backup-cleanup.test.ts b/tests/init-backup-cleanup.test.ts index ff5958971..54d4d1de7 100644 --- a/tests/init-backup-cleanup.test.ts +++ b/tests/init-backup-cleanup.test.ts @@ -1,9 +1,48 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { cleanupOpenAiTierBackupAfterInit } from "../src/cli/init"; -import { classifyOpenAiTierBackup, OpenAiTierRollbackPreserveError, preserveOpenAiTierRollbackSnapshot } from "../src/config"; +import { + classifyOpenAiTierBackup, + OpenAiTierRollbackPreserveClaimError, + OpenAiTierRollbackPreserveCleanupError, + OpenAiTierRollbackPreserveError, + OpenAiTierRollbackPreserveSecretResidualError, + preserveOpenAiTierRollbackSnapshot, + type OpenAiTierRollbackPreserveIO, +} from "../src/config"; + +function preserveIo( + backup: string, + overrides: Partial = {}, +): OpenAiTierRollbackPreserveIO { + return { + exists: existsSync, + read: path => readFileSync(path), + copyExclusive: (source, destination) => { + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + }, + harden: () => {}, + truncate: path => { + if (path === backup) throw new Error("source truncate must not run"); + truncateSync(path, 0); + }, + write: (path, bytes) => { + if (path === backup) throw new Error("source write must not run"); + writeFileSync(path, bytes); + }, + unlink: path => { + if (path === backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + mkdirExclusive: path => { mkdirSync(path, { mode: 0o700 }); }, + claimExclusive: (source, destination) => { renameSync(source, destination); }, + linkExclusive: (source, destination) => { linkSync(source, destination); }, + rmdir: path => { rmdirSync(path); }, + ...overrides, + }; +} describe("cleanupOpenAiTierBackupAfterInit", () => { const dirs: string[] = []; @@ -95,12 +134,17 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { const backup = `${configPath}.pre-openai-tiers-v2.bak`; const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); writeFileSync(backup, v1); - expect(() => preserveOpenAiTierRollbackSnapshot(configPath, { - exists: existsSync, - read: path => readFileSync(path), + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { copyExclusive: () => { throw new Error("copy failed"); }, + harden: () => { throw new Error("harden must not run"); }, + truncate: () => { throw new Error("truncate must not run"); }, + write: () => { throw new Error("write must not run"); }, unlink: () => { throw new Error("unlink must not run"); }, - })).toThrow("copy failed"); + mkdirExclusive: () => { throw new Error("mkdir must not run"); }, + claimExclusive: () => { throw new Error("claim must not run"); }, + linkExclusive: () => { throw new Error("link must not run"); }, + rmdir: () => { throw new Error("rmdir must not run"); }, + }))).toThrow("copy failed"); expect(readFileSync(backup, "utf8")).toBe(v1); expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); }); @@ -127,4 +171,328 @@ describe("cleanupOpenAiTierBackupAfterInit", () => { expect(readFileSync(`${configPath}.pre-openai-tiers-v1-rollback.${now}.bak`, "utf8")).toBe("occupied"); expect(readFileSync(`${configPath}.pre-openai-tiers-v1-rollback.${now}-1.bak`, "utf8")).toBe("occupied"); }); + + test("preserveOpenAiTierRollbackSnapshot hardens before claiming the source", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + const calls: string[] = []; + const preserved = preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + read: path => { + calls.push(path === backup ? "read-source" : path.endsWith("claimed.bak") ? "read-claimed" : "read-preserved"); + return readFileSync(path); + }, + copyExclusive: (source, destination) => { + calls.push("copy"); + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + }, + harden: path => { calls.push(`harden:${path}`); }, + truncate: () => { throw new Error("truncate must not run"); }, + write: () => { throw new Error("write must not run"); }, + unlink: path => { + calls.push(path === backup ? "unlink-source" : "unlink-claimed"); + if (path === backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + mkdirExclusive: path => { calls.push("mkdir-claim"); mkdirSync(path, { mode: 0o700 }); }, + claimExclusive: (source, destination) => { + calls.push("claim"); + renameSync(source, destination); + }, + rmdir: path => { calls.push("rmdir-claim"); rmdirSync(path); }, + })); + expect(calls).toEqual([ + "read-source", + "copy", + "read-preserved", + `harden:${preserved}`, + "mkdir-claim", + "claim", + "read-claimed", + "unlink-claimed", + "rmdir-claim", + ]); + expect(existsSync(backup)).toBe(false); + expect(readFileSync(preserved, "utf8")).toBe(v1); + }); + + test("preserveOpenAiTierRollbackSnapshot harden failure keeps the v2 backup", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + const calls: string[] = []; + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + harden: () => { throw new Error("harden failed"); }, + truncate: path => { calls.push(`truncate:${path}`); truncateSync(path, 0); }, + unlink: path => { + calls.push(path === backup ? "unlink-source" : "unlink-preserved"); + if (path === backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + }))).toThrow("harden failed"); + expect(readFileSync(backup, "utf8")).toBe(v1); + expect(calls[0]?.startsWith("truncate:") && calls[0]!.includes("pre-openai-tiers-v1-rollback")).toBe(true); + expect(calls).toEqual([calls[0]!, "unlink-preserved"]); + expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); + }); + + test("preserveOpenAiTierRollbackSnapshot does not unlink a source that changed after copy", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + const bytesB = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, bytesA); + const hardened: string[] = []; + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + copyExclusive: (source, destination) => { + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + writeFileSync(source, bytesB); + }, + harden: path => { hardened.push(path); }, + truncate: () => { throw new Error("verified hardened copy must not be scrubbed"); }, + write: () => { throw new Error("verified hardened copy must not be overwritten"); }, + unlink: () => { throw new Error("claimed mismatch must not delete either snapshot"); }, + }))).toThrow(OpenAiTierRollbackPreserveClaimError); + expect(readFileSync(backup, "utf8")).toBe(bytesB); + const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(preserved).toHaveLength(1); + expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); + expect(hardened[0]).toBe(join(dir, preserved[0]!)); + expect(hardened.some(path => path.endsWith("claimed.bak"))).toBe(true); + }); + + test("preserveOpenAiTierRollbackSnapshot removes an unverified copy when read(preserved) fails", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + const calls: string[] = []; + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + harden: () => { throw new Error("harden must not run"); }, + truncate: path => { calls.push(`truncate:${path}`); truncateSync(path, 0); }, + unlink: path => { + calls.push(path === backup ? "unlink-source" : "unlink-preserved"); + if (path === backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + }))).toThrow("Failed to read preserved rollback snapshot"); + expect(readFileSync(backup, "utf8")).toBe(v1); + expect(calls[0]?.startsWith("truncate:") && calls[0]!.includes("pre-openai-tiers-v1-rollback")).toBe(true); + expect(calls).toEqual([calls[0]!, "unlink-preserved"]); + expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); + }); + + test("preserveOpenAiTierRollbackSnapshot scrubs a byte-mismatched copy", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + const calls: string[] = []; + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + copyExclusive: (source, destination) => { + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + writeFileSync(destination, "tampered-secret"); + }, + harden: () => { throw new Error("harden must not run"); }, + truncate: path => { calls.push(`truncate:${path}`); truncateSync(path, 0); }, + unlink: path => { + calls.push(path === backup ? "unlink-source" : "unlink-preserved"); + if (path === backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + }))).toThrow("Preserved rollback snapshot does not match source bytes"); + expect(readFileSync(backup, "utf8")).toBe(v1); + expect(calls[0]?.startsWith("truncate:") && calls[0]!.includes("pre-openai-tiers-v1-rollback")).toBe(true); + expect(calls).toEqual([calls[0]!, "unlink-preserved"]); + expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); + }); + + test("preserveOpenAiTierRollbackSnapshot retries unlink once after the first cleanup unlink fails", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + let preservedUnlinks = 0; + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + harden: () => { throw new Error("harden must not run"); }, + unlink: path => { + if (path === backup) throw new Error("source unlink must not run"); + preservedUnlinks += 1; + if (preservedUnlinks === 1) throw new Error("first unlink failed"); + unlinkSync(path); + }, + }))).toThrow("Failed to read preserved rollback snapshot"); + expect(preservedUnlinks).toBe(2); + expect(readFileSync(backup, "utf8")).toBe(v1); + expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); + }); + + test("preserveOpenAiTierRollbackSnapshot does not leave plaintext when unlink keeps failing after a successful scrub", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + const leftoverHarden: string[] = []; + expect(() => preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + harden: path => { leftoverHarden.push(path); }, + unlink: path => { + if (path === backup) throw new Error("source unlink must not run"); + throw new Error("unlink failed"); + }, + }))).toThrow(OpenAiTierRollbackPreserveCleanupError); + expect(readFileSync(backup, "utf8")).toBe(v1); + const leftover = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(leftover).toHaveLength(1); + expect(readFileSync(join(dir, leftover[0]!), "utf8")).toBe(""); + expect(leftoverHarden).toEqual([join(dir, leftover[0]!)]); + }); + + test("preserveOpenAiTierRollbackSnapshot reports a residual-secret error when scrub and unlink both fail", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, v1); + const leftoverHarden: string[] = []; + try { + preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + harden: path => { leftoverHarden.push(path); }, + truncate: () => { throw new Error("truncate failed"); }, + write: () => { throw new Error("write failed"); }, + unlink: path => { + if (path === backup) throw new Error("source unlink must not run"); + throw new Error("unlink failed"); + }, + })); + throw new Error("expected residual-secret failure"); + } catch (error) { + expect(error).toBeInstanceOf(OpenAiTierRollbackPreserveSecretResidualError); + const residual = error as OpenAiTierRollbackPreserveSecretResidualError; + expect(residual.preservedPath.includes("pre-openai-tiers-v1-rollback")).toBe(true); + expect(existsSync(residual.preservedPath)).toBe(true); + expect(readFileSync(residual.preservedPath, "utf8")).toBe(v1); + expect(leftoverHarden).toEqual([residual.preservedPath]); + } + expect(readFileSync(backup, "utf8")).toBe(v1); + }); + + test("preserveOpenAiTierRollbackSnapshot claims A and leaves a replacement B at the v2 path", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + const bytesB = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai", providers: {} }); + writeFileSync(backup, bytesA); + const unlinks: string[] = []; + const preserved = preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + claimExclusive: (source, destination) => { + renameSync(source, destination); + writeFileSync(source, bytesB); + }, + unlink: path => { + unlinks.push(path); + if (path === backup) throw new Error("replacement B must not be unlinked"); + unlinkSync(path); + }, + })); + expect(readFileSync(backup, "utf8")).toBe(bytesB); + expect(readFileSync(preserved, "utf8")).toBe(bytesA); + expect(unlinks).toHaveLength(1); + expect(unlinks[0]!.endsWith("claimed.bak")).toBe(true); + expect(existsSync(unlinks[0]!)).toBe(false); + }); + + test("preserveOpenAiTierRollbackSnapshot restores a claimed snapshot when claimed-read fails", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + writeFileSync(backup, bytesA); + const hardened: string[] = []; + const unlinks: string[] = []; + try { + preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + read: path => { + if (path.endsWith("claimed.bak")) throw new Error("read claimed failed"); + return readFileSync(path); + }, + harden: path => { hardened.push(path); }, + unlink: path => { + unlinks.push(path); + throw new Error("claimed-read failure must not unlink"); + }, + })); + throw new Error("expected claimed-read failure"); + } catch (error) { + expect(error).toBeInstanceOf(OpenAiTierRollbackPreserveClaimError); + const claimed = error as OpenAiTierRollbackPreserveClaimError; + expect(claimed.claimedPath.endsWith("claimed.bak")).toBe(true); + expect(existsSync(claimed.claimedPath)).toBe(true); + expect(readFileSync(claimed.claimedPath, "utf8")).toBe(bytesA); + expect(claimed.cause).toBeInstanceOf(Error); + expect((claimed.cause as Error).message).toBe("read claimed failed"); + expect(hardened).toContain(claimed.claimedPath); + } + expect(readFileSync(backup, "utf8")).toBe(bytesA); + const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(preserved).toHaveLength(1); + expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); + expect(hardened[0]).toBe(join(dir, preserved[0]!)); + expect(unlinks).toEqual([]); + }); + + test("preserveOpenAiTierRollbackSnapshot keeps claimedPath when claimed harden and restore fail", () => { + const dir = makeDir(); + const configPath = join(dir, "config.json"); + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + writeFileSync(backup, bytesA); + try { + preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(backup, { + read: path => { + if (path.endsWith("claimed.bak")) throw new Error("read claimed failed"); + return readFileSync(path); + }, + harden: path => { + if (path.endsWith("claimed.bak")) throw new Error("claimed harden failed"); + }, + linkExclusive: () => { throw new Error("restore failed"); }, + unlink: () => { throw new Error("claimed-read failure must not unlink"); }, + })); + throw new Error("expected claimed-read failure"); + } catch (error) { + expect(error).toBeInstanceOf(OpenAiTierRollbackPreserveClaimError); + const claimed = error as OpenAiTierRollbackPreserveClaimError; + expect(claimed.claimedPath.endsWith("claimed.bak")).toBe(true); + expect(existsSync(claimed.claimedPath)).toBe(true); + expect(readFileSync(claimed.claimedPath, "utf8")).toBe(bytesA); + expect((claimed.cause as Error).message).toBe("read claimed failed"); + expect(existsSync(backup)).toBe(false); + } + }); }); diff --git a/tests/openai-provider-option-startup.test.ts b/tests/openai-provider-option-startup.test.ts index 9a281da11..f8595dc01 100644 --- a/tests/openai-provider-option-startup.test.ts +++ b/tests/openai-provider-option-startup.test.ts @@ -1,11 +1,16 @@ import { describe, expect, test } from "bun:test"; import { chmodSync, + constants as fsConstants, + copyFileSync, existsSync, linkSync, + mkdirSync, mkdtempSync, readFileSync, readdirSync, + renameSync, + rmdirSync, rmSync, truncateSync, unlinkSync, @@ -23,7 +28,10 @@ import { OpenAiTierBackupCollisionError, OpenAiTierBackupRollbackError, OpenAiTierBackupSecretResidualError, + OpenAiTierRollbackPreserveClaimError, + OpenAiTierRollbackPreserveCleanupError, OpenAiTierRollbackPreserveError, + OpenAiTierRollbackPreserveSecretResidualError, preserveOpenAiTierRollbackSnapshot, type OpenAiTierBackupIO, type OpenAiTierRollbackPreserveIO, @@ -39,6 +47,37 @@ const config: OcxConfig = { providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, }; +function preserveIo( + backup: string, + overrides: Partial = {}, +): OpenAiTierRollbackPreserveIO { + return { + exists: existsSync, + read: path => readFileSync(path), + copyExclusive: (source, destination) => { + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + }, + harden: () => {}, + truncate: path => { + if (path === backup) throw new Error("source truncate must not run"); + truncateSync(path, 0); + }, + write: (path, bytes) => { + if (path === backup) throw new Error("source write must not run"); + writeFileSync(path, bytes); + }, + unlink: path => { + if (path === backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + mkdirExclusive: path => { mkdirSync(path, { mode: 0o700 }); }, + claimExclusive: (source, destination) => { renameSync(source, destination); }, + linkExclusive: (source, destination) => { linkSync(source, destination); }, + rmdir: path => { rmdirSync(path); }, + ...overrides, + }; +} + function virtualBackupIO(initial: Record, fail: { publish?: Error; tempUnlink?: number; @@ -673,12 +712,15 @@ describe("OpenAI provider option startup coordinator", () => { const rollbackBytes = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); writeFileSync(configPath, currentBytes); writeFileSync(v2Backup, rollbackBytes); - const failingIo: OpenAiTierRollbackPreserveIO = { - exists: existsSync, - read: path => readFileSync(path), + const failingIo = preserveIo(v2Backup, { copyExclusive: () => { throw new Error("copy failed"); }, - unlink: unlinkSync, - }; + harden: () => { throw new Error("harden must not run"); }, + truncate: () => { throw new Error("truncate must not run"); }, + write: () => { throw new Error("write must not run"); }, + unlink: () => { throw new Error("unlink must not run"); }, + mkdirExclusive: () => { throw new Error("mkdir must not run"); }, + claimExclusive: () => { throw new Error("claim must not run"); }, + }); expect(() => runOpenAiTierStartupMigration(currentConfig, { project: projectOpenAiTierMigration, @@ -710,4 +752,373 @@ describe("OpenAI provider option startup coordinator", () => { rmSync(dir, { recursive: true, force: true }); } }); + + test("startup does not save when rollback harden fails before source unlink (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-hardenfail-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const rollbackBytes = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, rollbackBytes); + const calls: string[] = []; + const failingIo = preserveIo(v2Backup, { + copyExclusive: (source, destination) => { + calls.push("copy"); + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + }, + harden: () => { calls.push("harden"); throw new Error("harden failed"); }, + truncate: path => { calls.push("truncate"); truncateSync(path, 0); }, + write: () => { throw new Error("write must not run"); }, + unlink: path => { + calls.push(path === v2Backup ? "unlink-source" : "unlink-preserved"); + if (path === v2Backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + mkdirExclusive: () => { throw new Error("mkdir must not run"); }, + claimExclusive: () => { throw new Error("claim must not run"); }, + }); + + expect(() => runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => backupConfigBeforeOpenAiTierMigration(configPath), + preserveRollback: () => { preserveOpenAiTierRollbackSnapshot(configPath, failingIo); }, + save: () => { calls.push("save"); }, + })).toThrow("harden failed"); + + expect(calls).toEqual(["copy", "harden", "truncate", "unlink-preserved"]); + expect(readFileSync(v2Backup, "utf8")).toBe(rollbackBytes); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + expect(calls.includes("save")).toBe(false); + expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("startup does not save when the rollback source changes after copy (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-srcchange-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + const bytesB = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, bytesA); + const saves: number[] = []; + const hardened: string[] = []; + const changingIo = preserveIo(v2Backup, { + copyExclusive: (source, destination) => { + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + writeFileSync(source, bytesB); + }, + harden: path => { hardened.push(path); }, + truncate: () => { throw new Error("verified hardened copy must not be scrubbed"); }, + write: () => { throw new Error("verified hardened copy must not be overwritten"); }, + unlink: () => { throw new Error("claimed mismatch must not delete either snapshot"); }, + }); + + expect(() => runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => backupConfigBeforeOpenAiTierMigration(configPath), + preserveRollback: () => { preserveOpenAiTierRollbackSnapshot(configPath, changingIo); }, + save: () => { saves.push(1); }, + })).toThrow(OpenAiTierRollbackPreserveClaimError); + + expect(saves).toEqual([]); + expect(readFileSync(v2Backup, "utf8")).toBe(bytesB); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(preserved).toHaveLength(1); + expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); + expect(hardened[0]).toBe(join(dir, preserved[0]!)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("startup does not save when read(preserved) fails and still keeps the source (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-readfail-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const rollbackBytes = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, rollbackBytes); + const unlinks: string[] = []; + const saves: number[] = []; + const failingIo = preserveIo(v2Backup, { + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + harden: () => { throw new Error("harden must not run"); }, + write: () => { throw new Error("write must not run"); }, + unlink: path => { + unlinks.push(path); + if (path === v2Backup) throw new Error("source unlink must not run"); + unlinkSync(path); + }, + mkdirExclusive: () => { throw new Error("mkdir must not run"); }, + claimExclusive: () => { throw new Error("claim must not run"); }, + }); + + expect(() => runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => backupConfigBeforeOpenAiTierMigration(configPath), + preserveRollback: () => { preserveOpenAiTierRollbackSnapshot(configPath, failingIo); }, + save: () => { saves.push(1); }, + })).toThrow("Failed to read preserved rollback snapshot"); + + expect(saves).toEqual([]); + expect(unlinks).toHaveLength(1); + expect(unlinks[0]!.includes("pre-openai-tiers-v1-rollback")).toBe(true); + expect(readFileSync(v2Backup, "utf8")).toBe(rollbackBytes); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + expect(readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback"))).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("startup does not save when preserved cleanup cannot unlink a scrubbed copy (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-cleanupfail-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const rollbackBytes = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, rollbackBytes); + const saves: number[] = []; + const leftoverHarden: string[] = []; + const failingIo = preserveIo(v2Backup, { + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + harden: path => { leftoverHarden.push(path); }, + write: () => { throw new Error("write must not run"); }, + unlink: path => { + if (path === v2Backup) throw new Error("source unlink must not run"); + throw new Error("unlink failed"); + }, + mkdirExclusive: () => { throw new Error("mkdir must not run"); }, + claimExclusive: () => { throw new Error("claim must not run"); }, + }); + + expect(() => runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => backupConfigBeforeOpenAiTierMigration(configPath), + preserveRollback: () => { preserveOpenAiTierRollbackSnapshot(configPath, failingIo); }, + save: () => { saves.push(1); }, + })).toThrow(OpenAiTierRollbackPreserveCleanupError); + + expect(saves).toEqual([]); + expect(readFileSync(v2Backup, "utf8")).toBe(rollbackBytes); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + const leftover = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(leftover).toHaveLength(1); + expect(readFileSync(join(dir, leftover[0]!), "utf8")).toBe(""); + expect(leftoverHarden).toEqual([join(dir, leftover[0]!)]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("startup propagates residual-secret errors without saving or deleting the v2 backup (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-residual-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const rollbackBytes = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, rollbackBytes); + const saves: number[] = []; + const failingIo = preserveIo(v2Backup, { + read: path => { + if (path.includes("pre-openai-tiers-v1-rollback")) throw new Error("read preserved failed"); + return readFileSync(path); + }, + harden: () => {}, + truncate: () => { throw new Error("truncate failed"); }, + write: () => { throw new Error("write failed"); }, + unlink: path => { + if (path === v2Backup) throw new Error("source unlink must not run"); + throw new Error("unlink failed"); + }, + mkdirExclusive: () => { throw new Error("mkdir must not run"); }, + claimExclusive: () => { throw new Error("claim must not run"); }, + }); + + expect(() => runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => backupConfigBeforeOpenAiTierMigration(configPath), + preserveRollback: () => { preserveOpenAiTierRollbackSnapshot(configPath, failingIo); }, + save: () => { saves.push(1); }, + })).toThrow(OpenAiTierRollbackPreserveSecretResidualError); + + expect(saves).toEqual([]); + expect(readFileSync(v2Backup, "utf8")).toBe(rollbackBytes); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("startup does not save when a replacement backup appears during preserve claim (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-claimrace-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + const bytesB = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, bytesA); + const backups: string[] = []; + const saves: number[] = []; + const unlinks: string[] = []; + + expect(() => runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => { + backups.push(readFileSync(v2Backup, "utf8")); + backupConfigBeforeOpenAiTierMigration(configPath); + }, + preserveRollback: () => { + preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(v2Backup, { + claimExclusive: (source, destination) => { + renameSync(source, destination); + writeFileSync(source, bytesB); + }, + unlink: path => { + unlinks.push(path); + if (path === v2Backup) throw new Error("replacement B must not be unlinked"); + unlinkSync(path); + }, + })); + }, + save: () => { saves.push(1); }, + })).toThrow(OpenAiTierBackupCollisionError); + + expect(backups).toEqual([bytesA, bytesB]); + expect(saves).toEqual([]); + expect(readFileSync(v2Backup, "utf8")).toBe(bytesB); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(preserved).toHaveLength(1); + expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); + expect(unlinks).toHaveLength(1); + expect(unlinks[0]!.endsWith("claimed.bak")).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("startup does not save when claimed-read fails after a replacement backup appears (#1599)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-1599-claimedread-")); + try { + const configPath = join(dir, "config.json"); + const v2Backup = `${configPath}.pre-openai-tiers-v2.bak`; + const currentConfig: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { kimi: { adapter: "openai-chat", baseUrl: "https://api.moonshot.cn/v1" } }, + }; + const currentBytes = `${JSON.stringify(currentConfig, null, 2)}\n`; + const bytesA = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai-multi", providers: {} }); + const bytesB = JSON.stringify({ openaiProviderTierVersion: 1, defaultProvider: "openai", providers: {} }); + writeFileSync(configPath, currentBytes); + writeFileSync(v2Backup, bytesA); + const backups: string[] = []; + const saves: number[] = []; + const hardened: string[] = []; + const unlinks: string[] = []; + + let thrown: unknown; + try { + runOpenAiTierStartupMigration(currentConfig, { + project: projectOpenAiTierMigration, + backup: () => { + backups.push(readFileSync(v2Backup, "utf8")); + backupConfigBeforeOpenAiTierMigration(configPath); + }, + preserveRollback: () => { + preserveOpenAiTierRollbackSnapshot(configPath, preserveIo(v2Backup, { + read: path => { + if (path.endsWith("claimed.bak")) throw new Error("read claimed failed"); + return readFileSync(path); + }, + harden: path => { hardened.push(path); }, + claimExclusive: (source, destination) => { + renameSync(source, destination); + writeFileSync(source, bytesB); + }, + unlink: path => { + unlinks.push(path); + throw new Error("claimed-read failure must not unlink"); + }, + })); + }, + save: () => { saves.push(1); }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OpenAiTierRollbackPreserveClaimError); + const claimed = thrown as OpenAiTierRollbackPreserveClaimError; + expect(claimed.claimedPath.endsWith("claimed.bak")).toBe(true); + expect(existsSync(claimed.claimedPath)).toBe(true); + expect(readFileSync(claimed.claimedPath, "utf8")).toBe(bytesA); + expect((claimed.cause as Error).message).toBe("read claimed failed"); + expect(hardened).toContain(claimed.claimedPath); + expect(backups).toEqual([bytesA]); + expect(saves).toEqual([]); + expect(unlinks).toEqual([]); + expect(readFileSync(v2Backup, "utf8")).toBe(bytesB); + expect(readFileSync(configPath, "utf8")).toBe(currentBytes); + const preserved = readdirSync(dir).filter(name => name.includes("pre-openai-tiers-v1-rollback")); + expect(preserved).toHaveLength(1); + expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(bytesA); + expect(hardened[0]).toBe(join(dir, preserved[0]!)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); });