dsh-plugin-wsl-env 0.7.5 → 0.8.0

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +278 -0
  2. package/CONTRIBUTING.md +129 -39
  3. package/README.md +20 -20
  4. package/docs/ARCHITECTURE.md +6 -3
  5. package/docs/LIMITATIONS.md +13 -0
  6. package/docs/README.zh.md +20 -20
  7. package/lib/agent-confined.d.ts +42 -0
  8. package/lib/agent-confined.js +25 -41
  9. package/lib/agent-errors.d.ts +31 -0
  10. package/lib/agent-errors.js +19 -16
  11. package/lib/agent-exec.d.ts +159 -0
  12. package/lib/agent-exec.js +151 -165
  13. package/lib/agent-protocol.d.ts +192 -0
  14. package/lib/agent-protocol.js +76 -100
  15. package/lib/agent-shared.d.ts +27 -0
  16. package/lib/agent-shared.js +12 -12
  17. package/lib/agent.d.ts +353 -0
  18. package/lib/agent.js +536 -552
  19. package/lib/auto-preset.d.ts +110 -0
  20. package/lib/auto-preset.js +157 -141
  21. package/lib/bwrap.d.ts +90 -0
  22. package/lib/bwrap.js +18 -18
  23. package/lib/file-reference-wsl.d.ts +160 -0
  24. package/lib/file-reference-wsl.js +192 -173
  25. package/lib/fs-decisions.d.ts +107 -0
  26. package/lib/fs-decisions.js +64 -60
  27. package/lib/fs-routing.d.ts +182 -0
  28. package/lib/fs-routing.js +173 -116
  29. package/lib/fs-substrate.d.ts +258 -0
  30. package/lib/fs-substrate.js +248 -248
  31. package/lib/fs.d.ts +10 -0
  32. package/lib/fs.js +5 -0
  33. package/lib/fsio-agent.d.ts +307 -0
  34. package/lib/fsio-agent.js +414 -410
  35. package/lib/fsio-text.d.ts +115 -0
  36. package/lib/fsio-text.js +104 -84
  37. package/lib/git-route.d.ts +100 -0
  38. package/lib/git-route.js +46 -47
  39. package/lib/index.d.ts +1003 -0
  40. package/lib/index.js +1101 -1003
  41. package/lib/listing.d.ts +114 -0
  42. package/lib/listing.js +68 -55
  43. package/lib/paths.d.ts +182 -0
  44. package/lib/paths.js +134 -109
  45. package/lib/picker.d.ts +266 -0
  46. package/lib/picker.js +371 -351
  47. package/lib/ports.d.ts +44 -0
  48. package/lib/ports.js +39 -29
  49. package/lib/preset-choice.d.ts +33 -0
  50. package/lib/preset-choice.js +15 -7
  51. package/lib/probe-cache.d.ts +40 -0
  52. package/lib/probe-cache.js +35 -29
  53. package/lib/sandbox-core.d.ts +79 -0
  54. package/lib/sandbox-core.js +71 -88
  55. package/lib/sandbox.d.ts +48 -0
  56. package/lib/sandbox.js +4 -4
  57. package/lib/search-exec.d.ts +96 -0
  58. package/lib/search-exec.js +52 -79
  59. package/lib/search-route.d.ts +75 -0
  60. package/lib/search-route.js +30 -31
  61. package/lib/shell-env.d.ts +88 -0
  62. package/lib/shell-env.js +78 -74
  63. package/lib/shell.d.ts +10 -0
  64. package/lib/shell.js +5 -0
  65. package/lib/subprocess.d.ts +313 -0
  66. package/lib/subprocess.js +267 -262
  67. package/lib/terminal-activity.d.ts +98 -0
  68. package/lib/terminal-activity.js +29 -39
  69. package/lib/terminal-route.d.ts +27 -0
  70. package/lib/terminal-route.js +16 -8
  71. package/lib/watcher.d.ts +77 -0
  72. package/lib/watcher.js +120 -126
  73. package/lib/workspace-files-route.d.ts +132 -0
  74. package/lib/workspace-files-route.js +73 -81
  75. package/lib/workspace-files-wsl.d.ts +257 -0
  76. package/lib/workspace-files-wsl.js +311 -251
  77. package/lib/wsl.d.ts +266 -0
  78. package/lib/wsl.js +144 -155
  79. package/package.json +68 -20
package/lib/fsio-agent.js CHANGED
@@ -15,77 +15,70 @@
15
15
  * policy-free — the fence lives in the provider — and peer-free, so it is
16
16
  * unit-testable in a bare checkout against a scripted agent.
17
17
  *
