Skip to content

fix: harden storage path transactions#206

Open
ndycode wants to merge 1 commit intodevfrom
git-clean/pr147-storage-hardening
Open

fix: harden storage path transactions#206
ndycode wants to merge 1 commit intodevfrom
git-clean/pr147-storage-hardening

Conversation

@ndycode
Copy link
Owner

@ndycode ndycode commented Mar 21, 2026

Summary

  • pin transactional account writes to the storage path captured at transaction start
  • pin flagged-account writes to the matching flagged path so rollback restores the same storage root
  • fail fast when exportAccounts is called from an active transaction after the storage path drifts
  • allow equivalent Windows and symlink-resolved paths during that guard
  • mark transaction snapshots inactive once the handler exits so later exports reload fresh state

Validation

  • npm exec vitest run test/storage.test.ts
  • npm run typecheck

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

this pr hardens the storage transaction layer against a class of silent data-corruption bugs where getStoragePath() drifts mid-transaction (common in multi-worktree and test scenarios). it pins all reads and writes in withAccountStorageTransaction and withAccountAndFlaggedStorageTransaction — including the migration-persist callback and the rollback write — to the path captured at transaction start. it also marks the transaction snapshot inactive in a finally block so exportAccounts called after the handler exits reloads fresh state rather than re-using a stale snapshot. on the exportAccounts side, the old silent "load from wrong file" fallback is replaced with a fail-fast error when the active path has drifted away from the transaction path, with symlink and windows case-folding equivalence handled via areEquivalentStoragePaths.

key changes:

  • loadAccountsInternal and saveAccountsUnlocked now accept an explicit storagePath parameter (defaulting to getStoragePath()) so callers can pin writes to a specific root
  • saveFlaggedAccountsUnlocked similarly pinned; flaggedStoragePath captured at combined-transaction start and passed through rollback too
  • state.active = false in finally in both transaction functions; subsequent exportAccounts calls outside the transaction context reload from disk
  • exportAccounts throws with a descriptive error when called from an active transaction whose storage path no longer matches the active path
  • normalizeStorageComparisonPath resolves symlinks and folds case/slashes on win32 for the equivalence check
  • one test is potentially broken on linux ci: "allows equivalent Windows-style storage paths during export from an active transaction" patches process.platform but not Node's path.resolve semantics, so areEquivalentStoragePaths will return false for backslash paths on posix runners, causing the guard to throw instead of pass

Confidence Score: 4/5

  • implementation is correct and well-tested; one test is likely broken on linux ci due to a platform-mock gap that needs a one-liner fix before merge
  • the core storage pinning, rollback hardening, and fail-fast export guard are all sound. the windows-path equivalence test patches process.platform without patching Node's path.resolve semantics, which means areEquivalentStoragePaths returns false on linux runners and the test fails instead of passes. fixing it is a one-line skip guard (matching the pattern already used in the symlink test). no token safety or concurrency regressions introduced.
  • test/storage.test.ts — the windows-style path equivalence test needs a platform guard or the implementation needs path.win32.resolve

Important Files Changed

Filename Overview
lib/storage.ts core changes are correct: storagePath pinned at transaction start, flaggedStoragePath pinned for combined transaction, active flag torn down in finally, exportAccounts fails fast on path drift. normalizeStorageComparisonPath ENOENT guard and win32 branch are sound for production (both sides of the comparison use the same code path). no token/credential exposure introduced.
test/storage.test.ts good breadth: path-drift pinning, rollback, active-flag teardown, symlink resolution, and export-mismatch guard are all tested. one test ("allows equivalent Windows-style storage paths") is broken on Linux CI because it mocks process.platform without mocking Node's path.resolve semantics — areEquivalentStoragePaths returns false for backslash paths on POSIX, causing the test to throw instead of resolving. combined-transaction export-mismatch path also lacks coverage.

Sequence Diagram

sequenceDiagram
    participant C as Caller
    participant T as withAccountStorageTransaction
    participant L as loadAccountsInternal(storagePath)
    participant S as saveAccountsUnlocked(storagePath)
    participant E as exportAccounts
    participant CTX as transactionSnapshotContext

    C->>T: call handler
    T->>T: storagePath = getStoragePath() [pinned]
    T->>L: load(persistMigration pinned, storagePath)
    L-->>T: snapshot
    T->>CTX: run(state{snapshot, storagePath, active:true})
    CTX->>C: handler(current, persist)
    C->>S: persist(storage) → saveAccountsUnlocked(storage, storagePath)
    S-->>C: ok
    C->>E: exportAccounts(path)
    E->>E: activeStoragePath = getStoragePath()
    E->>E: areEquivalentStoragePaths(state.storagePath, activeStoragePath)?
    alt paths differ
        E-->>C: throws "Export blocked by storage path mismatch"
    else paths equivalent
        E->>E: use state.snapshot (no extra load)
        E-->>C: ok
    end
    CTX-->>T: handler returns/throws
    T->>T: finally: state.active = false
    T-->>C: result / rethrow
