Skip to content

fix(storage): cap import file size before readImportFile loads it (STORAGE-002) - #425

Merged
ndycode merged 2 commits into
mainfrom
fix/import-size-limit
Apr 18, 2026
Merged

fix(storage): cap import file size before readImportFile loads it (STORAGE-002)#425
ndycode merged 2 commits into
mainfrom
fix/import-size-limit

Conversation

@ndycode

@ndycode ndycode commented Apr 18, 2026

Copy link
Copy Markdown
Owner

Addresses STORAGE-002 from the deep storage audit.

readImportFile() previously went straight from existsSync() to fs.readFile() with no size check, allowing arbitrarily large JSON imports. This PR adds a 4 MiB MAX_IMPORT_BYTES guard via fs.stat() before reading, so oversized imports are rejected before allocating file contents.

Includes focused tests for:

  • oversized import rejection (and confirms readFile is never called)
  • successful import under the size limit

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

the pr upgrades readImportFile from a two-call fs.stat(path) + fs.readFile(path) pattern to a single fs.openhandle.stat()handle.readFile() pipeline on one file descriptor. this correctly closes the TOCTOU gap raised in the previous thread: both the size check and the read operate on the same fd, so an on-disk replacement after open cannot bypass the 4 MiB guard. tests verify the oversized-reject path (including that readFile is never called) and the boundary/happy-path cases.

Confidence Score: 5/5

safe to merge — the core TOCTOU fix is correct and all remaining findings are P2 coverage gaps

the handle-based stat+read correctly eliminates the TOCTOU concern from the prior review thread; boundary conditions in tests are accurate; no P0/P1 issues found. only gap is missing vitest coverage for the existsSync=false and fs.open-failure paths, both P2

test/storage-import-export.test.ts — missing coverage for file-not-found and windows EPERM entry paths

Important Files Changed

Filename Overview
lib/storage/import-export.ts replaces two-call fs.stat+fs.readFile with fs.open→handle.stat→handle.readFile on one fd, correctly closing the TOCTOU gap; size guard and finally-close are correct
test/storage-import-export.test.ts covers oversized rejection, boundary accept, and under-limit happy path; missing coverage for existsSync=false (file not found) and fs.open EPERM failure (windows locked-file path)

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[readImportFile] --> B{existsSync?}
    B -- No --> C[throw: file not found]
    B -- Yes --> D[fs.open → FileHandle]
    D --> E[handle.stat]
    E --> F{size > 4 MiB?}
    F -- Yes --> G[throw: exceeds max size]
    G --> H[handle.close - finally]
    F -- No --> I[handle.readFile]
    I --> J[handle.close - finally]
    J --> K{safeParseJson succeeds?}
    K -- Yes --> L[normalizeAccountStorage]
    K -- No --> M[JSON.parse fallback]
    M --> N{SyntaxError?}
    N -- Yes --> O[throw: invalid JSON]
    N -- No --> L
    L --> P{normalized non-null?}
    P -- No --> Q[throw: invalid format]
    P -- Yes --> R[return AccountStorageV3]
Loading

Fix All in Codex

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

Comment:
**missing coverage for error entry paths**

two branches in `readImportFile` have no tests: the `existsSync → false` path (file not found) and `fs.open` throwing (e.g. `EPERM` on windows when antivirus holds a lock between the sync exists check and the async open). the latter surfaces a raw node `ErrnoException` to callers rather than the friendly message from line 90. worth adding both to this describe block.

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

Reviews (2): Last reviewed commit: "fix(storage): use file handle stat/read ..." | Re-trigger Greptile

…E-002)

Addresses STORAGE-002 from the deep storage audit.

readImportFile previously went straight from existsSync() to fs.readFile()
with no size guard, allowing arbitrarily large JSON imports. Add a 4 MiB
MAX_IMPORT_BYTES check via fs.stat() before readFile() and reject oversized
imports before allocating the file contents.

Added focused unit tests covering oversized rejection and under-limit success.
@chatgpt-codex-connector

Copy link
Copy Markdown

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

coderabbitai Bot commented Apr 18, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

adds file size validation to import operations by introducing a MAX_IMPORT_BYTES constant and checking file size before attempting to read/parse, rejecting files exceeding 4 MB.

Changes

Cohort / File(s) Summary
File size validation
lib/storage/import-export.ts
adds MAX_IMPORT_BYTES constant and pre-read fs.stat check in readImportFile to reject files exceeding 4 MB before parsing.
Test coverage
test/storage-import-export.test.ts
new test suite validating size limit enforcement and verifying readFile is not called for oversized imports.

Suggested labels

bug

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes


few things to watch here. the stat-then-read pattern in lib/storage/import-export.ts:readImportFile introduces a TOCTOU window—file could grow or change between the stat call and the readFile. not typically a blocker for imports, but worth noting if this handles user-controlled paths.

