Files
orca/src/shared/memory-safety/node-bounded-file-reader.ts
T
Neil d38b04e383 refactor(memory-safety): group the shared bounding primitives into one folder
The six primitives that exist to stop unbounded memory growth were scattered
through `src/shared/` next to unrelated modules, so there was nowhere to state
when a new ceiling is warranted — and the last attempt at this work added many
that were not.

Moves them under `src/shared/memory-safety/` and adds a README carrying the
doctrine: bound at ingress, not in the interior; set limits far above real use;
decide overload behavior explicitly; and prefer fixing measured growth over
adding a speculative ceiling.

No behavior change. Pure move plus import-path updates across 48 files, with
`git mv` preserving history. Includes two `vi.mock` paths that had to move with
their modules — a mock left on the old path silently stops applying, which the
suite caught.

Verified: typecheck clean, oxlint 0 errors, 18,668 tests passing across
src/shared and src/main.
2026-07-26 18:54:06 -07:00

128 lines
3.7 KiB
TypeScript

import { closeSync, fstatSync, openSync, readSync, type Stats } from 'node:fs'
import { open } from 'node:fs/promises'
const MIN_GROWTH_BYTES = 64 * 1024
export class NodeFileReadTooLargeError extends Error {
constructor(
readonly observedBytes: number,
readonly maxBytes: number
) {
super(
`File too large: ${(observedBytes / 1024 / 1024).toFixed(1)}MB exceeds ${maxBytes / 1024 / 1024}MB limit`
)
this.name = 'NodeFileReadTooLargeError'
}
}
export type BoundedNodeFileRead = {
buffer: Buffer
stats: Stats
}
function validateSize(size: number, maxBytes: number): void {
if (!Number.isSafeInteger(size) || size < 0) {
throw new Error('File has an invalid byte size')
}
if (size > maxBytes) {
throw new NodeFileReadTooLargeError(size, maxBytes)
}
}
export async function readNodeFileWithinLimit(
filePath: string,
maxBytes: number
): Promise<BoundedNodeFileRead> {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
throw new RangeError('File read limit must be a non-negative safe integer')
}
const handle = await open(filePath, 'r')
try {
const stats = await handle.stat()
validateSize(stats.size, maxBytes)
let buffer = Buffer.allocUnsafe(stats.size)
let offset = 0
while (true) {
while (offset < buffer.length) {
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset)
if (bytesRead === 0) {
return { buffer: buffer.subarray(0, offset), stats }
}
offset += bytesRead
}
const probe = Buffer.allocUnsafe(1)
const { bytesRead } = await handle.read(probe, 0, 1, offset)
if (bytesRead === 0) {
return { buffer: buffer.subarray(0, offset), stats }
}
if (offset >= maxBytes) {
throw new NodeFileReadTooLargeError(offset + bytesRead, maxBytes)
}
// Why: ordinary readFile includes concurrent growth, so retain that behavior while capacity stays bounded.
const nextCapacity = Math.min(
maxBytes,
Math.max(MIN_GROWTH_BYTES, buffer.length * 2, offset + bytesRead)
)
const expanded = Buffer.allocUnsafe(nextCapacity)
buffer.copy(expanded, 0, 0, offset)
expanded[offset] = probe[0]!
buffer = expanded
offset += bytesRead
}
} finally {
await handle.close()
}
}
export function readNodeFileSyncWithinLimit(
filePath: string,
maxBytes: number
): BoundedNodeFileRead {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
throw new RangeError('File read limit must be a non-negative safe integer')
}
const descriptor = openSync(filePath, 'r')
try {
const stats = fstatSync(descriptor)
validateSize(stats.size, maxBytes)
let buffer = Buffer.allocUnsafe(stats.size)
let offset = 0
while (true) {
while (offset < buffer.length) {
const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, offset)
if (bytesRead === 0) {
return { buffer: buffer.subarray(0, offset), stats }
}
offset += bytesRead
}
const probe = Buffer.allocUnsafe(1)
const bytesRead = readSync(descriptor, probe, 0, 1, offset)
if (bytesRead === 0) {
return { buffer: buffer.subarray(0, offset), stats }
}
if (offset >= maxBytes) {
throw new NodeFileReadTooLargeError(offset + bytesRead, maxBytes)
}
const nextCapacity = Math.min(
maxBytes,
Math.max(MIN_GROWTH_BYTES, buffer.length * 2, offset + bytesRead)
)
const expanded = Buffer.allocUnsafe(nextCapacity)
buffer.copy(expanded, 0, 0, offset)
expanded[offset] = probe[0]!
buffer = expanded
offset += bytesRead
}
} finally {
closeSync(descriptor)
}
}