Loading

Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: test/storage.test.ts
Line: 1183-1240

Comment:
**Windows test broken on Linux CI**

`normalizeStorageComparisonPath` calls `resolvePath(path)` (which uses Node's platform-native `path.resolve`) *before* converting backslashes to forward slashes. On Linux, `path.resolve` does not treat `\` as a path separator, so `\TMP\TEST\ACCOUNTS.JSON` gets resolved as a relative path component: `${cwd}/\TMP\TEST\ACCOUNTS.JSON`. The subsequent `.replaceAll("\\", "/").toLowerCase()` then produces `${cwd}//tmp/test/accounts.json`, which is never equal to the canonical `/tmp/test/accounts.json`.

Concretely, patching `process.platform = "win32"` does not patch Node's `path.resolve` semantics, so `areEquivalentStoragePaths` returns `false` for the backslash-upper version even with the win32 branch active. The guard throws and the test fails on any Linux CI runner.

The symlink test already handles this correctly with a platform guard:
```suggestion
		it("allows equivalent Windows-style storage paths during export from an active transaction", async () => {
			if (process.platform !== "win32") {
				return;
			}
			const originalPlatform = process.platform;
```
This test should be restricted to actual win32 runs, or the implementation in `normalizeStorageComparisonPath` needs to use `path.win32.resolve` when on win32 instead of relying on the ambient `path.resolve`.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: test/storage.test.ts
Line: 1112-1140

Comment:
**Missing coverage: path-drift mismatch inside `withAccountAndFlaggedStorageTransaction`**

the test "fails fast when export is called from an active transaction after the storage path changes" covers the single-storage `withAccountStorageTransaction` path. there's no parallel test for `withAccountAndFlaggedStorageTransaction` + storage-path drift → `exportAccounts` throw. both functions use the same `transactionSnapshotContext`, so the guard behaviour is identical, but the combined-transaction code path through `exportAccounts` remains uncovered. also worth adding a test that a non-`ENOENT` error from `fs.realpath` inside `normalizeStorageComparisonPath` is re-thrown (e.g. `EPERM` on a locked directory). both are small gaps against the 80% coverage threshold.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (2): Last reviewed commit: "fix: harden storage path transactions" | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

@chatgpt-codex-connector
Copy link

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 21, 2026

Important

Review skipped

Auto reviews are limited based on label configuration.

🚫 Review skipped — only excluded labels are configured. (1)
  • skip-review

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c80a8be4-231d-4594-9b7c-8cf927dfe3f6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch git-clean/pr147-storage-hardening
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch git-clean/pr147-storage-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ndycode ndycode added passed needs-human-review Flagged by automated PR quality screening; maintainer review required. and removed passed labels Mar 21, 2026
@ndycode ndycode changed the base branch from main to dev March 21, 2026 19:57
@ndycode ndycode removed the needs-human-review Flagged by automated PR quality screening; maintainer review required. label Mar 22, 2026
@ndycode ndycode deleted the branch dev March 24, 2026 18:37
@ndycode ndycode closed this Mar 24, 2026
@ndycode ndycode deleted the git-clean/pr147-storage-hardening branch March 24, 2026 18:37
@ndycode ndycode restored the git-clean/pr147-storage-hardening branch March 24, 2026 18:40
@ndycode ndycode reopened this Mar 24, 2026
Comment on lines +1183 to +1240
withAccountStorageTransaction(async () => {
setStoragePathDirect(
testStoragePath.replaceAll("/", "\\").toUpperCase(),
);
await exportAccounts(exportPath);
}),
).resolves.toBeUndefined();

const exported = JSON.parse(await fs.readFile(exportPath, "utf-8"));
expect(exported.accounts[0].accountId).toBe("acct-primary");
} finally {
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
}
});

it("allows symlink-resolved storage paths during export from an active transaction", async () => {
if (process.platform === "win32") {
return;
}

const realStorageDir = join(testWorkDir, "real-storage");
const aliasStorageDir = join(testWorkDir, "alias-storage");
const realStoragePath = join(realStorageDir, "accounts.json");
const aliasStoragePath = join(aliasStorageDir, "accounts.json");

await fs.mkdir(realStorageDir, { recursive: true });
await fs.symlink(realStorageDir, aliasStorageDir, "dir");

setStoragePathDirect(realStoragePath);
await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "acct-primary",
refreshToken: "refresh-primary",
addedAt: 1,
lastUsed: 2,
},
],
});