the test mocks at test/storage-import-export.test.ts look solid and isolate the fs layer well. however, flagging: tests don't cover windows-specific edge cases (paths with backslashes, case sensitivity on filenames, or dos-style line endings in the parsed json). also no concurrency test—what if readImportFile is called simultaneously on the same file?

regression testing gap: confirm existing tests still pass and that the 4 MB threshold doesn't break legitimate imports in your codebase. verify this constant aligns with any documented max import size.

🚥 Pre-merge checks | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Title check ⚠️ Warning title exceeds 72 character limit at 79 chars; includes jira key (storage-002) which violates conventional commits format requirement. reformat to conventional commits without jira key and shorten summary. example: 'fix(storage): add 4mb size limit to import files' (57 chars).
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive pr description covers summary and changes comprehensively, includes validation checklist and risk assessment structure, but validation items are not marked as completed and docs/governance sections lack confirmation. mark validation checkboxes as completed (npm run lint, typecheck, npm test, build), confirm which docs were reviewed (README, SECURITY.md, CONTRIBUTING.md), and specify risk level and rollback plan clearly.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/import-size-limit
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/import-size-limit

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.

Comment thread lib/storage/import-export.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/storage/import-export.ts (1)

89-100: 🧹 Nitpick | 🔵 Trivial

drop the redundant existsSync and let fs.stat be the single source of truth.

lib/storage/import-export.ts:89 does existsSync then lib/storage/import-export.ts:93 does fs.stat. two syscalls for the same question, and they disagree under a classic toctou — on windows this race is not theoretical: an antivirus/explorer handle can make the file disappear or throw EBUSY/EPERM between the two calls, and the user gets a raw ENOENT from stat instead of the friendly Import file not found message. collapse to stat-only and map ENOENT yourself.

