Skip to content

Commit 4e2666a

Browse files
committed
fix: sanitize import backup failure reporting
1 parent b8bbd70 commit 4e2666a

2 files changed

Lines changed: 55 additions & 17 deletions

File tree

lib/storage.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { promises as fs, existsSync } from "node:fs";
22
import { randomBytes } from "node:crypto";
3-
import { dirname, join } from "node:path";
3+
import { basename, dirname, join } from "node:path";
44
import { ACCOUNT_LIMITS } from "./constants.js";
55
import { createLogger } from "./logger.js";
66
import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js";
@@ -1210,17 +1210,12 @@ function analyzeImportedAccounts(
12101210
importedAccounts: AccountStorageV3["accounts"],
12111211
): ImportPreviewResult & { accounts: AccountMetadataV3[] } {
12121212
const merged = [...existingAccounts, ...importedAccounts];
1213-
1214-
if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) {
1215-
const deduped = deduplicateAccountsForStorage(merged);
1216-
if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) {
1217-
throw new Error(
1218-
`Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`,
1219-
);
1220-
}
1221-
}
1222-
12231213
const accounts = deduplicateAccountsForStorage(merged);
1214+
if (accounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) {
1215+
throw new Error(
1216+
`Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${accounts.length})`,
1217+
);
1218+
}
12241219
const imported = Math.max(0, accounts.length - existingAccounts.length);
12251220
const skipped = Math.max(0, importedAccounts.length - imported);
12261221
return {
@@ -1231,6 +1226,14 @@ function analyzeImportedAccounts(
12311226
};
12321227
}
12331228

1229+
/**
1230+
* Import preview/apply analysis is pure in-memory work: it does not touch disk
1231+
* and it does not log token or workspace values. The surrounding
1232+
* `withAccountStorageTransaction` caller keeps Windows lock-retry and
1233+
* serialized read-modify-write behavior; see `test/storage.test.ts` for the
1234+
* overlapping transaction regression and pre-import backup lock coverage.
1235+
*/
1236+
12341237
export async function previewImportAccounts(
12351238
filePath: string,
12361239
): Promise<ImportPreviewResult> {
@@ -1313,6 +1316,7 @@ export async function importAccounts(
13131316
let backupStatus: ImportBackupStatus = "skipped";
13141317
let backupPath: string | undefined;
13151318
let backupError: string | undefined;
1319+
let backupLogError: string | undefined;
13161320
if (backupMode !== "none" && existingAccounts.length > 0) {
13171321
backupPath = createTimestampedBackupPath(backupPrefix);
13181322
try {
@@ -1321,12 +1325,20 @@ export async function importAccounts(
13211325
} catch (error) {
13221326
backupStatus = "failed";
13231327
backupError = error instanceof Error ? error.message : String(error);
1328+
const backupCode = (error as NodeJS.ErrnoException)?.code;
1329+
backupLogError = backupCode
1330+
? `pre-import backup failed (${backupCode})`
1331+
: "pre-import backup failed";
13241332
if (backupMode === "required") {
1325-
throw new Error(`Pre-import backup failed: ${backupError}`);
1333+
throw new Error(
1334+
backupCode
1335+
? `Pre-import backup failed (${backupCode})`
1336+
: "Pre-import backup failed",
1337+
);
13261338
}
13271339
log.warn("Pre-import backup failed; continuing import apply", {
1328-
path: backupPath,
1329-
error: backupError,
1340+
backupFile: backupPath ? basename(backupPath) : undefined,
1341+
error: backupLogError,
13301342
});
13311343
}
13321344
}
@@ -1384,8 +1396,8 @@ export async function importAccounts(
13841396
skipped: skippedCount,
13851397
total,
13861398
backupStatus,
1387-
backupPath,
1388-
backupError,
1399+
backupFile: backupPath ? basename(backupPath) : undefined,
1400+
backupError: backupError ? "available on command result" : undefined,
13891401
});
13901402

13911403
return {

test/storage.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,32 @@ describe("storage", () => {
204204
expect(loaded?.accounts[0]?.accountId).toBe("existing");
205205
});
206206

207+
it("keeps preview and apply counts aligned for the same import fixture", async () => {
208+
await saveAccounts({
209+
version: 3,
210+
activeIndex: 0,
211+
accounts: [{ accountId: "existing", refreshToken: "ref1", addedAt: 1, lastUsed: 2 }],
212+
});
213+
214+
await fs.writeFile(
215+
exportPath,
216+
JSON.stringify({
217+
version: 3,
218+
activeIndex: 0,
219+
accounts: [{ accountId: "preview", refreshToken: "ref2", addedAt: 3, lastUsed: 4 }],
220+
}),
221+
);
222+
223+
const preview = await previewImportAccounts(exportPath);
224+
const applied = await importAccounts(exportPath);
225+
226+
expect(preview).toMatchObject({
227+
imported: applied.imported,
228+
skipped: applied.skipped,
229+
total: applied.total,
230+
});
231+
});
232+
207233
it("creates timestamped backup paths in storage backups directory", () => {
208234
const path = createTimestampedBackupPath();
209235
const expectedBackupDir = join(dirname(testStoragePath), "backups");
@@ -730,7 +756,7 @@ describe("storage", () => {
730756
preImportBackupPrefix: "codex-pre-import-backup",
731757
backupMode: "required",
732758
}),
733-
).rejects.toThrow(/Pre-import backup failed: Timed out writing file/);
759+
).rejects.toThrow("Pre-import backup failed");
734760
} finally {
735761
writeSpy.mockRestore();
736762
}

0 commit comments

Comments
 (0)