/** * Host-filesystem implementation of `ctx.fs`. Realpath-derived target identity makes aliases * share stale guards, and writes through a symlink update its target without replacing the link. * @module @deepseek-ai/dsh-fs-local */ import { Context } from 'cordis' import { resolve } from 'node:path' import z from 'schemastery' import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, FsEditOutcome, FsEditRequest, FsInfo, FsPathInfo, FsTarget, FsWriteIntent, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' import { applyLiteralEdit, listDirectory, normalizeLineEndings, probe, probeNoFollow, readForEdit, readTextForDiff, readWholeText, resolveLocalTarget, restoreLineEndings, streamWholeText, writeFileAtomic, } from './fsio.ts' import type { FsIoInternals } from './fsio.ts' /** Configuration for the local filesystem backend. */ export interface Config { /** Base directory for relative paths. Defaults to `process.cwd()`. */ cwd?: string } type ResolvedConfig = Required /** * The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd} * (a resolution default, NOT a containment boundary — see the filesystem * capability-seam RFC); enforce * containment with a stricter backend or a `tools/execute` permission plugin. */ export class LocalFileSystem extends FileSystem { static Config: z = z.object({ cwd: z.string().default(process.cwd()), }) /** Validated config (schemastery applied the defaults before construction). */ readonly config: ResolvedConfig /** Test seam forwarded to fsio (force streaming path, pin temp names). */ internals: FsIoInternals = {} /** Per-targetKey tail promise: serializes mutating ops so the read→guard→write * window can't interleave, making concurrent writes/edits deterministically * ordered (one wins, the rest see the new version and reject as stale). */ private locks = new Map>() constructor(ctx: Context, config: Config) { super(ctx) this.config = config as ResolvedConfig } /** Run `op` with exclusive access to `targetKey` (FIFO per key). */ private async withLock(targetKey: string, op: () => Promise): Promise { const prior = this.locks.get(targetKey) ?? Promise.resolve() const run = prior.then(op, op) // Keep the chain alive but swallow this op's result/throw for the *next* waiter. const tail = run.then(() => undefined, () => undefined) this.locks.set(targetKey, tail) try { return await run } finally { if (this.locks.get(targetKey) === tail) { this.locks.delete(targetKey) } } } override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise { if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED') const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path) if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED') return { targetKey: local.targetKey, displayPath: local.displayPath } } override async stat(target: FsTarget, signal?: AbortSignal): Promise { if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED') const info = await probe(target.targetKey) if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED') if (!info) return undefined return { version: info.version, type: info.type, size: info.size } } override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise { if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED') if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND') const info = await probeNoFollow(resolve(opts?.cwd ?? this.config.cwd, path)) if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED') if (!info) return undefined return { version: info.version, type: info.type, size: info.size } } override async readText(target: FsTarget, signal?: AbortSignal): Promise { return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal) } override streamText(target: FsTarget, signal?: AbortSignal): Promise> { return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)) } override async listDir(target: FsTarget, signal?: AbortSignal): Promise { const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal) return entries.map(entry => ({ name: entry.name, type: entry.type, target: { targetKey: entry.target.targetKey, displayPath: entry.target.displayPath }, ...(entry.version !== undefined ? { version: entry.version } : {}), ...(entry.size !== undefined ? { size: entry.size } : {}), })) } override async writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, ): Promise { return this.withLock(target.targetKey, async () => { const existing = await probe(target.targetKey) if (existing && existing.type !== 'file') { throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } if (expected?.kind === 'replaceIfVersion') { // Stale guard: the file must still exist at the version the owner observed. if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION') if (existing.version !== expected.version) { throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION') } } else if (expected?.kind === 'createIfAbsent' && existing) { // createIfAbsent onto an existing file: a blind overwrite — require a read first. throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED') } // No expectation means an unconditional but still atomic write. // Preserve prior text for contextual diffs; null falls back to a whole-file diff. // TODO(overwrite-diff-bound): cap this UI-only pre-read for large files. const before = existing ? await readTextForDiff(target.targetKey, signal) : null await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals) const after = await probe(target.targetKey) return { operation: existing ? 'update' : 'create', version: this.versionAfterWrite(after, target), before, // LF-normalized to share the diff basis with `before` (also LF): a CRLF // overwrite must not read as every line changed. Line-ending restoration // is a storage detail the applied-hunk diff ignores. after: normalizeLineEndings(content), } }) } override async editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, ): Promise { return this.withLock(target.targetKey, async () => { const existing = await probe(target.targetKey) // Stale guard before literal matching: an edit based on an old read reports // FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content. // Missing targets use the same stale code on guarded and unconditional edit paths. if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION') if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') // expected === undefined: unconditional edit of the current content — no // version guard. Still inside the per-target lock, so the read→match→write // window is serialized and atomic. if (expected && existing.version !== expected.version) { throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION') } const original = await readForEdit(target.targetKey, target.displayPath, signal) const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath) const content = restoreLineEndings(edited.content, original.lineEndings) await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals) const after = await probe(target.targetKey) return { version: this.versionAfterWrite(after, target), // The LF-normalized before/after text (the applied-hunk diff basis); // line-ending restoration is a storage detail the diff ignores. before: original.content, after: edited.content, } }) } /* v8 ignore next 5 -- the post-write probe finding the file absent requires a * concurrent unlink between rename and stat; fall back to a sentinel version. */ private versionAfterWrite(after: { version: FsVersion } | null, target: FsTarget): FsVersion { if (after) return after.version return FsVersion(`missing:${target.targetKey}`) } } export default LocalFileSystem