Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 41 additions & 30 deletions packages/cli/src/lib/dsn/fs-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,39 @@
import { stat } from "node:fs/promises";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node";
import { logger } from "../logger.js";

const log = logger.withTag("fs");

/**
* Check if an error is an expected file system error that should be silently ignored.
*
* Expected errors during file scanning:
* - ENOENT: File or directory does not exist
* - EACCES: Permission denied (e.g., no read access)
* - EPERM: Operation not permitted (e.g., file locked, or system-level restriction)
* - EISDIR: Path is a directory, not a file (e.g., `.env/` directory instead of `.env` file)
* - ENOTDIR: A path component is not a directory (e.g., `/file.txt/child`)
* - EINVAL: Invalid argument (e.g., scandir on a special/virtual filesystem entry like /proc paths)
* - ELOOP: Too many symbolic links (e.g., cyclic symlink encountered during scan)
* - ETIMEDOUT: Connection timed out (e.g., transient read on a network or cloud-mounted filesystem)
* - ENOTCONN: Transport endpoint is not connected (e.g., a stale or disconnected FUSE/network mount)
* - Unknown system error: Non-POSIX kernel errors on macOS (e.g., errno -11 / EAGAIN on .trace bundles)
* that Node.js cannot map to a standard code string
* System error codes that point at a CLI bug rather than the user's
* filesystem: we leaked descriptors, exhausted memory, or used a closed fd.
* These stay reported even though they come from a syscall.
*/
const REPORTABLE_SYSTEM_ERROR_CODES: ReadonlySet<string> = new Set([
"EMFILE",
"ENFILE",
"ENOMEM",
"EBADF",
]);

/**
* POSIX errno names (`EIO`, `ENOTCONN`, …) plus libuv's `UNKNOWN`. Excludes
* Node's own `ERR_*` codes, which signal misuse of an API by our code.
*/
const ERRNO_CODE_RE = /^(E[A-Z0-9]+|UNKNOWN)$/;

/**
* Check if an error is an expected file system error that should be ignored.
*
* All other errors are unexpected and should be reported to Sentry.
* Scanning is best-effort: any directory or file the OS refuses to read is
* skipped. An OS errno describes the user's machine — missing files,
* permissions, virtual filesystems (`/proc` → EINVAL), and unhealthy
* network, cloud, or FUSE mounts (EIO, ENOTCONN, ETIMEDOUT, ECANCELED,
* EINTR, macOS "Unknown system error -11", …). None of these are
* actionable for the CLI, and allowlisting them one errno at a time kept
* missing the next one. Only {@link REPORTABLE_SYSTEM_ERROR_CODES} and
* non-errno errors (bugs in our own code) are reported to Sentry.
*
* @param error - The error to check
* @returns True if the error is expected and should be ignored
Expand All @@ -34,21 +49,8 @@ function isIgnorableFileError(error: unknown): boolean {
return false;
}
const code = (error as NodeJS.ErrnoException).code;
if (
code === "ENOENT" ||
code === "EACCES" ||
code === "EPERM" ||
code === "EISDIR" ||
code === "ENOTDIR" ||
code === "EINVAL" ||
code === "ELOOP" ||
code === "ETIMEDOUT" ||
code === "ENOTCONN" ||
// macOS surfaces non-POSIX kernel errors (e.g. errno -11 on .trace bundles)
// as "Unknown system error -N" without a standard POSIX code string.
code === "UNKNOWN"
) {
return true;
if (typeof code === "string" && ERRNO_CODE_RE.test(code)) {
return !REPORTABLE_SYSTEM_ERROR_CODES.has(code);
}
// Node.js may omit the code entirely for non-POSIX kernel errors; fall back
// to matching the message prefix that macOS / libuv uses in that case.
Expand All @@ -59,6 +61,9 @@ function isIgnorableFileError(error: unknown): boolean {
* Handle a file system error by either ignoring it (for expected errors)
* or capturing it to Sentry (for unexpected errors).
*
* Ignored errors other than ENOENT are logged at debug level so
* `--verbose` explains why a directory or file was skipped.
*
* @param error - The error that occurred
* @param context - Additional context for Sentry (e.g., file path, operation)
*/
Expand All @@ -75,6 +80,12 @@ export function handleFileError(
path: context.path,
},
});
return;
}
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
log.debug(
`${context.operation}: skipped unreadable path (${(error as Error).message})`,
);
}
}

Expand Down
39 changes: 34 additions & 5 deletions packages/cli/test/lib/dsn/fs-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Tests for DSN file system utilities.
*
* Verifies that handleFileError correctly distinguishes expected filesystem
* errors (silently ignored) from unexpected ones (reported to Sentry).
* errors (ignored, debug-logged) from unexpected ones (reported to Sentry).
*/

import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
Expand Down Expand Up @@ -118,11 +118,40 @@ describe("handleFileError", () => {
});
});

describe("unhealthy mounts and virtual filesystems (should NOT report)", () => {
// Real readdir failures seen in CLI-1ES: /proc (EINVAL), OneDrive FUSE
// (EIO), stale AppImage / FreeRDP mounts under /tmp (ENOTCONN), macOS
// .trace bundles (ECANCELED), Bun on cloud-storage dirs (EINTR).
test.each([
"EINVAL",
"EIO",
"ECANCELED",
"EINTR",
"EAGAIN",
"EBUSY",
"ESTALE",
"EHOSTDOWN",
"ELOOP",
])("%s from scandir", (code) => {
const err = errnoError(code, `${code}: failed, scandir '/mnt/x'`);
(err as NodeJS.ErrnoException).syscall = "scandir";
handleFileError(err, { operation: "scan.walk.readdir", path: "/mnt/x" });
expect(captureException).not.toHaveBeenCalled();
});
});

describe("unexpected errors (SHOULD report to Sentry)", () => {
test("EIO — I/O error", () => {
handleFileError(errnoError("EIO"), {
test.each(["EMFILE", "ENFILE", "EBADF"])(
"%s — descriptor misuse/exhaustion caused by the CLI",
(code) => {
handleFileError(errnoError(code), { operation: "scan.walk.readdir" });
expect(captureException).toHaveBeenCalledTimes(1);
},
);

test("Node ERR_* codes — API misuse in our own code", () => {
handleFileError(errnoError("ERR_INVALID_ARG_TYPE"), {
operation: "test",
path: "/disk-fail",
});
expect(captureException).toHaveBeenCalledTimes(1);
});
Expand All @@ -144,7 +173,7 @@ describe("handleFileError", () => {
});

test("passes context tags and extras to Sentry", () => {
const error = errnoError("EIO");
const error = new Error("unexpected");
handleFileError(error, {
operation: "checkEnvForDsn",
path: "/project/.env",
Expand Down
Loading