Skip to content

Commit 6b1cb80

Browse files
committed
fix: recover flagged account backups safely
1 parent 9610df9 commit 6b1cb80

3 files changed

Lines changed: 107 additions & 4 deletions

File tree

lib/storage/flagged-storage-io.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { existsSync, promises as fs } from "node:fs";
22
import { dirname } from "node:path";
33
import type { FlaggedAccountStorageV1 } from "../storage.js";
44

5+
function getFlaggedBackupPaths(path: string): string[] {
6+
return [`${path}.bak`, `${path}.bak.1`, `${path}.bak.2`];
7+
}
8+
59
export async function loadFlaggedAccountsState(params: {
610
path: string;
711
legacyPath: string;
@@ -12,6 +16,34 @@ export async function loadFlaggedAccountsState(params: {
1216
logInfo: (message: string, details: Record<string, unknown>) => void;
1317
}): Promise<FlaggedAccountStorageV1> {
1418
const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] };
19+
if (existsSync(params.resetMarkerPath)) {
20+
return empty;
21+
}
22+
const loadFlaggedBackup = async (): Promise<FlaggedAccountStorageV1 | null> => {
23+
for (const backupPath of getFlaggedBackupPaths(params.path)) {
24+
if (!existsSync(backupPath)) {
25+
continue;
26+
}
27+
try {
28+
const backupContent = await fs.readFile(backupPath, "utf-8");
29+
const backupData = JSON.parse(backupContent) as unknown;
30+
const recovered = params.normalizeFlaggedStorage(backupData);
31+
params.logInfo("Recovered flagged account storage from backup", {
32+
from: backupPath,
33+
to: params.path,
34+
accounts: recovered.accounts.length,
35+
});
36+
return recovered;
37+
} catch (backupError) {
38+
params.logError("Failed to recover flagged account storage from backup", {
39+
from: backupPath,
40+
to: params.path,
41+
error: String(backupError),
42+
});
43+
}
44+
}
45+
return null;
46+
};
1547

1648
try {
1749
const content = await fs.readFile(params.path, "utf-8");
@@ -28,10 +60,15 @@ export async function loadFlaggedAccountsState(params: {
2860
path: params.path,
2961
error: String(error),
3062
});
31-
return empty;
63+
return (await loadFlaggedBackup()) ?? empty;
3264
}
3365
}
3466

67+
const recoveredBackup = await loadFlaggedBackup();
68+
if (recoveredBackup) {
69+
return recoveredBackup;
70+
}
71+
3572
if (!existsSync(params.legacyPath)) {
3673
return empty;
3774
}
@@ -129,6 +166,7 @@ export async function clearFlaggedAccountsOnDisk(params: {
129166
backupPaths: string[];
130167
logError: (message: string, details: Record<string, unknown>) => void;
131168
}): Promise<void> {
169+
let keepResetMarker = false;
132170
try {
133171
await fs.writeFile(params.markerPath, "reset", {
134172
encoding: "utf-8",
@@ -145,7 +183,6 @@ export async function clearFlaggedAccountsOnDisk(params: {
145183
for (const candidate of [
146184
params.path,
147185
...params.backupPaths,
148-
params.markerPath,
149186
]) {
150187
try {
151188
await fs.unlink(candidate);
@@ -159,6 +196,21 @@ export async function clearFlaggedAccountsOnDisk(params: {
159196
if (candidate === params.path) {
160197
throw error;
161198
}
199+
keepResetMarker = true;
200+
}
201+
}
202+
}
203+
if (!keepResetMarker) {
204+
try {
205+
await fs.unlink(params.markerPath);
206+
} catch (error) {
207+
const code = (error as NodeJS.ErrnoException).code;
208+
if (code !== "ENOENT") {
209+
params.logError("Failed to clear flagged account storage", {
210+
path: params.markerPath,
211+
error: String(error),
212+
});
213+
throw error;
162214
}
163215
}
164216
}

test/storage-flagged.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ describe("flagged account storage", () => {
292292
unlinkSpy.mockRestore();
293293
});
294294

295-
it("does not recover flagged backups when the primary file exists but read fails", async () => {
295+
it("recovers flagged backups when the primary file exists but read fails", async () => {
296296
await saveFlaggedAccounts({
297297
version: 1,
298298
accounts: [
@@ -334,7 +334,8 @@ describe("flagged account storage", () => {
334334
});
335335

336336
const flagged = await loadFlaggedAccounts();
337-
expect(flagged.accounts).toHaveLength(0);
337+
expect(flagged.accounts).toHaveLength(1);
338+
expect(flagged.accounts[0]?.refreshToken).toBe("primary-flagged");
338339
expect(existsSync(flaggedPath)).toBe(true);
339340

340341
readSpy.mockRestore();

test/storage-recovery-paths.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
66
import { removeWithRetry } from "./helpers/remove-with-retry.js";
77
import {
88
loadAccounts,
9+
loadFlaggedAccounts,
910
getBackupMetadata,
1011
saveAccounts,
1112
setStorageBackupEnabled,
@@ -109,6 +110,55 @@ describe("storage recovery paths", () => {
109110
expect(persisted.accounts?.[0]?.accountId).toBe("from-backup");
110111
});
111112

113+
it("recovers flagged accounts from backup file when primary storage is missing", async () => {
114+
const flaggedPath = join(workDir, "openai-codex-flagged-accounts.json");
115+
await fs.writeFile(
116+
`${flaggedPath}.bak`,
117+
JSON.stringify({
118+
version: 1,
119+
accounts: [
120+
{
121+
refreshToken: "flagged-refresh",
122+
email: "flagged@example.com",
123+
addedAt: 7,
124+
lastUsed: 7,
125+
flaggedAt: 7,
126+
},
127+
],
128+
}),
129+
"utf-8",
130+
);
131+
132+
const recovered = await loadFlaggedAccounts();
133+
expect(recovered.accounts).toHaveLength(1);
134+
expect(recovered.accounts[0]?.email).toBe("flagged@example.com");
135+
});
136+
137+
it("recovers flagged accounts from backup file when primary storage is unreadable", async () => {
138+
const flaggedPath = join(workDir, "openai-codex-flagged-accounts.json");
139+
await fs.writeFile(flaggedPath, "{broken-primary", "utf-8");
140+
await fs.writeFile(
141+
`${flaggedPath}.bak`,
142+
JSON.stringify({
143+
version: 1,
144+
accounts: [
145+
{
146+
refreshToken: "flagged-refresh-2",
147+
email: "flagged2@example.com",
148+
addedAt: 8,
149+
lastUsed: 8,
150+
flaggedAt: 8,
151+
},
152+
],
153+
}),
154+
"utf-8",
155+
);
156+
157+
const recovered = await loadFlaggedAccounts();
158+
expect(recovered.accounts).toHaveLength(1);
159+
expect(recovered.accounts[0]?.email).toBe("flagged2@example.com");
160+
});
161+
112162
it("falls back to historical backup snapshots when the latest backup is unreadable", async () => {
113163
await fs.writeFile(storagePath, "{broken-primary", "utf-8");
114164
await fs.writeFile(`${storagePath}.bak`, "{broken-latest-backup", "utf-8");

0 commit comments

Comments
 (0)