proposed fix
-	if (!existsSync(params.resolvedPath)) {
-		throw new Error(`Import file not found: ${params.resolvedPath}`);
-	}
-
-	const stats = await fs.stat(params.resolvedPath);
+	let stats: import("node:fs").Stats;
+	try {
+		stats = await fs.stat(params.resolvedPath);
+	} catch (error) {
+		if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+			throw new Error(`Import file not found: ${params.resolvedPath}`);
+		}
+		throw error;
+	}
 	if (stats.size > MAX_IMPORT_BYTES) {

also worth noting: fs.stat follows symlinks, so a symlink pointing at a 10gb file is correctly rejected by size — good. if you ever want to additionally refuse symlinks, switch to fs.lstat first. not a blocker for this pr.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/storage/import-export.ts` around lines 89 - 100, Remove the redundant
existsSync check and rely solely on fs.stat for existence/metadata: delete the
existsSync branch and wrap the await fs.stat(params.resolvedPath) call in a
try/catch; if stat throws with error.code === "ENOENT" rethrow a friendly
Error(`Import file not found: ${params.resolvedPath}`), otherwise rethrow the
original error; keep the subsequent size check against MAX_IMPORT_BYTES and the
await fs.readFile(params.resolvedPath, "utf-8") as-is.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/storage-import-export.test.ts`:
- Around line 19-68: Add three regression tests to
test/storage-import-export.test.ts around the existing readImportFile cases: (1)
an "accepts files exactly at MAX_IMPORT_BYTES" test that sets
statMock.mockResolvedValue({ size: 4 * 1024 * 1024 }) and asserts readImportFile
resolves to the normalized storage; (2) a "handles windows stat race ENOENT"
test that keeps existsSyncMock.mockReturnValue(true) but has
statMock.mockRejectedValue({ code: "ENOENT" }) and asserts the promise rejects
with that error (no unhandled rejections); and (3) a similar race test for
statMock.mockRejectedValue({ code: "EBUSY" }). In the valid-file test tighten
the assertion on normalizeAccountStorage to expect it was called with the parsed
object (toHaveBeenCalledWith({ version: 3, accounts: [], activeIndex: 0 })).
When adding tests that re-import the module for different MAX_IMPORT_BYTES
behavior, use vi.resetModules() before importing readImportFile to ensure a
fresh module graph. Ensure all tests reference readImportFile,
normalizeAccountStorage, statMock, and existsSyncMock to locate the code under
test.

---

Outside diff comments:
In `@lib/storage/import-export.ts`:
- Around line 89-100: Remove the redundant existsSync check and rely solely on
fs.stat for existence/metadata: delete the existsSync branch and wrap the await
fs.stat(params.resolvedPath) call in a try/catch; if stat throws with error.code
=== "ENOENT" rethrow a friendly Error(`Import file not found:
${params.resolvedPath}`), otherwise rethrow the original error; keep the
subsequent size check against MAX_IMPORT_BYTES and the await
fs.readFile(params.resolvedPath, "utf-8") as-is.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 110e7b18-32bd-4e27-87fe-305b7c59a151

📥 Commits

Reviewing files that changed from the base of the PR and between 3f1c1fe and 8895b76.

📒 Files selected for processing (2)
  • lib/storage/import-export.ts
  • test/storage-import-export.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/storage/import-export.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/storage-import-export.test.ts

Comment on lines +19 to +68
describe("storage import-export", () => {
beforeEach(() => {
existsSyncMock.mockReset();
statMock.mockReset();
readFileMock.mockReset();
});

it("rejects oversized import files before reading them", async () => {
existsSyncMock.mockReturnValue(true);
statMock.mockResolvedValue({ size: 4 * 1024 * 1024 + 1 });

const { readImportFile } = await import("../lib/storage/import-export.js");

await expect(
readImportFile({
resolvedPath: "/mock/import.json",
normalizeAccountStorage: vi.fn(),
}),
).rejects.toThrow(/exceeds maximum size/i);

expect(readFileMock).not.toHaveBeenCalled();
});

it("reads valid import files under the size limit", async () => {
existsSyncMock.mockReturnValue(true);
statMock.mockResolvedValue({ size: 256 });
readFileMock.mockResolvedValue('{"version":3,"accounts":[],"activeIndex":0}');

const normalizeAccountStorage = vi.fn().mockReturnValue({
version: 3,
accounts: [],
activeIndex: 0,
});

const { readImportFile } = await import("../lib/storage/import-export.js");

await expect(
readImportFile({
resolvedPath: "/mock/import.json",
normalizeAccountStorage,
}),
).resolves.toEqual({
version: 3,
accounts: [],
activeIndex: 0,
});

expect(readFileMock).toHaveBeenCalledWith("/mock/import.json", "utf-8");
expect(normalizeAccountStorage).toHaveBeenCalled();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

missing regression cases for the boundary and for the windows stat-race.

two gaps worth closing in test/storage-import-export.test.ts:

  1. boundary: you test MAX_IMPORT_BYTES + 1 at test/storage-import-export.test.ts:28 but never the inclusive edge. a future refactor flipping > to >= in lib/storage/import-export.ts:94 would pass ci silently. add an it("accepts files exactly at MAX_IMPORT_BYTES", ...) with size: 4 * 1024 * 1024.
  2. windows filesystem behavior: per the repo guidelines for test/**, regressions for windows fs races are expected. add a case where statMock.mockRejectedValue({ code: "ENOENT" }) (file vanished after existsSync) and assert the error surfaces cleanly rather than as an unhandled rejection. same story for EBUSY if you adopt the stat-only refactor.

also minor: test/storage-import-export.test.ts:67 only asserts normalizeAccountStorage was called — tighten to toHaveBeenCalledWith({ version: 3, accounts: [], activeIndex: 0 }) so a broken json parse path can't pass this test.

note: no vi.resetModules() between tests. currently benign because both tests reset the mocks at test/storage-import-export.test.ts:20-24, but if you later add a test that needs a fresh module graph (e.g. to re-evaluate MAX_IMPORT_BYTES) you'll want it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/storage-import-export.test.ts` around lines 19 - 68, Add three
regression tests to test/storage-import-export.test.ts around the existing
readImportFile cases: (1) an "accepts files exactly at MAX_IMPORT_BYTES" test
that sets statMock.mockResolvedValue({ size: 4 * 1024 * 1024 }) and asserts
readImportFile resolves to the normalized storage; (2) a "handles windows stat
race ENOENT" test that keeps existsSyncMock.mockReturnValue(true) but has
statMock.mockRejectedValue({ code: "ENOENT" }) and asserts the promise rejects
with that error (no unhandled rejections); and (3) a similar race test for
statMock.mockRejectedValue({ code: "EBUSY" }). In the valid-file test tighten
the assertion on normalizeAccountStorage to expect it was called with the parsed
object (toHaveBeenCalledWith({ version: 3, accounts: [], activeIndex: 0 })).
When adding tests that re-import the module for different MAX_IMPORT_BYTES
behavior, use vi.resetModules() before importing readImportFile to ensure a
fresh module graph. Ensure all tests reference readImportFile,
normalizeAccountStorage, statMock, and existsSyncMock to locate the code under
test.

#425)

Responds to PR #425 review feedback.

- eliminate the stat/readFile TOCTOU gap by opening the import file once and
  performing both stat() and readFile() on the same handle
- add exact-boundary coverage at MAX_IMPORT_BYTES
- keep the oversized import rejection guarantee and verify the handle closes
  on both reject and success paths
@ndycode
ndycode merged commit 7c90a6e into main Apr 18, 2026
2 checks passed
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