/** * 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-fs-policy`. 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, readdir, rename, rm, stat } from 'node:fs/promises' import type { Dirent, 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' const BINARY_SAMPLE_BYTES = 8192 function isENOENT(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' } /** * A path component that is expected to be a directory is a regular file (e.g. * resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target * cannot exist — so the resolution/probe paths treat it as "absent" rather than * letting a raw Node error escape without the structured `FsError` taxonomy. */ function isENOTDIR(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOTDIR' } 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 isPermissionError(error: unknown): boolean { return error instanceof Error && 'code' in error && (error.code === 'EACCES' || error.code === 'EPERM') } 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 { 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 pin the atomic-write temp names (to prove * exclusive-open behavior without a name race) and observe the staged temp * file before it is renamed over the target. */ export interface FsIoInternals { /** 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 } /** 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 } /** One local directory child with a resolved target and cheap metadata. */ export interface LocalDirEntry { name: string type: 'file' | 'directory' | 'other' target: LocalTarget version?: FsVersion 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. * @param cwd - base directory a relative `path` resolves against. * @param path - absolute or relative path; empty/whitespace-only throws `FS_NOT_FOUND`. * @returns the absolute display path plus the realpath-derived stable target key. */ export async function resolveLocalTarget(cwd: string, path: string): Promise { 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) { // A path component is a file, not a directory (e.g. "afile/child.txt" where // "afile" is a regular file): the target can neither exist nor be created, // so surface the structured taxonomy instead of a raw Node ENOTDIR. if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND') /* 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. * @param absolutePath - the path to stat (typically a target key; symlinks are followed). * @returns the metadata, or null when the path — or a parent segment — does not exist. */ export async function probe(absolutePath: string): Promise { 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) { // ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean // the target is absent; any other stat failure is a real permission/IO fault. /* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */ if (!isENOENT(error) && !isENOTDIR(error)) throw error return null } } // --- Directory listing --- function listingIoError(displayPath: string, error: unknown): FsError { /* v8 ignore next -- defensive pass-through for races where a child resolver has already produced a structured FsError. */ if (error instanceof FsError) return error /* v8 ignore next -- requires the listed target/parent to disappear between successful preflight and listing/child resolution. */ if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error }) if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error }) return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error }) } async function resolveListedChildTarget(parent: LocalTarget, name: string): Promise { const identity = await resolveLocalTarget(parent.targetKey, name) return { displayPath: join(parent.displayPath, name), targetKey: identity.targetKey } } /** * List direct children of a directory in stable name order. Each child includes * a resolved target plus stat metadata when still available; file contents are * never read. * @param target - the resolved directory to list; a missing or non-directory target throws. * @param signal - aborts the listing, checked between children (`FS_ABORTED`). * @returns one entry per direct child, sorted by name. */ export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise { throwIfAborted(signal, 'list') let info: PathInfo | null try { info = await probe(target.targetKey) } catch (error: unknown) { throw listingIoError(target.displayPath, error) } if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND') if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY') let entries: Dirent[] try { entries = await readdir(target.targetKey, { withFileTypes: true, encoding: 'utf8' }) } catch (error: unknown) { /* v8 ignore next -- requires permission/kernel failure from readdir after a successful directory stat. */ throw listingIoError(target.displayPath, error) } throwIfAborted(signal, 'list') const result: LocalDirEntry[] = [] for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { throwIfAborted(signal, 'list') try { const childTarget = await resolveListedChildTarget(target, entry.name) const childInfo = await probe(childTarget.targetKey) result.push({ name: entry.name, type: childInfo?.type ?? 'other', target: childTarget, ...(childInfo ? { version: childInfo.version } : {}), ...(childInfo?.type === 'file' ? { size: childInfo.size } : {}), }) } catch (error: unknown) { throw listingIoError(join(target.displayPath, entry.name), error) } throwIfAborted(signal, 'list') } return result } // --- 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 { 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. * @param target - the resolved file to read. * @param signal - aborts the read (`FS_ABORTED`). * @returns the full decoded text, byte-for-byte (no normalization). */ export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise { 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. * @param target - the resolved file to stream. * @param signal - aborts the stream, including between chunks (`FS_ABORTED`). * @returns decoded text chunks in file order; chunk boundaries carry no meaning. */ export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal): AsyncIterable { 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) { 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 { 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. * @param absolutePath - the final destination (typically a target key); missing parent dirs are created. * @param content - the full UTF-8 text to write. * @param mode - final file mode applied before the rename (an existing file's, to preserve permissions); undefined leaves `0o600`. * @param signal - aborts the write (`FS_ABORTED`); checked before the rename, so the target is never left torn. * @param internals - test seam for pinning temp names and observing the staged file. */ export async function writeFileAtomic( absolutePath: string, content: string, mode: number | undefined, signal: AbortSignal | undefined, internals: FsIoInternals = {}, ): Promise { 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> | 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' /** * Collapse CRLF to LF — the canonical in-memory form every edit/diff basis * uses. Lone `\r` bytes (not followed by `\n`) are left untouched. * @param content - decoded text in whatever line-ending style the file had. * @returns the text with every `\r\n` pair replaced by `\n`. */ 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' } /** * Convert LF-normalized content back to the line-ending style detected at read * time, for write-back. `LF` returns the content unchanged; `CRLF` re-normalizes * first so an already-CRLF sequence is never doubled to `\r\r\n`. * @param content - the LF-normalized (edited) text. * @param lineEndings - the original file's style, as detected by {@link readForEdit}. * @returns the text in the original file's line-ending style. */ 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. * @param absolutePath - the file to read (typically a target key). * @param displayPath - the caller-facing path used in error messages. * @param signal - aborts the read (`FS_ABORTED`). * @returns the LF-normalized content and the detected style to restore on 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) } } /** * Best-effort read of a file's current text for a before/after diff basis, used * by an overwrite. Returns the LF-normalized decoded content, or `null` when the * file is binary or not valid UTF-8 — a write must succeed regardless of the * prior bytes, so an undiffable prior file simply yields no contextual-hunk basis * (the caller treats `null` the same as an absent file: the result renders a * whole-file diff rather than an applied hunk). * @param absolutePath - the file to read (typically a target key); it must exist. * @param signal - aborts the read (`FS_ABORTED`). * @returns the LF-normalized text, or null for a binary or non-UTF-8 file. */ export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise { const buffer = await readFileAbortable(absolutePath, 'read', signal) if (buffer.includes(0)) return null try { return normalizeLineEndings(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 return null } } /** * 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. * @param content - the current file content, already LF-normalized. * @param oldString - literal text to find; CRLF inside it is normalized to LF before matching. * @param newString - literal replacement text, normalized the same way. * @param replaceAll - replace every match instead of requiring exactly one. * @param displayPath - the caller-facing path used in error messages. * @returns the edited LF-normalized content plus how many occurrences were replaced. */ 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 { normalizeLineEndings, restoreLineEndings }