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.
- package/CHANGELOG.md +278 -0
- package/CONTRIBUTING.md +129 -39
- package/README.md +20 -20
- package/docs/ARCHITECTURE.md +6 -3
- package/docs/LIMITATIONS.md +13 -0
- package/docs/README.zh.md +20 -20
- package/lib/agent-confined.d.ts +42 -0
- package/lib/agent-confined.js +25 -41
- package/lib/agent-errors.d.ts +31 -0
- package/lib/agent-errors.js +19 -16
- package/lib/agent-exec.d.ts +159 -0
- package/lib/agent-exec.js +151 -165
- package/lib/agent-protocol.d.ts +192 -0
- package/lib/agent-protocol.js +76 -100
- package/lib/agent-shared.d.ts +27 -0
- package/lib/agent-shared.js +12 -12
- package/lib/agent.d.ts +353 -0
- package/lib/agent.js +536 -552
- package/lib/auto-preset.d.ts +110 -0
- package/lib/auto-preset.js +157 -141
- package/lib/bwrap.d.ts +90 -0
- package/lib/bwrap.js +18 -18
- package/lib/file-reference-wsl.d.ts +160 -0
- package/lib/file-reference-wsl.js +192 -173
- package/lib/fs-decisions.d.ts +107 -0
- package/lib/fs-decisions.js +64 -60
- package/lib/fs-routing.d.ts +182 -0
- package/lib/fs-routing.js +173 -116
- package/lib/fs-substrate.d.ts +258 -0
- package/lib/fs-substrate.js +248 -248
- package/lib/fs.d.ts +10 -0
- package/lib/fs.js +5 -0
- package/lib/fsio-agent.d.ts +307 -0
- package/lib/fsio-agent.js +414 -410
- package/lib/fsio-text.d.ts +115 -0
- package/lib/fsio-text.js +104 -84
- package/lib/git-route.d.ts +100 -0
- package/lib/git-route.js +46 -47
- package/lib/index.d.ts +1003 -0
- package/lib/index.js +1101 -1003
- package/lib/listing.d.ts +114 -0
- package/lib/listing.js +68 -55
- package/lib/paths.d.ts +182 -0
- package/lib/paths.js +134 -109
- package/lib/picker.d.ts +266 -0
- package/lib/picker.js +371 -351
- package/lib/ports.d.ts +44 -0
- package/lib/ports.js +39 -29
- package/lib/preset-choice.d.ts +33 -0
- package/lib/preset-choice.js +15 -7
- package/lib/probe-cache.d.ts +40 -0
- package/lib/probe-cache.js +35 -29
- package/lib/sandbox-core.d.ts +79 -0
- package/lib/sandbox-core.js +71 -88
- package/lib/sandbox.d.ts +48 -0
- package/lib/sandbox.js +4 -4
- package/lib/search-exec.d.ts +96 -0
- package/lib/search-exec.js +52 -79
- package/lib/search-route.d.ts +75 -0
- package/lib/search-route.js +30 -31
- package/lib/shell-env.d.ts +88 -0
- package/lib/shell-env.js +78 -74
- package/lib/shell.d.ts +10 -0
- package/lib/shell.js +5 -0
- package/lib/subprocess.d.ts +313 -0
- package/lib/subprocess.js +267 -262
- package/lib/terminal-activity.d.ts +98 -0
- package/lib/terminal-activity.js +29 -39
- package/lib/terminal-route.d.ts +27 -0
- package/lib/terminal-route.js +16 -8
- package/lib/watcher.d.ts +77 -0
- package/lib/watcher.js +120 -126
- package/lib/workspace-files-route.d.ts +132 -0
- package/lib/workspace-files-route.js +73 -81
- package/lib/workspace-files-wsl.d.ts +257 -0
- package/lib/workspace-files-wsl.js +311 -251
- package/lib/wsl.d.ts +266 -0
- package/lib/wsl.js +144 -155
- 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
|
-
*
|
|
43
|
-
* @
|
|
33
|
+
*
|
|
34
|
+
* @param value - the wire timestamp.
|
|
35
|
+
* @returns seconds followed by nine fractional digits.
|
|
44
36
|
*/
|
|
45
37
|
export function nanoseconds(value) {
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
*
|
|
55
|
-
* @
|
|
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
|
-
|
|
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
|
-
*
|
|
65
|
-
* @
|
|
55
|
+
*
|
|
56
|
+
* @param stdout - the op's payload.
|
|
57
|
+
* @returns the parsed row.
|
|
66
58
|
*/
|
|
67
59
|
export function parseStatRecord(stdout) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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;
|