@stdd/plugin 0.9.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/.claude-plugin/plugin.json +9 -0
- package/.codex-plugin/plugin.json +21 -0
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/extensions/stdd.mjs +77 -0
- package/hooks/claude-hooks.json +28 -0
- package/hooks/codex-hooks.json +28 -0
- package/package.json +38 -0
- package/runtime/adapters/README.md +158 -0
- package/runtime/cli/check.mjs +555 -0
- package/runtime/cli/ci.mjs +190 -0
- package/runtime/cli/claude-hooks.mjs +689 -0
- package/runtime/cli/config.mjs +27 -0
- package/runtime/cli/evidence.mjs +249 -0
- package/runtime/cli/generated-files.mjs +1693 -0
- package/runtime/cli/held-fs.mjs +415 -0
- package/runtime/cli/init.mjs +883 -0
- package/runtime/cli/ledger.mjs +1470 -0
- package/runtime/cli/lib.mjs +909 -0
- package/runtime/cli/path-bytes.mjs +83 -0
- package/runtime/cli/policy.mjs +112 -0
- package/runtime/cli/recorders.mjs +188 -0
- package/runtime/cli/review-fs.mjs +825 -0
- package/runtime/cli/review.mjs +1065 -0
- package/runtime/cli/runtime.mjs +32 -0
- package/runtime/cli/scope.mjs +185 -0
- package/runtime/cli/snapshot.mjs +897 -0
- package/runtime/cli/state-validation.mjs +168 -0
- package/runtime/cli/status.mjs +580 -0
- package/runtime/cli/stdd.mjs +536 -0
- package/runtime/cli/worker-fs.mjs +971 -0
- package/runtime/cli/worker-metadata.mjs +139 -0
- package/runtime/cli/worker.mjs +779 -0
- package/runtime/method/README.md +634 -0
- package/runtime/method/reference-commands.md +147 -0
- package/runtime/method/reference-generated-state.md +151 -0
- package/runtime/method/reference-integration.md +233 -0
- package/runtime/package.json +65 -0
- package/runtime/playbooks/brainstorming.md +46 -0
- package/runtime/playbooks/debugging.md +36 -0
- package/runtime/playbooks/delegate-slice.md +129 -0
- package/runtime/playbooks/finish-change.md +46 -0
- package/runtime/playbooks/implement.md +26 -0
- package/runtime/playbooks/investigation.md +33 -0
- package/runtime/playbooks/managed-playbooks.json +14 -0
- package/runtime/playbooks/planning.md +177 -0
- package/runtime/playbooks/pr-green.md +50 -0
- package/runtime/playbooks/start-change.md +37 -0
- package/runtime/playbooks/worktrees.md +45 -0
- package/runtime/prebuilds/stdd-fs/darwin-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/darwin-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/manifest.json +47 -0
- package/runtime/prebuilds/stdd-fs/win32-arm64/stdd-fs.exe +0 -0
- package/runtime/prebuilds/stdd-fs/win32-x64/stdd-fs.exe +0 -0
- package/runtime/sdk/adapters.mjs +279 -0
- package/runtime/sdk/file-observation.mjs +12 -0
- package/runtime/sdk/index.d.ts +140 -0
- package/runtime/sdk/index.mjs +31 -0
- package/runtime/sdk/native-fs.mjs +1235 -0
- package/runtime/sdk/path.mjs +71 -0
- package/runtime/sdk/text.mjs +42 -0
- package/runtime/sdk/workflow.mjs +294 -0
- package/runtime/templates/deferred-design.md +47 -0
- package/runtime/templates/github-stdd.yml +42 -0
- package/runtime/templates/gitlab-stdd.yml +72 -0
- package/runtime/templates/pr-description.md +35 -0
- package/scripts/adopting-root.mjs +42 -0
- package/scripts/stdd-hook.mjs +72 -0
- package/skills/stdd-brainstorming/SKILL.md +48 -0
- package/skills/stdd-debugging/SKILL.md +38 -0
- package/skills/stdd-delegate-slice/SKILL.md +118 -0
- package/skills/stdd-finish-change/SKILL.md +40 -0
- package/skills/stdd-implement/SKILL.md +28 -0
- package/skills/stdd-investigation/SKILL.md +35 -0
- package/skills/stdd-planning/SKILL.md +165 -0
- package/skills/stdd-pr-green/SKILL.md +52 -0
- package/skills/stdd-start-change/SKILL.md +39 -0
- package/skills/stdd-worktrees/SKILL.md +46 -0
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
export const NATIVE_FS_PROTOCOL_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
const IDENTITY_VERSION = 2;
|
|
10
|
+
const MAX_MANIFEST_BYTES = 64 * 1024;
|
|
11
|
+
const MAX_LINE_BYTES = 1024 * 1024;
|
|
12
|
+
const MAX_CHUNK_BYTES = 64 * 1024;
|
|
13
|
+
const MAX_LINK_TARGET_BYTES = 64 * 1024;
|
|
14
|
+
const MAX_LIST_ENTRIES = 1024;
|
|
15
|
+
const DEFAULT_LIST_ENTRIES = 256;
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 2_000;
|
|
17
|
+
const SHUTDOWN_GRACE_MS = 250;
|
|
18
|
+
const U64_MAX = (1n << 64n) - 1n;
|
|
19
|
+
const I64_MAX = (1n << 63n) - 1n;
|
|
20
|
+
const I128_MIN = -(1n << 127n);
|
|
21
|
+
const I128_MAX = (1n << 127n) - 1n;
|
|
22
|
+
const SUPPORTED_ARCHITECTURES = new Set(["x64", "arm64"]);
|
|
23
|
+
const PLATFORM_STRATEGIES = Object.freeze({
|
|
24
|
+
linux: Object.freeze({
|
|
25
|
+
artifactName: "stdd-fs",
|
|
26
|
+
executable: (artifact) => `/proc/${process.pid}/fd/${artifact.handle.fd}`,
|
|
27
|
+
environment: () => ({}),
|
|
28
|
+
}),
|
|
29
|
+
darwin: Object.freeze({
|
|
30
|
+
artifactName: "stdd-fs",
|
|
31
|
+
executable: (artifact) => artifact.path,
|
|
32
|
+
environment: () => ({}),
|
|
33
|
+
}),
|
|
34
|
+
win32: Object.freeze({
|
|
35
|
+
artifactName: "stdd-fs.exe",
|
|
36
|
+
executable: (artifact) => artifact.path,
|
|
37
|
+
environment: () => {
|
|
38
|
+
const environment = {};
|
|
39
|
+
if (process.env.SystemRoot) environment.SystemRoot = process.env.SystemRoot;
|
|
40
|
+
return environment;
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
const SUPPORTED_TARGETS = new Set(
|
|
45
|
+
Object.keys(PLATFORM_STRATEGIES).flatMap((platform) =>
|
|
46
|
+
[...SUPPORTED_ARCHITECTURES].map((architecture) => `${platform}-${architecture}`),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
const DIRECTORY_CREATION_MODES = new Set([0o700, 0o755]);
|
|
50
|
+
const FILE_CREATION_MODES = new Set([0o600, 0o644, 0o755]);
|
|
51
|
+
const MUTATING_OPERATIONS = new Set([
|
|
52
|
+
"create-directory",
|
|
53
|
+
"create-file",
|
|
54
|
+
"write",
|
|
55
|
+
"truncate",
|
|
56
|
+
"set-mode",
|
|
57
|
+
"flush",
|
|
58
|
+
"rename",
|
|
59
|
+
"symlink",
|
|
60
|
+
]);
|
|
61
|
+
const ERROR_FIELDS = ["class", "code", "mutation", "operation", "osCode", "retryable"];
|
|
62
|
+
const IDENTITY_FIELDS = ["version", "platform", "volume", "fileId", "kind"];
|
|
63
|
+
const OBSERVATION_FIELDS = [
|
|
64
|
+
"identity",
|
|
65
|
+
"owner",
|
|
66
|
+
"permissions",
|
|
67
|
+
"linkCount",
|
|
68
|
+
"size",
|
|
69
|
+
"modifiedNs",
|
|
70
|
+
"changedNs",
|
|
71
|
+
];
|
|
72
|
+
const PRIMITIVE_FIELDS = [
|
|
73
|
+
"identity",
|
|
74
|
+
"noFollow",
|
|
75
|
+
"atomicRename",
|
|
76
|
+
"noReplace",
|
|
77
|
+
"fileFlush",
|
|
78
|
+
"directoryFlush",
|
|
79
|
+
];
|
|
80
|
+
const MODULE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
81
|
+
|
|
82
|
+
class NativeFsError extends Error {
|
|
83
|
+
constructor(message, fields) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = "NativeFsError";
|
|
86
|
+
for (const key of ERROR_FIELDS) this[key] = fields[key];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function localError(
|
|
91
|
+
code,
|
|
92
|
+
operation,
|
|
93
|
+
mutation = "none",
|
|
94
|
+
errorClass = "transport",
|
|
95
|
+
retryable = false,
|
|
96
|
+
detail = "",
|
|
97
|
+
) {
|
|
98
|
+
return new NativeFsError(`${operation}: ${code}${detail ? ` (${detail})` : ""}`, {
|
|
99
|
+
code,
|
|
100
|
+
class: errorClass,
|
|
101
|
+
operation,
|
|
102
|
+
osCode: null,
|
|
103
|
+
mutation,
|
|
104
|
+
retryable,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function exactKeys(value, expected) {
|
|
109
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
110
|
+
const keys = Object.keys(value).sort();
|
|
111
|
+
const sorted = [...expected].sort();
|
|
112
|
+
return keys.length === sorted.length && keys.every((key, index) => key === sorted[index]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function safeInteger(value) {
|
|
116
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function printable(value) {
|
|
120
|
+
if (typeof value !== "string" || value.trim() === "") return false;
|
|
121
|
+
for (const character of value) {
|
|
122
|
+
const code = character.codePointAt(0);
|
|
123
|
+
if (
|
|
124
|
+
code <= 0x1f ||
|
|
125
|
+
(code >= 0x7f && code <= 0x9f) ||
|
|
126
|
+
code === 0x00ad ||
|
|
127
|
+
code === 0x034f ||
|
|
128
|
+
code === 0x061c ||
|
|
129
|
+
code === 0x180e ||
|
|
130
|
+
code === 0x200b ||
|
|
131
|
+
code === 0x200e ||
|
|
132
|
+
code === 0x200f ||
|
|
133
|
+
(code >= 0x202a && code <= 0x202e) ||
|
|
134
|
+
(code >= 0x2060 && code <= 0x206f) ||
|
|
135
|
+
code === 0xfeff ||
|
|
136
|
+
(code >= 0xfff9 && code <= 0xfffb) ||
|
|
137
|
+
(code >= 0x1bca0 && code <= 0x1bcaf) ||
|
|
138
|
+
(code >= 0x1d173 && code <= 0x1d17a) ||
|
|
139
|
+
code === 0xe0001
|
|
140
|
+
) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function decimalString(value) {
|
|
148
|
+
return (
|
|
149
|
+
typeof value === "string" &&
|
|
150
|
+
value.length <= 20 &&
|
|
151
|
+
/^(?:0|[1-9][0-9]*)$/.test(value) &&
|
|
152
|
+
BigInt(value) <= U64_MAX
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function signedDecimalString(value) {
|
|
157
|
+
if (
|
|
158
|
+
typeof value !== "string" ||
|
|
159
|
+
value === "-0" ||
|
|
160
|
+
value.length > 40 ||
|
|
161
|
+
!/^-?(?:0|[1-9][0-9]*)$/.test(value)
|
|
162
|
+
) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const parsed = BigInt(value);
|
|
166
|
+
return parsed >= I128_MIN && parsed <= I128_MAX;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function listCursor(value) {
|
|
170
|
+
return decimalString(value) && BigInt(value) <= I64_MAX;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function fileIdString(value, platform) {
|
|
174
|
+
return platform === "win32"
|
|
175
|
+
? typeof value === "string" && /^[0-9a-f]{32}$/.test(value)
|
|
176
|
+
: decimalString(value);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function windowsSecurityString(value, kind) {
|
|
180
|
+
return (
|
|
181
|
+
printable(value) &&
|
|
182
|
+
Buffer.byteLength(value) <= 4096 &&
|
|
183
|
+
(kind === "owner"
|
|
184
|
+
? /^S-(?:[0-9]+-)+[0-9]+$/.test(value)
|
|
185
|
+
: value.startsWith("O:") && value.includes("D:"))
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function basename(value) {
|
|
190
|
+
return (
|
|
191
|
+
printable(value) &&
|
|
192
|
+
Buffer.byteLength(value) <= 255 &&
|
|
193
|
+
value !== "." &&
|
|
194
|
+
value !== ".." &&
|
|
195
|
+
!value.includes("/") &&
|
|
196
|
+
!value.includes("\\")
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Directory enumeration must represent every host filename losslessly enough
|
|
201
|
+
// for policy code to reject it safely. Name-taking requests remain restricted
|
|
202
|
+
// to printable basenames by basename().
|
|
203
|
+
function listedBasename(value) {
|
|
204
|
+
return (
|
|
205
|
+
typeof value === "string" &&
|
|
206
|
+
value.length > 0 &&
|
|
207
|
+
Buffer.byteLength(value) <= 255 &&
|
|
208
|
+
value !== "." &&
|
|
209
|
+
value !== ".." &&
|
|
210
|
+
!value.includes("\0") &&
|
|
211
|
+
!value.includes("/") &&
|
|
212
|
+
!value.includes("\\")
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function assertTarget(target) {
|
|
217
|
+
if (!SUPPORTED_TARGETS.has(target)) {
|
|
218
|
+
throw new Error(`unsupported native filesystem target: ${target}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function targetParts(target) {
|
|
223
|
+
assertTarget(target);
|
|
224
|
+
const separator = target.lastIndexOf("-");
|
|
225
|
+
return {
|
|
226
|
+
platform: target.slice(0, separator),
|
|
227
|
+
architecture: target.slice(separator + 1),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function nativeFsTarget(platform = process.platform, architecture = process.arch) {
|
|
232
|
+
if (!Object.hasOwn(PLATFORM_STRATEGIES, platform) || !SUPPORTED_ARCHITECTURES.has(architecture)) {
|
|
233
|
+
throw new Error(`unsupported native filesystem target: ${platform}-${architecture}`);
|
|
234
|
+
}
|
|
235
|
+
return `${platform}-${architecture}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function readBoundedFile(filePath, maximum, subject) {
|
|
239
|
+
const observed = await fs.promises.lstat(filePath, { bigint: true });
|
|
240
|
+
if (!observed.isFile() || observed.isSymbolicLink()) {
|
|
241
|
+
throw new Error(`${subject} must be a regular non-symlink file`);
|
|
242
|
+
}
|
|
243
|
+
if (observed.size > BigInt(maximum)) throw new Error(`${subject} is oversized`);
|
|
244
|
+
const handle = await fs.promises.open(
|
|
245
|
+
filePath,
|
|
246
|
+
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
|
247
|
+
);
|
|
248
|
+
try {
|
|
249
|
+
const opened = await handle.stat({ bigint: true });
|
|
250
|
+
if (
|
|
251
|
+
!opened.isFile() ||
|
|
252
|
+
opened.isSymbolicLink() ||
|
|
253
|
+
opened.dev !== observed.dev ||
|
|
254
|
+
opened.ino !== observed.ino ||
|
|
255
|
+
opened.size !== observed.size ||
|
|
256
|
+
opened.size > BigInt(maximum)
|
|
257
|
+
) {
|
|
258
|
+
throw new Error(`${subject} changed while opening`);
|
|
259
|
+
}
|
|
260
|
+
const bytes = await handle.readFile();
|
|
261
|
+
if (bytes.length > maximum) throw new Error(`${subject} is oversized`);
|
|
262
|
+
const after = await handle.stat({ bigint: true });
|
|
263
|
+
const finalPath = await fs.promises.lstat(filePath, { bigint: true });
|
|
264
|
+
if (
|
|
265
|
+
after.dev !== opened.dev ||
|
|
266
|
+
after.ino !== opened.ino ||
|
|
267
|
+
after.size !== opened.size ||
|
|
268
|
+
finalPath.isSymbolicLink() ||
|
|
269
|
+
!finalPath.isFile() ||
|
|
270
|
+
finalPath.dev !== opened.dev ||
|
|
271
|
+
finalPath.ino !== opened.ino ||
|
|
272
|
+
finalPath.size !== opened.size
|
|
273
|
+
) {
|
|
274
|
+
throw new Error(`${subject} changed while reading`);
|
|
275
|
+
}
|
|
276
|
+
return bytes;
|
|
277
|
+
} finally {
|
|
278
|
+
await handle.close();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function expectedArtifactPath(target) {
|
|
283
|
+
const { platform } = targetParts(target);
|
|
284
|
+
return `${target}/${PLATFORM_STRATEGIES[platform].artifactName}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function parseManifest(bytes, target) {
|
|
288
|
+
let manifest;
|
|
289
|
+
try {
|
|
290
|
+
manifest = JSON.parse(bytes.toString("utf8"));
|
|
291
|
+
} catch {
|
|
292
|
+
throw new Error("native filesystem artifact manifest is not valid JSON");
|
|
293
|
+
}
|
|
294
|
+
if (!exactKeys(manifest, ["artifacts", "schema"])) {
|
|
295
|
+
throw new Error("native filesystem artifact manifest has an invalid shape");
|
|
296
|
+
}
|
|
297
|
+
if (manifest.schema !== 1 || !Array.isArray(manifest.artifacts) || manifest.artifacts.length > 6) {
|
|
298
|
+
throw new Error("native filesystem artifact manifest is incompatible");
|
|
299
|
+
}
|
|
300
|
+
const seen = new Set();
|
|
301
|
+
for (const artifact of manifest.artifacts) {
|
|
302
|
+
if (!exactKeys(artifact, ["path", "protocol", "sha256", "size", "target"])) {
|
|
303
|
+
throw new Error("native filesystem artifact manifest has an invalid entry");
|
|
304
|
+
}
|
|
305
|
+
assertTarget(artifact.target);
|
|
306
|
+
if (seen.has(artifact.target)) {
|
|
307
|
+
throw new Error("native filesystem artifact manifest has duplicate targets");
|
|
308
|
+
}
|
|
309
|
+
seen.add(artifact.target);
|
|
310
|
+
if (
|
|
311
|
+
artifact.protocol !== NATIVE_FS_PROTOCOL_VERSION ||
|
|
312
|
+
artifact.path !== expectedArtifactPath(artifact.target) ||
|
|
313
|
+
!safeInteger(artifact.size) ||
|
|
314
|
+
artifact.size < 1 ||
|
|
315
|
+
!/^sha256:[0-9a-f]{64}$/.test(artifact.sha256)
|
|
316
|
+
) {
|
|
317
|
+
throw new Error(`native filesystem artifact manifest has an invalid ${artifact.target} entry`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const artifact = manifest.artifacts.find((entry) => entry.target === target);
|
|
321
|
+
if (!artifact) {
|
|
322
|
+
throw new Error(`native filesystem artifact manifest has no exact ${target} entry`);
|
|
323
|
+
}
|
|
324
|
+
return artifact;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function assertArtifactPathIdentity(artifact) {
|
|
328
|
+
const opened = await artifact.handle.stat({ bigint: true });
|
|
329
|
+
const installed = await fs.promises.lstat(artifact.path, { bigint: true });
|
|
330
|
+
if (
|
|
331
|
+
!opened.isFile() ||
|
|
332
|
+
opened.isSymbolicLink() ||
|
|
333
|
+
!installed.isFile() ||
|
|
334
|
+
installed.isSymbolicLink() ||
|
|
335
|
+
opened.dev !== artifact.dev ||
|
|
336
|
+
opened.ino !== artifact.ino ||
|
|
337
|
+
opened.size !== BigInt(artifact.size) ||
|
|
338
|
+
installed.dev !== opened.dev ||
|
|
339
|
+
installed.ino !== opened.ino ||
|
|
340
|
+
installed.size !== opened.size ||
|
|
341
|
+
opened.mode !== artifact.mode ||
|
|
342
|
+
installed.mode !== opened.mode ||
|
|
343
|
+
(!artifact.target.startsWith("win32-") && (Number(installed.mode) & 0o111) === 0)
|
|
344
|
+
) {
|
|
345
|
+
throw new Error("native filesystem artifact changed after verification");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function inspectArtifact(packageRoot, target, keepOpen) {
|
|
350
|
+
if (typeof packageRoot !== "string" || !path.isAbsolute(packageRoot)) {
|
|
351
|
+
throw new Error("native filesystem package root must be absolute");
|
|
352
|
+
}
|
|
353
|
+
assertTarget(target);
|
|
354
|
+
const manifestPath = path.join(packageRoot, "prebuilds", "stdd-fs", "manifest.json");
|
|
355
|
+
const manifestBytes = await readBoundedFile(
|
|
356
|
+
manifestPath,
|
|
357
|
+
MAX_MANIFEST_BYTES,
|
|
358
|
+
"native filesystem artifact manifest",
|
|
359
|
+
);
|
|
360
|
+
const artifact = parseManifest(manifestBytes, target);
|
|
361
|
+
const artifactPath = path.join(packageRoot, "prebuilds", "stdd-fs", ...artifact.path.split("/"));
|
|
362
|
+
const observed = await fs.promises.lstat(artifactPath, { bigint: true });
|
|
363
|
+
if (!observed.isFile() || observed.isSymbolicLink()) {
|
|
364
|
+
throw new Error("native filesystem artifact must be a regular non-symlink file");
|
|
365
|
+
}
|
|
366
|
+
if (observed.size !== BigInt(artifact.size)) {
|
|
367
|
+
throw new Error("native filesystem artifact size does not match its manifest");
|
|
368
|
+
}
|
|
369
|
+
// Windows exposes synthetic POSIX mode bits; the manifest hash, PE shape,
|
|
370
|
+
// regular-file check, and CreateProcess are authoritative there.
|
|
371
|
+
if (!target.startsWith("win32-") && (Number(observed.mode) & 0o111) === 0) {
|
|
372
|
+
throw new Error("native filesystem artifact is not executable");
|
|
373
|
+
}
|
|
374
|
+
const handle = await fs.promises.open(
|
|
375
|
+
artifactPath,
|
|
376
|
+
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
|
377
|
+
);
|
|
378
|
+
try {
|
|
379
|
+
const opened = await handle.stat({ bigint: true });
|
|
380
|
+
if (
|
|
381
|
+
!opened.isFile() ||
|
|
382
|
+
opened.isSymbolicLink() ||
|
|
383
|
+
opened.dev !== observed.dev ||
|
|
384
|
+
opened.ino !== observed.ino ||
|
|
385
|
+
opened.size !== observed.size
|
|
386
|
+
) {
|
|
387
|
+
throw new Error("native filesystem artifact changed while opening");
|
|
388
|
+
}
|
|
389
|
+
const hash = createHash("sha256");
|
|
390
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
391
|
+
let offset = 0;
|
|
392
|
+
for (;;) {
|
|
393
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
|
394
|
+
if (bytesRead === 0) break;
|
|
395
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
396
|
+
offset += bytesRead;
|
|
397
|
+
}
|
|
398
|
+
const digest = `sha256:${hash.digest("hex")}`;
|
|
399
|
+
const result = {
|
|
400
|
+
target,
|
|
401
|
+
path: artifactPath,
|
|
402
|
+
size: artifact.size,
|
|
403
|
+
sha256: artifact.sha256,
|
|
404
|
+
handle,
|
|
405
|
+
dev: opened.dev,
|
|
406
|
+
ino: opened.ino,
|
|
407
|
+
mode: opened.mode,
|
|
408
|
+
};
|
|
409
|
+
await assertArtifactPathIdentity(result);
|
|
410
|
+
if (digest !== artifact.sha256 || offset !== artifact.size) {
|
|
411
|
+
throw new Error("native filesystem artifact hash does not match its manifest");
|
|
412
|
+
}
|
|
413
|
+
if (!keepOpen) {
|
|
414
|
+
await handle.close();
|
|
415
|
+
result.handle = null;
|
|
416
|
+
}
|
|
417
|
+
return result;
|
|
418
|
+
} catch (error) {
|
|
419
|
+
await handle.close().catch(() => {});
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export async function verifyNativeFsArtifact(packageRoot, target) {
|
|
425
|
+
const inspected = await inspectArtifact(packageRoot, target, false);
|
|
426
|
+
return Object.freeze({
|
|
427
|
+
target: inspected.target,
|
|
428
|
+
path: inspected.path,
|
|
429
|
+
size: inspected.size,
|
|
430
|
+
sha256: inspected.sha256,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function validateIdentity(identity, platform, operation) {
|
|
435
|
+
if (
|
|
436
|
+
!exactKeys(identity, IDENTITY_FIELDS) ||
|
|
437
|
+
identity.version !== IDENTITY_VERSION ||
|
|
438
|
+
identity.platform !== platform ||
|
|
439
|
+
!decimalString(identity.volume) ||
|
|
440
|
+
!fileIdString(identity.fileId, platform) ||
|
|
441
|
+
!["directory", "file", "symlink", "other"].includes(identity.kind)
|
|
442
|
+
) {
|
|
443
|
+
throw localError(
|
|
444
|
+
"malformed-response",
|
|
445
|
+
operation,
|
|
446
|
+
"none",
|
|
447
|
+
"transport",
|
|
448
|
+
false,
|
|
449
|
+
`identity ${JSON.stringify(identity)}`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
return identity;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function validateObservation(observation, platform, operation) {
|
|
456
|
+
if (!exactKeys(observation, OBSERVATION_FIELDS)) {
|
|
457
|
+
throw localError("malformed-response", operation, "none", "transport", false, "observation fields");
|
|
458
|
+
}
|
|
459
|
+
validateIdentity(observation.identity, platform, operation);
|
|
460
|
+
if (
|
|
461
|
+
platform === "win32"
|
|
462
|
+
? !windowsSecurityString(observation.owner, "owner") ||
|
|
463
|
+
!windowsSecurityString(observation.permissions, "permissions")
|
|
464
|
+
: !decimalString(observation.owner) || !decimalString(observation.permissions)
|
|
465
|
+
) {
|
|
466
|
+
throw localError(
|
|
467
|
+
"malformed-response",
|
|
468
|
+
operation,
|
|
469
|
+
"none",
|
|
470
|
+
"transport",
|
|
471
|
+
false,
|
|
472
|
+
`security ${JSON.stringify({ owner: observation.owner, permissions: observation.permissions })}`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
for (const field of ["linkCount", "size"]) {
|
|
476
|
+
if (!decimalString(observation[field])) throw localError("malformed-response", operation);
|
|
477
|
+
}
|
|
478
|
+
for (const field of ["modifiedNs", "changedNs"]) {
|
|
479
|
+
if (!signedDecimalString(observation[field])) {
|
|
480
|
+
throw localError("malformed-response", operation);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return observation;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function validateCapabilityResult(result, platform, operation) {
|
|
487
|
+
if (!exactKeys(result, ["cap", "observation"]) || !/^c[1-9][0-9]{0,19}$/.test(result.cap)) {
|
|
488
|
+
throw localError("malformed-response", operation);
|
|
489
|
+
}
|
|
490
|
+
validateObservation(result.observation, platform, operation);
|
|
491
|
+
return result;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function sameIdentity(left, right) {
|
|
495
|
+
return (
|
|
496
|
+
exactKeys(left, IDENTITY_FIELDS) &&
|
|
497
|
+
exactKeys(right, IDENTITY_FIELDS) &&
|
|
498
|
+
IDENTITY_FIELDS.every((field) => left[field] === right[field])
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function decodeBase64(value, operation, code = "invalid-base64", errorClass = "invalid-request") {
|
|
503
|
+
if (
|
|
504
|
+
typeof value !== "string" ||
|
|
505
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
|
|
506
|
+
) {
|
|
507
|
+
throw localError(code, operation, "none", errorClass);
|
|
508
|
+
}
|
|
509
|
+
const decoded = Buffer.from(value, "base64");
|
|
510
|
+
if (decoded.toString("base64") !== value) {
|
|
511
|
+
throw localError(code, operation, "none", errorClass);
|
|
512
|
+
}
|
|
513
|
+
return decoded;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function validateSuccessResult(result, pending) {
|
|
517
|
+
const { operation, fields, platform } = pending;
|
|
518
|
+
if (result === null || typeof result !== "object" || Array.isArray(result)) {
|
|
519
|
+
throw localError("malformed-response", operation);
|
|
520
|
+
}
|
|
521
|
+
switch (operation) {
|
|
522
|
+
case "hello":
|
|
523
|
+
if (
|
|
524
|
+
!exactKeys(result, ["helper", "maxChunkBytes", "maxLineBytes", "protocol"]) ||
|
|
525
|
+
result.protocol !== NATIVE_FS_PROTOCOL_VERSION ||
|
|
526
|
+
result.helper !== "stdd-fs" ||
|
|
527
|
+
result.maxLineBytes !== MAX_LINE_BYTES ||
|
|
528
|
+
result.maxChunkBytes !== MAX_CHUNK_BYTES
|
|
529
|
+
) {
|
|
530
|
+
throw localError("protocol-mismatch", "hello");
|
|
531
|
+
}
|
|
532
|
+
return result;
|
|
533
|
+
case "probe":
|
|
534
|
+
if (
|
|
535
|
+
!exactKeys(result, ["filesystem", "filesystemId", "platform", "primitives"]) ||
|
|
536
|
+
result.platform !== platform ||
|
|
537
|
+
!printable(result.filesystem) ||
|
|
538
|
+
Buffer.byteLength(result.filesystem) > 64 ||
|
|
539
|
+
!printable(result.filesystemId) ||
|
|
540
|
+
Buffer.byteLength(result.filesystemId) > 64 ||
|
|
541
|
+
!exactKeys(result.primitives, PRIMITIVE_FIELDS) ||
|
|
542
|
+
!PRIMITIVE_FIELDS.every(
|
|
543
|
+
(field) =>
|
|
544
|
+
printable(result.primitives[field]) && Buffer.byteLength(result.primitives[field]) <= 128,
|
|
545
|
+
)
|
|
546
|
+
) {
|
|
547
|
+
throw localError("malformed-response", operation);
|
|
548
|
+
}
|
|
549
|
+
return result;
|
|
550
|
+
case "open-root":
|
|
551
|
+
case "create-directory": {
|
|
552
|
+
const validated = validateCapabilityResult(result, platform, operation);
|
|
553
|
+
if (validated.observation.identity.kind !== "directory") {
|
|
554
|
+
throw localError("malformed-response", operation);
|
|
555
|
+
}
|
|
556
|
+
return validated;
|
|
557
|
+
}
|
|
558
|
+
case "create-file": {
|
|
559
|
+
const validated = validateCapabilityResult(result, platform, operation);
|
|
560
|
+
if (validated.observation.identity.kind !== "file") {
|
|
561
|
+
throw localError("malformed-response", operation);
|
|
562
|
+
}
|
|
563
|
+
return validated;
|
|
564
|
+
}
|
|
565
|
+
case "open-child": {
|
|
566
|
+
const validated = validateCapabilityResult(result, platform, operation);
|
|
567
|
+
if (!["directory", "file"].includes(validated.observation.identity.kind)) {
|
|
568
|
+
throw localError("malformed-response", operation);
|
|
569
|
+
}
|
|
570
|
+
return validated;
|
|
571
|
+
}
|
|
572
|
+
case "stat":
|
|
573
|
+
if (!exactKeys(result, ["observation"])) {
|
|
574
|
+
throw localError("malformed-response", operation);
|
|
575
|
+
}
|
|
576
|
+
validateObservation(result.observation, platform, operation);
|
|
577
|
+
return result;
|
|
578
|
+
case "set-mode":
|
|
579
|
+
if (!exactKeys(result, ["observation"])) {
|
|
580
|
+
throw localError("malformed-response", operation);
|
|
581
|
+
}
|
|
582
|
+
validateObservation(result.observation, platform, operation);
|
|
583
|
+
if (
|
|
584
|
+
result.observation.identity.kind !== "file" ||
|
|
585
|
+
!sameIdentity(result.observation.identity, fields.expected) ||
|
|
586
|
+
platform === "win32" ||
|
|
587
|
+
(Number(result.observation.permissions) & 0o7777) !== fields.mode
|
|
588
|
+
) {
|
|
589
|
+
throw localError("malformed-response", operation);
|
|
590
|
+
}
|
|
591
|
+
return result;
|
|
592
|
+
case "rename":
|
|
593
|
+
if (!exactKeys(result, ["observation"])) {
|
|
594
|
+
throw localError("malformed-response", operation);
|
|
595
|
+
}
|
|
596
|
+
validateObservation(result.observation, platform, operation);
|
|
597
|
+
if (!sameIdentity(result.observation.identity, fields.expected)) {
|
|
598
|
+
throw localError("malformed-response", operation);
|
|
599
|
+
}
|
|
600
|
+
return result;
|
|
601
|
+
case "symlink":
|
|
602
|
+
if (!exactKeys(result, ["observation"])) {
|
|
603
|
+
throw localError("malformed-response", operation);
|
|
604
|
+
}
|
|
605
|
+
validateObservation(result.observation, platform, operation);
|
|
606
|
+
if (result.observation.identity.kind !== "symlink") {
|
|
607
|
+
throw localError("malformed-response", operation);
|
|
608
|
+
}
|
|
609
|
+
return result;
|
|
610
|
+
case "list": {
|
|
611
|
+
if (
|
|
612
|
+
!exactKeys(result, ["cursor", "entries"]) ||
|
|
613
|
+
!Array.isArray(result.entries) ||
|
|
614
|
+
result.entries.length > fields.limit ||
|
|
615
|
+
!(result.cursor === null || listCursor(result.cursor))
|
|
616
|
+
) {
|
|
617
|
+
throw localError("malformed-response", operation);
|
|
618
|
+
}
|
|
619
|
+
const names = new Set();
|
|
620
|
+
for (const entry of result.entries) {
|
|
621
|
+
if (
|
|
622
|
+
!exactKeys(entry, ["name", "observation"]) ||
|
|
623
|
+
!listedBasename(entry.name) ||
|
|
624
|
+
names.has(entry.name)
|
|
625
|
+
) {
|
|
626
|
+
throw localError("malformed-response", operation);
|
|
627
|
+
}
|
|
628
|
+
validateObservation(entry.observation, platform, operation);
|
|
629
|
+
names.add(entry.name);
|
|
630
|
+
}
|
|
631
|
+
return result;
|
|
632
|
+
}
|
|
633
|
+
case "read": {
|
|
634
|
+
if (!exactKeys(result, ["data", "eof"]) || typeof result.eof !== "boolean") {
|
|
635
|
+
throw localError("malformed-response", operation);
|
|
636
|
+
}
|
|
637
|
+
const decoded = decodeBase64(result.data, operation, "malformed-response", "transport");
|
|
638
|
+
if (decoded.length > fields.length || decoded.length > MAX_CHUNK_BYTES) {
|
|
639
|
+
throw localError("malformed-response", operation);
|
|
640
|
+
}
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
case "read-link": {
|
|
644
|
+
if (!exactKeys(result, ["data"])) {
|
|
645
|
+
throw localError("malformed-response", operation);
|
|
646
|
+
}
|
|
647
|
+
const decoded = decodeBase64(result.data, operation, "malformed-response", "transport");
|
|
648
|
+
if (decoded.length > MAX_LINK_TARGET_BYTES) {
|
|
649
|
+
throw localError("malformed-response", operation);
|
|
650
|
+
}
|
|
651
|
+
return result;
|
|
652
|
+
}
|
|
653
|
+
case "write":
|
|
654
|
+
if (
|
|
655
|
+
!exactKeys(result, ["written"]) ||
|
|
656
|
+
!safeInteger(result.written) ||
|
|
657
|
+
result.written > decodeBase64(fields.data, operation).length
|
|
658
|
+
) {
|
|
659
|
+
throw localError("malformed-response", operation);
|
|
660
|
+
}
|
|
661
|
+
return result;
|
|
662
|
+
case "truncate":
|
|
663
|
+
case "flush":
|
|
664
|
+
case "preflight-symlink":
|
|
665
|
+
case "verify-private":
|
|
666
|
+
case "close":
|
|
667
|
+
if (!exactKeys(result, [])) throw localError("malformed-response", operation);
|
|
668
|
+
return result;
|
|
669
|
+
default:
|
|
670
|
+
throw localError("malformed-response", operation);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function validateStructuredError(error, expectedOperation) {
|
|
675
|
+
if (!exactKeys(error, ERROR_FIELDS)) {
|
|
676
|
+
throw localError("malformed-response", expectedOperation);
|
|
677
|
+
}
|
|
678
|
+
if (
|
|
679
|
+
!printable(error.code) ||
|
|
680
|
+
!printable(error.class) ||
|
|
681
|
+
!printable(error.operation) ||
|
|
682
|
+
error.operation !== expectedOperation ||
|
|
683
|
+
!(error.osCode === null || Number.isSafeInteger(error.osCode)) ||
|
|
684
|
+
!["none", "possible", "committed"].includes(error.mutation) ||
|
|
685
|
+
typeof error.retryable !== "boolean"
|
|
686
|
+
) {
|
|
687
|
+
throw localError("malformed-response", expectedOperation);
|
|
688
|
+
}
|
|
689
|
+
return new NativeFsError(`${error.operation}: ${error.code}`, error);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function validateResponse(response, pending) {
|
|
693
|
+
if (
|
|
694
|
+
response === null ||
|
|
695
|
+
typeof response !== "object" ||
|
|
696
|
+
Array.isArray(response) ||
|
|
697
|
+
response.v !== NATIVE_FS_PROTOCOL_VERSION ||
|
|
698
|
+
response.id !== pending.id ||
|
|
699
|
+
typeof response.ok !== "boolean"
|
|
700
|
+
) {
|
|
701
|
+
throw localError("malformed-response", pending.operation);
|
|
702
|
+
}
|
|
703
|
+
if (response.ok) {
|
|
704
|
+
if (!exactKeys(response, ["id", "ok", "result", "v"])) {
|
|
705
|
+
throw localError("malformed-response", pending.operation);
|
|
706
|
+
}
|
|
707
|
+
return validateSuccessResult(response.result, pending);
|
|
708
|
+
}
|
|
709
|
+
if (!exactKeys(response, ["error", "id", "ok", "v"])) {
|
|
710
|
+
throw localError("malformed-response", pending.operation);
|
|
711
|
+
}
|
|
712
|
+
throw validateStructuredError(response.error, pending.operation);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function requestBounds(operation, fields, platform) {
|
|
716
|
+
if (operation === "create-directory" || operation === "create-file") {
|
|
717
|
+
if (!exactKeys(fields, ["parent", "name", "mode", "expected"])) {
|
|
718
|
+
throw localError("invalid-fields", operation, "none", "invalid-request");
|
|
719
|
+
}
|
|
720
|
+
const allowed = operation === "create-directory" ? DIRECTORY_CREATION_MODES : FILE_CREATION_MODES;
|
|
721
|
+
if (!Number.isInteger(fields.mode) || !allowed.has(fields.mode)) {
|
|
722
|
+
throw localError("invalid-mode", operation, "none", "invalid-request");
|
|
723
|
+
}
|
|
724
|
+
if (fields.expected !== null) {
|
|
725
|
+
throw localError("null-required", operation, "none", "invalid-request");
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
for (const field of ["offset", "length", "size", "limit"]) {
|
|
729
|
+
if (field in fields && !safeInteger(fields[field])) {
|
|
730
|
+
throw localError(`invalid-${field}`, operation, "none", "invalid-request");
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (operation === "read" && fields.length > MAX_CHUNK_BYTES) {
|
|
734
|
+
throw localError("chunk-too-large", operation, "none", "invalid-request");
|
|
735
|
+
}
|
|
736
|
+
if (operation === "read-link") {
|
|
737
|
+
if (!exactKeys(fields, ["parent", "name", "expected"])) {
|
|
738
|
+
throw localError("invalid-fields", operation, "none", "invalid-request");
|
|
739
|
+
}
|
|
740
|
+
validCapability(fields.parent, operation);
|
|
741
|
+
if (!basename(fields.name)) {
|
|
742
|
+
throw localError("invalid-basename", operation, "none", "invalid-request");
|
|
743
|
+
}
|
|
744
|
+
expectedIdentity(fields.expected, platform, operation);
|
|
745
|
+
if (fields.expected.kind !== "symlink") {
|
|
746
|
+
throw localError("symlink-identity-required", operation, "none", "invalid-request");
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (operation === "set-mode") {
|
|
750
|
+
if (!exactKeys(fields, ["cap", "mode", "expected"])) {
|
|
751
|
+
throw localError("invalid-fields", operation, "none", "invalid-request");
|
|
752
|
+
}
|
|
753
|
+
validCapability(fields.cap, operation);
|
|
754
|
+
if (!Number.isInteger(fields.mode) || fields.mode < 0 || fields.mode > 0o777) {
|
|
755
|
+
throw localError("invalid-mode", operation, "none", "invalid-request");
|
|
756
|
+
}
|
|
757
|
+
expectedIdentity(fields.expected, platform, operation);
|
|
758
|
+
if (fields.expected.kind !== "file") {
|
|
759
|
+
throw localError("file-identity-required", operation, "none", "invalid-request");
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (operation === "write") {
|
|
763
|
+
const decoded = decodeBase64(fields.data, operation);
|
|
764
|
+
if (decoded.length > MAX_CHUNK_BYTES) {
|
|
765
|
+
throw localError("chunk-too-large", operation, "none", "invalid-request");
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function plainFields(fields, operation) {
|
|
771
|
+
if (fields === null || typeof fields !== "object" || Array.isArray(fields)) {
|
|
772
|
+
throw localError("invalid-request", operation, "none", "invalid-request");
|
|
773
|
+
}
|
|
774
|
+
const snapshot = {};
|
|
775
|
+
for (const key of Object.keys(fields)) {
|
|
776
|
+
const descriptor = Object.getOwnPropertyDescriptor(fields, key);
|
|
777
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
778
|
+
throw localError("invalid-request", operation, "none", "invalid-request");
|
|
779
|
+
}
|
|
780
|
+
snapshot[key] = descriptor.value;
|
|
781
|
+
}
|
|
782
|
+
return snapshot;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function validCapability(cap, operation) {
|
|
786
|
+
if (typeof cap !== "string" || !/^c[1-9][0-9]{0,19}$/.test(cap)) {
|
|
787
|
+
throw localError("invalid-capability", operation, "none", "invalid-request");
|
|
788
|
+
}
|
|
789
|
+
return cap;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function expectedIdentity(identity, platform, operation) {
|
|
793
|
+
if (!exactKeys(identity, IDENTITY_FIELDS) || identity.version !== IDENTITY_VERSION) {
|
|
794
|
+
throw localError("invalid-identity", operation, "none", "invalid-request");
|
|
795
|
+
}
|
|
796
|
+
if (identity.platform !== platform) {
|
|
797
|
+
throw localError("foreign-identity", operation, "none", "invalid-request");
|
|
798
|
+
}
|
|
799
|
+
if (
|
|
800
|
+
!decimalString(identity.volume) ||
|
|
801
|
+
!fileIdString(identity.fileId, platform) ||
|
|
802
|
+
!["directory", "file", "symlink", "other"].includes(identity.kind)
|
|
803
|
+
) {
|
|
804
|
+
throw localError("invalid-identity", operation, "none", "invalid-request");
|
|
805
|
+
}
|
|
806
|
+
return identity;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
class NativeFsSession {
|
|
810
|
+
constructor(child, artifact, timeoutMs, platform) {
|
|
811
|
+
this.child = child;
|
|
812
|
+
this.artifact = artifact;
|
|
813
|
+
this.timeoutMs = timeoutMs;
|
|
814
|
+
this.platform = platform;
|
|
815
|
+
this.nextId = 1;
|
|
816
|
+
this.pending = null;
|
|
817
|
+
this.buffer = Buffer.alloc(0);
|
|
818
|
+
this.closed = false;
|
|
819
|
+
this.failure = null;
|
|
820
|
+
this.queue = Promise.resolve();
|
|
821
|
+
this.stderrBytes = 0;
|
|
822
|
+
this.shutdownPromise = null;
|
|
823
|
+
child.stdout.on("data", (chunk) => this.onData(chunk));
|
|
824
|
+
child.stderr.on("data", (chunk) => {
|
|
825
|
+
this.stderrBytes += chunk.length;
|
|
826
|
+
if (this.stderrBytes > MAX_LINE_BYTES) this.failTransport("helper-stderr-too-large");
|
|
827
|
+
});
|
|
828
|
+
child.stdout.on("end", () => this.failTransport("unexpected-eof"));
|
|
829
|
+
child.on("error", () => this.failTransport("helper-spawn-failed"));
|
|
830
|
+
child.on("exit", () => this.failTransport("helper-exit"));
|
|
831
|
+
child.stdin.on("error", () => this.failTransport("unexpected-eof"));
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
onData(chunk) {
|
|
835
|
+
if (this.failure) return;
|
|
836
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
837
|
+
if (this.buffer.length > MAX_LINE_BYTES && !this.buffer.includes(0x0a)) {
|
|
838
|
+
this.failTransport("line-too-large");
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
for (;;) {
|
|
842
|
+
const newline = this.buffer.indexOf(0x0a);
|
|
843
|
+
if (newline === -1) break;
|
|
844
|
+
const line = this.buffer.subarray(0, newline);
|
|
845
|
+
this.buffer = this.buffer.subarray(newline + 1);
|
|
846
|
+
if (line.length > MAX_LINE_BYTES) {
|
|
847
|
+
this.failTransport("line-too-large");
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (!this.pending) {
|
|
851
|
+
this.failTransport("out-of-order-response");
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
let response;
|
|
855
|
+
try {
|
|
856
|
+
response = JSON.parse(line.toString("utf8"));
|
|
857
|
+
const result = validateResponse(response, this.pending);
|
|
858
|
+
const pending = this.pending;
|
|
859
|
+
this.pending = null;
|
|
860
|
+
clearTimeout(pending.timer);
|
|
861
|
+
pending.resolve(result);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
const pending = this.pending;
|
|
864
|
+
this.pending = null;
|
|
865
|
+
let structured =
|
|
866
|
+
error instanceof NativeFsError
|
|
867
|
+
? error
|
|
868
|
+
: localError("malformed-response", pending?.operation ?? "transport");
|
|
869
|
+
if (
|
|
870
|
+
pending?.mutating &&
|
|
871
|
+
structured.mutation === "none" &&
|
|
872
|
+
structured.code === "malformed-response"
|
|
873
|
+
) {
|
|
874
|
+
structured = new NativeFsError(structured.message, {
|
|
875
|
+
code: structured.code,
|
|
876
|
+
class: structured.class,
|
|
877
|
+
operation: structured.operation,
|
|
878
|
+
osCode: structured.osCode,
|
|
879
|
+
mutation: "possible",
|
|
880
|
+
retryable: structured.retryable,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
if (pending) {
|
|
884
|
+
clearTimeout(pending.timer);
|
|
885
|
+
pending.reject(structured);
|
|
886
|
+
}
|
|
887
|
+
if (!(error instanceof NativeFsError) || error.class === "transport") {
|
|
888
|
+
this.failTransport("malformed-response");
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
failTransport(code) {
|
|
895
|
+
if (this.closed || this.failure) return;
|
|
896
|
+
const pending = this.pending;
|
|
897
|
+
const operation = pending?.operation ?? "transport";
|
|
898
|
+
const mutation = pending?.mutating && pending.issued ? "possible" : "none";
|
|
899
|
+
const error = localError(code, operation, mutation);
|
|
900
|
+
this.failure = error;
|
|
901
|
+
this.pending = null;
|
|
902
|
+
if (pending) {
|
|
903
|
+
clearTimeout(pending.timer);
|
|
904
|
+
pending.reject(error);
|
|
905
|
+
}
|
|
906
|
+
void this.shutdown();
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
request(operation, fields = {}) {
|
|
910
|
+
let snapshot;
|
|
911
|
+
try {
|
|
912
|
+
if (!printable(operation)) {
|
|
913
|
+
throw localError("invalid-request", String(operation), "none", "invalid-request");
|
|
914
|
+
}
|
|
915
|
+
snapshot = plainFields(fields, operation);
|
|
916
|
+
if ("v" in snapshot || "id" in snapshot || "op" in snapshot) {
|
|
917
|
+
throw localError("reserved-field", operation, "none", "invalid-request");
|
|
918
|
+
}
|
|
919
|
+
requestBounds(operation, snapshot, this.platform);
|
|
920
|
+
} catch (error) {
|
|
921
|
+
return Promise.reject(error);
|
|
922
|
+
}
|
|
923
|
+
const run = () => this.requestNow(operation, snapshot);
|
|
924
|
+
const result = this.queue.then(run);
|
|
925
|
+
this.queue = result.catch(() => {});
|
|
926
|
+
return result;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
requestNow(operation, fields) {
|
|
930
|
+
if (this.failure) return Promise.reject(this.failure);
|
|
931
|
+
if (this.closed) return Promise.reject(localError("session-closed", operation));
|
|
932
|
+
const id = String(this.nextId++);
|
|
933
|
+
const envelope = { v: NATIVE_FS_PROTOCOL_VERSION, id, op: operation, ...fields };
|
|
934
|
+
const line = `${JSON.stringify(envelope)}\n`;
|
|
935
|
+
if (Buffer.byteLength(line) > MAX_LINE_BYTES) {
|
|
936
|
+
return Promise.reject(localError("line-too-large", operation, "none", "invalid-request"));
|
|
937
|
+
}
|
|
938
|
+
return new Promise((resolve, reject) => {
|
|
939
|
+
const pending = {
|
|
940
|
+
id,
|
|
941
|
+
operation,
|
|
942
|
+
fields,
|
|
943
|
+
platform: this.platform,
|
|
944
|
+
mutating: MUTATING_OPERATIONS.has(operation),
|
|
945
|
+
issued: false,
|
|
946
|
+
resolve,
|
|
947
|
+
reject,
|
|
948
|
+
timer: null,
|
|
949
|
+
};
|
|
950
|
+
pending.timer = setTimeout(() => {
|
|
951
|
+
if (this.pending !== pending) return;
|
|
952
|
+
this.pending = null;
|
|
953
|
+
const mutation = pending.mutating && pending.issued ? "possible" : "none";
|
|
954
|
+
const error = localError("timeout", operation, mutation, "timeout", true);
|
|
955
|
+
this.failure = error;
|
|
956
|
+
reject(error);
|
|
957
|
+
void this.shutdown();
|
|
958
|
+
}, this.timeoutMs);
|
|
959
|
+
this.pending = pending;
|
|
960
|
+
pending.issued = true;
|
|
961
|
+
this.child.stdin.write(line, (error) => {
|
|
962
|
+
if (error && this.pending === pending) this.failTransport("unexpected-eof");
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async probe(root) {
|
|
968
|
+
return this.request("probe", { root: validCapability(root, "probe") });
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
async preflightSymlink(root) {
|
|
972
|
+
return this.request("preflight-symlink", {
|
|
973
|
+
root: validCapability(root, "preflight-symlink"),
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async verifyPrivate(cap) {
|
|
978
|
+
return this.request("verify-private", {
|
|
979
|
+
cap: validCapability(cap, "verify-private"),
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async openRoot(rootPath) {
|
|
984
|
+
if (typeof rootPath !== "string" || !path.isAbsolute(rootPath)) {
|
|
985
|
+
return Promise.reject(
|
|
986
|
+
localError("absolute-path-required", "open-root", "none", "invalid-request"),
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
return this.request("open-root", { path: rootPath });
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
async openChild(parent, name) {
|
|
993
|
+
return this.request("open-child", {
|
|
994
|
+
parent: validCapability(parent, "open-child"),
|
|
995
|
+
name,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async createDirectory(parent, name, mode) {
|
|
1000
|
+
return this.request("create-directory", {
|
|
1001
|
+
parent: validCapability(parent, "create-directory"),
|
|
1002
|
+
name,
|
|
1003
|
+
mode,
|
|
1004
|
+
expected: null,
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
async createFile(parent, name, mode) {
|
|
1009
|
+
return this.request("create-file", {
|
|
1010
|
+
parent: validCapability(parent, "create-file"),
|
|
1011
|
+
name,
|
|
1012
|
+
mode,
|
|
1013
|
+
expected: null,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
async stat(capOrParent, name) {
|
|
1018
|
+
if (name === undefined) {
|
|
1019
|
+
return this.request("stat", { cap: validCapability(capOrParent, "stat") });
|
|
1020
|
+
}
|
|
1021
|
+
return this.request("stat", {
|
|
1022
|
+
parent: validCapability(capOrParent, "stat"),
|
|
1023
|
+
name,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
async list(cap, options = {}) {
|
|
1028
|
+
const { cursor = null, limit = DEFAULT_LIST_ENTRIES } = options;
|
|
1029
|
+
if (
|
|
1030
|
+
!(cursor === null || listCursor(cursor)) ||
|
|
1031
|
+
!safeInteger(limit) ||
|
|
1032
|
+
limit < 1 ||
|
|
1033
|
+
limit > MAX_LIST_ENTRIES
|
|
1034
|
+
) {
|
|
1035
|
+
return Promise.reject(localError("invalid-list-options", "list", "none", "invalid-request"));
|
|
1036
|
+
}
|
|
1037
|
+
return this.request("list", { cap: validCapability(cap, "list"), cursor, limit });
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async read(cap, offset, length) {
|
|
1041
|
+
return this.request("read", { cap: validCapability(cap, "read"), offset, length });
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
async readLink(parent, name, expected) {
|
|
1045
|
+
return this.request("read-link", {
|
|
1046
|
+
parent: validCapability(parent, "read-link"),
|
|
1047
|
+
name,
|
|
1048
|
+
expected,
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
async write(cap, offset, data, expected) {
|
|
1053
|
+
const encoded =
|
|
1054
|
+
Buffer.isBuffer(data) || data instanceof Uint8Array ? Buffer.from(data).toString("base64") : data;
|
|
1055
|
+
expectedIdentity(expected, this.platform, "write");
|
|
1056
|
+
return this.request("write", {
|
|
1057
|
+
cap: validCapability(cap, "write"),
|
|
1058
|
+
offset,
|
|
1059
|
+
data: encoded,
|
|
1060
|
+
expected,
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async truncate(cap, size, expected) {
|
|
1065
|
+
expectedIdentity(expected, this.platform, "truncate");
|
|
1066
|
+
return this.request("truncate", {
|
|
1067
|
+
cap: validCapability(cap, "truncate"),
|
|
1068
|
+
size,
|
|
1069
|
+
expected,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async setMode(cap, mode, expected) {
|
|
1074
|
+
expectedIdentity(expected, this.platform, "set-mode");
|
|
1075
|
+
if (expected.kind !== "file") {
|
|
1076
|
+
return Promise.reject(localError("file-identity-required", "set-mode", "none", "invalid-request"));
|
|
1077
|
+
}
|
|
1078
|
+
return this.request("set-mode", {
|
|
1079
|
+
cap: validCapability(cap, "set-mode"),
|
|
1080
|
+
mode,
|
|
1081
|
+
expected,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
async flush(cap, mode, expected) {
|
|
1086
|
+
expectedIdentity(expected, this.platform, "flush");
|
|
1087
|
+
return this.request("flush", {
|
|
1088
|
+
cap: validCapability(cap, "flush"),
|
|
1089
|
+
mode,
|
|
1090
|
+
expected,
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
async rename(options) {
|
|
1095
|
+
const operation = "rename";
|
|
1096
|
+
const input = plainFields(options, operation);
|
|
1097
|
+
if (!["never", "any", "expected"].includes(input.replace)) {
|
|
1098
|
+
return Promise.reject(localError("invalid-replace-mode", operation, "none", "invalid-request"));
|
|
1099
|
+
}
|
|
1100
|
+
const expectedFields =
|
|
1101
|
+
input.replace === "never"
|
|
1102
|
+
? ["fromParent", "from", "expected", "toParent", "to", "replace"]
|
|
1103
|
+
: ["fromParent", "from", "expected", "toParent", "to", "replace", "expectedTarget"];
|
|
1104
|
+
if (!exactKeys(input, expectedFields)) {
|
|
1105
|
+
throw localError("invalid-fields", operation, "none", "invalid-request");
|
|
1106
|
+
}
|
|
1107
|
+
expectedIdentity(input.expected, this.platform, operation);
|
|
1108
|
+
const fields = {
|
|
1109
|
+
fromParent: validCapability(input.fromParent, operation),
|
|
1110
|
+
from: input.from,
|
|
1111
|
+
expected: input.expected,
|
|
1112
|
+
toParent: validCapability(input.toParent, operation),
|
|
1113
|
+
to: input.to,
|
|
1114
|
+
replace: input.replace,
|
|
1115
|
+
};
|
|
1116
|
+
if (input.replace === "any") {
|
|
1117
|
+
if (input.expectedTarget !== null) {
|
|
1118
|
+
return Promise.reject(localError("null-required", operation, "none", "invalid-request"));
|
|
1119
|
+
}
|
|
1120
|
+
fields.expectedTarget = null;
|
|
1121
|
+
} else if (input.replace === "expected") {
|
|
1122
|
+
expectedIdentity(input.expectedTarget, this.platform, operation);
|
|
1123
|
+
fields.expectedTarget = input.expectedTarget;
|
|
1124
|
+
}
|
|
1125
|
+
return this.request(operation, fields);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
async symlink(parent, name, target) {
|
|
1129
|
+
return this.request("symlink", {
|
|
1130
|
+
parent: validCapability(parent, "symlink"),
|
|
1131
|
+
name,
|
|
1132
|
+
target,
|
|
1133
|
+
expected: null,
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async closeCapability(cap) {
|
|
1138
|
+
return this.request("close", { cap: validCapability(cap, "close") });
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
shutdown() {
|
|
1142
|
+
if (this.shutdownPromise) return this.shutdownPromise;
|
|
1143
|
+
this.shutdownPromise = (async () => {
|
|
1144
|
+
const artifact = this.artifact;
|
|
1145
|
+
this.artifact = null;
|
|
1146
|
+
if (artifact?.handle) {
|
|
1147
|
+
await artifact.handle.close().catch(() => {});
|
|
1148
|
+
artifact.handle = null;
|
|
1149
|
+
}
|
|
1150
|
+
this.child.stdin.end();
|
|
1151
|
+
if (this.child.exitCode === null && this.child.signalCode === null) {
|
|
1152
|
+
this.child.kill("SIGTERM");
|
|
1153
|
+
await new Promise((resolve) => {
|
|
1154
|
+
const timer = setTimeout(resolve, SHUTDOWN_GRACE_MS);
|
|
1155
|
+
this.child.once("exit", () => {
|
|
1156
|
+
clearTimeout(timer);
|
|
1157
|
+
resolve();
|
|
1158
|
+
});
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
if (this.child.exitCode === null && this.child.signalCode === null) {
|
|
1162
|
+
this.child.kill("SIGKILL");
|
|
1163
|
+
await new Promise((resolve) => {
|
|
1164
|
+
const timer = setTimeout(resolve, SHUTDOWN_GRACE_MS);
|
|
1165
|
+
this.child.once("exit", () => {
|
|
1166
|
+
clearTimeout(timer);
|
|
1167
|
+
resolve();
|
|
1168
|
+
});
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
})();
|
|
1172
|
+
return this.shutdownPromise;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
async close() {
|
|
1176
|
+
if (!this.closed) {
|
|
1177
|
+
this.closed = true;
|
|
1178
|
+
const pending = this.pending;
|
|
1179
|
+
if (pending) {
|
|
1180
|
+
clearTimeout(pending.timer);
|
|
1181
|
+
this.pending = null;
|
|
1182
|
+
pending.reject(localError("session-closed", pending.operation, "none"));
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
await this.shutdown();
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
export async function openNativeFsSession(options = {}) {
|
|
1190
|
+
if (options === null || typeof options !== "object" || Array.isArray(options)) {
|
|
1191
|
+
throw new TypeError("native filesystem session options must be an object");
|
|
1192
|
+
}
|
|
1193
|
+
const packageRoot = options.packageRoot ?? MODULE_ROOT;
|
|
1194
|
+
const target = options.target ?? nativeFsTarget();
|
|
1195
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1196
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) {
|
|
1197
|
+
throw new RangeError("native filesystem timeoutMs must be an integer from 1 to 60000");
|
|
1198
|
+
}
|
|
1199
|
+
const artifact = await inspectArtifact(packageRoot, target, true);
|
|
1200
|
+
const currentTarget = nativeFsTarget();
|
|
1201
|
+
if (target !== currentTarget) {
|
|
1202
|
+
await artifact.handle.close();
|
|
1203
|
+
throw localError("unsupported-execution-target", "hello", "none", "unsupported");
|
|
1204
|
+
}
|
|
1205
|
+
const { platform } = targetParts(target);
|
|
1206
|
+
const strategy = PLATFORM_STRATEGIES[platform];
|
|
1207
|
+
await assertArtifactPathIdentity(artifact);
|
|
1208
|
+
let child;
|
|
1209
|
+
let session;
|
|
1210
|
+
try {
|
|
1211
|
+
// The installed package tree is trusted executing code. Linux can bind
|
|
1212
|
+
// exec to the verified fd; Darwin and Windows use the trusted package
|
|
1213
|
+
// path with pre/post identity checks. Same-user live package replacement
|
|
1214
|
+
// remains outside the target-namespace race model.
|
|
1215
|
+
child = spawn(strategy.executable(artifact), [], {
|
|
1216
|
+
cwd: packageRoot,
|
|
1217
|
+
env: strategy.environment(),
|
|
1218
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1219
|
+
windowsHide: true,
|
|
1220
|
+
});
|
|
1221
|
+
session = new NativeFsSession(child, artifact, timeoutMs, platform);
|
|
1222
|
+
await assertArtifactPathIdentity(artifact);
|
|
1223
|
+
} catch {
|
|
1224
|
+
if (session) await session.close();
|
|
1225
|
+
else await artifact.handle.close().catch(() => {});
|
|
1226
|
+
throw localError("helper-spawn-failed", "hello");
|
|
1227
|
+
}
|
|
1228
|
+
try {
|
|
1229
|
+
await session.request("hello");
|
|
1230
|
+
return session;
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
await session.close();
|
|
1233
|
+
throw error;
|
|
1234
|
+
}
|
|
1235
|
+
}
|