await expect(
withAccountStorageTransaction(async () => {
setStoragePathDirect(aliasStoragePath);
await exportAccounts(exportPath);
}),
).resolves.toBeUndefined();

const exported = JSON.parse(await fs.readFile(exportPath, "utf-8"));
expect(exported.accounts[0].accountId).toBe("acct-primary");
});

it("reloads fresh storage after a transaction handler throws", async () => {
await saveAccounts({
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Windows test broken on Linux CI

normalizeStorageComparisonPath calls resolvePath(path) (which uses Node's platform-native path.resolve) before converting backslashes to forward slashes. On Linux, path.resolve does not treat \ as a path separator, so \TMP\TEST\ACCOUNTS.JSON gets resolved as a relative path component: ${cwd}/\TMP\TEST\ACCOUNTS.JSON. The subsequent .replaceAll("\\", "/").toLowerCase() then produces ${cwd}//tmp/test/accounts.json, which is never equal to the canonical /tmp/test/accounts.json.

Concretely, patching process.platform = "win32" does not patch Node's path.resolve semantics, so areEquivalentStoragePaths returns false for the backslash-upper version even with the win32 branch active. The guard throws and the test fails on any Linux CI runner.

The symlink test already handles this correctly with a platform guard:

Suggested change
withAccountStorageTransaction(async () => {
setStoragePathDirect(
testStoragePath.replaceAll("/", "\\").toUpperCase(),
);
await exportAccounts(exportPath);
}),
).resolves.toBeUndefined();
const exported = JSON.parse(await fs.readFile(exportPath, "utf-8"));
expect(exported.accounts[0].accountId).toBe("acct-primary");
} finally {
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
}
});
it("allows symlink-resolved storage paths during export from an active transaction", async () => {
if (process.platform === "win32") {
return;
}
const realStorageDir = join(testWorkDir, "real-storage");
const aliasStorageDir = join(testWorkDir, "alias-storage");
const realStoragePath = join(realStorageDir, "accounts.json");
const aliasStoragePath = join(aliasStorageDir, "accounts.json");
await fs.mkdir(realStorageDir, { recursive: true });
await fs.symlink(realStorageDir, aliasStorageDir, "dir");
setStoragePathDirect(realStoragePath);
await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "acct-primary",
refreshToken: "refresh-primary",
addedAt: 1,
lastUsed: 2,
},
],
});
await expect(
withAccountStorageTransaction(async () => {
setStoragePathDirect(aliasStoragePath);
await exportAccounts(exportPath);
}),
).resolves.toBeUndefined();
const exported = JSON.parse(await fs.readFile(exportPath, "utf-8"));
expect(exported.accounts[0].accountId).toBe("acct-primary");
});
it("reloads fresh storage after a transaction handler throws", async () => {
await saveAccounts({
it("allows equivalent Windows-style storage paths during export from an active transaction", async () => {
if (process.platform !== "win32") {
return;
}
const originalPlatform = process.platform;

This test should be restricted to actual win32 runs, or the implementation in normalizeStorageComparisonPath needs to use path.win32.resolve when on win32 instead of relying on the ambient path.resolve.

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/storage.test.ts
Line: 1183-1240

Comment:
**Windows test broken on Linux CI**

`normalizeStorageComparisonPath` calls `resolvePath(path)` (which uses Node's platform-native `path.resolve`) *before* converting backslashes to forward slashes. On Linux, `path.resolve` does not treat `\` as a path separator, so `\TMP\TEST\ACCOUNTS.JSON` gets resolved as a relative path component: `${cwd}/\TMP\TEST\ACCOUNTS.JSON`. The subsequent `.replaceAll("\\", "/").toLowerCase()` then produces `${cwd}//tmp/test/accounts.json`, which is never equal to the canonical `/tmp/test/accounts.json`.

Concretely, patching `process.platform = "win32"` does not patch Node's `path.resolve` semantics, so `areEquivalentStoragePaths` returns `false` for the backslash-upper version even with the win32 branch active. The guard throws and the test fails on any Linux CI runner.

The symlink test already handles this correctly with a platform guard:
```suggestion
		it("allows equivalent Windows-style storage paths during export from an active transaction", async () => {
			if (process.platform !== "win32") {
				return;
			}
			const originalPlatform = process.platform;
```
This test should be restricted to actual win32 runs, or the implementation in `normalizeStorageComparisonPath` needs to use `path.win32.resolve` when on win32 instead of relying on the ambient `path.resolve`.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant