Skip to content

Commit 20a612d

Browse files
authored
fix(dsn): treat EISDIR and ENOTDIR as ignorable file errors (#464)
## Summary Fixes [CLI-G5](https://sentry.sentry.io/issues/7344013745/) — high priority, 9 occurrences. When a user has `.env` as a **directory** (not a file) in their project, the DSN scanner tries to read it with `Bun.file(path).text()`, which throws `EISDIR`. This error was caught but not recognized as ignorable, so it got reported to Sentry as an unexpected error. ## Changes - **`src/lib/dsn/fs-utils.ts`**: Add `EISDIR` and `ENOTDIR` to `isIgnorableFileError()`. These are normal filesystem conditions during scanning — a path being a directory or a path component not being a directory — and should be silently skipped, just like `ENOENT`, `EACCES`, and `EPERM` already are. This single change fixes all 4 call sites that use `handleFileError()`. - **`test/lib/dsn/fs-utils.test.ts`**: New test file covering all 5 ignorable error codes, unexpected errors, and context propagation to Sentry.
1 parent f2fead1 commit 20a612d

File tree

2 files changed

+125
-1
lines changed

2 files changed

+125
-1
lines changed

src/lib/dsn/fs-utils.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import * as Sentry from "@sentry/bun";
1414
* - ENOENT: File or directory does not exist
1515
* - EACCES: Permission denied (e.g., no read access)
1616
* - EPERM: Operation not permitted (e.g., file locked, or system-level restriction)
17+
* - EISDIR: Path is a directory, not a file (e.g., `.env/` directory instead of `.env` file)
18+
* - ENOTDIR: A path component is not a directory (e.g., `/file.txt/child`)
1719
*
1820
* All other errors are unexpected and should be reported to Sentry.
1921
*
@@ -23,7 +25,13 @@ import * as Sentry from "@sentry/bun";
2325
function isIgnorableFileError(error: unknown): boolean {
2426
if (error instanceof Error && "code" in error) {
2527
const code = (error as NodeJS.ErrnoException).code;
26-
return code === "ENOENT" || code === "EACCES" || code === "EPERM";
28+
return (
29+
code === "ENOENT" ||
30+
code === "EACCES" ||
31+
code === "EPERM" ||
32+
code === "EISDIR" ||
33+
code === "ENOTDIR"
34+
);
2735
}
2836
return false;
2937
}

test/isolated/dsn/fs-utils.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Tests for DSN file system utilities.
3+
*
4+
* Verifies that handleFileError correctly distinguishes expected filesystem
5+
* errors (silently ignored) from unexpected ones (reported to Sentry).
6+
*/
7+
8+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
9+
10+
const captureException = mock();
11+
12+
mock.module("@sentry/bun", () => ({
13+
captureException,
14+
startSpan: (_opts: unknown, fn: () => unknown) => fn(),
15+
}));
16+
17+
const { handleFileError } = await import("../../../src/lib/dsn/fs-utils.js");
18+
19+
/** Create an Error with a `code` property, mimicking Node/Bun errno errors. */
20+
function errnoError(code: string, message?: string): Error {
21+
const err = new Error(message ?? code) as NodeJS.ErrnoException;
22+
err.code = code;
23+
return err;
24+
}
25+
26+
describe("handleFileError", () => {
27+
beforeEach(() => {
28+
captureException.mockClear();
29+
});
30+
31+
afterEach(() => {
32+
captureException.mockClear();
33+
});
34+
35+
describe("ignorable errors (should NOT report to Sentry)", () => {
36+
test("ENOENT — file does not exist", () => {
37+
handleFileError(errnoError("ENOENT"), {
38+
operation: "test",
39+
path: "/missing",
40+
});
41+
expect(captureException).not.toHaveBeenCalled();
42+
});
43+
44+
test("EACCES — permission denied", () => {
45+
handleFileError(errnoError("EACCES"), {
46+
operation: "test",
47+
path: "/secret",
48+
});
49+
expect(captureException).not.toHaveBeenCalled();
50+
});
51+
52+
test("EPERM — operation not permitted", () => {
53+
handleFileError(errnoError("EPERM"), {
54+
operation: "test",
55+
path: "/locked",
56+
});
57+
expect(captureException).not.toHaveBeenCalled();
58+
});
59+
60+
test("EISDIR — path is a directory, not a file", () => {
61+
handleFileError(
62+
errnoError("EISDIR", "Directories cannot be read like files"),
63+
{
64+
operation: "checkEnvForDsn",
65+
path: "/project/.env",
66+
}
67+
);
68+
expect(captureException).not.toHaveBeenCalled();
69+
});
70+
71+
test("ENOTDIR — path component is not a directory", () => {
72+
handleFileError(errnoError("ENOTDIR"), {
73+
operation: "test",
74+
path: "/file.txt/child",
75+
});
76+
expect(captureException).not.toHaveBeenCalled();
77+
});
78+
});
79+
80+
describe("unexpected errors (SHOULD report to Sentry)", () => {
81+
test("EIO — I/O error", () => {
82+
handleFileError(errnoError("EIO"), {
83+
operation: "test",
84+
path: "/disk-fail",
85+
});
86+
expect(captureException).toHaveBeenCalledTimes(1);
87+
});
88+
89+
test("ENOMEM — out of memory", () => {
90+
handleFileError(errnoError("ENOMEM"), { operation: "test" });
91+
expect(captureException).toHaveBeenCalledTimes(1);
92+
});
93+
94+
test("generic Error without code", () => {
95+
handleFileError(new Error("something broke"), { operation: "test" });
96+
expect(captureException).toHaveBeenCalledTimes(1);
97+
});
98+
99+
test("non-Error value", () => {
100+
handleFileError("string error", { operation: "test" });
101+
expect(captureException).toHaveBeenCalledTimes(1);
102+
});
103+
});
104+
105+
test("passes context tags and extras to Sentry", () => {
106+
const error = errnoError("EIO");
107+
handleFileError(error, {
108+
operation: "checkEnvForDsn",
109+
path: "/project/.env",
110+
});
111+
expect(captureException).toHaveBeenCalledWith(error, {
112+
tags: { operation: "checkEnvForDsn" },
113+
extra: { path: "/project/.env" },
114+
});
115+
});
116+
});

0 commit comments

Comments
 (0)