Skip to content

Commit 38640a9

Browse files
BYKclaude
andauthored
ref(fs): add safeReadFile helper for FIFO-safe config reads (#819)
## Summary Extends the FIFO-safety pattern from #806 beyond DSN detection. All remaining single-file reads of user-controlled paths now have a `stat.isFile()` guard (either via a new `safeReadFile` helper or inline), eliminating the FIFO-hang bug class across the codebase. ## Why PR #806 fixed `Bun.file().text()` hanging on 1Password-style symlinked FIFOs for DSN detection (4 sites in `src/lib/dsn/`). But the CLI has ~4 more read sites under user-controlled paths with the same vulnerability: - **`src/lib/sentryclirc.ts`** — reads `.sentryclirc` at every directory during walk-up (hit on every CLI invocation). - **`src/lib/init/tools/read-files.ts`** — LLM-driven reads of arbitrary project files during init wizard. - **`src/lib/init/tools/apply-patchset.ts`** — reads modify-target files during init wizard. - **`src/lib/init/workflow-inputs.ts::preReadCommonFiles`** — scans a fixed allowlist of common config filenames. All four would hang on a 1Password-symlinked `.env`, `.sentryclirc`, `package.json`, etc. An audit pass also surfaced a subtle bug in the two sites that already had a `stat()`-based size check: `stat` on a FIFO returns successfully with `size=0`, so the size-check passes and the subsequent read still hangs. Adding `stat.isFile()` alongside the size check fixes this without a second `stat` call. ## Design Two distinct policies, corresponding to two different read contexts: ### `src/lib/safe-read.ts::safeReadFile` — opportunistic best-effort reads ```ts safeReadFile(path, operation) → Promise<string | null> ``` Chains `isRegularFile` (stat + FIFO guard) with `Bun.file(path).text()`. Returns `null` for any expected error (ENOENT, EACCES, EPERM, EISDIR, ENOTDIR, non-regular file, etc.); unexpected errors route through the existing `handleFileError` → Sentry pipeline and also return `null`. Used by `apply-patchset.ts` (where null is translated to a targeted throw). ### `sentryclirc.ts::tryReadSentryCliRc` — committed config load Direct `fs.promises.stat` + narrow `try/catch` that returns `null` only on ENOENT/EACCES; re-throws every other error. A user with a broken `.sentryclirc` (EPERM at the dir level, EISDIR, EIO, etc.) sees the error clearly rather than getting confusing downstream "no auth token" failures. The two sites with size-gated reads (`read-files.ts`, `workflow-inputs.ts::preReadCommonFiles`) keep their existing `fs.promises.stat` call but now check `stat.isFile()` alongside `stat.size`. No extra stat calls. ## Per-site changes | Site | Before | After | |---|---|---| | `sentryclirc.ts::tryApplyFile` | Local `tryReadFile` (18 LOC, ENOENT/EACCES-only) | `tryReadSentryCliRc` — adds `stat.isFile()` FIFO guard, keeps the narrow ENOENT/EACCES re-throw policy intact | | `init/tools/read-files.ts` | `fs.promises.stat` + size gate + read | Same flow + explicit `stat.isFile()` check before the read | | `init/tools/apply-patchset.ts` | Direct `fs.promises.readFile(absPath)` | `safeReadFile` → throws targeted "not a regular file or read failed" on null | | `init/workflow-inputs.ts::preReadCommonFiles` | `stat` + size gate + read | Same + `stat.isFile()` check; sets `cache[filePath] = null` on non-regular | ## Regression tests `test/lib/safe-read.test.ts` — 11 tests covering every migration site: - `safeReadFile` on regular file / missing / FIFO / symlink→FIFO / directory - `sentryclirc` loader on a FIFO-backed `.sentryclirc` → empty config, no hang - `readFiles` tool on FIFO and symlink→FIFO paths (both test cases) - `applyPatchset` tool on FIFO and symlink→FIFO targets (both test cases) - `preReadCommonFiles` on a FIFO-backed common-config file **Verified negatively**: for each migrated site, temporarily removing the FIFO guard causes the corresponding regression test to time out at Bun's 5s default, confirming the guards are genuinely the condition being exercised. ## Review cycle The PR went through multiple rounds of self-review + bot review after the initial post. Key fixes from review: - **Seer (HIGH)** flagged that my first pass at migrating sentryclirc silently swallowed EPERM/EISDIR/EIO errors. Fixed by reverting sentryclirc to direct `fs.promises.stat` with the narrow original error policy (not via `isRegularFile`, which had a broader `handleFileError` swallow-list). - **Cursor (Medium)** caught that my second pass *still* had the bug because `isRegularFile` internally calls `handleFileError`. Fixed by calling `stat` directly. - **Cursor (Low)** caught unused re-exports in `safe-read.ts` after the sentryclirc-goes-direct change removed the last consumer. Dropped. - **Cursor (Low)** caught an orphaned JSDoc block after a helper-extraction mid-file. Swapped order. - **Self-review subagent** caught that one test was calling `precomputeDirListing` (a listing op) instead of `preReadCommonFiles` (the function with the new guard). Fixed to call the right function and negative-verified. ## Test plan - [x] `bunx tsc --noEmit` — clean - [x] `bun run lint` — clean (1 pre-existing markdown.ts warning) - [x] `bun test test/lib/safe-read.test.ts` — **11 pass** - [x] `bun test --timeout 15000 test/lib test/commands test/types` — **5687 pass, 0 fail** - [x] `bun test test/isolated` — 138 pass - [x] Negative verification for each guarded site — confirmed genuine regression coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ba7fc85 commit 38640a9

6 files changed

Lines changed: 411 additions & 21 deletions

File tree

src/lib/init/tools/apply-patchset.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from "node:fs";
22
import path from "node:path";
3+
import { safeReadFile } from "../../safe-read.js";
34
import { replace } from "../replacers.js";
45
import type {
56
ApplyPatchsetPatch,
@@ -149,7 +150,20 @@ async function applyEdits(
149150
filePath: string,
150151
edits: Array<{ oldString: string; newString: string }>
151152
): Promise<string> {
152-
let content = await fs.promises.readFile(absPath, "utf-8");
153+
const initialContent = await safeReadFile(absPath, "apply-patchset.read");
154+
if (initialContent === null) {
155+
// `applyPatchset`'s earlier `access()` call only verifies
156+
// existence — it follows symlinks and succeeds on FIFOs/sockets,
157+
// so this branch is the primary guard against non-regular files
158+
// (FIFO, socket, symlink → FIFO) that would otherwise hang
159+
// `readFile` indefinitely, plus any other expected I/O failure
160+
// (permission, transient read error) routed through
161+
// `safeReadFile`.
162+
throw new Error(
163+
`Cannot read "${filePath}": not a regular file or read failed`
164+
);
165+
}
166+
let content = initialContent;
153167

154168
for (let i = 0; i < edits.length; i += 1) {
155169
const edit = edits[i];

src/lib/init/tools/read-files.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ async function readSingleFile(
3636
try {
3737
const absPath = safePath(cwd, filePath);
3838
const stat = await fs.promises.stat(absPath);
39+
// Guard against FIFOs / sockets / devices — both `readFile` and
40+
// `open("r")` block indefinitely on a FIFO waiting for a writer.
41+
// `stat` follows symlinks, so symlink → FIFO is caught too.
42+
if (!stat.isFile()) {
43+
return null;
44+
}
3945
if (stat.size <= maxBytes) {
4046
return await fs.promises.readFile(absPath, "utf-8");
4147
}

src/lib/init/workflow-inputs.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ export async function preReadCommonFiles(
121121
try {
122122
const absPath = path.join(directory, filePath);
123123
const stat = await fs.promises.stat(absPath);
124+
// Guard against FIFOs / sockets / devices — `fs.readFile` on a
125+
// FIFO blocks indefinitely waiting for a writer. `stat` follows
126+
// symlinks, so a symlink → FIFO is also caught here.
127+
if (!stat.isFile()) {
128+
cache[filePath] = null;
129+
continue;
130+
}
124131
if (stat.size > MAX_FILE_BYTES) {
125132
continue;
126133
}

src/lib/safe-read.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* FIFO-safe file-read helper for user-controlled paths.
3+
*
4+
* Named pipes (FIFOs), commonly created by 1Password's `.env` symlink
5+
* integration, cause `Bun.file(path).text()` to block indefinitely
6+
* waiting for a writer. Any read of a path under the user's project
7+
* tree or home directory needs a `stat`-based regular-file check
8+
* first.
9+
*
10+
* Prefer this helper over calling `isRegularFile` + `Bun.file().text()`
11+
* by hand: a single call, consistent error handling, no way to forget
12+
* the guard.
13+
*/
14+
15+
import { handleFileError, isRegularFile } from "./dsn/fs-utils.js";
16+
17+
/**
18+
* Read a file's text content, returning `null` when the path is:
19+
* - missing / inaccessible (ENOENT / EACCES / EPERM / EISDIR / ENOTDIR)
20+
* - not a regular file (FIFO, socket, device, symlink to any of these)
21+
* - any other expected I/O-level failure routed through
22+
* {@link handleFileError}
23+
*
24+
* Unexpected errors are captured to Sentry via `handleFileError` and
25+
* also return `null`, so callers don't need their own try/catch.
26+
*
27+
* @param filePath - Absolute path to read.
28+
* @param operation - Logical operation name, reported to Sentry when a
29+
* stat or read fails unexpectedly. Keep it short and specific (e.g.,
30+
* `"sentryclirc.read"`, `"read-files.tool"`).
31+
*/
32+
export async function safeReadFile(
33+
filePath: string,
34+
operation: string
35+
): Promise<string | null> {
36+
if (!(await isRegularFile(filePath, `${operation}.stat`))) {
37+
return null;
38+
}
39+
try {
40+
return await Bun.file(filePath).text();
41+
} catch (error) {
42+
handleFileError(error, { operation, path: filePath });
43+
return null;
44+
}
45+
}

src/lib/sentryclirc.ts

Lines changed: 63 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* `resolve-target.ts`.
1717
*/
1818

19+
import { stat } from "node:fs/promises";
1920
import { homedir } from "node:os";
2021
import { join } from "node:path";
2122
import { getConfigDir } from "./db/index.js";
@@ -57,25 +58,6 @@ export type SentryCliRcConfig = {
5758
*/
5859
const cache = new Map<string, Promise<SentryCliRcConfig>>();
5960

60-
/**
61-
* Read a file's text content, returning null for expected I/O errors.
62-
* ENOENT (missing) and EACCES (permission denied) return null.
63-
* All other errors propagate.
64-
*/
65-
async function tryReadFile(filePath: string): Promise<string | null> {
66-
try {
67-
return await Bun.file(filePath).text();
68-
} catch (error: unknown) {
69-
if (error instanceof Error && "code" in error) {
70-
const { code } = error as NodeJS.ErrnoException;
71-
if (code === "ENOENT" || code === "EACCES") {
72-
return null;
73-
}
74-
}
75-
throw error;
76-
}
77-
}
78-
7961
/**
8062
* Fields we extract from an INI config, keyed by section.field.
8163
*
@@ -125,6 +107,67 @@ function isComplete(result: SentryCliRcConfig): boolean {
125107
);
126108
}
127109

110+
/**
111+
* True for the two I/O errors we treat as "file effectively absent"
112+
* — a missing path and a permission-denied read. Any other error
113+
* code signals something worth surfacing to the user.
114+
*/
115+
function isNarrowAbsenceError(error: unknown): boolean {
116+
if (error instanceof Error && "code" in error) {
117+
const { code } = error as NodeJS.ErrnoException;
118+
return code === "ENOENT" || code === "EACCES";
119+
}
120+
return false;
121+
}
122+
123+
/**
124+
* Read a `.sentryclirc` file's text content. Returns `null` when
125+
* the file:
126+
*
127+
* - is absent (ENOENT)
128+
* - is unreadable for a common reason (EACCES)
129+
* - is a non-regular file (FIFO / socket / symlink → FIFO — the
130+
* 1Password pattern, which would otherwise block `text()`
131+
* indefinitely)
132+
*
133+
* Re-throws every other I/O error (EPERM, EISDIR, EIO,
134+
* ENOTDIR, etc.). A `.sentryclirc` is a committed config file — a
135+
* user with `chmod 000` at the directory level (EPERM), a typo
136+
* pointing at a directory (EISDIR), or a disk throwing EIO needs
137+
* to see that clearly, not have it silently suppressed and surface
138+
* downstream as a confusing "no auth token" error.
139+
*
140+
* Note: cannot use {@link safeReadFile} / `isRegularFile` from
141+
* `safe-read.ts` — their `handleFileError` treats EPERM and
142+
* EISDIR as ignorable, swallowing them during the stat phase.
143+
* That broader policy is correct for opportunistic DSN scans; not
144+
* for this committed config load.
145+
*/
146+
async function tryReadSentryCliRc(filePath: string): Promise<string | null> {
147+
let statResult: Awaited<ReturnType<typeof stat>>;
148+
try {
149+
statResult = await stat(filePath);
150+
} catch (error) {
151+
if (isNarrowAbsenceError(error)) {
152+
return null;
153+
}
154+
throw error;
155+
}
156+
// Non-regular file (FIFO, socket, device, symlink → any of these)
157+
// — `text()` would block. Return null so the walk-up moves on.
158+
if (!statResult.isFile()) {
159+
return null;
160+
}
161+
try {
162+
return await Bun.file(filePath).text();
163+
} catch (error) {
164+
if (isNarrowAbsenceError(error)) {
165+
return null;
166+
}
167+
throw error;
168+
}
169+
}
170+
128171
/**
129172
* Try to read and apply a `.sentryclirc` file to the result.
130173
* No-op if the file doesn't exist or can't be read.
@@ -134,7 +177,7 @@ async function tryApplyFile(
134177
filePath: string,
135178
isGlobal: boolean
136179
): Promise<void> {
137-
const content = await tryReadFile(filePath);
180+
const content = await tryReadSentryCliRc(filePath);
138181
if (content !== null) {
139182
log.debug(
140183
`Found ${isGlobal ? "global" : "local"} ${CONFIG_FILENAME} at ${filePath}`

0 commit comments

Comments
 (0)