diff --git a/packages/cli/src/lib/dsn/detector.ts b/packages/cli/src/lib/dsn/detector.ts index 069f47032..59f6fe42b 100644 --- a/packages/cli/src/lib/dsn/detector.ts +++ b/packages/cli/src/lib/dsn/detector.ts @@ -24,6 +24,7 @@ import { getCachedProjectRoot, setCachedProjectRoot, } from "../db/project-root-cache.js"; +import { logger } from "../logger.js"; import { extractFirstDsnFromContent, scanCodeForDsns, @@ -37,7 +38,7 @@ import { } from "./env-file.js"; import { isRegularFile } from "./fs-utils.js"; import { createDetectedDsn, createDsnFingerprint, parseDsn } from "./parser.js"; -import { findProjectRoot, isHomeOrAncestor } from "./project-root.js"; +import { findProjectRoot, shouldSkipDownwardScan } from "./project-root.js"; import type { CachedDsnEntry, DetectedDsn, @@ -45,6 +46,15 @@ import type { DsnSource, } from "./types.js"; +const log = logger.withTag("dsn"); + +/** Explain at debug level why DSN auto-detection did not scan any files. */ +function logSkippedScan(projectRoot: string, reason: string): void { + log.debug( + `Skipping DSN file scan: project root ${projectRoot} (${reason}) is the home directory, an ancestor of it, or a shared temp directory`, + ); +} + /** * Detect DSN with project root detection and caching support. * @@ -67,11 +77,12 @@ export async function detectDsn(cwd: string): Promise { // return it immediately - code DSNs take priority) const { projectRoot, reason } = await findProjectRoot(cwd); - // When project root detection fell back to the home directory (or an - // ancestor of it), a downward scan would reach OS app-data and credential - // directories (~/Library, ~/.ssh, ~/.aws, …). Skip the filesystem scan - // entirely in that case and only consult the SENTRY_DSN env var. - if (reason === "fallback" && isHomeOrAncestor(projectRoot)) { + // When the project root is the home directory, an ancestor of it, or a + // shared temp root, a downward scan would reach OS app-data, credential, + // and mounted dirs (~/Library, ~/.ssh, /tmp/.mount_*, …). Skip the + // filesystem scan entirely and only consult the SENTRY_DSN env var. + if (shouldSkipDownwardScan(projectRoot)) { + logSkippedScan(projectRoot, reason); return detectFromEnv(); } @@ -156,11 +167,10 @@ export async function detectAllDsns(cwd: string): Promise { }); } - // When project root detection fell back to the home directory (or an - // ancestor of it), a downward scan would reach OS app-data and credential - // directories (~/Library, ~/.ssh, ~/.aws, …). Skip the filesystem scan - // entirely and only report the SENTRY_DSN env var, if any. - if (reason === "fallback" && isHomeOrAncestor(projectRoot)) { + // Same guard as detectDsn: never scan down from $HOME, its ancestors, or a + // shared temp root. Only report the SENTRY_DSN env var, if any. + if (shouldSkipDownwardScan(projectRoot)) { + logSkippedScan(projectRoot, reason); const envDsn = detectFromEnv(); const all = envDsn ? [envDsn] : []; return { diff --git a/packages/cli/src/lib/dsn/project-root.ts b/packages/cli/src/lib/dsn/project-root.ts index 40838b496..68d26943e 100644 --- a/packages/cli/src/lib/dsn/project-root.ts +++ b/packages/cli/src/lib/dsn/project-root.ts @@ -14,7 +14,7 @@ */ import { opendir, readFile, stat } from "node:fs/promises"; -import { homedir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; import pLimit from "p-limit"; import picomatch from "picomatch"; @@ -424,6 +424,31 @@ export function isHomeOrAncestor(dir: string): boolean { return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); } +/** + * Shared system temp roots. Other processes mount private FUSE filesystems + * directly under these (AppImage `/tmp/.mount_*`, FreeRDP clipboard dirs), + * and stale mounts fail `readdir` with ENOTCONN — so a temp root is never + * a project directory worth scanning. Subdirectories are still scanned. + */ +function isSharedTempRoot(dir: string): boolean { + const resolvedDir = resolve(dir); + return [tmpdir(), "/tmp", "/var/tmp", "/private/tmp"].some( + (root) => resolve(root) === resolvedDir, + ); +} + +/** + * True when a downward DSN scan from `projectRoot` must be skipped: the + * root is `$HOME`, an ancestor of it, or a shared temp root. Applies to + * every root-detection reason — a stray `~/package.json` (language marker) + * or a dotfiles `~/.git` makes `$HOME` the "project root" just like the + * no-marker fallback does, and scanning from there walks into `~/Library`, + * cloud-storage mounts, and credential dirs. + */ +export function shouldSkipDownwardScan(projectRoot: string): boolean { + return isHomeOrAncestor(projectRoot) || isSharedTempRoot(projectRoot); +} + /** * Process one directory level during the walk-up search */ diff --git a/packages/cli/test/lib/dsn/home-fallback.test.ts b/packages/cli/test/lib/dsn/home-fallback.test.ts index 9f89b3c16..ae136f5c2 100644 --- a/packages/cli/test/lib/dsn/home-fallback.test.ts +++ b/packages/cli/test/lib/dsn/home-fallback.test.ts @@ -13,13 +13,17 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -const { fakeHome } = vi.hoisted(() => ({ fakeHome: { path: "" } })); +const { fakeHome, fakeTmp } = vi.hoisted(() => ({ + fakeHome: { path: "" }, + fakeTmp: { path: "" }, +})); vi.mock("node:os", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: () => fakeHome.path, + tmpdir: () => fakeTmp.path || actual.tmpdir(), }; }); @@ -42,6 +46,7 @@ describe("DSN detection from $HOME fallback", () => { afterEach(() => { fakeHome.path = ""; + fakeTmp.path = ""; delete process.env.SENTRY_DSN; }); @@ -117,6 +122,78 @@ describe("DSN detection from $HOME fallback", () => { expect(result?.source).toBe("code"); }); + describe("when a stray project marker sits directly in $HOME", () => { + // A ~/package.json or a dotfiles ~/.git makes $HOME the project root + // with reason "language" / "vcs" instead of "fallback". The scan must + // still be skipped — otherwise it walks ~/Library, cloud-storage and + // FUSE mounts, and can pick up an unrelated DSN from anywhere in $HOME. + const strayDsn = "https://stray@o444.ingest.sentry.io/444"; + + beforeEach(() => { + mkdirSync(join(home, "notes"), { recursive: true }); + writeFileSync( + join(home, "notes", "old-demo.ts"), + `Sentry.init({ dsn: "${strayDsn}" });`, + ); + }); + + test.each([ + ["language marker", "package.json"], + ["build-system marker", "Makefile"], + ])("skips the scan with a %s in $HOME", async (_label, marker) => { + writeFileSync(join(home, marker), ""); + + expect(await detectDsn(home)).toBeNull(); + expect((await detectAllDsns(home)).all).toHaveLength(0); + }); + + test("skips the scan with a VCS root at $HOME", async () => { + mkdirSync(join(home, ".git"), { recursive: true }); + + expect(await detectDsn(home)).toBeNull(); + expect((await detectAllDsns(home)).all).toHaveLength(0); + }); + + test("skips the scan when a marker-less subdirectory walks up to $HOME", async () => { + writeFileSync(join(home, "package.json"), "{}"); + const workspace = join(home, ".agent", "workspaces", "w1"); + mkdirSync(workspace, { recursive: true }); + + expect(await detectDsn(workspace)).toBeNull(); + expect((await detectAllDsns(workspace)).all).toHaveLength(0); + }); + }); + + describe("from a shared temp root", () => { + test("does not scan the temp root itself", async () => { + const tmpRoot = join(getConfigDir(), "tmp"); + fakeTmp.path = tmpRoot; + const mount = join(tmpRoot, ".mount_app"); + mkdirSync(mount, { recursive: true }); + writeFileSync( + join(mount, "config.ts"), + `Sentry.init({ dsn: "https://mnt@o555.ingest.sentry.io/555" });`, + ); + + expect(await detectDsn(tmpRoot)).toBeNull(); + expect((await detectAllDsns(tmpRoot)).all).toHaveLength(0); + }); + + test("still scans a project directory inside the temp root", async () => { + const tmpRoot = join(getConfigDir(), "tmp"); + fakeTmp.path = tmpRoot; + const project = join(tmpRoot, "checkout"); + mkdirSync(join(project, ".git"), { recursive: true }); + const projectDsn = "https://code@o666.ingest.sentry.io/666"; + writeFileSync( + join(project, "config.ts"), + `Sentry.init({ dsn: "${projectDsn}" });`, + ); + + expect((await detectDsn(project))?.raw).toBe(projectDsn); + }); + }); + describe("from an ancestor of $HOME", () => { let ancestor: string;