diff --git a/src/lib/dsn/detector.ts b/src/lib/dsn/detector.ts index 726f7de6b..05352a848 100644 --- a/src/lib/dsn/detector.ts +++ b/src/lib/dsn/detector.ts @@ -35,6 +35,7 @@ import { detectFromEnvFiles, extractDsnFromEnvContent, } from "./env-file.js"; +import { isRegularFile } from "./fs-utils.js"; import { createDetectedDsn, createDsnFingerprint, parseDsn } from "./parser.js"; import { findProjectRoot } from "./project-root.js"; import type { @@ -284,6 +285,12 @@ async function verifyFileDsnCache( const filePath = join(cwd, cached.sourcePath); try { + // Guard: skip non-regular files (FIFOs, sockets, etc.) that would block. + // 1Password streams secrets via symlinked named pipes; Bun.file().text() + // blocks indefinitely on these. + if (!(await isRegularFile(filePath, "verifyFileDsnCache.stat"))) { + return null; + } const content = await Bun.file(filePath).text(); const foundDsn = extractDsnFromContent(content, cached.source); diff --git a/src/lib/dsn/fs-utils.ts b/src/lib/dsn/fs-utils.ts index ea788b593..e0bc0f9e1 100644 --- a/src/lib/dsn/fs-utils.ts +++ b/src/lib/dsn/fs-utils.ts @@ -58,3 +58,29 @@ export function handleFileError( }); } } + +/** + * Check if a path points to a regular file (not a FIFO, socket, device, etc.). + * + * Named pipes (FIFOs) — commonly used by 1Password to stream secrets via + * symlinked `.env` files — cause `Bun.file().text()` to block indefinitely + * waiting for a writer. This guard uses `Bun.file(path).stat()`, which follows + * symlinks and inspects file type without performing the blocking read, so a + * symlink → FIFO is correctly detected. + * + * @param filePath - Absolute path to check + * @param operation - Logical operation name for unexpected stat error reporting + * @returns True if the path is a regular file safe to read, false otherwise + */ +export async function isRegularFile( + filePath: string, + operation = "isRegularFile" +): Promise { + try { + const stats = await Bun.file(filePath).stat(); + return stats.isFile(); + } catch (error) { + handleFileError(error, { operation, path: filePath }); + return false; + } +} diff --git a/src/lib/dsn/project-root.ts b/src/lib/dsn/project-root.ts index bd9581f2d..fbde43247 100644 --- a/src/lib/dsn/project-root.ts +++ b/src/lib/dsn/project-root.ts @@ -29,7 +29,7 @@ import { import { withFsSpan, withTracingSpan } from "../telemetry.js"; import { walkUpFrom } from "../walk-up.js"; import { ENV_FILES, extractDsnFromEnvContent } from "./env-file.js"; -import { handleFileError } from "./fs-utils.js"; +import { handleFileError, isRegularFile } from "./fs-utils.js"; import { createDetectedDsn } from "./parser.js"; import type { DetectedDsn } from "./types.js"; @@ -262,6 +262,12 @@ async function anyGlobMatches( async function checkEditorConfigRoot(dir: string): Promise { const editorConfigPath = join(dir, ".editorconfig"); try { + // Guard: skip non-regular files (FIFOs, sockets, etc.) that would block + if ( + !(await isRegularFile(editorConfigPath, "checkEditorConfigRoot.stat")) + ) { + return false; + } const content = await Bun.file(editorConfigPath).text(); return EDITORCONFIG_ROOT_REGEX.test(content); } catch (error) { @@ -361,6 +367,12 @@ function checkEnvForDsn(dir: string): Promise { for (const filename of ENV_FILES) { const filePath = join(dir, filename); try { + // Guard: skip non-regular files (FIFOs, sockets, etc.) that would block. + // 1Password streams secrets via symlinked named pipes; Bun.file().text() + // blocks indefinitely on these. + if (!(await isRegularFile(filePath, "checkEnvForDsn.stat"))) { + continue; + } const content = await Bun.file(filePath).text(); const dsn = extractDsnFromEnvContent(content); if (dsn) { diff --git a/src/lib/dsn/scanner.ts b/src/lib/dsn/scanner.ts index ed101ce39..883408750 100644 --- a/src/lib/dsn/scanner.ts +++ b/src/lib/dsn/scanner.ts @@ -6,7 +6,7 @@ */ import { join } from "node:path"; -import { handleFileError } from "./fs-utils.js"; +import { handleFileError, isRegularFile } from "./fs-utils.js"; import type { DetectedDsn } from "./types.js"; /** Result of processing a single file for DSN extraction. */ @@ -70,8 +70,13 @@ export async function scanSpecificFiles( const filepath = join(cwd, filename); try { + // Guard: skip non-regular files (FIFOs, sockets, etc.) that would block. + // 1Password streams secrets via symlinked named pipes; Bun.file().text() + // blocks indefinitely on these. + if (!(await isRegularFile(filepath, "scanSpecificFiles.stat"))) { + continue; + } const file = Bun.file(filepath); - // Read file directly - handles ENOENT gracefully const content = await file.text(); const result = processFile(filename, content); diff --git a/test/lib/dsn/fifo-safety.test.ts b/test/lib/dsn/fifo-safety.test.ts new file mode 100644 index 000000000..308955d61 --- /dev/null +++ b/test/lib/dsn/fifo-safety.test.ts @@ -0,0 +1,266 @@ +/** + * FIFO / Named Pipe Safety Tests + * + * Verifies that DSN detection gracefully skips non-regular files + * (FIFOs, sockets, etc.) instead of blocking indefinitely. + * + * Motivation: 1Password streams secrets via symlinked named pipes for .env + * files. Bun.file().text() blocks indefinitely on FIFOs because open() + * waits for a writer. The isRegularFile() guard prevents this hang by + * checking file type with stat() before attempting to read. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "node:child_process"; +import { + mkdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isRegularFile } from "../../../src/lib/dsn/fs-utils.js"; +import { useTestConfigDir } from "../../helpers.js"; + +/** Create a FIFO (named pipe) at the given path using mkfifo(1). */ +function createFifo(path: string): void { + execSync(`mkfifo ${JSON.stringify(path)}`); +} + +const getConfigDir = useTestConfigDir("fifo-safety-"); + +describe("isRegularFile", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + tmpdir(), + `sentry-fifo-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + test("returns true for regular files", async () => { + const filePath = join(testDir, ".env"); + writeFileSync(filePath, "SENTRY_DSN=https://key@sentry.io/123\n"); + expect(await isRegularFile(filePath)).toBe(true); + }); + + test("returns false for FIFOs (named pipes)", async () => { + const fifoPath = join(testDir, ".env"); + createFifo(fifoPath); + // Verify it's actually a FIFO + expect(statSync(fifoPath).isFIFO()).toBe(true); + expect(await isRegularFile(fifoPath)).toBe(false); + }); + + test("returns false for symlinks to FIFOs (1Password pattern)", async () => { + const fifoPath = join(testDir, ".env.fifo"); + createFifo(fifoPath); + const symlinkPath = join(testDir, ".env"); + symlinkSync(fifoPath, symlinkPath); + // stat() follows the symlink and sees the FIFO target + expect(await isRegularFile(symlinkPath)).toBe(false); + }); + + test("returns true for symlinks to regular files", async () => { + const realPath = join(testDir, ".env.real"); + writeFileSync(realPath, "SENTRY_DSN=https://key@sentry.io/123\n"); + const symlinkPath = join(testDir, ".env"); + symlinkSync(realPath, symlinkPath); + expect(await isRegularFile(symlinkPath)).toBe(true); + }); + + test("returns false for directories", async () => { + const dirPath = join(testDir, ".env"); + mkdirSync(dirPath); + expect(await isRegularFile(dirPath)).toBe(false); + }); + + test("returns false for non-existent paths", async () => { + expect(await isRegularFile(join(testDir, "no-such-file"))).toBe(false); + }); +}); + +describe("FIFO: env file detection", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + tmpdir(), + `sentry-fifo-env-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + // Create .git so this is treated as project root (stops walk-up) + mkdirSync(join(testDir, ".git")); + }); + + afterEach(() => { + delete process.env.SENTRY_DSN; + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + test("detectFromEnvFiles skips FIFO .env without hanging", async () => { + // Ensure config dir is set for DB access + getConfigDir(); + + const fifoPath = join(testDir, ".env"); + createFifo(fifoPath); + + const { detectFromEnvFiles } = await import( + "../../../src/lib/dsn/env-file.js" + ); + + // This would hang indefinitely before the fix. + // 2-second timeout ensures the test fails fast if the guard is broken. + const result = await Promise.race([ + detectFromEnvFiles(testDir), + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), 2000) + ), + ]); + + expect(result).not.toBe("timeout"); + expect(result).toBeNull(); + }); + + test("detectFromEnvFiles reads regular .env normally", async () => { + getConfigDir(); + + writeFileSync( + join(testDir, ".env"), + "SENTRY_DSN=https://abc123@o456.ingest.sentry.io/789\n" + ); + + const { detectFromEnvFiles } = await import( + "../../../src/lib/dsn/env-file.js" + ); + const result = await detectFromEnvFiles(testDir); + + expect(result).not.toBeNull(); + expect(result!.raw).toBe("https://abc123@o456.ingest.sentry.io/789"); + }); + + test("detectFromEnvFiles skips FIFO .env but reads regular .env.local", async () => { + getConfigDir(); + + // .env is a FIFO (would hang) + createFifo(join(testDir, ".env")); + // .env.local is a regular file with a DSN + writeFileSync( + join(testDir, ".env.local"), + "SENTRY_DSN=https://key@o1.ingest.sentry.io/111\n" + ); + + const { detectFromEnvFiles } = await import( + "../../../src/lib/dsn/env-file.js" + ); + const result = await Promise.race([ + detectFromEnvFiles(testDir), + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), 2000) + ), + ]); + + expect(result).not.toBe("timeout"); + expect(result).not.toBeNull(); + expect(result!.raw).toBe("https://key@o1.ingest.sentry.io/111"); + }); + + test("findProjectRoot walk-up skips FIFO .env without hanging", async () => { + getConfigDir(); + + // Create a nested dir structure: testDir/.git + testDir/sub/.env (FIFO) + const subDir = join(testDir, "sub"); + mkdirSync(subDir); + createFifo(join(subDir, ".env")); + + const { findProjectRoot } = await import( + "../../../src/lib/dsn/project-root.js" + ); + + const result = await Promise.race([ + findProjectRoot(subDir), + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), 2000) + ), + ]); + + expect(result).not.toBe("timeout"); + // Should still find the project root (.git in parent) + expect((result as { projectRoot: string }).projectRoot).toBe(testDir); + }); + + test("detectDsn skips symlinked FIFO .env.local and falls back to regular .env", async () => { + getConfigDir(); + + const fifoPath = join(testDir, ".env.local.fifo"); + createFifo(fifoPath); + symlinkSync(fifoPath, join(testDir, ".env.local")); + writeFileSync( + join(testDir, ".env"), + "SENTRY_DSN=https://fallback@o1.ingest.sentry.io/222\n" + ); + + const { detectDsn } = await import("../../../src/lib/dsn/detector.js"); + + const result = await Promise.race([ + detectDsn(testDir), + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), 2000) + ), + ]); + + expect(result).not.toBe("timeout"); + expect(result).not.toBeNull(); + expect(result!.raw).toBe("https://fallback@o1.ingest.sentry.io/222"); + expect(result!.source).toBe("env_file"); + }); + + test("detectDsn cache verification skips source file after it becomes a symlinked FIFO", async () => { + getConfigDir(); + + const cachedDsn = "https://cached@o1.ingest.sentry.io/111"; + const fallbackDsn = "https://fallback@o2.ingest.sentry.io/333"; + const envPath = join(testDir, ".env"); + writeFileSync(envPath, `SENTRY_DSN=${cachedDsn}\n`); + + const { detectDsn } = await import("../../../src/lib/dsn/detector.js"); + + const firstResult = await detectDsn(testDir); + expect(firstResult?.raw).toBe(cachedDsn); + expect(firstResult?.source).toBe("env_file"); + + rmSync(envPath); + const fifoPath = join(testDir, ".env.fifo"); + createFifo(fifoPath); + symlinkSync(fifoPath, envPath); + process.env.SENTRY_DSN = fallbackDsn; + + const result = await Promise.race([ + detectDsn(testDir), + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), 2000) + ), + ]); + + expect(result).not.toBe("timeout"); + expect(result).not.toBeNull(); + expect(result!.raw).toBe(fallbackDsn); + expect(result!.source).toBe("env"); + }); +});