From e27371f4a30fd19aa2100a949c8ca64f249703d3 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Tue, 21 Apr 2026 15:52:45 -0400 Subject: [PATCH 1/2] fix(dsn): skip non-regular env files during detection DSN auto-detection reads .env* files to infer SENTRY_DSN and project context. When one of those paths is a FIFO (including 1Password's symlink-to-FIFO env files), Bun.file().text() can block indefinitely waiting for a writer. Stat each .env* path before reading and skip non-regular files during: - explicit env-file scanning - project-root walk-up checks - cached DSN source verification This intentionally avoids the hang rather than trying to consume FIFO contents. Regular files and symlinks to regular files still work as before. Also add regression coverage for top-level detectDsn() and the cached verification path when an env file becomes a symlinked FIFO. --- src/lib/dsn/detector.ts | 7 + src/lib/dsn/fs-utils.ts | 26 +++ src/lib/dsn/project-root.ts | 14 +- src/lib/dsn/scanner.ts | 9 +- test/lib/dsn/fifo-safety.test.ts | 266 +++++++++++++++++++++++++++++++ 5 files changed, 319 insertions(+), 3 deletions(-) create mode 100644 test/lib/dsn/fifo-safety.test.ts 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..cb87b7e95 100644 --- a/src/lib/dsn/fs-utils.ts +++ b/src/lib/dsn/fs-utils.ts @@ -4,6 +4,7 @@ * Shared utilities for handling file system errors during scanning. */ +import { stat } from "node:fs/promises"; // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; @@ -58,3 +59,28 @@ 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 follows symlinks (uses `stat`, not `lstat`) + * 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 stat(filePath); + 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"); + }); +}); From 9cb0d33ac39f68d70f5d13217a266af2b225bbd8 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Tue, 21 Apr 2026 17:25:14 -0400 Subject: [PATCH 2/2] refactor(dsn): use Bun stat in regular-file guard --- src/lib/dsn/fs-utils.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/dsn/fs-utils.ts b/src/lib/dsn/fs-utils.ts index cb87b7e95..e0bc0f9e1 100644 --- a/src/lib/dsn/fs-utils.ts +++ b/src/lib/dsn/fs-utils.ts @@ -4,7 +4,6 @@ * Shared utilities for handling file system errors during scanning. */ -import { stat } from "node:fs/promises"; // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; @@ -65,8 +64,9 @@ export function handleFileError( * * 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 follows symlinks (uses `stat`, not `lstat`) - * so a symlink → FIFO is correctly detected. + * 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 @@ -77,7 +77,7 @@ export async function isRegularFile( operation = "isRegularFile" ): Promise { try { - const stats = await stat(filePath); + const stats = await Bun.file(filePath).stat(); return stats.isFile(); } catch (error) { handleFileError(error, { operation, path: filePath });