Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/lib/init/tools/apply-patchset.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { safeReadFile } from "../../safe-read.js";
import { replace } from "../replacers.js";
import type {
ApplyPatchsetPatch,
Expand Down Expand Up @@ -149,7 +150,20 @@ async function applyEdits(
filePath: string,
edits: Array<{ oldString: string; newString: string }>
): Promise<string> {
let content = await fs.promises.readFile(absPath, "utf-8");
const initialContent = await safeReadFile(absPath, "apply-patchset.read");
if (initialContent === null) {
// `applyPatchset`'s earlier `access()` call only verifies
// existence — it follows symlinks and succeeds on FIFOs/sockets,
// so this branch is the primary guard against non-regular files
// (FIFO, socket, symlink → FIFO) that would otherwise hang
// `readFile` indefinitely, plus any other expected I/O failure
// (permission, transient read error) routed through
// `safeReadFile`.
throw new Error(
`Cannot read "${filePath}": not a regular file or read failed`
);
}
let content = initialContent;

for (let i = 0; i < edits.length; i += 1) {
const edit = edits[i];
Expand Down
6 changes: 6 additions & 0 deletions src/lib/init/tools/read-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ async function readSingleFile(
try {
const absPath = safePath(cwd, filePath);
const stat = await fs.promises.stat(absPath);
// Guard against FIFOs / sockets / devices — both `readFile` and
// `open("r")` block indefinitely on a FIFO waiting for a writer.
// `stat` follows symlinks, so symlink → FIFO is caught too.
if (!stat.isFile()) {
return null;
}
if (stat.size <= maxBytes) {
return await fs.promises.readFile(absPath, "utf-8");
}
Expand Down
7 changes: 7 additions & 0 deletions src/lib/init/workflow-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ export async function preReadCommonFiles(
try {
const absPath = path.join(directory, filePath);
const stat = await fs.promises.stat(absPath);
// Guard against FIFOs / sockets / devices — `fs.readFile` on a
// FIFO blocks indefinitely waiting for a writer. `stat` follows
// symlinks, so a symlink → FIFO is also caught here.
if (!stat.isFile()) {
cache[filePath] = null;
continue;
}
if (stat.size > MAX_FILE_BYTES) {
continue;
}
Expand Down
45 changes: 45 additions & 0 deletions src/lib/safe-read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* FIFO-safe file-read helper for user-controlled paths.
*
* Named pipes (FIFOs), commonly created by 1Password's `.env` symlink
* integration, cause `Bun.file(path).text()` to block indefinitely
* waiting for a writer. Any read of a path under the user's project
* tree or home directory needs a `stat`-based regular-file check
* first.
*
* Prefer this helper over calling `isRegularFile` + `Bun.file().text()`
* by hand: a single call, consistent error handling, no way to forget
* the guard.
*/

import { handleFileError, isRegularFile } from "./dsn/fs-utils.js";

/**
* Read a file's text content, returning `null` when the path is:
* - missing / inaccessible (ENOENT / EACCES / EPERM / EISDIR / ENOTDIR)
* - not a regular file (FIFO, socket, device, symlink to any of these)
* - any other expected I/O-level failure routed through
* {@link handleFileError}
*
* Unexpected errors are captured to Sentry via `handleFileError` and
* also return `null`, so callers don't need their own try/catch.
*
* @param filePath - Absolute path to read.
* @param operation - Logical operation name, reported to Sentry when a
* stat or read fails unexpectedly. Keep it short and specific (e.g.,
* `"sentryclirc.read"`, `"read-files.tool"`).
*/
export async function safeReadFile(
filePath: string,
operation: string
): Promise<string | null> {
if (!(await isRegularFile(filePath, `${operation}.stat`))) {
return null;
}
try {
return await Bun.file(filePath).text();
} catch (error) {
handleFileError(error, { operation, path: filePath });
return null;
}
}
83 changes: 63 additions & 20 deletions src/lib/sentryclirc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* `resolve-target.ts`.
*/

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

/**
* Read a file's text content, returning null for expected I/O errors.
* ENOENT (missing) and EACCES (permission denied) return null.
* All other errors propagate.
*/
async function tryReadFile(filePath: string): Promise<string | null> {
try {
return await Bun.file(filePath).text();
} catch (error: unknown) {
if (error instanceof Error && "code" in error) {
const { code } = error as NodeJS.ErrnoException;
if (code === "ENOENT" || code === "EACCES") {
return null;
}
}
throw error;
}
}

/**
* Fields we extract from an INI config, keyed by section.field.
*
Expand Down Expand Up @@ -125,6 +107,67 @@ function isComplete(result: SentryCliRcConfig): boolean {
);
}

/**
* True for the two I/O errors we treat as "file effectively absent"
* — a missing path and a permission-denied read. Any other error
* code signals something worth surfacing to the user.
*/
function isNarrowAbsenceError(error: unknown): boolean {
if (error instanceof Error && "code" in error) {
const { code } = error as NodeJS.ErrnoException;
return code === "ENOENT" || code === "EACCES";
}
return false;
}

/**
* Read a `.sentryclirc` file's text content. Returns `null` when
* the file:
*
* - is absent (ENOENT)
* - is unreadable for a common reason (EACCES)
* - is a non-regular file (FIFO / socket / symlink → FIFO — the
* 1Password pattern, which would otherwise block `text()`
* indefinitely)
*
* Re-throws every other I/O error (EPERM, EISDIR, EIO,
* ENOTDIR, etc.). A `.sentryclirc` is a committed config file — a
* user with `chmod 000` at the directory level (EPERM), a typo
* pointing at a directory (EISDIR), or a disk throwing EIO needs
* to see that clearly, not have it silently suppressed and surface
* downstream as a confusing "no auth token" error.
*
* Note: cannot use {@link safeReadFile} / `isRegularFile` from
* `safe-read.ts` — their `handleFileError` treats EPERM and
* EISDIR as ignorable, swallowing them during the stat phase.
* That broader policy is correct for opportunistic DSN scans; not
* for this committed config load.
*/
Comment thread
cursor[bot] marked this conversation as resolved.
async function tryReadSentryCliRc(filePath: string): Promise<string | null> {
let statResult: Awaited<ReturnType<typeof stat>>;
try {
statResult = await stat(filePath);
} catch (error) {
if (isNarrowAbsenceError(error)) {
return null;
}
throw error;
}
// Non-regular file (FIFO, socket, device, symlink → any of these)
// — `text()` would block. Return null so the walk-up moves on.
if (!statResult.isFile()) {
return null;
}
try {
return await Bun.file(filePath).text();
} catch (error) {
if (isNarrowAbsenceError(error)) {
return null;
}
throw error;
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Try to read and apply a `.sentryclirc` file to the result.
* No-op if the file doesn't exist or can't be read.
Expand All @@ -134,7 +177,7 @@ async function tryApplyFile(
filePath: string,
isGlobal: boolean
): Promise<void> {
Comment thread
sentry[bot] marked this conversation as resolved.
const content = await tryReadFile(filePath);
const content = await tryReadSentryCliRc(filePath);
if (content !== null) {
log.debug(
`Found ${isGlobal ? "global" : "local"} ${CONFIG_FILENAME} at ${filePath}`
Expand Down
Loading
Loading