Files
deepseek-harness/packages/fs/fs-local/src/fsio.ts
Dudu-0223 b802912067 fix: address codex review round 2
Resolve targetKey by realpathing the nearest EXISTING ancestor and re-appending
the missing suffix, so a not-yet-created file under a symlinked ancestor with
missing intermediate dirs gets the same key before and after creation — keeping
observed-state intact across a write→edit cycle. Make the socket-type probe test
skip (not fail) when a sandbox forbids unix-domain sockets.
2026-06-26 17:59:40 +08:00

398 lines
16 KiB
TypeScript

/**
* Cordis-free local-filesystem I/O for `@deepseek-ai/dsh-fs-local`. Kept
* separate from the service class (mirroring `dsh-bash-local`'s `run.ts`) so
* the raw stat/read/write/edit mechanics can be unit-tested without a Context.
*
* This is the PROVIDER layer: it hands back decoded whole-file text (validated
* UTF-8, binary rejected) — never line windows or numbered lines, which are
* model-facing read policy owned by `@deepseek-ai/dsh-file-context`. Large files
* stream their text in chunks so a huge file never has to be held whole in
* memory; the binary/NUL sample and cross-chunk UTF-8 decoding stay here.
*
* Writes are atomic: content goes to a temp file opened exclusively (`wx`,
* `0o600`, so a pre-existing path can never be clobbered and write-in-progress
* bytes stay owner-only) inside a randomly-named private staging directory
* (`0o700`) next to the target, then `rename`d over the target. Edits are
* read-modify-write over the same atomic primitive.
*
* @module @deepseek-ai/dsh-fs-local/fsio
*/
import { randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises'
import type { Stats } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { TextDecoder } from 'node:util'
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
/** Files at or above this size stream their text; smaller files read whole. */
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
const BINARY_SAMPLE_BYTES = 8192
function isENOENT(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
/* v8 ignore start -- composes secondary cleanup-failure messages, which require a filesystem/kernel fault after the primary failure. */
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/* v8 ignore stop */
function throwIfAborted(signal: AbortSignal | undefined, verb: string): void {
if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED')
}
/**
* `readFile` with the supplied signal, translating a mid-read `AbortError` into
* the seam's structured `FsError('FS_ABORTED')` (Node rejects an aborted
* `readFile` with a bare `AbortError`, which would otherwise escape the seam's
* error taxonomy — the streaming/write paths translate it the same way).
*/
async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', signal?: AbortSignal): Promise<Buffer> {
try {
return await readFile(absolutePath, signal ? { signal } : {})
} catch (error: unknown) {
/* v8 ignore next 2 -- a non-abort readFile rejection needs a permission/IO fault racing an open file. */
if (!isAbortError(error)) throw error
throw new FsError(`${verb} aborted`, 'FS_ABORTED')
}
}
/** Opaque version token from a stat: mtime (ns precision) + size. */
function versionOf(info: Stats): FsVersion {
return FsVersion(`${info.mtimeMs}:${info.size}`)
}
/**
* Test seam: lets specs force the streaming read path (via a small
* `streamMinSize`) and pin the temp-file name (to prove exclusive-open
* behavior) without a 10 MB fixture or a name race.
*/
export interface FsIoInternals {
/** Override {@link STREAM_MIN_SIZE} for read routing. */
streamMinSize?: number
/** Override the generated private staging-dir name (relative to the target dir). */
tempDirName?: (writePath: string) => string
/** Override the generated temp-file name (relative to the private staging dir). */
tempName?: (writePath: string) => string
/** Test hook after the temp file is written/synced but before final chmod+rename. */
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
}
/** A resolved local path: the absolute path shown to callers and its realpath identity. */
export interface LocalTarget {
/** Absolute path (symlinks not resolved) — used for display. */
displayPath: string
/** Realpath identity — used as the stable target key and the I/O path. */
targetKey: FsTargetKey
}
/** Result of probing a path: null when it does not exist. */
export interface PathInfo {
version: FsVersion
mode: number
type: 'file' | 'directory' | 'other'
size: number
}
/**
* Resolve a path to its absolute display path and realpath identity. Relative
* paths are based on `cwd`. When the file itself does not yet exist, the
* `targetKey` realpaths the nearest EXISTING ancestor directory and re-appends
* the still-missing suffix, so a not-yet-created file gets the same stable key
* it will have after creation — even when an ancestor (e.g. `cwd`) is a symlink
* and intermediate directories are created by the write. Two input paths
* reaching the same file via symlinks share one key. Falls back to the absolute
* path only when no ancestor (not even the filesystem root) can be resolved.
*/
export async function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget> {
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
const displayPath = resolve(cwd, path)
try {
// Prefer the file's own realpath (resolves a symlinked file to its target).
return { displayPath, targetKey: FsTargetKey(await realpath(displayPath)) }
} catch (error: unknown) {
/* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */
if (!isENOENT(error)) throw error
}
// File absent: realpath the nearest existing ancestor and re-append the
// missing suffix (the file basename plus any not-yet-created intermediate
// dirs), so the key is stable across creation of those dirs.
const missing = [basename(displayPath)]
let ancestor = dirname(displayPath)
while (true) {
try {
const realAncestor = await realpath(ancestor)
return { displayPath, targetKey: FsTargetKey(join(realAncestor, ...missing)) }
} catch (error: unknown) {
/* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */
if (!isENOENT(error)) throw error
const parent = dirname(ancestor)
/* v8 ignore next -- the filesystem root always realpaths, so the walk terminates before parent === ancestor. */
if (parent === ancestor) return { displayPath, targetKey: FsTargetKey(displayPath) }
missing.unshift(basename(ancestor))
ancestor = parent
}
}
}
/** Probe a path for its version, mode, type, and size. Null if absent. */
export async function probe(absolutePath: string): Promise<PathInfo | null> {
try {
const info = await stat(absolutePath)
const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size }
} catch (error: unknown) {
/* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; surface it. */
if (!isENOENT(error)) throw error
return null
}
}
// --- Reading ---
function notTextError(verb: 'read' | 'edit', displayPath: string): FsError {
return new FsError(`cannot ${verb} "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT')
}
function decodeUtf8(buffer: Uint8Array, verb: 'read' | 'edit', displayPath: string): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
} catch (error: unknown) {
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
if (!(error instanceof TypeError)) throw error
throw notTextError(verb, displayPath)
}
}
function decodeUtf8Stream(
decoder: TextDecoder,
chunk: Uint8Array | undefined,
verb: 'read' | 'edit',
displayPath: string,
): string {
try {
return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode()
} catch (error: unknown) {
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
if (!(error instanceof TypeError)) throw error
throw notTextError(verb, displayPath)
}
}
async function statRegularFile(target: LocalTarget, verb: 'read', signal?: AbortSignal): Promise<Stats> {
throwIfAborted(signal, verb)
let info: Stats
try {
info = await stat(target.targetKey)
} catch (error: unknown) {
/* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; only the not-found path is reachable in tests. */
if (!isENOENT(error)) throw error
throw new FsError(`cannot ${verb} "${target.displayPath}": not found`, 'FS_NOT_FOUND')
}
if (!info.isFile()) throw new FsError(`cannot ${verb} "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
return info
}
/**
* Read a whole regular UTF-8 text file into a single decoded string. Rejects
* non-regular files, invalid UTF-8, and NUL-byte binary samples.
*/
export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise<string> {
await statRegularFile(target, 'read', signal)
const raw = await readFileAbortable(target.targetKey, 'read', signal)
throwIfAborted(signal, 'read')
if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
}
return decodeUtf8(raw, 'read', target.displayPath)
}
/**
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
* cross-chunk UTF-8 decoding), but never holds the whole file in memory.
*/
export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal): AsyncIterable<string> {
await statRegularFile(target, 'read', signal)
const stream = createReadStream(target.targetKey, signal ? { signal } : {})
const decoder = new TextDecoder('utf-8', { fatal: true })
let sampledBytes = 0
function scanBinarySample(chunk: Buffer): void {
if (sampledBytes >= BINARY_SAMPLE_BYTES) return
const sample = chunk.subarray(0, Math.min(chunk.length, BINARY_SAMPLE_BYTES - sampledBytes))
if (sample.includes(0)) {
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
}
sampledBytes += sample.length
}
try {
for await (const chunk of stream as AsyncIterable<Buffer>) {
scanBinarySample(chunk)
yield decodeUtf8Stream(decoder, chunk, 'read', target.displayPath)
}
yield decodeUtf8Stream(decoder, undefined, 'read', target.displayPath)
} catch (error: unknown) {
/* v8 ignore next 4 -- mid-stream errors need an abort/IO fault racing the loop; pre-abort is caught by throwIfAborted. */
if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED')
throw error
}
}
// --- Writing ---
async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise<never> {
try {
await rm(stagingDir, { recursive: true, force: true })
} catch (cleanupError: unknown) {
/* v8 ignore next 1 -- cleanup failure here needs a second filesystem fault after the primary write failure. */
throw new FsError(`write failed (${errorMessage(originalError)}) and temp cleanup failed (${errorMessage(cleanupError)})`, 'FS_NOT_FOUND', { cause: originalError })
}
throw originalError
}
/**
* Atomically write `content` to `absolutePath`: create parent dirs, write to a
* randomly-named temp file opened exclusively (`wx`, `0o600`) inside a private
* (`0o700`) staging directory, fsync, optionally chmod to the final mode while
* still private, then rename over the target. `mode` (when given) preserves an
* existing file's permissions across the replace.
*/
export async function writeFileAtomic(
absolutePath: string,
content: string,
mode: number | undefined,
signal: AbortSignal | undefined,
internals: FsIoInternals = {},
): Promise<void> {
throwIfAborted(signal, 'write')
const directory = dirname(absolutePath)
await mkdir(directory, { recursive: true })
throwIfAborted(signal, 'write')
const stagingDirName = internals.tempDirName?.(absolutePath) ?? `.${basename(absolutePath)}.${process.pid}.${randomUUID()}.tmpdir`
const stagingDir = join(directory, stagingDirName)
const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`
const tempPath = join(stagingDir, tempName)
let handle: Awaited<ReturnType<typeof open>> | undefined
let stagingCreated = false
try {
await mkdir(stagingDir, { mode: 0o700 })
stagingCreated = true
await chmod(stagingDir, 0o700)
handle = await open(tempPath, 'wx', 0o600)
await handle.chmod(0o600)
await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} })
await handle.sync()
await internals.inspectTemp?.({ stagingDir, tempPath })
if (mode !== undefined) await handle.chmod(mode)
await handle.close()
handle = undefined
throwIfAborted(signal, 'write')
await rename(tempPath, absolutePath)
await rm(stagingDir, { recursive: true, force: true })
} catch (error: unknown) {
/* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */
let failure: unknown = isAbortError(error) ? new FsError('write aborted', 'FS_ABORTED') : error
/* v8 ignore next 8 -- reached only if writeFile/sync throws with the handle open (IO fault); close-failure is a double fault. */
if (handle) {
try {
await handle.close()
} catch (closeError: unknown) {
failure = new FsError(`write failed (${errorMessage(failure)}) and temp close failed (${errorMessage(closeError)})`, 'FS_NOT_FOUND', { cause: failure })
}
}
if (!stagingCreated) throw failure
return removeStagingDirOrThrow(stagingDir, failure)
}
}
// --- Editing ---
/** Line ending style detected before LF normalization. */
export type LineEndings = 'LF' | 'CRLF'
function normalizeLineEndings(content: string): string {
return content.replaceAll('\r\n', '\n')
}
function detectLineEndings(raw: string): LineEndings {
const sample = raw.slice(0, 4096)
const crlfCount = sample.split('\r\n').length - 1
const lfCount = sample.split('\n').length - 1 - crlfCount
return crlfCount > lfCount ? 'CRLF' : 'LF'
}
function restoreLineEndings(content: string, lineEndings: LineEndings): string {
return lineEndings === 'LF' ? content : normalizeLineEndings(content).split('\n').join('\r\n')
}
function countOccurrences(content: string, needle: string): number {
let count = 0
let index = 0
while (true) {
const found = content.indexOf(needle, index)
if (found === -1) return count
count += 1
index = found + needle.length
}
}
/**
* Read and decode a file for editing: rejects binaries, returns LF-normalized
* content plus the original line-ending style for write-back.
*/
export async function readForEdit(
absolutePath: string,
displayPath: string,
signal?: AbortSignal,
): Promise<{ content: string; lineEndings: LineEndings }> {
throwIfAborted(signal, 'edit')
const buffer = await readFileAbortable(absolutePath, 'edit', signal)
throwIfAborted(signal, 'edit')
if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT')
const raw = decodeUtf8(buffer, 'edit', displayPath)
return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) }
}
/**
* Apply a literal replacement to LF-normalized content. Throws
* `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and
* `FS_AMBIGUOUS_EDIT` on multiple matches when `replaceAll` is false. Returns
* the edited content (still LF-normalized) and the replacement count.
*/
export function applyLiteralEdit(
content: string,
oldString: string,
newString: string,
replaceAll: boolean,
displayPath: string,
): { content: string; replacements: number } {
const oldNorm = normalizeLineEndings(oldString)
if (oldNorm.length === 0) {
throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND')
}
const newNorm = normalizeLineEndings(newString)
const replacements = countOccurrences(content, oldNorm)
if (replacements === 0) {
throw new FsError(`old_string was not found in "${displayPath}"`, 'FS_EDIT_NOT_FOUND')
}
if (!replaceAll && replacements > 1) {
throw new FsError(`old_string matched ${replacements} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`, 'FS_AMBIGUOUS_EDIT')
}
return { content: content.split(oldNorm).join(newNorm), replacements }
}
export { restoreLineEndings }