refactor(runtime): compose consumers over fs and subprocess
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { constants, createReadStream } from 'node:fs'
|
||||
import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import type { BigIntStats, Dirent, Stats } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
@@ -369,6 +369,77 @@ export async function readWholeText(target: LocalTarget, signal?: AbortSignal):
|
||||
return decodeUtf8(raw, 'read', target.displayPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one regular UTF-8 file through a single no-follow handle, retaining at
|
||||
* most `maxBytes + 1` bytes so a concurrent grow cannot bypass the bound.
|
||||
* @param target - the resolved file to read.
|
||||
* @param maxBytes - positive safe-integer byte ceiling.
|
||||
* @param signal - aborts between handle operations.
|
||||
* @returns the complete decoded text when it fits.
|
||||
*/
|
||||
export async function readWholeTextBounded(
|
||||
target: LocalTarget,
|
||||
maxBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new Error('bounded read maxBytes must be a positive safe integer')
|
||||
}
|
||||
throwIfAborted(signal, 'read')
|
||||
let handle: Awaited<ReturnType<typeof open>>
|
||||
try {
|
||||
handle = await open(
|
||||
target.targetKey,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
if (isENOENT(error)) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
|
||||
if (isPermissionError(error)) throw new FsError(`cannot read "${target.displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
|
||||
throw new FsError(`cannot read "${target.displayPath}" safely: ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error })
|
||||
}
|
||||
try {
|
||||
throwIfAborted(signal, 'read')
|
||||
const info = await handle.stat()
|
||||
if (!info.isFile()) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
}
|
||||
if (info.size > maxBytes) {
|
||||
throw new FsError(
|
||||
`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`,
|
||||
'FS_IO_ERROR',
|
||||
)
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
for (;;) {
|
||||
throwIfAborted(signal, 'read')
|
||||
// Allocate in fixed internal chunks so a permissive deployment cap does
|
||||
// not reserve that entire cap for a small file. Once exactly at the
|
||||
// bound, one final byte detects concurrent growth without retaining it.
|
||||
const remaining = total === maxBytes ? 1 : Math.min(64 * 1024, maxBytes - total)
|
||||
const chunk = Buffer.allocUnsafe(remaining)
|
||||
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total)
|
||||
if (bytesRead === 0) break
|
||||
total += bytesRead
|
||||
if (total > maxBytes) {
|
||||
throw new FsError(
|
||||
`cannot read "${target.displayPath}": file grew past the ${maxBytes}-byte limit while reading`,
|
||||
'FS_IO_ERROR',
|
||||
)
|
||||
}
|
||||
chunks.push(chunk.subarray(0, bytesRead))
|
||||
}
|
||||
throwIfAborted(signal, 'read')
|
||||
const bytes = chunks.length === 1 ? chunks[0] as Buffer : Buffer.concat(chunks, total)
|
||||
if (bytes.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
}
|
||||
return decodeUtf8(bytes, 'read', target.displayPath)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
|
||||
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { resolve } from 'node:path'
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import z from 'schemastery'
|
||||
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
readForEdit,
|
||||
readTextForDiff,
|
||||
readWholeText,
|
||||
readWholeTextBounded,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
streamWholeText,
|
||||
@@ -90,6 +92,19 @@ export class LocalFileSystem extends FileSystem {
|
||||
return { targetKey: local.targetKey, displayPath: local.displayPath }
|
||||
}
|
||||
|
||||
override processPath(target: FsTarget): string {
|
||||
return String(target.targetKey)
|
||||
}
|
||||
|
||||
override fileUrl(target: FsTarget): string {
|
||||
return pathToFileURL(this.processPath(target)).href
|
||||
}
|
||||
|
||||
override contains(parent: FsTarget, child: FsTarget): boolean {
|
||||
const path = relative(this.processPath(parent), this.processPath(child))
|
||||
return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path))
|
||||
}
|
||||
|
||||
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
||||
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
|
||||
const info = await probe(target.targetKey)
|
||||
@@ -111,6 +126,10 @@ export class LocalFileSystem extends FileSystem {
|
||||
return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
|
||||
}
|
||||
|
||||
override async readTextBounded(target: FsTarget, maxBytes: number, signal?: AbortSignal): Promise<string> {
|
||||
return readWholeTextBounded({ displayPath: target.displayPath, targetKey: target.targetKey }, maxBytes, signal)
|
||||
}
|
||||
|
||||
override streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
||||
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user