@stackstackstack/dsh-fs-local 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,824 @@
1
+ import { constants } from "node:buffer";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep, toNamespacedPath } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import z from "@deepseek-ai/schemastery";
5
+ import { FileSystem, FsError, FsTargetKey, FsVersion } from "@stackstackstack/dsh-fs";
6
+ import { randomUUID } from "node:crypto";
7
+ import { createReadStream } from "node:fs";
8
+ import { chmod, link, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
9
+ import { TextDecoder } from "node:util";
10
+ //#region lib/types/win32.js
11
+ /**
12
+ * Windows security-descriptor helpers for atomic local-file replacement. Koffi loads lazily so
13
+ * non-Windows processes never open Win32 libraries.
14
+ * @module @stackstackstack/dsh-fs-local/win32
15
+ */
16
+ const DACL_SECURITY_INFORMATION = 4;
17
+ const ERROR_FILE_NOT_FOUND = 2;
18
+ const ERROR_PATH_NOT_FOUND = 3;
19
+ const ERROR_ACCESS_DENIED = 5;
20
+ let bindings;
21
+ async function win32() {
22
+ if (bindings !== void 0) return bindings;
23
+ const koffi = (await import("koffi")).default;
24
+ const advapi32 = koffi.load("advapi32.dll");
25
+ const kernel32 = koffi.load("kernel32.dll");
26
+ bindings = {
27
+ getFileSecurityW: advapi32.func("int __stdcall GetFileSecurityW(const char16_t *path, uint32_t requested, void *descriptor, uint32_t length, _Out_ uint32_t *needed)"),
28
+ setFileSecurityW: advapi32.func("int __stdcall SetFileSecurityW(const char16_t *path, uint32_t information, const void *descriptor)"),
29
+ replaceFileW: kernel32.func("int __stdcall ReplaceFileW(const char16_t *replaced, const char16_t *replacement, const char16_t *backup, uint32_t flags, void *exclude, void *reserved)"),
30
+ getLastError: kernel32.func("uint32_t __stdcall GetLastError()")
31
+ };
32
+ return bindings;
33
+ }
34
+ function errnoCode(win32Code) {
35
+ switch (win32Code) {
36
+ case ERROR_FILE_NOT_FOUND:
37
+ case ERROR_PATH_NOT_FOUND: return "ENOENT";
38
+ case ERROR_ACCESS_DENIED: return "EACCES";
39
+ default: return "EIO";
40
+ }
41
+ }
42
+ function win32Error(syscall, win32Code, path) {
43
+ const code = errnoCode(win32Code);
44
+ const error = /* @__PURE__ */ new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path}`);
45
+ error.code = code;
46
+ error.errno = win32Code;
47
+ error.syscall = syscall;
48
+ error.path = path;
49
+ error.win32Code = win32Code;
50
+ return error;
51
+ }
52
+ /**
53
+ * Read a file's self-relative DACL security descriptor.
54
+ * @param path - existing file whose DACL is read.
55
+ * @returns a descriptor buffer accepted by `SetFileSecurityW`.
56
+ */
57
+ async function readFileDaclWin32(path) {
58
+ const api = await win32();
59
+ const nativePath = toNamespacedPath(path);
60
+ const needed = [0];
61
+ api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, null, 0, needed);
62
+ if (needed[0] === 0) throw win32Error("GetFileSecurityW", api.getLastError(), path);
63
+ const descriptor = Buffer.alloc(needed[0]);
64
+ if (api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, descriptor, descriptor.length, needed) === 0) throw win32Error("GetFileSecurityW", api.getLastError(), path);
65
+ return descriptor.subarray(0, needed[0]);
66
+ }
67
+ /**
68
+ * Copy an existing file's DACL onto another file and protect it from staging-parent inheritance.
69
+ * The destination must still be empty when confidentiality depends on this call.
70
+ * @param source - existing file whose DACL is copied.
71
+ * @param destination - existing file that receives the protected DACL.
72
+ */
73
+ async function copyFileDaclWin32(source, destination) {
74
+ const descriptor = await readFileDaclWin32(source);
75
+ const api = await win32();
76
+ if (api.setFileSecurityW(toNamespacedPath(destination), 2147483652, descriptor) === 0) throw win32Error("SetFileSecurityW", api.getLastError(), destination);
77
+ }
78
+ /**
79
+ * Replace a Windows file while preserving the replaced file's ACL and other replace metadata.
80
+ * @param replaced - existing destination file.
81
+ * @param replacement - closed staging file on the same volume.
82
+ */
83
+ async function replaceFileWin32(replaced, replacement) {
84
+ const api = await win32();
85
+ if (api.replaceFileW(toNamespacedPath(replaced), toNamespacedPath(replacement), null, 0, null, null) === 0) throw win32Error("ReplaceFileW", api.getLastError(), replaced);
86
+ }
87
+ //#endregion
88
+ //#region lib/types/fsio.js
89
+ /**
90
+ * Cordis-free local filesystem mechanics. This provider layer returns validated UTF-8 text,
91
+ * streams large files, and rejects binary data; line windows belong to `dsh-tool-fs`. Writes
92
+ * stage an exclusive owner-only file in a private sibling directory and atomically publish it.
93
+ * @module @stackstackstack/dsh-fs-local/fsio
94
+ */
95
+ const BINARY_SAMPLE_BYTES = 8192;
96
+ const DIFF_BASIS_READ_CHUNK_BYTES = 64 * 1024;
97
+ function isENOENT(error) {
98
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
99
+ }
100
+ function isEEXIST(error) {
101
+ return error instanceof Error && "code" in error && error.code === "EEXIST";
102
+ }
103
+ /**
104
+ * A path component that is expected to be a directory is a regular file (e.g.
105
+ * resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target
106
+ * cannot exist — so the resolution/probe paths treat it as "absent" rather than
107
+ * letting a raw Node error escape without the structured `FsError` taxonomy.
108
+ */
109
+ function isENOTDIR(error) {
110
+ return error instanceof Error && "code" in error && error.code === "ENOTDIR";
111
+ }
112
+ function isAbortError(error) {
113
+ return error instanceof Error && error.name === "AbortError";
114
+ }
115
+ /* v8 ignore start -- composes secondary cleanup-failure messages, which require a filesystem/kernel fault after the primary failure. */
116
+ function errorMessage(error) {
117
+ return error instanceof Error ? error.message : String(error);
118
+ }
119
+ /* v8 ignore stop */
120
+ function isPermissionError(error) {
121
+ return error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
122
+ }
123
+ function throwIfAborted(signal, verb) {
124
+ if (signal?.aborted) throw new FsError(`${verb} aborted`, "FS_ABORTED");
125
+ }
126
+ /**
127
+ * `readFile` with the supplied signal, translating a mid-read `AbortError` into
128
+ * the seam's structured `FsError('FS_ABORTED')` (Node rejects an aborted
129
+ * `readFile` with a bare `AbortError`, which would otherwise escape the seam's
130
+ * error taxonomy — the streaming/write paths translate it the same way).
131
+ */
132
+ async function readFileAbortable(absolutePath, verb, signal) {
133
+ try {
134
+ return await readFile(absolutePath, signal ? { signal } : {});
135
+ } catch (error) {
136
+ /* v8 ignore next 2 -- a non-abort readFile rejection needs a permission/IO fault racing an open file. */
137
+ if (!isAbortError(error)) throw error;
138
+ throw new FsError(`${verb} aborted`, "FS_ABORTED");
139
+ }
140
+ }
141
+ /** Opaque version token from high-resolution identity and freshness metadata. */
142
+ function versionOf(info) {
143
+ return FsVersion(`${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`);
144
+ }
145
+ /**
146
+ * Resolve a path to its absolute display path and realpath identity. For a missing target,
147
+ * realpath the nearest existing ancestor and append the missing suffix, preserving identity
148
+ * across symlinked ancestors before and after creation.
149
+ * @param cwd - base directory a relative `path` resolves against.
150
+ * @param path - absolute or relative path; empty/whitespace-only throws `FS_NOT_FOUND`.
151
+ * @returns the absolute display path plus the realpath-derived stable target key.
152
+ */
153
+ async function resolveLocalTarget(cwd, path) {
154
+ if (path.trim().length === 0) throw new FsError("file_path must be a non-empty string", "FS_NOT_FOUND");
155
+ const displayPath = resolve(cwd, path);
156
+ try {
157
+ return {
158
+ displayPath,
159
+ targetKey: FsTargetKey(await realpath(displayPath))
160
+ };
161
+ } catch (error) {
162
+ /* v8 ignore next -- Windows reports this case as ENOENT and repairs it in the ancestor walk below. */
163
+ if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, "FS_NOT_FOUND");
164
+ /* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */
165
+ if (!isENOENT(error)) throw error;
166
+ }
167
+ const missing = [basename(displayPath)];
168
+ let ancestor = dirname(displayPath);
169
+ while (true) try {
170
+ const realAncestor = await realpath(ancestor);
171
+ /* v8 ignore start -- native Windows coverage exercises this repair; POSIX reports ENOTDIR before this point. */
172
+ if (process.platform === "win32") {
173
+ if (!(await stat(realAncestor)).isDirectory()) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, "FS_NOT_FOUND");
174
+ }
175
+ /* v8 ignore stop */
176
+ return {
177
+ displayPath,
178
+ targetKey: FsTargetKey(join(realAncestor, ...missing))
179
+ };
180
+ } catch (error) {
181
+ /* v8 ignore next -- native Windows coverage exercises the FsError raised by the repair above. */
182
+ if (error instanceof FsError) throw error;
183
+ /* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */
184
+ if (!isENOENT(error)) throw error;
185
+ const parent = dirname(ancestor);
186
+ /* v8 ignore next -- the filesystem root always realpaths, so the walk terminates before parent === ancestor. */
187
+ if (parent === ancestor) return {
188
+ displayPath,
189
+ targetKey: FsTargetKey(displayPath)
190
+ };
191
+ missing.unshift(basename(ancestor));
192
+ ancestor = parent;
193
+ }
194
+ }
195
+ function pathType(info) {
196
+ if (info.isFile()) return "file";
197
+ /* v8 ignore else -- Windows has no special-entry fixture for the non-directory branch. */
198
+ if (info.isDirectory()) return "directory";
199
+ /* v8 ignore next -- the corresponding special-entry return is covered on POSIX. */
200
+ return "other";
201
+ }
202
+ function pathLinkType(info) {
203
+ if (info.isSymbolicLink()) return "symlink";
204
+ return pathType(info);
205
+ }
206
+ async function probeStats(absolutePath, readStats) {
207
+ try {
208
+ return await readStats(absolutePath);
209
+ } catch (error) {
210
+ /* v8 ignore next -- a non-ENOENT/ENOTDIR metadata failure needs a permission/IO fault; surface it. */
211
+ if (!isENOENT(error) && !isENOTDIR(error)) throw error;
212
+ return null;
213
+ }
214
+ }
215
+ /**
216
+ * Probe a path for its version, mode, type, and size. Null if absent.
217
+ * @param absolutePath - the path to stat (typically a target key; symlinks are followed).
218
+ * @returns the metadata, or null when the path — or a parent segment — does not exist.
219
+ */
220
+ async function probe(absolutePath) {
221
+ const info = await probeStats(absolutePath, (path) => stat(path, { bigint: true }));
222
+ if (!info) return null;
223
+ return {
224
+ version: versionOf(info),
225
+ mode: Number(info.mode & 511n),
226
+ type: pathType(info),
227
+ size: Number(info.size)
228
+ };
229
+ }
230
+ /**
231
+ * Probe a path without following the final symlink component.
232
+ * @param absolutePath - the path entry to inspect with `lstat` semantics.
233
+ * @returns path-entry metadata, or null when the entry is absent.
234
+ */
235
+ async function probeNoFollow(absolutePath) {
236
+ const info = await probeStats(absolutePath, (path) => lstat(path, { bigint: true }));
237
+ if (!info) return null;
238
+ return {
239
+ version: versionOf(info),
240
+ mode: Number(info.mode & 511n),
241
+ type: pathLinkType(info),
242
+ size: Number(info.size)
243
+ };
244
+ }
245
+ function listingIoError(displayPath, error) {
246
+ /* v8 ignore next -- defensive pass-through for races where a child resolver has already produced a structured FsError. */
247
+ if (error instanceof FsError) return error;
248
+ /* v8 ignore next -- requires the listed target/parent to disappear between successful preflight and listing/child resolution. */
249
+ if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, "FS_NOT_FOUND", { cause: error });
250
+ /* v8 ignore next -- Windows chmod does not deny directory listing; POSIX covers permission translation. */
251
+ if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, "FS_PERMISSION_DENIED", { cause: error });
252
+ return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, "FS_IO_ERROR", { cause: error });
253
+ }
254
+ async function resolveListedChildTarget(parent, name) {
255
+ const identity = await resolveLocalTarget(parent.targetKey, name);
256
+ return {
257
+ displayPath: join(parent.displayPath, name),
258
+ targetKey: identity.targetKey
259
+ };
260
+ }
261
+ /**
262
+ * List direct children of a directory in stable name order. Each child includes
263
+ * a resolved target plus stat metadata when still available; file contents are
264
+ * never read.
265
+ * @param target - the resolved directory to list; a missing or non-directory target throws.
266
+ * @param signal - aborts the listing, checked between children (`FS_ABORTED`).
267
+ * @returns one entry per direct child, sorted by name.
268
+ */
269
+ async function listDirectory(target, signal) {
270
+ throwIfAborted(signal, "list");
271
+ let info;
272
+ try {
273
+ info = await probe(target.targetKey);
274
+ } catch (error) {
275
+ throw listingIoError(target.displayPath, error);
276
+ }
277
+ if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, "FS_NOT_FOUND");
278
+ if (info.type !== "directory") throw new FsError(`cannot list "${target.displayPath}": not a directory`, "FS_NOT_DIRECTORY");
279
+ let entries;
280
+ try {
281
+ entries = await readdir(target.targetKey, {
282
+ withFileTypes: true,
283
+ encoding: "utf8"
284
+ });
285
+ } catch (error) {
286
+ /* v8 ignore next -- requires permission/kernel failure from readdir after a successful directory stat. */
287
+ throw listingIoError(target.displayPath, error);
288
+ }
289
+ throwIfAborted(signal, "list");
290
+ const result = [];
291
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
292
+ throwIfAborted(signal, "list");
293
+ try {
294
+ const childTarget = await resolveListedChildTarget(target, entry.name);
295
+ const childInfo = await probe(childTarget.targetKey);
296
+ result.push({
297
+ name: entry.name,
298
+ type: childInfo?.type ?? "other",
299
+ target: childTarget,
300
+ ...childInfo ? { version: childInfo.version } : {},
301
+ ...childInfo?.type === "file" ? { size: childInfo.size } : {}
302
+ });
303
+ } catch (error) {
304
+ throw listingIoError(join(target.displayPath, entry.name), error);
305
+ }
306
+ throwIfAborted(signal, "list");
307
+ }
308
+ return result;
309
+ }
310
+ function notTextError(verb, displayPath) {
311
+ return new FsError(`cannot ${verb} "${displayPath}": invalid UTF-8 text`, "FS_NOT_TEXT");
312
+ }
313
+ function decodeUtf8(buffer, verb, displayPath) {
314
+ try {
315
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
316
+ } catch (error) {
317
+ /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
318
+ if (!(error instanceof TypeError)) throw error;
319
+ throw notTextError(verb, displayPath);
320
+ }
321
+ }
322
+ function decodeUtf8Stream(decoder, chunk, verb, displayPath) {
323
+ try {
324
+ return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode();
325
+ } catch (error) {
326
+ /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
327
+ if (!(error instanceof TypeError)) throw error;
328
+ throw notTextError(verb, displayPath);
329
+ }
330
+ }
331
+ async function statRegularFile(target, verb, signal) {
332
+ throwIfAborted(signal, verb);
333
+ let info;
334
+ try {
335
+ info = await stat(target.targetKey);
336
+ } catch (error) {
337
+ /* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; only the not-found path is reachable in tests. */
338
+ if (!isENOENT(error)) throw error;
339
+ throw new FsError(`cannot ${verb} "${target.displayPath}": not found`, "FS_NOT_FOUND");
340
+ }
341
+ if (!info.isFile()) throw new FsError(`cannot ${verb} "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
342
+ return info;
343
+ }
344
+ /**
345
+ * Read a whole regular UTF-8 text file into a single decoded string. Rejects
346
+ * non-regular files, invalid UTF-8, and NUL-byte binary samples.
347
+ * @param target - the resolved file to read.
348
+ * @param signal - aborts the read (`FS_ABORTED`).
349
+ * @returns the full decoded text, byte-for-byte (no normalization).
350
+ */
351
+ async function readWholeText(target, signal) {
352
+ await statRegularFile(target, "read", signal);
353
+ const raw = await readFileAbortable(target.targetKey, "read", signal);
354
+ throwIfAborted(signal, "read");
355
+ if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) throw new FsError(`cannot read "${target.displayPath}": binary file`, "FS_NOT_TEXT");
356
+ return decodeUtf8(raw, "read", target.displayPath);
357
+ }
358
+ /**
359
+ * Read a whole regular file as raw bytes with no decoding or binary rejection.
360
+ * `maxBytes` bounds the complete content: the stat size short-circuits an
361
+ * oversized file before any content I/O, and the stream retains at most the
362
+ * cap while detecting post-stat growth.
363
+ * @param target - the resolved file to read.
364
+ * @param signal - aborts the read (`FS_ABORTED`).
365
+ * @param maxBytes - inclusive byte cap on the complete content (`FS_TOO_LARGE`).
366
+ * @param internals - test seam for a deterministic post-stat growth race.
367
+ * @returns the full raw content, at most `maxBytes` long.
368
+ */
369
+ async function readWholeBytes(target, signal, maxBytes, internals = {}) {
370
+ const info = await statRegularFile(target, "read", signal);
371
+ if (info.size > maxBytes) throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, "FS_TOO_LARGE");
372
+ await internals.inspectReadBytesAfterStat?.(target);
373
+ const stream = createReadStream(target.targetKey, {
374
+ end: maxBytes,
375
+ ...signal ? { signal } : {}
376
+ });
377
+ const chunks = [];
378
+ let bytes = 0;
379
+ try {
380
+ for await (const chunk of stream) {
381
+ if (chunk.length > maxBytes - bytes) throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, "FS_TOO_LARGE");
382
+ chunks.push(chunk);
383
+ bytes += chunk.length;
384
+ }
385
+ } catch (error) {
386
+ /* v8 ignore next 2 -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */
387
+ if (isAbortError(error)) throw new FsError("read aborted", "FS_ABORTED");
388
+ throw error;
389
+ }
390
+ return Buffer.concat(chunks, bytes);
391
+ }
392
+ /**
393
+ * Stream a whole regular UTF-8 text file as decoded text chunks. Same text
394
+ * semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
395
+ * cross-chunk UTF-8 decoding), but never holds the whole file in memory.
396
+ * @param target - the resolved file to stream.
397
+ * @param signal - aborts the stream, including between chunks (`FS_ABORTED`).
398
+ * @returns decoded text chunks in file order; chunk boundaries carry no meaning.
399
+ */
400
+ async function* streamWholeText(target, signal) {
401
+ await statRegularFile(target, "read", signal);
402
+ const stream = createReadStream(target.targetKey, signal ? { signal } : {});
403
+ const decoder = new TextDecoder("utf-8", { fatal: true });
404
+ let sampledBytes = 0;
405
+ function scanBinarySample(chunk) {
406
+ if (sampledBytes >= BINARY_SAMPLE_BYTES) return;
407
+ const sample = chunk.subarray(0, Math.min(chunk.length, BINARY_SAMPLE_BYTES - sampledBytes));
408
+ if (sample.includes(0)) throw new FsError(`cannot read "${target.displayPath}": binary file`, "FS_NOT_TEXT");
409
+ sampledBytes += sample.length;
410
+ }
411
+ try {
412
+ for await (const chunk of stream) {
413
+ scanBinarySample(chunk);
414
+ yield decodeUtf8Stream(decoder, chunk, "read", target.displayPath);
415
+ }
416
+ yield decodeUtf8Stream(decoder, void 0, "read", target.displayPath);
417
+ } catch (error) {
418
+ /* v8 ignore next 4 -- mid-stream errors need an abort/IO fault racing the loop; pre-abort is caught by throwIfAborted. */
419
+ if (isAbortError(error)) throw new FsError("read aborted", "FS_ABORTED");
420
+ throw error;
421
+ }
422
+ }
423
+ async function removeStagingDirOrThrow(stagingDir, originalError, removeStagingDir) {
424
+ try {
425
+ await removeStagingDir(stagingDir);
426
+ } catch (cleanupError) {
427
+ /* v8 ignore next 1 -- cleanup failure here needs a second filesystem fault after the primary write failure. */
428
+ throw new FsError(`write failed (${errorMessage(originalError)}) and temp cleanup failed (${errorMessage(cleanupError)})`, "FS_NOT_FOUND", { cause: originalError });
429
+ }
430
+ throw originalError;
431
+ }
432
+ async function throwGuardedCreateFailure(error, absolutePath, displayPath, inspectPublicationTarget) {
433
+ let existing;
434
+ try {
435
+ existing = await inspectPublicationTarget(absolutePath);
436
+ } catch (metadataError) {
437
+ if (!isENOENT(metadataError) && !isENOTDIR(metadataError)) throw new FsError(`cannot write "${displayPath}": ${errorMessage(metadataError)}`, "FS_IO_ERROR", { cause: metadataError });
438
+ }
439
+ if (existing !== void 0) {
440
+ if (!existing.isFile()) throw new FsError(`cannot write "${displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE", { cause: error });
441
+ throw new FsError(`cannot overwrite existing "${displayPath}" without reading it first`, "FS_NOT_OBSERVED", { cause: error });
442
+ }
443
+ if (isEEXIST(error)) throw new FsError(`cannot overwrite existing "${displayPath}" without reading it first`, "FS_NOT_OBSERVED", { cause: error });
444
+ throw new FsError(`cannot write "${displayPath}": ${errorMessage(error)}`, "FS_IO_ERROR", { cause: error });
445
+ }
446
+ /**
447
+ * Atomically replace a file through a private, synced staging file in the same directory.
448
+ * POSIX protects the staging directory and file with `0o700` and `0o600`. A new Windows file
449
+ * inherits the destination directory's DACL; a replacement copies the existing target's DACL
450
+ * onto the empty temp before writing and preserves the target descriptor at publication.
451
+ * @param absolutePath - destination; missing parent directories are created.
452
+ * @param content - the full UTF-8 text to write.
453
+ * @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file;
454
+ * inert as a mode on Windows but identifies replacement security semantics.
455
+ * @param signal - cancellation checked before final publication.
456
+ * @param internals - Test hook for pinning temp names and observing the staged file.
457
+ * @param createIfAbsent - when provided, publish with a hard-link no-replace
458
+ * primitive; a concurrent creator's file is preserved and this write is
459
+ * rejected with `FS_NOT_OBSERVED` using the supplied display path.
460
+ */
461
+ async function writeFileAtomic(absolutePath, content, mode, signal, internals = {}, createIfAbsent) {
462
+ throwIfAborted(signal, "write");
463
+ const directory = dirname(absolutePath);
464
+ await mkdir(directory, { recursive: true });
465
+ throwIfAborted(signal, "write");
466
+ const stagingDir = join(directory, internals.tempDirName?.(absolutePath) ?? `.${basename(absolutePath)}.${process.pid}.${randomUUID()}.tmpdir`);
467
+ const tempPath = join(stagingDir, internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`);
468
+ const platform = internals.platform ?? process.platform;
469
+ const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32;
470
+ const replaceFile = internals.replaceFile ?? replaceFileWin32;
471
+ const linkFile = internals.linkFile ?? link;
472
+ const inspectPublicationTarget = internals.inspectPublicationTarget ?? ((path) => lstat(path, { bigint: true }));
473
+ const removeStagingDir = internals.removeStagingDir ?? ((path) => rm(path, {
474
+ recursive: true,
475
+ force: true
476
+ }));
477
+ let handle;
478
+ let stagingCreated = false;
479
+ try {
480
+ await mkdir(stagingDir, { mode: 448 });
481
+ stagingCreated = true;
482
+ await chmod(stagingDir, 448);
483
+ handle = await open(tempPath, "wx", 384);
484
+ await handle.chmod(384);
485
+ if (platform === "win32" && mode !== void 0) await copyFileDacl(absolutePath, tempPath);
486
+ await handle.writeFile(content, {
487
+ encoding: "utf8",
488
+ ...signal ? { signal } : {}
489
+ });
490
+ await handle.sync();
491
+ await internals.inspectTemp?.({
492
+ stagingDir,
493
+ tempPath
494
+ });
495
+ if (mode !== void 0) await handle.chmod(mode);
496
+ await handle.close();
497
+ handle = void 0;
498
+ throwIfAborted(signal, "write");
499
+ if (createIfAbsent !== void 0) try {
500
+ await linkFile(tempPath, absolutePath);
501
+ } catch (error) {
502
+ await throwGuardedCreateFailure(error, absolutePath, createIfAbsent.displayPath, inspectPublicationTarget);
503
+ }
504
+ else if (platform === "win32" && mode !== void 0) try {
505
+ await replaceFile(absolutePath, tempPath);
506
+ } catch (error) {
507
+ if (!isENOENT(error)) throw error;
508
+ await rename(tempPath, absolutePath);
509
+ }
510
+ else await rename(tempPath, absolutePath);
511
+ try {
512
+ await removeStagingDir(stagingDir);
513
+ } catch (_committedStagingCleanupFailure) {}
514
+ } catch (error) {
515
+ /* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */
516
+ let failure = isAbortError(error) ? new FsError("write aborted", "FS_ABORTED") : error;
517
+ /* v8 ignore next 8 -- reached only if writeFile/sync throws with the handle open (IO fault); close-failure is a double fault. */
518
+ if (handle) try {
519
+ await handle.close();
520
+ } catch (closeError) {
521
+ failure = new FsError(`write failed (${errorMessage(failure)}) and temp close failed (${errorMessage(closeError)})`, "FS_NOT_FOUND", { cause: failure });
522
+ }
523
+ if (!stagingCreated) throw failure;
524
+ return removeStagingDirOrThrow(stagingDir, failure, removeStagingDir);
525
+ }
526
+ }
527
+ /**
528
+ * Collapse CRLF to LF — the canonical in-memory form every edit/diff basis
529
+ * uses. Lone `\r` bytes (not followed by `\n`) are left untouched.
530
+ * @param content - decoded text in whatever line-ending style the file had.
531
+ * @returns the text with every `\r\n` pair replaced by `\n`.
532
+ */
533
+ function normalizeLineEndings(content) {
534
+ return content.replaceAll("\r\n", "\n");
535
+ }
536
+ function detectLineEndings(raw) {
537
+ const sample = raw.slice(0, 4096);
538
+ const crlfCount = sample.split("\r\n").length - 1;
539
+ return crlfCount > sample.split("\n").length - 1 - crlfCount ? "CRLF" : "LF";
540
+ }
541
+ /**
542
+ * Convert LF-normalized content back to the line-ending style detected at read
543
+ * time, for write-back. `LF` returns the content unchanged; `CRLF` re-normalizes
544
+ * first so an already-CRLF sequence is never doubled to `\r\r\n`.
545
+ * @param content - the LF-normalized (edited) text.
546
+ * @param lineEndings - the original file's style, as detected by {@link readForEdit}.
547
+ * @returns the text in the original file's line-ending style.
548
+ */
549
+ function restoreLineEndings(content, lineEndings) {
550
+ return lineEndings === "LF" ? content : normalizeLineEndings(content).split("\n").join("\r\n");
551
+ }
552
+ function countOccurrences(content, needle) {
553
+ let count = 0;
554
+ let index = 0;
555
+ while (true) {
556
+ const found = content.indexOf(needle, index);
557
+ if (found === -1) return count;
558
+ count += 1;
559
+ index = found + needle.length;
560
+ }
561
+ }
562
+ /**
563
+ * Read and decode a file for editing: rejects binaries, returns LF-normalized
564
+ * content plus the original line-ending style for write-back.
565
+ * @param absolutePath - the file to read (typically a target key).
566
+ * @param displayPath - the caller-facing path used in error messages.
567
+ * @param signal - aborts the read (`FS_ABORTED`).
568
+ * @returns the LF-normalized content and the detected style to restore on write-back.
569
+ */
570
+ async function readForEdit(absolutePath, displayPath, signal) {
571
+ throwIfAborted(signal, "edit");
572
+ const buffer = await readFileAbortable(absolutePath, "edit", signal);
573
+ throwIfAborted(signal, "edit");
574
+ if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, "FS_NOT_TEXT");
575
+ const raw = decodeUtf8(buffer, "edit", displayPath);
576
+ return {
577
+ content: normalizeLineEndings(raw),
578
+ lineEndings: detectLineEndings(raw)
579
+ };
580
+ }
581
+ /**
582
+ * Best-effort overwrite diff basis. Binary, invalid UTF-8, a file at/above the byte limit,
583
+ * or a file deleted/made unreadable after the caller's preflight returns `null` so the write
584
+ * still succeeds and presentation falls back to a whole-file diff. The bound is enforced on
585
+ * the opened descriptor rather than a prior path stat, so concurrent external replacement or
586
+ * size changes cannot make this helper buffer more than `maxBytes`.
587
+ * @param absolutePath - the file to read (typically a target key).
588
+ * @param maxBytes - exclusive upper bound for bytes held as the contextual-diff basis.
589
+ * @param signal - aborts the read (`FS_ABORTED`); cancellation propagates, unlike I/O failure.
590
+ * @returns the LF-normalized text, or null for a non-regular, at/above-limit, binary, non-UTF-8,
591
+ * descriptor-size-changed, or unreadable file.
592
+ */
593
+ async function readTextForDiff(absolutePath, maxBytes, signal) {
594
+ throwIfAborted(signal, "read");
595
+ try {
596
+ const handle = await open(absolutePath, "r");
597
+ let buffer;
598
+ let total = 0;
599
+ let openedSize = 0;
600
+ try {
601
+ throwIfAborted(signal, "read");
602
+ const info = await handle.stat();
603
+ throwIfAborted(signal, "read");
604
+ if (!info.isFile()) return null;
605
+ if (info.size >= maxBytes) return null;
606
+ openedSize = info.size;
607
+ buffer = Buffer.allocUnsafe(openedSize + 1);
608
+ while (total < buffer.length) {
609
+ throwIfAborted(signal, "read");
610
+ const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES);
611
+ const { bytesRead } = await handle.read(buffer, total, length, null);
612
+ if (bytesRead === 0) break;
613
+ total += bytesRead;
614
+ }
615
+ } finally {
616
+ await handle.close();
617
+ }
618
+ throwIfAborted(signal, "read");
619
+ if (total !== openedSize) return null;
620
+ const basis = buffer.subarray(0, total);
621
+ if (basis.includes(0)) return null;
622
+ try {
623
+ return normalizeLineEndings(new TextDecoder("utf-8", { fatal: true }).decode(basis));
624
+ } catch (error) {
625
+ /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes;
626
+ * any other throw is an unreachable runtime fault. */
627
+ if (!(error instanceof TypeError)) throw error;
628
+ return null;
629
+ }
630
+ } catch (error) {
631
+ if (error instanceof FsError) throw error;
632
+ if (error instanceof Error && "code" in error) return null;
633
+ throw error;
634
+ }
635
+ }
636
+ /**
637
+ * Apply a literal replacement to LF-normalized content. Empty or missing search text throws
638
+ * `FS_EDIT_NOT_FOUND`; multiple matches throw `FS_AMBIGUOUS_EDIT` unless `replaceAll` is true.
639
+ * @param content - the current file content, already LF-normalized.
640
+ * @param oldString - literal text to find; CRLF inside it is normalized to LF before
641
+ * matching.
642
+ * @param newString - literal replacement text, normalized the same way.
643
+ * @param replaceAll - replace every match instead of requiring exactly one.
644
+ * @param displayPath - the caller-facing path used in error messages.
645
+ * @returns the edited LF-normalized content plus how many occurrences were replaced.
646
+ */
647
+ function applyLiteralEdit(content, oldString, newString, replaceAll, displayPath) {
648
+ const oldNorm = normalizeLineEndings(oldString);
649
+ if (oldNorm.length === 0) throw new FsError("old_string must be a non-empty string", "FS_EDIT_NOT_FOUND");
650
+ const newNorm = normalizeLineEndings(newString);
651
+ const replacements = countOccurrences(content, oldNorm);
652
+ if (replacements === 0) throw new FsError(`old_string was not found in "${displayPath}"`, "FS_EDIT_NOT_FOUND");
653
+ 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");
654
+ return {
655
+ content: content.split(oldNorm).join(newNorm),
656
+ replacements
657
+ };
658
+ }
659
+ //#endregion
660
+ //#region lib/types/index.js
661
+ /**
662
+ * Host-filesystem implementation of `ctx.fs`. Realpath-derived target identity makes aliases
663
+ * share stale guards, and writes through a symlink update its target without replacing the link.
664
+ * @module @stackstackstack/dsh-fs-local
665
+ */
666
+ const DEFAULT_DIFF_BASIS_MAX_BYTES = 10 * 1024 * 1024;
667
+ const MAX_DIFF_BASIS_BYTES = Math.min(constants.MAX_LENGTH, constants.MAX_STRING_LENGTH);
668
+ /**
669
+ * The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
670
+ * (a resolution default, NOT a containment boundary — see the filesystem
671
+ * capability-seam Agent Note); enforce
672
+ * containment with a stricter backend or a `tools/execute` permission plugin.
673
+ */
674
+ var LocalFileSystem = class extends FileSystem {
675
+ static Config = z.object({
676
+ cwd: z.string().default(process.cwd()),
677
+ diffBasisMaxBytes: z.number().default(DEFAULT_DIFF_BASIS_MAX_BYTES)
678
+ });
679
+ /** Validated config (schemastery applied the defaults before construction). */
680
+ config;
681
+ /** Test hook forwarded to fsio for atomic-publication boundaries. */
682
+ internals = {};
683
+ /** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
684
+ * window can't interleave, making concurrent writes/edits deterministically
685
+ * ordered (one wins, the rest see the new version and reject as stale). */
686
+ locks = /* @__PURE__ */ new Map();
687
+ constructor(ctx, config) {
688
+ super(ctx);
689
+ const resolved = config;
690
+ if (!Number.isSafeInteger(resolved.diffBasisMaxBytes) || resolved.diffBasisMaxBytes <= 0 || resolved.diffBasisMaxBytes > MAX_DIFF_BASIS_BYTES) throw new Error(`fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${MAX_DIFF_BASIS_BYTES}`);
691
+ this.config = resolved;
692
+ }
693
+ /** Run `op` with exclusive access to `targetKey` (FIFO per key). */
694
+ async withLock(targetKey, op) {
695
+ const run = (this.locks.get(targetKey) ?? Promise.resolve()).then(op, op);
696
+ const tail = run.then(() => void 0, () => void 0);
697
+ this.locks.set(targetKey, tail);
698
+ try {
699
+ return await run;
700
+ } finally {
701
+ if (this.locks.get(targetKey) === tail) this.locks.delete(targetKey);
702
+ }
703
+ }
704
+ async resolve(path, opts) {
705
+ if (opts?.signal?.aborted) throw new FsError("resolve aborted", "FS_ABORTED");
706
+ const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path);
707
+ if (opts?.signal?.aborted) throw new FsError("resolve aborted", "FS_ABORTED");
708
+ return {
709
+ targetKey: local.targetKey,
710
+ displayPath: local.displayPath
711
+ };
712
+ }
713
+ processPath(target) {
714
+ return String(target.targetKey);
715
+ }
716
+ fileUrl(target) {
717
+ return pathToFileURL(this.processPath(target)).href;
718
+ }
719
+ contains(parent, child) {
720
+ const path = relative(this.processPath(parent), this.processPath(child));
721
+ return path === "" || path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path);
722
+ }
723
+ async stat(target, signal) {
724
+ if (signal?.aborted) throw new FsError("stat aborted", "FS_ABORTED");
725
+ const info = await probe(target.targetKey);
726
+ if (signal?.aborted) throw new FsError("stat aborted", "FS_ABORTED");
727
+ if (!info) return void 0;
728
+ return {
729
+ version: info.version,
730
+ type: info.type,
731
+ size: info.size
732
+ };
733
+ }
734
+ async lstat(path, opts, signal) {
735
+ if (signal?.aborted) throw new FsError("lstat aborted", "FS_ABORTED");
736
+ if (path.trim().length === 0) throw new FsError("file_path must be a non-empty string", "FS_NOT_FOUND");
737
+ const info = await probeNoFollow(resolve(opts?.cwd ?? this.config.cwd, path));
738
+ if (signal?.aborted) throw new FsError("lstat aborted", "FS_ABORTED");
739
+ if (!info) return void 0;
740
+ return {
741
+ version: info.version,
742
+ type: info.type,
743
+ size: info.size
744
+ };
745
+ }
746
+ async readText(target, signal) {
747
+ return readWholeText({
748
+ displayPath: target.displayPath,
749
+ targetKey: target.targetKey
750
+ }, signal);
751
+ }
752
+ streamText(target, signal) {
753
+ return Promise.resolve(streamWholeText({
754
+ displayPath: target.displayPath,
755
+ targetKey: target.targetKey
756
+ }, signal));
757
+ }
758
+ async readBytes(target, signal, maxBytes) {
759
+ return readWholeBytes({
760
+ displayPath: target.displayPath,
761
+ targetKey: target.targetKey
762
+ }, signal, maxBytes, this.internals);
763
+ }
764
+ async listDir(target, signal) {
765
+ return (await listDirectory({
766
+ displayPath: target.displayPath,
767
+ targetKey: target.targetKey
768
+ }, signal)).map((entry) => ({
769
+ name: entry.name,
770
+ type: entry.type,
771
+ target: {
772
+ targetKey: entry.target.targetKey,
773
+ displayPath: entry.target.displayPath
774
+ },
775
+ ...entry.version !== void 0 ? { version: entry.version } : {},
776
+ ...entry.size !== void 0 ? { size: entry.size } : {}
777
+ }));
778
+ }
779
+ async writeText(target, content, expected, signal) {
780
+ return this.withLock(target.targetKey, async () => {
781
+ const existing = await probe(target.targetKey);
782
+ if (existing && existing.type !== "file") throw new FsError(`cannot write "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
783
+ if (expected?.kind === "replaceIfVersion") {
784
+ if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
785
+ if (existing.version !== expected.version) throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
786
+ } else if (expected?.kind === "createIfAbsent" && existing) throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
787
+ const before = existing !== null && Buffer.byteLength(content, "utf8") < this.config.diffBasisMaxBytes ? await readTextForDiff(target.targetKey, this.config.diffBasisMaxBytes, signal) : null;
788
+ await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals, expected?.kind === "createIfAbsent" ? { displayPath: target.displayPath } : void 0);
789
+ const after = await probe(target.targetKey);
790
+ return {
791
+ operation: existing ? "update" : "create",
792
+ version: this.versionAfterWrite(after, target),
793
+ before,
794
+ after: normalizeLineEndings(content)
795
+ };
796
+ });
797
+ }
798
+ async editText(target, edit, expected, signal) {
799
+ return this.withLock(target.targetKey, async () => {
800
+ const existing = await probe(target.targetKey);
801
+ if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
802
+ if (existing.type !== "file") throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
803
+ if (expected && existing.version !== expected.version) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
804
+ const original = await readForEdit(target.targetKey, target.displayPath, signal);
805
+ const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath);
806
+ const content = restoreLineEndings(edited.content, original.lineEndings);
807
+ await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals);
808
+ const after = await probe(target.targetKey);
809
+ return {
810
+ version: this.versionAfterWrite(after, target),
811
+ before: original.content,
812
+ after: edited.content
813
+ };
814
+ });
815
+ }
816
+ /* v8 ignore next 5 -- the post-write probe finding the file absent requires a
817
+ * concurrent unlink between rename and stat; fall back to a sentinel version. */
818
+ versionAfterWrite(after, target) {
819
+ if (after) return after.version;
820
+ return FsVersion(`missing:${target.targetKey}`);
821
+ }
822
+ };
823
+ //#endregion
824
+ export { LocalFileSystem, LocalFileSystem as default };