返回源码地图

packages/fs/fs-local/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 10 / 10 / 10;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Host-filesystem implementation of `ctx.fs`. Realpath-derived target identity makes aliases
3 * share stale guards, and writes through a symlink update its target without replacing the link.
4 * @module @deepseek-ai/dsh-fs-local
5 */
6
7import { Context } from '@deepseek-ai/cordis'
8import { constants as bufferConstants } from 'node:buffer'
9import { once } from 'node:events'
10import { watch } from 'chokidar'
11import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
12import { pathToFileURL } from 'node:url'
13import z from '@deepseek-ai/schemastery'
14import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
15import type {
16 FsDirEntry,
17 FsEditOutcome,
18 FsEditRequest,
19 FsInfo,
20 FsPathInfo,
21 FsTarget,
22 FsWriteIntent,
23 FsWriteOutcome,
24} from '@deepseek-ai/dsh-fs'
25import {
26 applyLiteralEdit,
27 listDirectory,
28 localDisplayPath,
29 normalizeLineEndings,
30 probe,
31 probeNoFollow,
32 readForEdit,
33 readByteWindow,
34 readTextForDiff,
35 readWholeBytes,
36 readWholeText,
37 resolveLocalTarget,
38 restoreLineEndings,
39 streamWholeText,
40 writeFileAtomic,
41} from './fsio.ts'
42import type { FsIoInternals } from './fsio.ts'
43
44/** Configuration for the local filesystem backend. */
45export interface Config {
46 /** Base directory for relative paths. Defaults to `process.cwd()`. */
47 cwd?: string
48 /**
49 * Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the
50 * runtime's safe allocation/decode maximum. Defaults to 10 MiB.
51 */
52 diffBasisMaxBytes?: number
53}
54
55type ResolvedConfig = Required<Config>
56const DEFAULT_DIFF_BASIS_MAX_BYTES = 10 * 1024 * 1024
57const MAX_DIFF_BASIS_BYTES = Math.min(
58 bufferConstants.MAX_LENGTH,
59 bufferConstants.MAX_STRING_LENGTH,
60)
61
62/**
63 * The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
64 * (a resolution default, NOT a containment boundary — see the filesystem
65 * capability-seam Agent Note); enforce
66 * containment with a stricter backend or a `tools/execute` permission plugin.
67 */
68export class LocalFileSystem extends FileSystem {
69 override async watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise<void>> {
70 signal.throwIfAborted()
71 const path = resolve(this.processPath(target))
72 const directory = (await this.stat(target, signal))?.type === 'directory'
73 signal.throwIfAborted()
74 const root = directory ? path : dirname(path)
75 const watcher = watch(root, {
76 ignoreInitial: true, depth: 0,
77 ignored: entry => !directory && resolve(entry) !== root && resolve(entry) !== path,
78 })
79 watcher.on('all', (_event, entry) => {
80 if (directory || resolve(entry) === path) changed()
81 })
82 watcher.on('error', (error) => { changed(error instanceof Error ? error : new Error(String(error))) })
83 try {
84 await once(watcher, 'ready', { signal })
85 return () => watcher.close()
86 } catch (error) {
87 await watcher.close()
88 throw error
89 }
90 }
91
92 static Config: z<Config> = z.object({
93 cwd: z.string().default(process.cwd()),
94 diffBasisMaxBytes: z.number().default(DEFAULT_DIFF_BASIS_MAX_BYTES),
95 })
96
97 /** Validated config (schemastery applied the defaults before construction). */
98 readonly config: ResolvedConfig
99 /** Test hook forwarded to fsio for atomic-publication boundaries. */
100 internals: FsIoInternals = {}
101 /** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
102 * window can't interleave, making concurrent writes/edits deterministically
103 * ordered (one wins, the rest see the new version and reject as stale). */
104 private locks = new Map<string, Promise<unknown>>()
105
106 constructor(ctx: Context, config: Config) {
107 super(ctx)
108 const resolved = config as ResolvedConfig
109 if (!Number.isSafeInteger(resolved.diffBasisMaxBytes)
110 || resolved.diffBasisMaxBytes <= 0
111 || resolved.diffBasisMaxBytes > MAX_DIFF_BASIS_BYTES) {
112 throw new Error(`fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${MAX_DIFF_BASIS_BYTES}`)
113 }
114 this.config = resolved
115 }
116
117 /** Run `op` with exclusive access to `targetKey` (FIFO per key). */
118 private async withLock<T>(targetKey: string, op: () => Promise<T>): Promise<T> {
119 const prior = this.locks.get(targetKey) ?? Promise.resolve()
120 const run = prior.then(op, op)
121 // Keep the chain alive but swallow this op's result/throw for the *next* waiter.
122 const tail = run.then(() => undefined, () => undefined)
123 this.locks.set(targetKey, tail)
124 try {
125 return await run
126 } finally {
127 if (this.locks.get(targetKey) === tail) {
128 this.locks.delete(targetKey)
129 }
130 }
131 }
132
133 override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
134 if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
135 const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)
136 if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
137 return { targetKey: local.targetKey, displayPath: local.displayPath }
138 }
139
140 override processPath(target: FsTarget): string {
141 return String(target.targetKey)
142 }
143
144 override processPathFromHostPath(hostPath: string): string | undefined {
145 return isAbsolute(hostPath) ? resolve(hostPath) : undefined
146 }
147
148 override fileUrl(target: FsTarget): string {
149 return pathToFileURL(this.processPath(target)).href
150 }
151
152 override contains(parent: FsTarget, child: FsTarget): boolean {
153 const path = relative(this.processPath(parent), this.processPath(child))
154 return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path))
155 }
156
157 override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
158 if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
159 const info = await probe(target.targetKey)
160 if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
161 if (!info) return undefined
162 return { version: info.version, type: info.type, size: info.size }
163 }
164
165 override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
166 if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
167 if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
168 const cwd = opts?.cwd ?? this.config.cwd
169 const info = await probeNoFollow(localDisplayPath(cwd, path))
170 if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
171 if (!info) return undefined
172 return { version: info.version, type: info.type, size: info.size }
173 }
174
175 override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
176 return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
177 }
178
179 override streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
180 return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
181 }
182
183 override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
184 return readWholeBytes({ displayPath: target.displayPath, targetKey: target.targetKey }, signal, maxBytes, this.internals)
185 }
186
187 override async readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise<Uint8Array> {
188 return readByteWindow({ displayPath: target.displayPath, targetKey: target.targetKey }, range, signal)
189 }
190
191 override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
192 const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
193 return entries.map(entry => ({
194 name: entry.name,
195 type: entry.type,
196 target: { targetKey: entry.target.targetKey, displayPath: entry.target.displayPath },
197 ...(entry.version !== undefined ? { version: entry.version } : {}),
198 ...(entry.size !== undefined ? { size: entry.size } : {}),
199 }))
200 }
201
202 override async writeText(
203 target: FsTarget,
204 content: string,
205 expected?: FsWriteIntent,
206 signal?: AbortSignal,
207 ): Promise<FsWriteOutcome> {
208 return this.withLock(target.targetKey, async () => {
209 const existing = await probe(target.targetKey)
210 if (existing && existing.type !== 'file') {
211 throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
212 }
213
214 if (expected?.kind === 'replaceIfVersion') {
215 // Stale guard: the file must still exist at the version the owner observed.
216 if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION')
217 if (existing.version !== expected.version) {
218 throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
219 }
220 } else if (expected?.kind === 'createIfAbsent' && existing) {
221 // createIfAbsent onto an existing file: a blind overwrite — require a read first.
222 throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
223 }
224 // No expectation means an unconditional but still atomic write.
225
226 // Capture an optional contextual-diff basis before the write. The bounded
227 // reader checks the opened file itself, so an external replacement after
228 // `probe()` cannot turn this best-effort presentation read into an
229 // unbounded allocation. Either side at/above the configured limit yields
230 // `before: null`; consumers retain their whole-file fallback.
231 const diffable = existing !== null
232 && Buffer.byteLength(content, 'utf8') < this.config.diffBasisMaxBytes
233 const before = diffable
234 ? await readTextForDiff(target.targetKey, this.config.diffBasisMaxBytes, signal)
235 : null
236 await writeFileAtomic(
237 target.targetKey,
238 content,
239 existing?.mode,
240 signal,
241 this.internals,
242 expected?.kind === 'createIfAbsent' ? { displayPath: target.displayPath } : undefined,
243 )
244 const after = await probe(target.targetKey)
245 return {
246 operation: existing ? 'update' : 'create',
247 version: this.versionAfterWrite(after, target),
248 before,
249 // LF-normalized to share the diff basis with `before` (also LF): a CRLF
250 // overwrite must not read as every line changed. Line-ending restoration
251 // is a storage detail the applied-hunk diff ignores.
252 after: normalizeLineEndings(content),
253 }
254 })
255 }
256
257 override async editText(
258 target: FsTarget,
259 edit: FsEditRequest,
260 expected?: { version: FsVersion },
261 signal?: AbortSignal,
262 ): Promise<FsEditOutcome> {
263 return this.withLock(target.targetKey, async () => {
264 const existing = await probe(target.targetKey)
265 // Stale guard before literal matching: an edit based on an old read reports
266 // FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content.
267 // Missing targets use the same stale code on guarded and unconditional edit paths.
268 if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
269 if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
270 // expected === undefined: unconditional edit of the current content — no
271 // version guard. Still inside the per-target lock, so the read→match→write
272 // window is serialized and atomic.
273 if (expected && existing.version !== expected.version) {
274 throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
275 }
276
277 const original = await readForEdit(target.targetKey, target.displayPath, signal)
278 const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath)
279 const content = restoreLineEndings(edited.content, original.lineEndings)
280 await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals)
281
282 const after = await probe(target.targetKey)
283 return {
284 version: this.versionAfterWrite(after, target),
285 // The LF-normalized before/after text (the applied-hunk diff basis);
286 // line-ending restoration is a storage detail the diff ignores.
287 before: original.content,
288 after: edited.content,
289 }
290 })
291 }
292
293 /* v8 ignore next 5 -- the post-write probe finding the file absent requires a
294 * concurrent unlink between rename and stat; fall back to a sentinel version. */
295 private versionAfterWrite(after: { version: FsVersion } | null, target: FsTarget): FsVersion {
296 if (after) return after.version
297 return FsVersion(`missing:${target.targetKey}`)
298 }
299}
300
301export default LocalFileSystem