Skip to content

Commit 2976356

Browse files
committed
fix(init): publish preserved rollback snapshot with no-replace copy
Date.now-based renameSync could silently overwrite an existing rollback snapshot on POSIX when the destination already existed (frozen clock, clock rollback, pre-created file). Use copyFileSync with COPYFILE_EXCL plus a sequence-suffix retry so a destination collision never destroys another snapshot, then unlink the source only after publication.
1 parent a41170c commit 2976356

2 files changed

Lines changed: 42 additions & 4 deletions

File tree

src/cli/init.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as readline from "node:readline";
2-
import { existsSync, readFileSync, renameSync, unlinkSync } from "node:fs";
2+
import { constants as fsConstants, copyFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
33
import { injectCodexConfig } from "../codex/inject";
44
import { classifyOpenAiTierBackup, getConfigPath, getDefaultConfig, isValidProviderName, saveConfig } from "../config";
55
import { enrichProviderFromCatalog } from "../oauth/key-providers";
@@ -63,9 +63,21 @@ export function cleanupOpenAiTierBackupAfterInit(configPath = getConfigPath()):
6363
unlinkSync(backup);
6464
return;
6565
}
66-
const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}.bak`;
67-
renameSync(backup, preserved);
68-
console.warn(`⚠️ Kept your pre-migration config rollback snapshot at ${preserved}`);
66+
// Publish the preserved snapshot with a no-replace copy (COPYFILE_EXCL) so a
67+
// destination collision (frozen/rolled-back clock, pre-created file) can never
68+
// silently overwrite another rollback snapshot; retry with a sequence suffix.
69+
for (let attempt = 0; attempt < 16; attempt++) {
70+
const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`;
71+
try {
72+
copyFileSync(backup, preserved, fsConstants.COPYFILE_EXCL);
73+
} catch (error) {
74+
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
75+
throw error;
76+
}
77+
unlinkSync(backup);
78+
console.warn(`⚠️ Kept your pre-migration config rollback snapshot at ${preserved}`);
79+
return;
80+
}
6981
} catch { /* cleanup is best-effort; never block init on backup housekeeping */ }
7082
}
7183

tests/init-backup-cleanup.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,32 @@ describe("cleanupOpenAiTierBackupAfterInit", () => {
5555
expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(v1);
5656
});
5757

58+
test("does not overwrite an occupied rollback destination (no-replace publication)", () => {
59+
const dir = makeDir();
60+
const configPath = join(dir, "config.json");
61+
const backup = `${configPath}.pre-openai-tiers-v2.bak`;
62+
const v1 = JSON.stringify({ openaiProviderTierVersion: 1, port: 10100, defaultProvider: "openai", providers: {} });
63+
writeFileSync(backup, v1);
64+
// Pre-occupy the first-choice destination for the current clock tick(s).
65+
const now = Date.now();
66+
const existing = `${configPath}.pre-openai-tiers-v1-rollback.${now}.bak`;
67+
writeFileSync(existing, "existing rollback");
68+
const realNow = Date.now;
69+
Date.now = () => now; // freeze the clock so the collision is deterministic
70+
try {
71+
cleanupOpenAiTierBackupAfterInit(configPath);
72+
} finally {
73+
Date.now = realNow;
74+
}
75+
// The pre-existing snapshot must be untouched, and the v1 backup preserved elsewhere.
76+
expect(readFileSync(existing, "utf8")).toBe("existing rollback");
77+
expect(existsSync(backup)).toBe(false);
78+
const preserved = readdirSync(dir)
79+
.filter(name => name.includes("pre-openai-tiers-v1-rollback") && join(dir, name) !== existing);
80+
expect(preserved).toHaveLength(1);
81+
expect(readFileSync(join(dir, preserved[0]!), "utf8")).toBe(v1);
82+
});
83+
5884
test("classifyOpenAiTierBackup shares the migration policy", () => {
5985
const enc = (value: string) => new TextEncoder().encode(value);
6086
expect(classifyOpenAiTierBackup(enc(JSON.stringify({ openaiProviderTierVersion: 2 })))).toBe("stale");

0 commit comments

Comments
 (0)