diff --git a/lib/runtime-paths.ts b/lib/runtime-paths.ts index dd8ab244..427749e3 100644 --- a/lib/runtime-paths.ts +++ b/lib/runtime-paths.ts @@ -1,7 +1,37 @@ import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, win32 } from "node:path"; import { existsSync } from "node:fs"; +function firstNonEmpty(values: Array): string | null { + for (const value of values) { + const trimmed = (value ?? "").trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + return null; +} + +function getResolvedUserHomeDir(): string { + if (process.platform === "win32") { + const homeDrive = (process.env.HOMEDRIVE ?? "").trim(); + const homePath = (process.env.HOMEPATH ?? "").trim(); + const drivePathHome = + homeDrive.length > 0 && homePath.length > 0 + ? win32.resolve(`${homeDrive}\\`, homePath) + : undefined; + return ( + firstNonEmpty([ + process.env.USERPROFILE, + process.env.HOME, + drivePathHome, + homedir(), + ]) ?? homedir() + ); + } + return firstNonEmpty([process.env.HOME, homedir()]) ?? homedir(); +} + /** * Resolve the Codex home directory path used by the CLI, honoring an environment override or a sensible default. * @@ -12,7 +42,7 @@ import { existsSync } from "node:fs"; export function getCodexHomeDir(): string { const fromEnv = (process.env.CODEX_HOME ?? "").trim(); - return fromEnv.length > 0 ? fromEnv : join(homedir(), ".codex"); + return fromEnv.length > 0 ? fromEnv : join(getResolvedUserHomeDir(), ".codex"); } /** @@ -93,10 +123,11 @@ function hasAccountsStorage(dir: string): boolean { * @returns An array of unique, trimmed directory paths to probe for Codex home data, in prioritized order. */ function getFallbackCodexHomeDirs(): string[] { + const userHome = getResolvedUserHomeDir(); return deduplicatePaths([ getCodexHomeDir(), - join(homedir(), "DevTools", "config", "codex"), - join(homedir(), ".codex"), + join(userHome, "DevTools", "config", "codex"), + join(userHome, ".codex"), ]); } @@ -189,6 +220,6 @@ export function getCodexLogDir(): string { * @returns The filesystem path for the legacy directory (e.g. `/home/alice/.codex`). */ export function getLegacyCodexDir(): string { - return join(homedir(), ".codex"); + return join(getResolvedUserHomeDir(), ".codex"); } diff --git a/lib/storage.ts b/lib/storage.ts index 1f6c5cb1..30c765c2 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -31,6 +31,7 @@ const FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-flagged-accounts.json"; const LEGACY_FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-blocked-accounts.json"; const ACCOUNTS_BACKUP_SUFFIX = ".bak"; const ACCOUNTS_WAL_SUFFIX = ".wal"; +const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; @@ -156,10 +157,176 @@ function getAccountsBackupPath(path: string): string { return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; } +function getAccountsBackupPathAtIndex(path: string, index: number): string { + if (index <= 0) { + return getAccountsBackupPath(path); + } + return `${path}${ACCOUNTS_BACKUP_SUFFIX}.${index}`; +} + +function getAccountsBackupRecoveryCandidates(path: string): string[] { + const candidates: string[] = []; + for (let i = 0; i < ACCOUNTS_BACKUP_HISTORY_DEPTH; i += 1) { + candidates.push(getAccountsBackupPathAtIndex(path, i)); + } + return candidates; +} + function getAccountsWalPath(path: string): string { return `${path}${ACCOUNTS_WAL_SUFFIX}`; } +async function copyFileWithRetry( + sourcePath: string, + destinationPath: string, + options?: { allowMissingSource?: boolean }, +): Promise { + const allowMissingSource = options?.allowMissingSource ?? false; + for (let attempt = 0; attempt < BACKUP_COPY_MAX_ATTEMPTS; attempt += 1) { + try { + await fs.copyFile(sourcePath, destinationPath); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (allowMissingSource && code === "ENOENT") { + return; + } + const canRetry = + (code === "EPERM" || code === "EBUSY") && + attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS; + if (canRetry) { + await new Promise((resolve) => + setTimeout(resolve, BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt), + ); + continue; + } + throw error; + } + } +} + +async function renameFileWithRetry(sourcePath: string, destinationPath: string): Promise { + for (let attempt = 0; attempt < BACKUP_COPY_MAX_ATTEMPTS; attempt += 1) { + try { + await fs.rename(sourcePath, destinationPath); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const canRetry = + (code === "EPERM" || code === "EBUSY" || code === "EAGAIN") && + attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS; + if (!canRetry) { + throw error; + } + const jitterMs = Math.floor(Math.random() * BACKUP_COPY_BASE_DELAY_MS); + await new Promise((resolve) => + setTimeout(resolve, BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt + jitterMs), + ); + } + } +} + +async function createRotatingAccountsBackup(path: string): Promise { + const candidates = getAccountsBackupRecoveryCandidates(path); + const rotationNonce = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; + const stagedWrites: Array<{ targetPath: string; stagedPath: string }> = []; + const buildStagedPath = (targetPath: string, label: string): string => + `${targetPath}.rotate.${rotationNonce}.${label}.tmp`; + + try { + for (let i = candidates.length - 1; i > 0; i -= 1) { + const previousPath = candidates[i - 1]; + const currentPath = candidates[i]; + if (!previousPath || !currentPath || !existsSync(previousPath)) { + continue; + } + const stagedPath = buildStagedPath(currentPath, `slot-${i}`); + await copyFileWithRetry(previousPath, stagedPath, { allowMissingSource: true }); + if (existsSync(stagedPath)) { + stagedWrites.push({ targetPath: currentPath, stagedPath }); + } + } + + const latestBackupPath = candidates[0]; + if (!latestBackupPath) { + return; + } + const latestStagedPath = buildStagedPath(latestBackupPath, "latest"); + await copyFileWithRetry(path, latestStagedPath); + if (existsSync(latestStagedPath)) { + stagedWrites.push({ targetPath: latestBackupPath, stagedPath: latestStagedPath }); + } + + for (const stagedWrite of stagedWrites) { + await renameFileWithRetry(stagedWrite.stagedPath, stagedWrite.targetPath); + } + } finally { + for (const stagedWrite of stagedWrites) { + if (!existsSync(stagedWrite.stagedPath)) { + continue; + } + try { + await fs.unlink(stagedWrite.stagedPath); + } catch { + // Best effort cleanup for staged rotation artifacts. + } + } + } +} + +function isRotatingBackupTempArtifact(storagePath: string, candidatePath: string): boolean { + const backupPrefix = `${storagePath}${ACCOUNTS_BACKUP_SUFFIX}`; + if (!candidatePath.startsWith(backupPrefix) || !candidatePath.endsWith(".tmp")) { + return false; + } + + const suffix = candidatePath.slice(backupPrefix.length); + const rotateSeparatorIndex = suffix.indexOf(".rotate."); + if (rotateSeparatorIndex === -1) { + return false; + } + + const backupIndexSuffix = suffix.slice(0, rotateSeparatorIndex); + if (backupIndexSuffix.length > 0 && !/^\.\d+$/.test(backupIndexSuffix)) { + return false; + } + + return true; +} + +async function cleanupStaleRotatingBackupArtifacts(path: string): Promise { + const directoryPath = dirname(path); + try { + const directoryEntries = await fs.readdir(directoryPath, { withFileTypes: true }); + const staleArtifacts = directoryEntries + .filter((entry) => entry.isFile()) + .map((entry) => join(directoryPath, entry.name)) + .filter((entryPath) => isRotatingBackupTempArtifact(path, entryPath)); + + for (const staleArtifactPath of staleArtifacts) { + try { + await fs.unlink(staleArtifactPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to remove stale rotating backup artifact", { + path: staleArtifactPath, + error: String(error), + }); + } + } + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to scan for stale rotating backup artifacts", { + path, + error: String(error), + }); + } + } +} + function computeSha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } @@ -675,6 +842,7 @@ async function loadAccountsInternal( persistMigration: ((storage: AccountStorageV3) => Promise) | null, ): Promise { const path = getStoragePath(); + await cleanupStaleRotatingBackupArtifacts(path); const migratedLegacyStorage = persistMigration ? await migrateLegacyProjectStorageIfNeeded(persistMigration) : null; @@ -718,36 +886,38 @@ async function loadAccountsInternal( } if (storageBackupEnabled) { - const backupPath = getAccountsBackupPath(path); - try { - const backup = await loadAccountsFromPath(backupPath); - if (backup.schemaErrors.length > 0) { - log.warn("Backup account storage schema validation warnings", { - path: backupPath, - errors: backup.schemaErrors.slice(0, 5), - }); - } - if (backup.normalized) { - log.warn("Recovered account storage from backup file", { path, backupPath }); - if (persistMigration) { - try { - await persistMigration(backup.normalized); - } catch (persistError) { - log.warn("Failed to persist recovered backup storage", { - path, - error: String(persistError), - }); + const backupCandidates = getAccountsBackupRecoveryCandidates(path); + for (const backupPath of backupCandidates) { + try { + const backup = await loadAccountsFromPath(backupPath); + if (backup.schemaErrors.length > 0) { + log.warn("Backup account storage schema validation warnings", { + path: backupPath, + errors: backup.schemaErrors.slice(0, 5), + }); + } + if (backup.normalized) { + log.warn("Recovered account storage from backup file", { path, backupPath }); + if (persistMigration) { + try { + await persistMigration(backup.normalized); + } catch (persistError) { + log.warn("Failed to persist recovered backup storage", { + path, + error: String(persistError), + }); + } } + return backup.normalized; + } + } catch (backupError) { + const backupCode = (backupError as NodeJS.ErrnoException).code; + if (backupCode !== "ENOENT") { + log.warn("Failed to load backup account storage", { + path: backupPath, + error: String(backupError), + }); } - return backup.normalized; - } - } catch (backupError) { - const backupCode = (backupError as NodeJS.ErrnoException).code; - if (backupCode !== "ENOENT") { - log.warn("Failed to load backup account storage", { - path: backupPath, - error: String(backupError), - }); } } } @@ -770,29 +940,12 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { await ensureGitignore(path); if (storageBackupEnabled && existsSync(path)) { - const backupPath = getAccountsBackupPath(path); try { - for (let attempt = 0; attempt < BACKUP_COPY_MAX_ATTEMPTS; attempt += 1) { - try { - await fs.copyFile(path, backupPath); - break; - } catch (backupError) { - const code = (backupError as NodeJS.ErrnoException).code; - const canRetry = (code === "EPERM" || code === "EBUSY") && - attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS; - if (canRetry) { - await new Promise((resolve) => - setTimeout(resolve, BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt) - ); - continue; - } - throw backupError; - } - } + await createRotatingAccountsBackup(path); } catch (backupError) { log.warn("Failed to create account storage backup", { path, - backupPath, + backupPath: getAccountsBackupPath(path), error: String(backupError), }); } @@ -902,7 +1055,7 @@ export async function clearAccounts(): Promise { return withStorageLock(async () => { const path = getStoragePath(); const walPath = getAccountsWalPath(path); - const backupPath = getAccountsBackupPath(path); + const backupPaths = getAccountsBackupRecoveryCandidates(path); const clearPath = async (targetPath: string): Promise => { try { await fs.unlink(targetPath); @@ -918,7 +1071,7 @@ export async function clearAccounts(): Promise { }; try { - await Promise.all([clearPath(path), clearPath(walPath), clearPath(backupPath)]); + await Promise.all([clearPath(path), clearPath(walPath), ...backupPaths.map(clearPath)]); } catch { // Individual path cleanup is already best-effort with per-artifact logging. } diff --git a/test/runtime-paths.test.ts b/test/runtime-paths.test.ts index 7d52fee6..aeba1d52 100644 --- a/test/runtime-paths.test.ts +++ b/test/runtime-paths.test.ts @@ -7,16 +7,39 @@ const homedir = vi.fn(() => "/home/neil"); vi.mock("node:fs", () => ({ existsSync })); vi.mock("node:os", () => ({ homedir })); +const ENV_KEYS = [ + "CODEX_HOME", + "CODEX_MULTI_AUTH_DIR", + "USERPROFILE", + "HOME", + "HOMEDRIVE", + "HOMEPATH", +] as const; + +type EnvKey = (typeof ENV_KEYS)[number]; + describe("runtime-paths", () => { + const originalEnv: Partial> = {}; + beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - delete process.env.CODEX_HOME; - delete process.env.CODEX_MULTI_AUTH_DIR; + for (const key of ENV_KEYS) { + originalEnv[key] = process.env[key]; + delete process.env[key]; + } homedir.mockReturnValue("/home/neil"); }); afterEach(() => { + for (const key of ENV_KEYS) { + const value = originalEnv[key]; + if (typeof value === "string") { + process.env[key] = value; + } else { + delete process.env[key]; + } + } vi.restoreAllMocks(); }); @@ -68,4 +91,55 @@ describe("runtime-paths", () => { platformSpy.mockRestore(); } }); + + it("prefers USERPROFILE over os.homedir on Windows when CODEX_HOME is unset", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + homedir.mockReturnValue("C:\\Windows\\System32\\config\\systemprofile"); + process.env.USERPROFILE = "C:\\Users\\Alice"; + const mod = await import("../lib/runtime-paths.js"); + expect(mod.getCodexHomeDir()).toBe("C:\\Users\\Alice\\.codex"); + expect(mod.getLegacyCodexDir()).toBe("C:\\Users\\Alice\\.codex"); + } finally { + platformSpy.mockRestore(); + } + }); + + it("falls back to HOME when USERPROFILE is missing on Windows", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + homedir.mockReturnValue("C:\\Windows\\System32\\config\\systemprofile"); + process.env.HOME = "D:\\Users\\Bob"; + const mod = await import("../lib/runtime-paths.js"); + expect(mod.getCodexHomeDir()).toBe("D:\\Users\\Bob\\.codex"); + } finally { + platformSpy.mockRestore(); + } + }); + + it("falls back to HOMEDRIVE and HOMEPATH when USERPROFILE and HOME are missing on Windows", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + homedir.mockReturnValue("C:\\Windows\\System32\\config\\systemprofile"); + process.env.HOMEDRIVE = "E:"; + process.env.HOMEPATH = "\\Users\\Carol"; + const mod = await import("../lib/runtime-paths.js"); + expect(mod.getCodexHomeDir()).toBe("E:\\Users\\Carol\\.codex"); + } finally { + platformSpy.mockRestore(); + } + }); + + it("normalizes HOMEPATH without a leading slash on Windows", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + homedir.mockReturnValue("C:\\Windows\\System32\\config\\systemprofile"); + process.env.HOMEDRIVE = "E:"; + process.env.HOMEPATH = "Users\\Carol"; + const mod = await import("../lib/runtime-paths.js"); + expect(mod.getCodexHomeDir()).toBe("E:\\Users\\Carol\\.codex"); + } finally { + platformSpy.mockRestore(); + } + }); }); diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index f4f63f79..3b7ab43c 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { promises as fs } from "node:fs"; +import { promises as fs, existsSync } from "node:fs"; import { createHash } from "node:crypto"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -93,6 +93,97 @@ describe("storage recovery paths", () => { expect(persisted.accounts?.[0]?.accountId).toBe("from-backup"); }); + it("falls back to historical backup snapshots when the latest backup is unreadable", async () => { + await fs.writeFile(storagePath, "{broken-primary", "utf-8"); + await fs.writeFile(`${storagePath}.bak`, "{broken-latest-backup", "utf-8"); + + const historicalBackupPayload = { + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "historical-refresh", + accountId: "from-backup-history", + addedAt: 4, + lastUsed: 4, + }, + ], + }; + await fs.writeFile(`${storagePath}.bak.1`, JSON.stringify(historicalBackupPayload), "utf-8"); + + const recovered = await loadAccounts(); + expect(recovered?.accounts).toHaveLength(1); + expect(recovered?.accounts[0]?.accountId).toBe("from-backup-history"); + + const persisted = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { + accounts?: Array<{ accountId?: string }>; + }; + expect(persisted.accounts?.[0]?.accountId).toBe("from-backup-history"); + }); + + it("falls back to .bak.2 when newer backups are unreadable", async () => { + await fs.writeFile(storagePath, "{broken-primary", "utf-8"); + await fs.writeFile(`${storagePath}.bak`, "{broken-bak", "utf-8"); + await fs.writeFile(`${storagePath}.bak.1`, "{broken-bak-1", "utf-8"); + + const oldestBackupPayload = { + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "deep-refresh", + accountId: "from-backup-2", + addedAt: 5, + lastUsed: 5, + }, + ], + }; + await fs.writeFile(`${storagePath}.bak.2`, JSON.stringify(oldestBackupPayload), "utf-8"); + + const recovered = await loadAccounts(); + expect(recovered?.accounts).toHaveLength(1); + expect(recovered?.accounts[0]?.accountId).toBe("from-backup-2"); + + const persisted = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { + accounts?: Array<{ accountId?: string }>; + }; + expect(persisted.accounts?.[0]?.accountId).toBe("from-backup-2"); + }); + + it("cleans up stale staged backup artifacts during load", async () => { + await fs.writeFile( + storagePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "primary-refresh", accountId: "primary", addedAt: 6, lastUsed: 6 }], + }), + "utf-8", + ); + + const staleArtifacts = [ + `${storagePath}.bak.rotate.12345.abc123.latest.tmp`, + `${storagePath}.bak.1.rotate.12345.abc123.slot-1.tmp`, + `${storagePath}.bak.2.rotate.12345.abc123.slot-2.tmp`, + ]; + for (const staleArtifactPath of staleArtifacts) { + await fs.writeFile(staleArtifactPath, "stale", "utf-8"); + expect(existsSync(staleArtifactPath)).toBe(true); + } + const unrelatedArtifactPath = `${storagePath}.rotate.12345.abc123.latest.tmp`; + await fs.writeFile(unrelatedArtifactPath, "keep", "utf-8"); + expect(existsSync(unrelatedArtifactPath)).toBe(true); + + const recovered = await loadAccounts(); + expect(recovered?.accounts).toHaveLength(1); + expect(recovered?.accounts[0]?.accountId).toBe("primary"); + + for (const staleArtifactPath of staleArtifacts) { + expect(existsSync(staleArtifactPath)).toBe(false); + } + expect(existsSync(unrelatedArtifactPath)).toBe(true); + }); + it("does not use backup recovery when backups are disabled", async () => { setStorageBackupEnabled(false); await fs.writeFile(storagePath, "{broken-primary", "utf-8"); diff --git a/test/storage.test.ts b/test/storage.test.ts index a6edd30b..3c3157e8 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1561,14 +1561,250 @@ describe("storage", () => { } return originalCopy(src as string, dest as string); }); + try { + await saveAccounts({ + ...storage, + accounts: [{ refreshToken: "token-next", addedAt: now, lastUsed: now }], + }); + + expect(copyAttempts).toBe(2); + } finally { + copySpy.mockRestore(); + } + }); + + it("retries backup copyFile on transient EPERM and succeeds", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + // Seed a primary file so backup creation path runs on next save. + await saveAccounts(storage); + + const originalCopy = fs.copyFile.bind(fs); + let copyAttempts = 0; + const copySpy = vi.spyOn(fs, "copyFile").mockImplementation(async (src, dest) => { + copyAttempts += 1; + if (copyAttempts === 1) { + const err = new Error("EPERM copy") as NodeJS.ErrnoException; + err.code = "EPERM"; + throw err; + } + return originalCopy(src as string, dest as string); + }); + try { + await saveAccounts({ + ...storage, + accounts: [{ refreshToken: "token-next", addedAt: now, lastUsed: now }], + }); + + expect(copyAttempts).toBe(2); + } finally { + copySpy.mockRestore(); + } + }); + + it("retries staged backup rename on transient EBUSY and succeeds", async () => { + const now = Date.now(); + const storagePath = getStoragePath(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + // Seed a primary file so backup creation path runs on next save. + await saveAccounts(storage); + + const originalRename = fs.rename.bind(fs); + let stagedRenameAttempts = 0; + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (oldPath, newPath) => { + const sourcePath = String(oldPath); + if (sourcePath.includes(".rotate.")) { + stagedRenameAttempts += 1; + if (stagedRenameAttempts === 1) { + const err = new Error("EBUSY staged rename") as NodeJS.ErrnoException; + err.code = "EBUSY"; + throw err; + } + } + return originalRename(oldPath as string, newPath as string); + }); + try { + await saveAccounts({ + ...storage, + accounts: [{ refreshToken: "token-next", addedAt: now + 1, lastUsed: now + 1 }], + }); + + expect(stagedRenameAttempts).toBe(2); + const latestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token"); + } finally { + renameSpy.mockRestore(); + } + }); + + it("rotates backups and retains historical snapshots", async () => { + const now = Date.now(); + const storagePath = getStoragePath(); + + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-1", addedAt: now, lastUsed: now }], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-3", addedAt: now + 2, lastUsed: now + 2 }], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-4", addedAt: now + 3, lastUsed: now + 3 }], + }); + + const latestBackupRaw = await fs.readFile(`${storagePath}.bak`, "utf-8"); + const historicalBackupRaw = await fs.readFile(`${storagePath}.bak.1`, "utf-8"); + const oldestBackupRaw = await fs.readFile(`${storagePath}.bak.2`, "utf-8"); + const latestBackup = JSON.parse(latestBackupRaw) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const historicalBackup = JSON.parse(historicalBackupRaw) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const oldestBackup = JSON.parse(oldestBackupRaw) as { + accounts?: Array<{ refreshToken?: string }>; + }; + + expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-3"); + expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); + expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); + }); + + it("preserves historical backups when creating the latest backup fails", async () => { + const now = Date.now(); + const storagePath = getStoragePath(); + + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-1", addedAt: now, lastUsed: now }], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-3", addedAt: now + 2, lastUsed: now + 2 }], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-4", addedAt: now + 3, lastUsed: now + 3 }], + }); + + const originalCopy = fs.copyFile.bind(fs); + const copySpy = vi.spyOn(fs, "copyFile").mockImplementation(async (src, dest) => { + if (src === storagePath) { + const err = new Error("ENOSPC backup copy") as NodeJS.ErrnoException; + err.code = "ENOSPC"; + throw err; + } + return originalCopy(src as string, dest as string); + }); + try { + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-5", addedAt: now + 4, lastUsed: now + 4 }], + }); + } finally { + copySpy.mockRestore(); + } + + const primary = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const latestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const historicalBackup = JSON.parse(await fs.readFile(`${storagePath}.bak.1`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const oldestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak.2`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + + expect(primary.accounts?.[0]?.refreshToken).toBe("token-5"); + expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-3"); + expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); + expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); + }); + + it("keeps rotating backup order deterministic across parallel saves", async () => { + const now = Date.now(); + const storagePath = getStoragePath(); await saveAccounts({ - ...storage, - accounts: [{ refreshToken: "token-next", addedAt: now, lastUsed: now }], + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-0", addedAt: now, lastUsed: now }], }); - expect(copyAttempts).toBe(2); - copySpy.mockRestore(); + await Promise.all([ + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-1", addedAt: now + 1, lastUsed: now + 1 }], + }), + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-2", addedAt: now + 2, lastUsed: now + 2 }], + }), + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-3", addedAt: now + 3, lastUsed: now + 3 }], + }), + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-4", addedAt: now + 4, lastUsed: now + 4 }], + }), + ]); + + const latestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const primary = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const historicalBackup = JSON.parse(await fs.readFile(`${storagePath}.bak.1`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const oldestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak.2`, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + + expect(primary.accounts?.[0]?.refreshToken).toBe("token-4"); + expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-3"); + expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); + expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); }); }); @@ -1584,16 +1820,22 @@ describe("storage", () => { const storagePath = getStoragePath(); await saveAccounts(storage); await fs.writeFile(`${storagePath}.bak`, JSON.stringify(storage), "utf-8"); + await fs.writeFile(`${storagePath}.bak.1`, JSON.stringify(storage), "utf-8"); + await fs.writeFile(`${storagePath}.bak.2`, JSON.stringify(storage), "utf-8"); await fs.writeFile(`${storagePath}.wal`, JSON.stringify(storage), "utf-8"); expect(existsSync(storagePath)).toBe(true); expect(existsSync(`${storagePath}.bak`)).toBe(true); + expect(existsSync(`${storagePath}.bak.1`)).toBe(true); + expect(existsSync(`${storagePath}.bak.2`)).toBe(true); expect(existsSync(`${storagePath}.wal`)).toBe(true); await clearAccounts(); expect(existsSync(storagePath)).toBe(false); expect(existsSync(`${storagePath}.bak`)).toBe(false); + expect(existsSync(`${storagePath}.bak.1`)).toBe(false); + expect(existsSync(`${storagePath}.bak.2`)).toBe(false); expect(existsSync(`${storagePath}.wal`)).toBe(false); });