diff --git a/packages/cli/src/lib/dsn/fs-utils.ts b/packages/cli/src/lib/dsn/fs-utils.ts index 9d568be99..61c0bbad8 100644 --- a/packages/cli/src/lib/dsn/fs-utils.ts +++ b/packages/cli/src/lib/dsn/fs-utils.ts @@ -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 = 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 @@ -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. @@ -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) */ @@ -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})`, + ); } } diff --git a/packages/cli/test/lib/dsn/fs-utils.test.ts b/packages/cli/test/lib/dsn/fs-utils.test.ts index 2f886c535..5227908d1 100644 --- a/packages/cli/test/lib/dsn/fs-utils.test.ts +++ b/packages/cli/test/lib/dsn/fs-utils.test.ts @@ -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"; @@ -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); }); @@ -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",