18
+ * This is a TypeScript source built to `lib/fsio-agent.js`; edit THIS file and
19
+ * run `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
20
+ * fails when it drifts.
21
+ *
18
22
  * @module dsh-plugin-wsl/fsio-agent
19
23
  */
20
-
21
24
  import { FS_READ_CHUNK_BYTES } from "./agent-protocol.js";
22
- import {
23
- BINARY_SAMPLE_BYTES,
24
- FsCodedError,
25
- decodeUtf8,
26
- decodeUtf8Stream,
27
- detectLineEndings,
28
- normalizeLineEndings,
29
- restoreLineEndings,
30
- applyLiteralEdit,
31
- throwIfAborted,
32
- } from "./fsio-text.js";
25
+ import { BINARY_SAMPLE_BYTES, FsCodedError, decodeUtf8, decodeUtf8Stream, detectLineEndings, normalizeLineEndings, restoreLineEndings, applyLiteralEdit, throwIfAborted, } from "./fsio-text.js";
33
26
  import { posixToUnc, uncToPosix } from "./paths.js";
34
-
35
27
  /** One FS frame's in-distro watchdog; fs ops are short, writes bounded by content size. */
36
28
  const FS_OP_TIMEOUT_MS = 30_000;
37
-
38
29
  /**
39
30
  * Normalize one agent timestamp ("seconds.frac", 9 or 10 fractional digits
40
31
  * depending on whether stat or find produced it) into the peer's nanosecond
41
32
  * string: exactly nine fractional digits, no dot.
42
- * @param {string} value - the wire timestamp.
43
- * @returns {string} seconds followed by nine fractional digits.
33
+ *
34
+ * @param value - the wire timestamp.
35
+ * @returns seconds followed by nine fractional digits.
44
36
  */
45
37
  export function nanoseconds(value) {
46
- const [seconds, fraction = ""] = String(value).split(".");
47
- return `${seconds}${fraction.padEnd(9, "0").slice(0, 9)}`;
38
+ const [seconds, fraction = ""] = String(value).split(".");
39
+ return `${seconds}${fraction.padEnd(9, "0").slice(0, 9)}`;
48
40
  }
49
-
50
41
  /**
51
42
  * The peer's `versionOf`: dev, inode, size and both nanosecond timestamps.
52
43
  * Self-consistency within this provider is what versions need; the ingredients
53
44
  * come from one kernel, so a change in any byte of the file changes the string.
54
- * @param {object} ingredients - the stat record's identity fields.
55
- * @returns {string} the opaque version string.
45
+ *
46
+ * @param ingredients - the stat record's identity fields.
47
+ * @returns the opaque version string.
56
48
  */
57
49
  export function versionOf({ dev, ino, size, mtimeNs, ctimeNs }) {
58
- return `${dev}:${ino}:${size}:${mtimeNs}:${ctimeNs}`;
50
+ return `${dev}:${ino}:${size}:${mtimeNs}:${ctimeNs}`;
59
51
  }
60
-
61
52
  /**
62
53
  * Parse one stat record as the agent emits it:
63
54
  * `type \t mode \t size \t dev \t ino \t mtime \t ctime`.
64
- * @param {Buffer} stdout - the op's payload.
65
- * @returns {{type: string, mode: number, size: number, dev: string, ino: string, version: string}}
55
+ *
56
+ * @param stdout - the op's payload.
57
+ * @returns the parsed row.
66
58
  */
67
59
  export function parseStatRecord(stdout) {
68
- const [type, mode, size, dev, ino, mtime, ctime] = stdout.toString("utf8").split("\t");
69
- return {
70
- type,
71
- mode: Number.parseInt(mode, 8) & 0o7777,
72
- size: Number(size),
73
- dev,
74
- ino,
75
- version: versionOf({ dev, ino, size, mtimeNs: nanoseconds(mtime), ctimeNs: nanoseconds(ctime) }),
76
- };
60
+ const [type, mode, size, dev, ino, mtime, ctime] = stdout.toString("utf8").split("\t");
61
+ if (type === undefined || mode === undefined || size === undefined || dev === undefined || ino === undefined || mtime === undefined || ctime === undefined) {
62
+ throw new FsCodedError(`malformed stat record: ${JSON.stringify(stdout.toString("utf8"))}`, "FS_IO_ERROR");
63
+ }
64
+ return {
65
+ type,
66
+ mode: Number.parseInt(mode, 8) & 0o7777,
67
+ size: Number(size),
68
+ dev,
69
+ ino,
70
+ version: versionOf({ dev, ino, size, mtimeNs: nanoseconds(mtime), ctimeNs: nanoseconds(ctime) }),
71
+ };
77
72
  }
78
-
79
73
  const REASON_CODES = {
80
- notfound: "FS_NOT_FOUND",
81
- notdir: "FS_NOT_FOUND",
82
- perm: "FS_IO_ERROR",
83
- loop: "FS_IO_ERROR",
84
- exists: "FS_NOT_OBSERVED",
85
- stale: "FS_STALE_VERSION",
86
- io: "FS_IO_ERROR",
74
+ notfound: "FS_NOT_FOUND",
75
+ notdir: "FS_NOT_FOUND",
76
+ perm: "FS_IO_ERROR",
77
+ loop: "FS_IO_ERROR",
78
+ exists: "FS_NOT_OBSERVED",
79
+ stale: "FS_STALE_VERSION",
80
+ io: "FS_IO_ERROR",
87
81
  };
88
-
89
82
  /**
90
83
  * The kernel's denial dialect: a write that a read-only bind refused. The
91
84
  * command path classifies the same signature into its sandbox denial, so a
@@ -93,389 +86,400 @@ const REASON_CODES = {
93
86
  * model sees the escalation offer instead of an unexplained I/O failure.
94
87
  */
95
88
  const DENIAL_SIGNATURES = ["read-only file system"];
96
-
97
89
  /**
98
90
  * One distro's filesystem face. Owns the agent round trips and the host-side
99
91
  * validation; the provider layers the fence on top.
100
92
  */
101
93
  export class DistroFs {
102
- /**
103
- * @param {object} deps - the substrate's collaborators.
104
- * @param {import("./agent.js").WslAgent} deps.agent - the resident in-distro agent.
105
- * @param {string} deps.distro - the pinned distro name, for UNC translation.
106
- */
107
- constructor({ agent, distro }) {
108
- this.agent = agent;
109
- this.distro = distro;
110
- }
111
-
112
- /**
113
- * Run one FS op, mapping an aborted request onto the structured code the
114
- * provider's callers expect. A mutation may name the agent that must run it
115
- * — the confined resident whose mount table matches the granted rights —
116
- * while reads ride the plain resident.
117
- * @private
118
- */
119
- async request(op, args, signal, agent = this.agent) {
120
- try {
121
- return await agent.fs({ op, args, timeoutMs: FS_OP_TIMEOUT_MS, signal });
122
- } catch (error) {
123
- if (signal?.aborted) {
124
- throw new FsCodedError(`${op === "write" ? "write" : "read"} aborted`, "FS_ABORTED", error instanceof Error ? error : undefined);
125
- }
126
- throw error;
94
+ /**
95
+ * @param deps - the substrate's collaborators.
96
+ */
97
+ constructor({ agent, distro }) {
98
+ this.agent = agent;
99
+ this.distro = distro;
127
100
  }
128
- }
129
-
130
- /**
131
- * Turn a failed op into the coded error, reading the agent's
132
- * `dsh-fs|<reason>|<b64 message>` line; a frame without one is a raw I/O
133
- * fault carrying whatever stderr the agent did produce. On a WRITE, the
134
- * kernel's read-only-bind denial is classified as the sandbox refusal it is,
135
- * mirroring the command path's denial signatures.
136
- * @private
137
- */
138
- fail(op, result, displayPath) {
139
- const text = result.stderr.toString("utf8");
140
- const first = text.split("\n")[0] ?? "";
141
- if (first.startsWith("dsh-fs|")) {
142
- const [, reason, encoded] = first.split("|");
143
- const message = Buffer.from(encoded ?? "", "base64").toString("utf8");
144
- if (op === "write" && DENIAL_SIGNATURES.some((signature) => message.toLowerCase().includes(signature))) {
145
- return new FsCodedError(`cannot write "${displayPath}": ${message}`, "FS_SANDBOX_DENIED");
146
- }
147
- return new FsCodedError(message, REASON_CODES[reason] ?? "FS_IO_ERROR");
101
+ /**
102
+ * Run one FS op, mapping an aborted request onto the structured code the
103
+ * provider's callers expect. A mutation may name the agent that must run it
104
+ * — the confined resident whose mount table matches the granted rights —
105
+ * while reads ride the plain resident.
106
+ *
107
+ * @param op - the FS op name (`stat`, `lstat`, `realpath`, `list`, `read`, `write`).
108
+ * @param args - the op's arguments, one wire line each.
109
+ * @param signal - the caller's cancellation.
110
+ * @param agent - the agent that must run the op; the plain resident by default.
111
+ * @returns the op's result.
112
+ */
113
+ async request(op, args, signal, agent = this.agent) {
114
+ try {
115
+ return await agent.fs({ op, args: args, timeoutMs: FS_OP_TIMEOUT_MS, signal });
116
+ }
117
+ catch (error) {
118
+ if (signal?.aborted) {
119
+ throw new FsCodedError(`${op === "write" ? "write" : "read"} aborted`, "FS_ABORTED", error instanceof Error ? error : undefined);
120
+ }
121
+ throw error;
122
+ }
123
+ }
124
+ /**
125
+ * Turn a failed op into the coded error, reading the agent's
126
+ * `dsh-fs|<reason>|<b64 message>` line; a frame without one is a raw I/O
127
+ * fault carrying whatever stderr the agent did produce. On a WRITE, the
128
+ * kernel's read-only-bind denial is classified as the sandbox refusal it is,
129
+ * mirroring the command path's denial signatures.
130
+ *
131
+ * @param op - the failed op (`read` or `write`).
132
+ * @param result - the failed round trip's result.
133
+ * @param displayPath - the caller-facing path for the message.
134
+ * @returns the structured failure.
135
+ */
136
+ fail(op, result, displayPath) {
137
+ const text = result.stderr.toString("utf8");
138
+ const first = text.split("\n")[0] ?? "";
139
+ if (first.startsWith("dsh-fs|")) {
140
+ const [, reason, encoded] = first.split("|");
141
+ const message = Buffer.from(encoded ?? "", "base64").toString("utf8");
142
+ if (op === "write" && DENIAL_SIGNATURES.some((signature) => message.toLowerCase().includes(signature))) {
143
+ return new FsCodedError(`cannot write "${displayPath}": ${message}`, "FS_SANDBOX_DENIED");
144
+ }
145
+ // `first` starts with "dsh-fs|", so the split always yields the reason slot.
146
+ return new FsCodedError(message, REASON_CODES[reason] ?? "FS_IO_ERROR");
147
+ }
148
+ return new FsCodedError(`cannot ${op} "${displayPath}": ${text.trim() || "distro I/O failure"}`, "FS_IO_ERROR");
148
149
  }
149
- return new FsCodedError(`cannot ${op} "${displayPath}": ${text.trim() || "distro I/O failure"}`, "FS_IO_ERROR");
150
- }
151
-
152
- /**
153
- * Stat one Linux path, following or not. A missing path is `null`, exactly
154
- * like the peer's `probe`; everything else that is not found-class is a
155
- * structured failure.
156
- * @param {string} linuxPath - the path inside the distro.
157
- * @param {object} [options] - the stat flavour.
158
- * @param {boolean} [options.follow] - follow symlinks (default true).
159
- * @param {AbortSignal} [options.signal] - cancels the round trip.
160
- * @returns {Promise<{type: string, mode: number, size: number, version: string}|null>}
161
- */
162
- async stat(linuxPath, { follow = true, signal } = {}) {
163
- throwIfAborted(signal, "stat");
164
- const result = await this.request(follow ? "stat" : "lstat", [linuxPath], signal);
165
- if (result.exitCode === 0) return parseStatRecord(result.stdout);
166
- const failure = this.fail("stat", result, linuxPath);
167
- if (failure.code === "FS_NOT_FOUND") return null;
168
- throw failure;
169
- }
170
-
171
- /**
172
- * The distro-side identity of one path: the strict realpath, or on a missing
173
- * target the nearest existing ancestor with the missing suffix — the peer's
174
- * `resolveLocalTarget`, run where the symlinks live.
175
- * @param {string} linuxPath - the absolute Linux path to resolve.
176
- * @param {AbortSignal} [signal] - cancels the round trip.
177
- * @returns {Promise<string>} the canonical Linux path.
178
- */
179
- async canonicalPath(linuxPath, signal) {
180
- throwIfAborted(signal, "resolve");
181
- if (String(linuxPath).trim().length === 0) {
182
- throw new FsCodedError("file_path must be a non-empty string", "FS_NOT_FOUND");
150
+ /**
151
+ * Stat one Linux path, following or not. A missing path is `null`, exactly
152
+ * like the peer's `probe`; everything else that is not found-class is a
153
+ * structured failure.
154
+ *
155
+ * @param linuxPath - the path inside the distro.
156
+ * @param options - the stat flavour.
157
+ * @returns the stat row, or null when the path is absent.
158
+ */
159
+ async stat(linuxPath, { follow = true, signal } = {}) {
160
+ throwIfAborted(signal, "stat");
161
+ const result = await this.request(follow ? "stat" : "lstat", [linuxPath], signal);
162
+ if (result.exitCode === 0)
163
+ return parseStatRecord(result.stdout);
164
+ const failure = this.fail("stat", result, linuxPath);
165
+ if (failure.code === "FS_NOT_FOUND")
166
+ return null;
167
+ throw failure;
183
168
  }
184
- const result = await this.request("realpath", [linuxPath], signal);
185
- if (result.exitCode !== 0) throw this.fail("resolve", result, linuxPath);
186
- return result.stdout.toString("utf8");
187
- }
188
-
189
- /**
190
- * Resolve one Linux path to the target shape the provider and tooling use:
191
- * the Linux spelling the model sees and the UNC identity the harness keys on.
192
- * @param {string} linuxPath - the absolute Linux path to resolve.
193
- * @param {AbortSignal} [signal] - cancels the round trip.
194
- * @returns {Promise<{displayPath: string, targetKey: string}>}
195
- */
196
- async resolveTarget(linuxPath, signal) {
197
- const canonical = await this.canonicalPath(linuxPath, signal);
198
- return { displayPath: linuxPath, targetKey: posixToUnc(this.distro, canonical) };
199
- }
200
-
201
- /**
202
- * List one directory's direct children in name order — one `find -L` pass in
203
- * the distro, sorted and shaped host-side like the peer's `listDirectory`.
204
- * A symlink child's identity is resolved with one extra round trip, because
205
- * the follow-stat the list already carried belongs to its target.
206
- * @param {object} target - the resolved directory to list.
207
- * @param {string} target.displayPath - the Linux directory spelling.
208
- * @param {string} target.targetKey - the directory's UNC identity (canonical).
209
- * @param {AbortSignal} [signal] - aborts between children.
210
- * @returns {Promise<Array<{name: string, type: string, target: {displayPath: string, targetKey: string}, version?: string, size?: number}>>}
211
- */
212
- /**
213
- * List one directory's direct children in name order — one `find` pass in
214
- * the distro, sorted and shaped host-side like the peer's `listDirectory`.
215
- * The wire type is the entry's own (lstat): a symlink child then gets one
216
- * follow-up stat (its target's version and size) plus a realpath (its
217
- * identity), and a dangling symlink degrades to the peer's `other` with no
218
- * version — exactly what the peer's null follow-probe produces.
219
- * @param {object} target - the resolved directory to list.
220
- * @param {string} target.displayPath - the Linux directory spelling.
221
- * @param {string} target.targetKey - the directory's UNC identity (canonical).
222
- * @param {AbortSignal} [signal] - aborts between children.
223
- * @returns {Promise<Array<{name: string, type: string, target: {displayPath: string, targetKey: string}, version?: string, size?: number}>>}
224
- */
225
- async listChildren(target, signal) {
226
- throwIfAborted(signal, "list");
227
- const linuxDir = uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
228
- const info = await this.stat(linuxDir, { signal });
229
- if (!info) throw new FsCodedError(`cannot list "${target.displayPath}": not found`, "FS_NOT_FOUND");
230
- if (info.type !== "d") throw new FsCodedError(`cannot list "${target.displayPath}": not a directory`, "FS_NOT_DIRECTORY");
231
- const result = await this.request("list", [linuxDir], signal);
232
- if (result.exitCode !== 0) throw this.fail("list", result, target.displayPath);
233
- const parentCanonical = linuxDir;
234
- const records = result.stdout.toString("utf8").split("\0").filter((record) => record.length > 0);
235
- const entries = [];
236
- for (const record of records) {
237
- throwIfAborted(signal, "list");
238
- const fields = record.split("\t");
239
- const wireType = fields[0];
240
- const [, , dev, ino, mtime, ctime] = fields;
241
- const path = fields.slice(6).join("\t");
242
- // `find` joins each child onto the parent path. A root listing's parent
243
- // is bare "/", where `slice(length + 1)` sheared the first character off
244
- // EVERY name ("/etc" -> "tc") and the next write then created "/tc" — so
245
- // strip the exact prefix plus any separators instead. The join below
246
- // then owes its own slash: an empty spelling is what "/" contributes.
247
- const directory = parentCanonical === "/" ? "" : parentCanonical;
248
- const name = path.slice(parentCanonical.length).replace(/^\/+/, "");
249
- const displayPath = `${directory}/${name}`;
250
- // Non-link entries: the record's own stat IS the follow stat. A link
251
- // costs one stat plus one realpath, where the symlink actually lives.
252
- let { type, version, size } = { type: wireType, version: undefined, size: undefined };
253
- let childCanonical = `${directory}/${name}`;
254
- if (wireType === "l") {
255
- const followed = await this.stat(childCanonical, { signal }).catch(() => null);
256
- if (followed) {
257
- type = followed.type;
258
- version = followed.version;
259
- if (followed.type === "f") size = followed.size;
260
- childCanonical = await this.canonicalPath(childCanonical, signal);
169
+ /**
170
+ * The distro-side identity of one path: the strict realpath, or on a missing
171
+ * target the nearest existing ancestor with the missing suffix — the peer's
172
+ * `resolveLocalTarget`, run where the symlinks live.
173
+ *
174
+ * @param linuxPath - the absolute Linux path to resolve.
175
+ * @param signal - cancels the round trip.
176
+ * @returns the canonical Linux path.
177
+ */
178
+ async canonicalPath(linuxPath, signal) {
179
+ throwIfAborted(signal, "resolve");
180
+ if (String(linuxPath).trim().length === 0) {
181
+ throw new FsCodedError("file_path must be a non-empty string", "FS_NOT_FOUND");
261
182
  }
262
- } else {
263
- version = versionOf({ dev, ino, size: fields[1], mtimeNs: nanoseconds(mtime), ctimeNs: nanoseconds(ctime) });
264
- if (type === "f") size = Number(fields[1]);
265
- }
266
- entries.push({
267
- name,
268
- type: type === "f" ? "file" : type === "d" ? "directory" : "other",
269
- target: { displayPath, targetKey: posixToUnc(this.distro, childCanonical) },
270
- ...(version !== undefined ? { version } : {}),
271
- ...(size !== undefined ? { size } : {}),
272
- });
183
+ const result = await this.request("realpath", [linuxPath], signal);
184
+ if (result.exitCode !== 0)
185
+ throw this.fail("resolve", result, linuxPath);
186
+ return result.stdout.toString("utf8");
273
187
  }
274
- return entries.sort((left, right) => left.name.localeCompare(right.name));
275
- }
276
-
277
- /**
278
- * Stat the target as a regular file or refuse, mirroring the peer's
279
- * `statRegularFile` messages.
280
- * @private
281
- */
282
- async statRegularFile(target, verb, signal) {
283
- throwIfAborted(signal, verb);
284
- const info = await this.stat(uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath, { signal });
285
- if (!info) throw new FsCodedError(`cannot ${verb} "${target.displayPath}": not found`, "FS_NOT_FOUND");
286
- if (info.type !== "f") throw new FsCodedError(`cannot ${verb} "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
287
- return info;
288
- }
289
-
290
- /**
291
- * Read the whole file's bytes through windowed frames.
292
- * @private
293
- */
294
- async readWhole(target, signal, maxBytes = Number.MAX_SAFE_INTEGER) {
295
- const linuxPath = uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
296
- const chunks = [];
297
- let offset = 0;
298
- for (;;) {
299
- throwIfAborted(signal, "read");
300
- const result = await this.request("read", [linuxPath, String(offset), String(maxBytes === Number.MAX_SAFE_INTEGER ? FS_READ_CHUNK_BYTES : maxBytes - offset + 1)], signal);
301
- if (result.exitCode !== 0) throw this.fail("read", result, target.displayPath);
302
- const chunk = result.stdout;
303
- if (chunk.length === 0) break;
304
- chunks.push(chunk);
305
- offset += chunk.length;
306
- if (offset > maxBytes) {
307
- throw new FsCodedError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, "FS_TOO_LARGE");
308
- }
309
- if (chunk.length < FS_READ_CHUNK_BYTES) break;
188
+ /**
189
+ * Resolve one Linux path to the target shape the provider and tooling use:
190
+ * the Linux spelling the model sees and the UNC identity the harness keys on.
191
+ *
192
+ * @param linuxPath - the absolute Linux path to resolve.
193
+ * @param signal - cancels the round trip.
194
+ * @returns the resolved target.
195
+ */
196
+ async resolveTarget(linuxPath, signal) {
197
+ const canonical = await this.canonicalPath(linuxPath, signal);
198
+ return { displayPath: linuxPath, targetKey: posixToUnc(this.distro, canonical) };
310
199
  }
311
- return Buffer.concat(chunks, offset);
312
- }
313
-
314
- /**
315
- * Read a whole regular UTF-8 file, rejecting binaries and invalid UTF-8.
316
- * @param {object} target - the resolved file.
317
- * @param {AbortSignal} [signal] - aborts the read.
318
- * @returns {Promise<string>} the full decoded text, byte-for-byte.
319
- */
320
- async readWholeText(target, signal) {
321
- await this.statRegularFile(target, "read", signal);
322
- const raw = await this.readWhole(target, signal);
323
- if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
324
- throw new FsCodedError(`cannot read "${target.displayPath}": binary file`, "FS_NOT_TEXT");
200
+ /**
201
+ * List one directory's direct children in name order — one `find` pass in
202
+ * the distro, sorted and shaped host-side like the peer's `listDirectory`.
203
+ * The wire type is the entry's own (lstat): a symlink child then gets one
204
+ * follow-up stat (its target's version and size) plus a realpath (its
205
+ * identity), and a dangling symlink degrades to the peer's `other` with no
206
+ * version — exactly what the peer's null follow-probe produces.
207
+ *
208
+ * @param target - the resolved directory to list.
209
+ * @param signal - aborts between children.
210
+ * @returns the child rows, name-sorted.
211
+ */
212
+ async listChildren(target, signal) {
213
+ throwIfAborted(signal, "list");
214
+ const linuxDir = uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
215
+ const info = await this.stat(linuxDir, { signal });
216
+ if (!info)
217
+ throw new FsCodedError(`cannot list "${target.displayPath}": not found`, "FS_NOT_FOUND");
218
+ if (info.type !== "d")
219
+ throw new FsCodedError(`cannot list "${target.displayPath}": not a directory`, "FS_NOT_DIRECTORY");
220
+ const result = await this.request("list", [linuxDir], signal);
221
+ if (result.exitCode !== 0)
222
+ throw this.fail("list", result, target.displayPath);
223
+ const parentCanonical = linuxDir;
224
+ const records = result.stdout.toString("utf8").split("\0").filter((record) => record.length > 0);
225
+ const entries = [];
226
+ for (const record of records) {
227
+ throwIfAborted(signal, "list");
228
+ const fields = record.split("\t");
229
+ const [wireType, sizeField, dev, ino, mtime, ctime] = fields;
230
+ if (wireType === undefined || sizeField === undefined || dev === undefined || ino === undefined || mtime === undefined || ctime === undefined) {
231
+ throw new FsCodedError(`malformed listing record: ${JSON.stringify(record)}`, "FS_IO_ERROR");
232
+ }
233
+ const path = fields.slice(6).join("\t");
234
+ // `find` joins each child onto the parent path. A root listing's parent
235
+ // is bare "/", where `slice(length + 1)` sheared the first character off
236
+ // EVERY name ("/etc" -> "tc") and the next write then created "/tc" — so
237
+ // strip the exact prefix plus any separators instead. The join below
238
+ // then owes its own slash: an empty spelling is what "/" contributes.
239
+ const directory = parentCanonical === "/" ? "" : parentCanonical;
240
+ const name = path.slice(parentCanonical.length).replace(/^\/+/, "");
241
+ const displayPath = `${directory}/${name}`;
242
+ // Non-link entries: the record's own stat IS the follow stat. A link
243
+ // costs one stat plus one realpath, where the symlink actually lives.
244
+ let { type, version, size } = { type: wireType, version: undefined, size: undefined };
245
+ let childCanonical = `${directory}/${name}`;
246
+ if (wireType === "l") {
247
+ const followed = await this.stat(childCanonical, { signal }).catch(() => null);
248
+ if (followed) {
249
+ type = followed.type;
250
+ version = followed.version;
251
+ if (followed.type === "f")
252
+ size = followed.size;
253
+ childCanonical = await this.canonicalPath(childCanonical, signal);
254
+ }
255
+ }
256
+ else {
257
+ version = versionOf({ dev, ino, size: sizeField, mtimeNs: nanoseconds(mtime), ctimeNs: nanoseconds(ctime) });
258
+ if (type === "f")
259
+ size = Number(sizeField);
260
+ }
261
+ entries.push({
262
+ name,
263
+ type: type === "f" ? "file" : type === "d" ? "directory" : "other",
264
+ target: { displayPath, targetKey: posixToUnc(this.distro, childCanonical) },
265
+ ...(version !== undefined ? { version } : {}),
266
+ ...(size !== undefined ? { size } : {}),
267
+ });
268
+ }
269
+ return entries.sort((left, right) => left.name.localeCompare(right.name));
325
270
  }
326
- return decodeUtf8(raw, "read", target.displayPath);
327
- }
328
-
329
- /**
330
- * Read a whole regular file as raw bytes, bounded by `maxBytes`.
331
- * @param {object} target - the resolved file.
332
- * @param {AbortSignal|undefined} signal - aborts the read.
333
- * @param {number} maxBytes - inclusive byte cap on the complete content.
334
- * @returns {Promise<Uint8Array>} the full raw content, at most `maxBytes` long.
335
- */
336
- async readWholeBytes(target, signal, maxBytes) {
337
- const info = await this.statRegularFile(target, "read", signal);
338
- if (info.size > maxBytes) {
339
- throw new FsCodedError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, "FS_TOO_LARGE");
271
+ /**
272
+ * Stat the target as a regular file or refuse, mirroring the peer's
273
+ * `statRegularFile` messages.
274
+ *
275
+ * @param target - the resolved file.
276
+ * @param verb - the operation name for the messages.
277
+ * @param signal - the caller's cancellation.
278
+ * @returns the stat row of the regular file.
279
+ */
280
+ async statRegularFile(target, verb, signal) {
281
+ throwIfAborted(signal, verb);
282
+ const info = await this.stat(uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath, { signal });
283
+ if (!info)
284
+ throw new FsCodedError(`cannot ${verb} "${target.displayPath}": not found`, "FS_NOT_FOUND");
285
+ if (info.type !== "f")
286
+ throw new FsCodedError(`cannot ${verb} "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
287
+ return info;
340
288
  }
341
- return this.readWhole(target, signal, maxBytes);
342
- }
343
-
344
- /**
345
- * Read the bytes at `[offset, offset + length)` of a regular file. A window
346
- * at or past the end is empty.
347
- * @param {object} target - the resolved file.
348
- * @param {{offset: number, length: number}} range - the byte window.
349
- * @param {AbortSignal} [signal] - aborts the read.
350
- * @returns {Promise<Uint8Array>} the window's bytes.
351
- */
352
- async readByteWindow(target, range, signal) {
353
- await this.statRegularFile(target, "read", signal);
354
- if (range.length === 0) return new Uint8Array(0);
355
- const linuxPath = uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
356
- const result = await this.request("read", [linuxPath, String(range.offset), String(range.length)], signal);
357
- if (result.exitCode !== 0) throw this.fail("read", result, target.displayPath);
358
- return result.stdout;
359
- }
360
-
361
- /**
362
- * Stream a whole regular UTF-8 file as decoded text chunks — the binary
363
- * sample scan and cross-chunk decoding stay host-side, exactly the peer's.
364
- * @param {object} target - the resolved file.
365
- * @param {AbortSignal} [signal] - aborts the stream.
366
- * @returns {AsyncGenerator<string>} decoded text chunks in file order.
367
- */
368
- async *streamWholeText(target, signal) {
369
- await this.statRegularFile(target, "read", signal);
370
- const linuxPath = uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
371
- const decoder = new TextDecoder("utf-8", { fatal: true });
372
- let sampledBytes = 0;
373
- let offset = 0;
374
- const scanBinarySample = (chunk) => {
375
- if (sampledBytes >= BINARY_SAMPLE_BYTES) return;
376
- const sample = chunk.subarray(0, Math.min(chunk.length, BINARY_SAMPLE_BYTES - sampledBytes));
377
- if (sample.includes(0)) throw new FsCodedError(`cannot read "${target.displayPath}": binary file`, "FS_NOT_TEXT");
378
- sampledBytes += sample.length;
379
- };
380
- for (;;) {
381
- throwIfAborted(signal, "read");
382
- const result = await this.request("read", [linuxPath, String(offset), String(FS_READ_CHUNK_BYTES)], signal);
383
- if (result.exitCode !== 0) throw this.fail("read", result, target.displayPath);
384
- const chunk = result.stdout;
385
- if (chunk.length === 0) break;
386
- scanBinarySample(chunk);
387
- yield decodeUtf8Stream(decoder, chunk, "read", target.displayPath);
388
- offset += chunk.length;
389
- if (chunk.length < FS_READ_CHUNK_BYTES) break;
289
+ /**
290
+ * Read the whole file's bytes through windowed frames.
291
+ *
292
+ * @param target - the resolved file.
293
+ * @param signal - the caller's cancellation.
294
+ * @param maxBytes - inclusive byte cap on the complete content.
295
+ * @returns the file's bytes.
296
+ */
297
+ async readWhole(target, signal, maxBytes = Number.MAX_SAFE_INTEGER) {
298
+ const linuxPath = uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
299
+ const chunks = [];
300
+ let offset = 0;
301
+ for (;;) {
302
+ throwIfAborted(signal, "read");
303
+ const result = await this.request("read", [linuxPath, String(offset), String(maxBytes === Number.MAX_SAFE_INTEGER ? FS_READ_CHUNK_BYTES : maxBytes - offset + 1)], signal);
304
+ if (result.exitCode !== 0)
305
+ throw this.fail("read", result, target.displayPath);
306
+ const chunk = result.stdout;
307
+ if (chunk.length === 0)
308
+ break;
309
+ chunks.push(chunk);
310
+ offset += chunk.length;
311
+ if (offset > maxBytes) {
312
+ throw new FsCodedError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, "FS_TOO_LARGE");
313
+ }
314
+ if (chunk.length < FS_READ_CHUNK_BYTES)
315
+ break;
316
+ }
317
+ return Buffer.concat(chunks, offset);
318
+ }
319
+ /**
320
+ * Read a whole regular UTF-8 file, rejecting binaries and invalid UTF-8.
321
+ *
322
+ * @param target - the resolved file.
323
+ * @param signal - aborts the read.
324
+ * @returns the full decoded text, byte-for-byte.
325
+ */
326
+ async readWholeText(target, signal) {
327
+ await this.statRegularFile(target, "read", signal);
328
+ const raw = await this.readWhole(target, signal);
329
+ if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
330
+ throw new FsCodedError(`cannot read "${target.displayPath}": binary file`, "FS_NOT_TEXT");
331
+ }
332
+ return decodeUtf8(raw, "read", target.displayPath);
333
+ }
334
+ /**
335
+ * Read a whole regular file as raw bytes, bounded by `maxBytes`.
336
+ *
337
+ * @param target - the resolved file.
338
+ * @param signal - aborts the read.
339
+ * @param maxBytes - inclusive byte cap on the complete content.
340
+ * @returns the full raw content, at most `maxBytes` long.
341
+ */
342
+ async readWholeBytes(target, signal, maxBytes) {
343
+ const info = await this.statRegularFile(target, "read", signal);
344
+ if (info.size > maxBytes) {
345
+ throw new FsCodedError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, "FS_TOO_LARGE");
346
+ }
347
+ return this.readWhole(target, signal, maxBytes);
348
+ }
349
+ /**
350
+ * Read the bytes at `[offset, offset + length)` of a regular file. A window
351
+ * at or past the end is empty.
352
+ *
353
+ * @param target - the resolved file.
354
+ * @param range - the byte window.
355
+ * @param signal - aborts the read.
356
+ * @returns the window's bytes.
357
+ */
358
+ async readByteWindow(target, range, signal) {
359
+ await this.statRegularFile(target, "read", signal);
360
+ if (range.length === 0)
361
+ return new Uint8Array(0);
362
+ const linuxPath = uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
363
+ const result = await this.request("read", [linuxPath, String(range.offset), String(range.length)], signal);
364
+ if (result.exitCode !== 0)
365
+ throw this.fail("read", result, target.displayPath);
366
+ return result.stdout;
390
367
  }
391
- yield decodeUtf8Stream(decoder, undefined, "read", target.displayPath);
392
- }
393
-
394
- /**
395
- * Publish `content` over `linuxPath` the POSIX way: the agent stages into a
396
- * private 0700 sibling, fsyncs, chmods, and renames — or hard links for a
397
- * guarded create. A `replace` publish carrying `expectedVersion` is
398
- * re-verified by the agent against a fresh stat one syscall before the
399
- * rename: a concurrent writer (an editor save, a `git stash`) wins, and the
400
- * stale write refuses with `FS_STALE_VERSION` instead of landing.
401
- * @param {string} linuxPath - the target path inside the distro.
402
- * @param {string|Uint8Array} content - the full new content.
403
- * @param {object} [options]
404
- * @param {number} [options.mode] - the mode to publish (omitted keeps the
405
- * temp's 0600 for a create; the caller passes the incumbent's for an edit).
406
- * @param {{displayPath: string}} [options.createIfAbsent] - guarded-create
407
- * intent: publish with a no-replace link carrying this display path in errors.
408
- * @param {string} [options.expectedVersion] - the version string this write
409
- * was based on (a stat-derived one); re-verified distro-side before the rename.
410
- * @param {AbortSignal} [options.signal] - aborts the write.
411
- * @param {object} [options.agent] - the agent this mutation rides (the
412
- * confined resident under a confined policy).
413
- * @returns {Promise<void>} resolves when the publish landed.
414
- */
415
- async writeFileAtomic(linuxPath, content, { mode, createIfAbsent, expectedVersion, signal, agent } = {}) {
416
- throwIfAborted(signal, "write");
417
- const displayPath = createIfAbsent?.displayPath ?? linuxPath;
418
- const result = await this.request(
419
- "write",
420
- [
421
- linuxPath,
422
- mode === undefined ? "-" : (mode & 0o7777).toString(8),
423
- createIfAbsent ? "no-replace" : "replace",
424
- expectedVersion ?? "-",
425
- content,
426
- ],
427
- signal,
428
- agent,
429
- );
430
- if (result.exitCode === 0) return;
431
- const failure = this.fail("write", result, displayPath);
432
- if (failure.code !== "FS_NOT_OBSERVED") throw failure;
433
- // The guarded create hit an existing target: classify it the way the
434
- // peer's throwGuardedCreateFailure does — a non-regular incumbent is a
435
- // different refusal than an unread file.
436
- const incumbent = await this.stat(linuxPath, { follow: false, signal }).catch(() => null);
437
- if (incumbent && incumbent.type !== "f") {
438
- throw new FsCodedError(`cannot write "${displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE", failure);
368
+ /**
369
+ * Stream a whole regular UTF-8 file as decoded text chunks — the binary
370
+ * sample scan and cross-chunk decoding stay host-side, exactly the peer's.
371
+ *
372
+ * @param target - the resolved file.
373
+ * @param signal - aborts the stream.
374
+ * @returns decoded text chunks in file order.
375
+ */
376
+ async *streamWholeText(target, signal) {
377
+ await this.statRegularFile(target, "read", signal);
378
+ const linuxPath = uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
379
+ const decoder = new TextDecoder("utf-8", { fatal: true });
380
+ let sampledBytes = 0;
381
+ let offset = 0;
382
+ const scanBinarySample = (chunk) => {
383
+ if (sampledBytes >= BINARY_SAMPLE_BYTES)
384
+ return;
385
+ const sample = chunk.subarray(0, Math.min(chunk.length, BINARY_SAMPLE_BYTES - sampledBytes));
386
+ if (sample.includes(0))
387
+ throw new FsCodedError(`cannot read "${target.displayPath}": binary file`, "FS_NOT_TEXT");
388
+ sampledBytes += sample.length;
389
+ };
390
+ for (;;) {
391
+ throwIfAborted(signal, "read");
392
+ const result = await this.request("read", [linuxPath, String(offset), String(FS_READ_CHUNK_BYTES)], signal);
393
+ if (result.exitCode !== 0)
394
+ throw this.fail("read", result, target.displayPath);
395
+ const chunk = result.stdout;
396
+ if (chunk.length === 0)
397
+ break;
398
+ scanBinarySample(chunk);
399
+ yield decodeUtf8Stream(decoder, chunk, "read", target.displayPath);
400
+ offset += chunk.length;
401
+ if (chunk.length < FS_READ_CHUNK_BYTES)
402
+ break;
403
+ }
404
+ yield decodeUtf8Stream(decoder, undefined, "read", target.displayPath);
405
+ }
406
+ /**
407
+ * Publish `content` over `linuxPath` the POSIX way: the agent stages into a
408
+ * private 0700 sibling, fsyncs, chmods, and renames — or hard links for a
409
+ * guarded create. A `replace` publish carrying `expectedVersion` is
410
+ * re-verified by the agent against a fresh stat one syscall before the
411
+ * rename: a concurrent writer (an editor save, a `git stash`) wins, and the
412
+ * stale write refuses with `FS_STALE_VERSION` instead of landing.
413
+ *
414
+ * @param linuxPath - the target path inside the distro.
415
+ * @param content - the full new content.
416
+ * @param options - the publish options.
417
+ * @returns resolves when the publish landed.
418
+ */
419
+ async writeFileAtomic(linuxPath, content, { mode, createIfAbsent, expectedVersion, signal, agent } = {}) {
420
+ throwIfAborted(signal, "write");
421
+ const displayPath = createIfAbsent?.displayPath ?? linuxPath;
422
+ const result = await this.request("write", [
423
+ linuxPath,
424
+ mode === undefined ? "-" : (mode & 0o7777).toString(8),
425
+ createIfAbsent ? "no-replace" : "replace",
426
+ expectedVersion ?? "-",
427
+ content,
428
+ ], signal, agent);
429
+ if (result.exitCode === 0)
430
+ return;
431
+ const failure = this.fail("write", result, displayPath);
432
+ if (failure.code !== "FS_NOT_OBSERVED")
433
+ throw failure;
434
+ // The guarded create hit an existing target: classify it the way the
435
+ // peer's throwGuardedCreateFailure does — a non-regular incumbent is a
436
+ // different refusal than an unread file.
437
+ const incumbent = await this.stat(linuxPath, { follow: false, signal }).catch(() => null);
438
+ if (incumbent && incumbent.type !== "f") {
439
+ throw new FsCodedError(`cannot write "${displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE", failure);
440
+ }
441
+ throw new FsCodedError(`cannot overwrite existing "${displayPath}" without reading it first`, "FS_NOT_OBSERVED", failure);
439
442
  }
440
- throw new FsCodedError(`cannot overwrite existing "${displayPath}" without reading it first`, "FS_NOT_OBSERVED", failure);
441
- }
442
-
443
- /**
444
- * Read and decode a file for editing: LF-normalized content plus the style
445
- * to restore on write-back.
446
- * @param {object} target - the resolved file.
447
- * @param {AbortSignal} [signal] - aborts the read.
448
- * @returns {Promise<{content: string, lineEndings: "LF"|"CRLF"}>}
449
- */
450
- async readForEdit(target, signal) {
451
- const raw = await this.readWholeText(target, signal);
452
- const lineEndings = detectLineEndings(raw);
453
- return { content: normalizeLineEndings(raw), lineEndings };
454
- }
455
-
456
- /**
457
- * Best-effort overwrite diff basis: `null` whenever the file cannot serve
458
- * one (binary, at/above the bound, vanished), so the write still succeeds
459
- * and presentation falls back to a whole-file diff.
460
- * @param {object} target - the resolved file.
461
- * @param {number} maxBytes - exclusive upper bound for the held basis.
462
- * @param {AbortSignal} [signal] - cancellation propagates, unlike I/O failure.
463
- * @returns {Promise<string|null>} the LF-normalized text, or null.
464
- */
465
- async readTextForDiff(target, maxBytes, signal) {
466
- throwIfAborted(signal, "read");
467
- try {
468
- const info = await this.stat(uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath, { signal });
469
- if (!info || info.type !== "f" || info.size >= maxBytes) return null;
470
- const raw = await this.readWhole(target, signal, maxBytes - 1);
471
- if (raw.includes(0)) return null;
472
- return normalizeLineEndings(decodeUtf8(raw, "read", target.displayPath));
473
- } catch (error) {
474
- if (error instanceof FsCodedError && error.code === "FS_ABORTED") throw error;
475
- return null;
443
+ /**
444
+ * Read and decode a file for editing: LF-normalized content plus the style
445
+ * to restore on write-back.
446
+ *
447
+ * @param target - the resolved file.
448
+ * @param signal - aborts the read.
449
+ * @returns the LF-normalized content and its line-ending style.
450
+ */
451
+ async readForEdit(target, signal) {
452
+ const raw = await this.readWholeText(target, signal);
453
+ const lineEndings = detectLineEndings(raw);
454
+ return { content: normalizeLineEndings(raw), lineEndings };
455
+ }
456
+ /**
457
+ * Best-effort overwrite diff basis: `null` whenever the file cannot serve
458
+ * one (binary, at/above the bound, vanished), so the write still succeeds
459
+ * and presentation falls back to a whole-file diff.
460
+ *
461
+ * @param target - the resolved file.
462
+ * @param maxBytes - exclusive upper bound for the held basis.
463
+ * @param signal - cancellation propagates, unlike I/O failure.
464
+ * @returns the LF-normalized text, or null.
465
+ */
466
+ async readTextForDiff(target, maxBytes, signal) {
467
+ throwIfAborted(signal, "read");
468
+ try {
469
+ const info = await this.stat(uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath, { signal });
470
+ if (!info || info.type !== "f" || info.size >= maxBytes)
471
+ return null;
472
+ const raw = await this.readWhole(target, signal, maxBytes - 1);
473
+ if (raw.includes(0))
474
+ return null;
475
+ return normalizeLineEndings(decodeUtf8(raw, "read", target.displayPath));
476
+ }
477
+ catch (error) {
478
+ if (error instanceof FsCodedError && error.code === "FS_ABORTED")
479
+ throw error;
480
+ return null;
481
+ }
476
482
  }
477
- }
478
483
  }
479
-
480
484
  export { applyLiteralEdit, normalizeLineEndings, restoreLineEndings };
481
485
  export default DistroFs;