fix: make credential file writes atomic and fail closed on corruption#244
fix: make credential file writes atomic and fail closed on corruption#244bukinoshita wants to merge 2 commits intomainfrom
Conversation
…losed on corruption - Add writeFileAtomic: writes to tmp file, fsyncs, renames over original - Add withFileLock: advisory lock with stale detection for read/modify/write - readCredentials now throws CorruptedCredentialsError on malformed JSON instead of silently returning null (which caused storeApiKey to rebuild an empty config and erase all existing profiles) - removeApiKey no longer deletes the file when it can't parse JSON - All mutators (storeApiKey, removeApiKey, setActiveProfile, renameProfile, storeApiKeyAsync) wrapped in file lock to prevent concurrent overwrites - All mutations use immutable spread patterns instead of in-place mutation - logout command gracefully handles corrupted credentials by catching the error and proceeding to the removal path Fixes: BU-652 Co-authored-by: Bu Kinoshita <bukinoshita@users.noreply.github.com>
|
@cubic-dev-ai can you review? |
@bukinoshita I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
3 issues found across 7 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/lib/file-lock.ts">
<violation number="1" location="src/lib/file-lock.ts:61">
P2: `sleepSync` burns 100% CPU while spinning. Use `Atomics.wait` for a zero-CPU synchronous sleep — it's available in all supported Node versions (≥20).</violation>
</file>
<file name="src/lib/config.ts">
<violation number="1" location="src/lib/config.ts:394">
P1: The auto-migration write inside `resolveApiKeyAsync` still mutates `creds` in-place and calls `writeCredentials` without `withFileLock`, leaving the same lost-update race this PR eliminates everywhere else. Wrap the write in `withFileLock` and use immutable updates to match the other mutators.</violation>
</file>
<file name="src/commands/auth/logout.ts">
<violation number="1" location="src/commands/auth/logout.ts:52">
P2: This change still allows an uncaught credentials-read throw via `resolveProfileName()`, so logout can crash on corrupted credentials instead of reaching the `remove_failed` handling path.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
| ); | ||
| } catch { | ||
| // Non-fatal — plaintext key still works | ||
| /* non-fatal */ |
There was a problem hiding this comment.
P1: The auto-migration write inside resolveApiKeyAsync still mutates creds in-place and calls writeCredentials without withFileLock, leaving the same lost-update race this PR eliminates everywhere else. Wrap the write in withFileLock and use immutable updates to match the other mutators.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/config.ts, line 394:
<comment>The auto-migration write inside `resolveApiKeyAsync` still mutates `creds` in-place and calls `writeCredentials` without `withFileLock`, leaving the same lost-update race this PR eliminates everywhere else. Wrap the write in `withFileLock` and use immutable updates to match the other mutators.</comment>
<file context>
@@ -357,7 +391,7 @@ export async function resolveApiKeyAsync(
);
} catch {
- // Non-fatal — plaintext key still works
+ /* non-fatal */
}
}
</file context>
| } | ||
| }; | ||
|
|
||
| const sleepSync = (ms: number): void => { |
There was a problem hiding this comment.
P2: sleepSync burns 100% CPU while spinning. Use Atomics.wait for a zero-CPU synchronous sleep — it's available in all supported Node versions (≥20).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/file-lock.ts, line 61:
<comment>`sleepSync` burns 100% CPU while spinning. Use `Atomics.wait` for a zero-CPU synchronous sleep — it's available in all supported Node versions (≥20).</comment>
<file context>
@@ -0,0 +1,66 @@
+ }
+};
+
+const sleepSync = (ms: number): void => {
+ const end = Date.now() + ms;
+ while (Date.now() < end) {
</file context>
| const configPath = getCredentialsPath(); | ||
| const creds = readCredentials(); | ||
|
|
||
| const creds = tryReadCredentials(); |
There was a problem hiding this comment.
P2: This change still allows an uncaught credentials-read throw via resolveProfileName(), so logout can crash on corrupted credentials instead of reaching the remove_failed handling path.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/auth/logout.ts, line 52:
<comment>This change still allows an uncaught credentials-read throw via `resolveProfileName()`, so logout can crash on corrupted credentials instead of reaching the `remove_failed` handling path.</comment>
<file context>
@@ -39,7 +48,8 @@ If no credentials file exists, exits cleanly with no error.`,
const configPath = getCredentialsPath();
- const creds = readCredentials();
+
+ const creds = tryReadCredentials();
if (!creds && !existsSync(configPath)) {
</file context>
Resolves BU-652: Profile config race can silently erase saved auth profiles.
Problem
credentials.jsonwas rewritten in place with no temp file, rename, or locking. Any JSON parse error was silently collapsed tonull, causing mutators likestoreApiKey()to rebuild an empty credentials object and overwrite the live file — erasing all previously saved profiles. Concurrent CLI mutations could also produce lost-update races.Changes
Atomic writes (
src/lib/write-file-atomic.ts).tmpfile,fsyncs it, thenrenames over the original — prevents truncated/partially-written credentials files.Advisory file locking (
src/lib/file-lock.ts)withFileLock()wraps read/modify/write cycles with anO_EXCL-based lock file, stale-lock detection (10 s), and configurable retry — prevents concurrent CLI processes from clobbering each other.Fail-closed on corrupted JSON (
src/lib/corrupted-credentials-error.ts)readCredentials()now distinguishes "file does not exist" (returnsnull) from "file exists but contains invalid JSON" (throwsCorruptedCredentialsError).removeApiKey()no longer deletes the file outright on parse failure.Immutable mutation patterns (
src/lib/config.ts)storeApiKey,removeApiKey,setActiveProfile,renameProfile,storeApiKeyAsync) now use spread-based immutable updates instead of in-place mutation.withFileLock().Logout resilience (
src/commands/auth/logout.ts)remove_failedon downstream errors.Testing
readCredentialsreturningnullfor missing/empty files, throwingCorruptedCredentialsErrorfor malformed JSON.tmpfilesstoreApiKeyandremoveApiKeythrowing instead of silently replacing profiles when file is corruptedLinear Issue: BU-652
Summary by cubic
Prevents silent loss of saved auth profiles (BU-652) by making credential writes atomic and locked, and by failing closed on corrupted JSON.
credentials.json.readCredentials()throwsCorruptedCredentialsErroron invalid JSON; file is preserved; mutators stop instead of rebuilding empty state.storeApiKey,removeApiKey,setActiveProfile,renameProfile,storeApiKeyAsync) use immutable updates under the lock;removeApiKeyno longer deletes on parse failure.Written for commit 219f90b. Summary will update on new commits.