You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
0 commit comments