ast-mcp-server 0.7.1 → 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 +44 -1
- package/README.md +36 -14
- package/SECURITY.md +8 -7
- package/dist/cli.js +18 -14
- package/dist/services/agent-setup.js +248 -27
- package/dist/services/agent-targets.js +209 -63
- package/dist/services/checkbox-state.js +31 -0
- package/dist/services/managed-file.js +709 -0
- package/dist/services/managed-guidance.js +229 -0
- package/dist/services/opencode-config.js +135 -0
- package/dist/services/raw-tty.js +75 -0
- package/dist/services/setup-wizard.js +45 -93
- package/dist/services/skill-installer.js +145 -84
- package/docs/support.md +13 -13
- package/package.json +6 -3
- package/skills/structural-code-editing/SKILL.md +9 -3
- package/skills/structural-code-editing/guidance.md +3 -0
- package/skills/structural-code-editing/releases.json +45 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, open, realpath, rm } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
export class ManagedFileApplyError extends Error {
|
|
7
|
+
commitState;
|
|
8
|
+
rollbackState;
|
|
9
|
+
constructor(message, commitState, options, rollbackState) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.name = "ManagedFileApplyError";
|
|
12
|
+
this.commitState = commitState;
|
|
13
|
+
this.rollbackState = rollbackState;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class ManagedFileRollbackError extends Error {
|
|
17
|
+
rollbackState;
|
|
18
|
+
constructor(message, options) {
|
|
19
|
+
super(message, options);
|
|
20
|
+
this.name = "ManagedFileRollbackError";
|
|
21
|
+
this.rollbackState = "succeeded";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const GNU_LN_PATH = "/usr/bin/ln";
|
|
25
|
+
const GNU_MV_PATH = "/usr/bin/mv";
|
|
26
|
+
const COREUTILS_TIMEOUT_MS = 5_000;
|
|
27
|
+
const COREUTILS_STDERR_LIMIT = 4_096;
|
|
28
|
+
function errno(error) {
|
|
29
|
+
return error.code;
|
|
30
|
+
}
|
|
31
|
+
function objectIdentity(stats) {
|
|
32
|
+
return { dev: stats.dev.toString(), ino: stats.ino.toString() };
|
|
33
|
+
}
|
|
34
|
+
function identitiesEqual(left, right) {
|
|
35
|
+
return (left !== undefined && right !== undefined && left.dev === right.dev && left.ino === right.ino);
|
|
36
|
+
}
|
|
37
|
+
async function runCoreutils(executable, args, inheritedDescriptors) {
|
|
38
|
+
await new Promise((resolve, reject) => {
|
|
39
|
+
const child = spawn(executable, args, {
|
|
40
|
+
env: { LANG: "C", LC_ALL: "C", PATH: "/usr/bin:/bin" },
|
|
41
|
+
shell: false,
|
|
42
|
+
stdio: ["ignore", "ignore", "pipe", ...inheritedDescriptors],
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
});
|
|
45
|
+
let stderrBytes = 0;
|
|
46
|
+
let settled = false;
|
|
47
|
+
let requestedFailure;
|
|
48
|
+
const requestFailure = (message, cause) => {
|
|
49
|
+
if (settled || requestedFailure !== undefined)
|
|
50
|
+
return;
|
|
51
|
+
requestedFailure = new Error(message, cause === undefined ? undefined : { cause });
|
|
52
|
+
clearTimeout(timeout);
|
|
53
|
+
child.kill("SIGKILL");
|
|
54
|
+
};
|
|
55
|
+
const timeout = setTimeout(() => requestFailure(`Managed Linux primitive timed out: ${path.basename(executable)}`), COREUTILS_TIMEOUT_MS);
|
|
56
|
+
child.stderr.on("data", (chunk) => {
|
|
57
|
+
stderrBytes += chunk.byteLength;
|
|
58
|
+
if (stderrBytes > COREUTILS_STDERR_LIMIT) {
|
|
59
|
+
requestFailure(`Managed Linux primitive exceeded its stderr limit: ${path.basename(executable)}`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
child.on("error", (error) => {
|
|
63
|
+
requestFailure(`Managed Linux primitive is unavailable: ${path.basename(executable)}`, error);
|
|
64
|
+
});
|
|
65
|
+
child.on("close", (code, signal) => {
|
|
66
|
+
if (settled)
|
|
67
|
+
return;
|
|
68
|
+
settled = true;
|
|
69
|
+
clearTimeout(timeout);
|
|
70
|
+
if (requestedFailure !== undefined) {
|
|
71
|
+
reject(requestedFailure);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (code === 0 && signal === null) {
|
|
75
|
+
resolve();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
reject(new Error(`Managed Linux primitive failed: ${path.basename(executable)} (${signal ?? code ?? "unknown"})`));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export function hashBytes(content) {
|
|
83
|
+
return createHash("sha256").update(content).digest("hex");
|
|
84
|
+
}
|
|
85
|
+
export async function canonicalizeFuturePath(target) {
|
|
86
|
+
const absolute = path.resolve(target);
|
|
87
|
+
const suffix = [];
|
|
88
|
+
let cursor = path.dirname(absolute);
|
|
89
|
+
for (;;) {
|
|
90
|
+
try {
|
|
91
|
+
return path.join(await realpath(cursor), ...suffix.reverse(), path.basename(absolute));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (errno(error) !== "ENOENT")
|
|
95
|
+
throw error;
|
|
96
|
+
const parent = path.dirname(cursor);
|
|
97
|
+
if (parent === cursor)
|
|
98
|
+
throw error;
|
|
99
|
+
suffix.push(path.basename(cursor));
|
|
100
|
+
cursor = parent;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function sameIdentity(before, after) {
|
|
105
|
+
return (before.dev === after.dev &&
|
|
106
|
+
before.ino === after.ino &&
|
|
107
|
+
before.size === after.size &&
|
|
108
|
+
before.mtimeNs === after.mtimeNs &&
|
|
109
|
+
before.ctimeNs === after.ctimeNs &&
|
|
110
|
+
before.mode === after.mode);
|
|
111
|
+
}
|
|
112
|
+
function directoryDescriptorPath(handle) {
|
|
113
|
+
return `/proc/self/fd/${handle.fd}`;
|
|
114
|
+
}
|
|
115
|
+
function directoryOpenFlags() {
|
|
116
|
+
if (process.platform !== "linux") {
|
|
117
|
+
throw new Error("Managed file mutation is supported only on the verified Linux target.");
|
|
118
|
+
}
|
|
119
|
+
return fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW;
|
|
120
|
+
}
|
|
121
|
+
async function captureDirectoryBinding(directory) {
|
|
122
|
+
const missingDirectories = [];
|
|
123
|
+
let cursor = path.resolve(directory);
|
|
124
|
+
for (;;) {
|
|
125
|
+
let handle;
|
|
126
|
+
try {
|
|
127
|
+
handle = await open(cursor, directoryOpenFlags());
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (errno(error) !== "ENOENT") {
|
|
131
|
+
throw new Error(`Managed file parent is not a safe directory: ${cursor}`, {
|
|
132
|
+
cause: error,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const parent = path.dirname(cursor);
|
|
136
|
+
if (parent === cursor)
|
|
137
|
+
throw error;
|
|
138
|
+
missingDirectories.push(path.basename(cursor));
|
|
139
|
+
cursor = parent;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const directoryStat = await handle.stat({ bigint: true });
|
|
144
|
+
if (!directoryStat.isDirectory()) {
|
|
145
|
+
throw new Error(`Managed file parent is not a directory: ${cursor}`);
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
anchorPath: await realpath(directoryDescriptorPath(handle)),
|
|
149
|
+
anchorDev: directoryStat.dev.toString(),
|
|
150
|
+
anchorIno: directoryStat.ino.toString(),
|
|
151
|
+
missingDirectories: missingDirectories.reverse(),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
await handle.close();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function sameDirectoryBinding(left, right) {
|
|
160
|
+
return (left.anchorPath === right.anchorPath &&
|
|
161
|
+
left.anchorDev === right.anchorDev &&
|
|
162
|
+
left.anchorIno === right.anchorIno &&
|
|
163
|
+
left.missingDirectories.length === right.missingDirectories.length &&
|
|
164
|
+
left.missingDirectories.every((segment, index) => segment === right.missingDirectories[index]));
|
|
165
|
+
}
|
|
166
|
+
export function createManagedFileApplyContext() {
|
|
167
|
+
return { createdDirectories: new Map(), authenticatedFiles: new Map() };
|
|
168
|
+
}
|
|
169
|
+
function createdDirectoryKey(binding, segments) {
|
|
170
|
+
return `${binding.anchorDev}:${binding.anchorIno}:${segments.join("/")}`;
|
|
171
|
+
}
|
|
172
|
+
function managedFileKey(plan) {
|
|
173
|
+
const binding = plan.snapshot.directory;
|
|
174
|
+
return `${binding.anchorDev}:${binding.anchorIno}:${binding.missingDirectories.join("/")}:${path.basename(plan.path)}`;
|
|
175
|
+
}
|
|
176
|
+
async function openBoundDirectory(binding, createMissing, context) {
|
|
177
|
+
let current = await open(binding.anchorPath, directoryOpenFlags());
|
|
178
|
+
try {
|
|
179
|
+
const anchorStat = await current.stat({ bigint: true });
|
|
180
|
+
if (!anchorStat.isDirectory() ||
|
|
181
|
+
anchorStat.dev.toString() !== binding.anchorDev ||
|
|
182
|
+
anchorStat.ino.toString() !== binding.anchorIno) {
|
|
183
|
+
throw new Error(`Managed file parent changed after preflight: ${binding.anchorPath}`);
|
|
184
|
+
}
|
|
185
|
+
const traversedSegments = [];
|
|
186
|
+
for (const segment of binding.missingDirectories) {
|
|
187
|
+
const childPath = path.join(directoryDescriptorPath(current), segment);
|
|
188
|
+
const key = createdDirectoryKey(binding, [...traversedSegments, segment]);
|
|
189
|
+
const expectedIdentity = context.createdDirectories.get(key);
|
|
190
|
+
let child;
|
|
191
|
+
if (expectedIdentity !== undefined) {
|
|
192
|
+
child = await open(childPath, directoryOpenFlags());
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
if (!createMissing) {
|
|
196
|
+
throw new Error(`Managed file parent changed after preflight: ${childPath}`);
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
await mkdir(childPath);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (errno(error) === "EEXIST") {
|
|
203
|
+
throw new Error(`Managed file parent appeared after preflight: ${childPath}`, {
|
|
204
|
+
cause: error,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
const pathIdentity = await lstat(childPath);
|
|
210
|
+
if (!pathIdentity.isDirectory() || pathIdentity.isSymbolicLink()) {
|
|
211
|
+
throw new Error(`Managed file parent changed after preflight: ${childPath}`);
|
|
212
|
+
}
|
|
213
|
+
child = await open(childPath, directoryOpenFlags());
|
|
214
|
+
const openedIdentity = await child.stat();
|
|
215
|
+
if (pathIdentity.dev !== openedIdentity.dev ||
|
|
216
|
+
pathIdentity.ino !== openedIdentity.ino ||
|
|
217
|
+
!openedIdentity.isDirectory()) {
|
|
218
|
+
await child.close();
|
|
219
|
+
throw new Error(`Managed file parent changed after preflight: ${childPath}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const childStat = await child.stat({ bigint: true });
|
|
223
|
+
if (!childStat.isDirectory()) {
|
|
224
|
+
await child.close();
|
|
225
|
+
throw new Error(`Managed file parent is not a directory: ${childPath}`);
|
|
226
|
+
}
|
|
227
|
+
const identity = { dev: childStat.dev.toString(), ino: childStat.ino.toString() };
|
|
228
|
+
if (expectedIdentity !== undefined &&
|
|
229
|
+
(identity.dev !== expectedIdentity.dev || identity.ino !== expectedIdentity.ino)) {
|
|
230
|
+
await child.close();
|
|
231
|
+
throw new Error(`Managed file parent changed after preflight: ${childPath}`);
|
|
232
|
+
}
|
|
233
|
+
if (expectedIdentity === undefined)
|
|
234
|
+
context.createdDirectories.set(key, identity);
|
|
235
|
+
await current.close();
|
|
236
|
+
current = child;
|
|
237
|
+
traversedSegments.push(segment);
|
|
238
|
+
}
|
|
239
|
+
return current;
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
await current.close();
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async function captureManagedFileState(directory, basename) {
|
|
247
|
+
const destination = path.join(directoryDescriptorPath(directory), basename);
|
|
248
|
+
let pathBefore;
|
|
249
|
+
try {
|
|
250
|
+
pathBefore = await lstat(destination, { bigint: true });
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (errno(error) === "ENOENT") {
|
|
254
|
+
return { state: { exists: false, kind: "missing" } };
|
|
255
|
+
}
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
if (!pathBefore.isFile() || pathBefore.isSymbolicLink()) {
|
|
259
|
+
throw new Error(`Managed file destination is not a regular file: ${destination}`);
|
|
260
|
+
}
|
|
261
|
+
const handle = await open(destination, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
262
|
+
try {
|
|
263
|
+
const before = await handle.stat({ bigint: true });
|
|
264
|
+
if (!before.isFile() || !sameIdentity(pathBefore, before)) {
|
|
265
|
+
throw new Error(`Managed file changed while it was being inspected: ${destination}`);
|
|
266
|
+
}
|
|
267
|
+
const content = await handle.readFile();
|
|
268
|
+
const after = await handle.stat({ bigint: true });
|
|
269
|
+
const pathAfter = await lstat(destination, { bigint: true }).catch((error) => {
|
|
270
|
+
if (errno(error) === "ENOENT") {
|
|
271
|
+
throw new Error(`Managed file changed while it was being inspected: ${destination}`);
|
|
272
|
+
}
|
|
273
|
+
throw error;
|
|
274
|
+
});
|
|
275
|
+
if (!pathAfter.isFile() ||
|
|
276
|
+
pathAfter.isSymbolicLink() ||
|
|
277
|
+
!sameIdentity(before, after) ||
|
|
278
|
+
!sameIdentity(after, pathAfter)) {
|
|
279
|
+
throw new Error(`Managed file changed while it was being inspected: ${destination}`);
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
state: {
|
|
283
|
+
exists: true,
|
|
284
|
+
kind: "regular",
|
|
285
|
+
sha256: hashBytes(content),
|
|
286
|
+
mode: Number(before.mode & 511n),
|
|
287
|
+
dev: before.dev.toString(),
|
|
288
|
+
ino: before.ino.toString(),
|
|
289
|
+
},
|
|
290
|
+
content,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
await handle.close();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
export async function captureManagedFileSnapshot(destination) {
|
|
298
|
+
return (await captureManagedFilePreimage(destination)).snapshot;
|
|
299
|
+
}
|
|
300
|
+
export async function captureManagedFilePreimage(destination) {
|
|
301
|
+
const directory = await captureDirectoryBinding(path.dirname(destination));
|
|
302
|
+
if (directory.missingDirectories.length > 0) {
|
|
303
|
+
return { snapshot: { exists: false, kind: "missing", directory } };
|
|
304
|
+
}
|
|
305
|
+
const handle = await openBoundDirectory(directory, false, createManagedFileApplyContext());
|
|
306
|
+
try {
|
|
307
|
+
const captured = await captureManagedFileState(handle, path.basename(destination));
|
|
308
|
+
return {
|
|
309
|
+
snapshot: { ...captured.state, directory },
|
|
310
|
+
...(captured.content === undefined ? {} : { content: captured.content }),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
await handle.close();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
export function snapshotsEqual(left, right) {
|
|
318
|
+
if (!sameDirectoryBinding(left.directory, right.directory))
|
|
319
|
+
return false;
|
|
320
|
+
if (left.exists !== right.exists)
|
|
321
|
+
return false;
|
|
322
|
+
if (!left.exists || !right.exists)
|
|
323
|
+
return true;
|
|
324
|
+
return (left.sha256 === right.sha256 &&
|
|
325
|
+
left.mode === right.mode &&
|
|
326
|
+
left.dev === right.dev &&
|
|
327
|
+
left.ino === right.ino);
|
|
328
|
+
}
|
|
329
|
+
function stateMatchesSnapshot(state, snapshot) {
|
|
330
|
+
if (state.exists !== snapshot.exists)
|
|
331
|
+
return false;
|
|
332
|
+
if (!state.exists || !snapshot.exists)
|
|
333
|
+
return true;
|
|
334
|
+
return (state.sha256 === snapshot.sha256 &&
|
|
335
|
+
state.mode === snapshot.mode &&
|
|
336
|
+
state.dev === snapshot.dev &&
|
|
337
|
+
state.ino === snapshot.ino);
|
|
338
|
+
}
|
|
339
|
+
function stateIdentity(state) {
|
|
340
|
+
return state.exists ? { dev: state.dev, ino: state.ino } : undefined;
|
|
341
|
+
}
|
|
342
|
+
async function capturePathIdentity(directory, basename) {
|
|
343
|
+
try {
|
|
344
|
+
return objectIdentity(await lstat(path.join(directoryDescriptorPath(directory), basename), { bigint: true }));
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (errno(error) === "ENOENT")
|
|
348
|
+
return undefined;
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async function openAuthenticatedPreimage(directory, basename, snapshot) {
|
|
353
|
+
if (!snapshot.exists) {
|
|
354
|
+
throw new Error("Cannot authenticate a missing managed-file preimage.");
|
|
355
|
+
}
|
|
356
|
+
const destination = path.join(directoryDescriptorPath(directory), basename);
|
|
357
|
+
const handle = await open(destination, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
358
|
+
try {
|
|
359
|
+
const opened = await handle.stat({ bigint: true });
|
|
360
|
+
const currentPath = await lstat(destination, { bigint: true });
|
|
361
|
+
if (!opened.isFile() ||
|
|
362
|
+
!currentPath.isFile() ||
|
|
363
|
+
currentPath.isSymbolicLink() ||
|
|
364
|
+
!sameIdentity(opened, currentPath) ||
|
|
365
|
+
opened.dev.toString() !== snapshot.dev ||
|
|
366
|
+
opened.ino.toString() !== snapshot.ino ||
|
|
367
|
+
Number(opened.mode & 511n) !== snapshot.mode) {
|
|
368
|
+
throw new Error(`Managed file changed after preflight: ${destination}`);
|
|
369
|
+
}
|
|
370
|
+
return handle;
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
await handle.close();
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async function captureHeldFileState(handle) {
|
|
378
|
+
const before = await handle.stat({ bigint: true });
|
|
379
|
+
if (!before.isFile()) {
|
|
380
|
+
throw new Error("Managed file preimage descriptor is not a regular file");
|
|
381
|
+
}
|
|
382
|
+
if (before.size > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
383
|
+
throw new Error("Managed file preimage is too large to authenticate");
|
|
384
|
+
}
|
|
385
|
+
const content = Buffer.alloc(Number(before.size));
|
|
386
|
+
let offset = 0;
|
|
387
|
+
while (offset < content.length) {
|
|
388
|
+
const { bytesRead } = await handle.read(content, offset, content.length - offset, offset);
|
|
389
|
+
if (bytesRead === 0) {
|
|
390
|
+
throw new Error("Managed file preimage changed while it was being authenticated");
|
|
391
|
+
}
|
|
392
|
+
offset += bytesRead;
|
|
393
|
+
}
|
|
394
|
+
const after = await handle.stat({ bigint: true });
|
|
395
|
+
if (!after.isFile() || !sameIdentity(before, after)) {
|
|
396
|
+
throw new Error("Managed held preimage changed while it was being inspected.");
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
exists: true,
|
|
400
|
+
kind: "regular",
|
|
401
|
+
sha256: hashBytes(content),
|
|
402
|
+
mode: Number(after.mode & 511n),
|
|
403
|
+
dev: after.dev.toString(),
|
|
404
|
+
ino: after.ino.toString(),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
async function assertTemporaryPathIdentity(handle, temporaryPath) {
|
|
408
|
+
const opened = await handle.stat({ bigint: true });
|
|
409
|
+
const currentPath = await lstat(temporaryPath, { bigint: true });
|
|
410
|
+
if (!opened.isFile() ||
|
|
411
|
+
!currentPath.isFile() ||
|
|
412
|
+
currentPath.isSymbolicLink() ||
|
|
413
|
+
!sameIdentity(opened, currentPath)) {
|
|
414
|
+
throw new Error(`Managed temporary file changed before publication: ${temporaryPath}`);
|
|
415
|
+
}
|
|
416
|
+
return objectIdentity(opened);
|
|
417
|
+
}
|
|
418
|
+
async function removePathIfOwned(directory, basename, expectedIdentity) {
|
|
419
|
+
const current = await capturePathIdentity(directory, basename);
|
|
420
|
+
if (current === undefined)
|
|
421
|
+
return "missing";
|
|
422
|
+
if (!identitiesEqual(current, expectedIdentity))
|
|
423
|
+
return "changed";
|
|
424
|
+
await rm(path.join(directoryDescriptorPath(directory), basename));
|
|
425
|
+
return (await capturePathIdentity(directory, basename)) === undefined ? "removed" : "changed";
|
|
426
|
+
}
|
|
427
|
+
async function linkHeldFile(source, directory, basename) {
|
|
428
|
+
await runCoreutils(GNU_LN_PATH, ["-L", "-T", "--", "/proc/self/fd/3", `/proc/self/fd/4/${basename}`], [source.fd, directory.fd]);
|
|
429
|
+
}
|
|
430
|
+
async function exchangeDirectoryEntries(directory, leftBasename, rightBasename) {
|
|
431
|
+
await runCoreutils(GNU_MV_PATH, [
|
|
432
|
+
"--exchange",
|
|
433
|
+
"--no-copy",
|
|
434
|
+
"-T",
|
|
435
|
+
"--",
|
|
436
|
+
`/proc/self/fd/3/${leftBasename}`,
|
|
437
|
+
`/proc/self/fd/3/${rightBasename}`,
|
|
438
|
+
], [directory.fd]);
|
|
439
|
+
}
|
|
440
|
+
function publicationError(plan, commitState, message, cause, rollbackState) {
|
|
441
|
+
return new ManagedFileApplyError(`${message}: ${plan.path}`, commitState, cause === undefined ? undefined : { cause }, rollbackState);
|
|
442
|
+
}
|
|
443
|
+
async function rollbackExchangedPair(plan, directory, temporaryBasename, destinationBasename, exchangedDestination, exchangedTemporary, hooks, hookContext, cause) {
|
|
444
|
+
let rollbackError;
|
|
445
|
+
try {
|
|
446
|
+
await hooks.beforeRollback?.(hookContext);
|
|
447
|
+
const currentDestination = await capturePathIdentity(directory, destinationBasename);
|
|
448
|
+
const currentTemporary = await capturePathIdentity(directory, temporaryBasename);
|
|
449
|
+
if (!identitiesEqual(currentDestination, exchangedDestination) ||
|
|
450
|
+
!identitiesEqual(currentTemporary, exchangedTemporary)) {
|
|
451
|
+
throw new Error(`Managed file exchange pair changed before rollback: ${plan.path}`);
|
|
452
|
+
}
|
|
453
|
+
await exchangeDirectoryEntries(directory, temporaryBasename, destinationBasename);
|
|
454
|
+
await hooks.afterRollback?.(hookContext);
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
rollbackError = error;
|
|
458
|
+
}
|
|
459
|
+
let rollbackDestination;
|
|
460
|
+
let rollbackTemporary;
|
|
461
|
+
try {
|
|
462
|
+
rollbackDestination = await capturePathIdentity(directory, destinationBasename);
|
|
463
|
+
rollbackTemporary = await capturePathIdentity(directory, temporaryBasename);
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
throw publicationError(plan, "possibly_committed", "Managed file publication could not inspect rollback after an atomic exchange race", error, "failed");
|
|
467
|
+
}
|
|
468
|
+
if (identitiesEqual(rollbackDestination, exchangedTemporary) &&
|
|
469
|
+
identitiesEqual(rollbackTemporary, exchangedDestination)) {
|
|
470
|
+
throw new ManagedFileRollbackError(`Managed file publication race was rolled back: ${plan.path}`, rollbackError === undefined ? undefined : { cause: rollbackError });
|
|
471
|
+
}
|
|
472
|
+
throw publicationError(plan, "possibly_committed", "Managed file publication could not prove rollback after an atomic exchange race", rollbackError ?? cause, "failed");
|
|
473
|
+
}
|
|
474
|
+
export async function revalidateManagedFilePlan(plan) {
|
|
475
|
+
const current = await captureManagedFileSnapshot(plan.path);
|
|
476
|
+
if (!snapshotsEqual(current, plan.snapshot)) {
|
|
477
|
+
throw new Error(`Managed file changed after preflight: ${plan.path}`);
|
|
478
|
+
}
|
|
479
|
+
if (plan.status === "unchanged" && (!current.exists || current.sha256 !== plan.postimageSha256)) {
|
|
480
|
+
throw new Error(`Managed file unchanged postimage is not current: ${plan.path}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
export async function verifyManagedFilePostimage(plan, context) {
|
|
484
|
+
const directory = await openBoundDirectory(plan.snapshot.directory, false, context);
|
|
485
|
+
try {
|
|
486
|
+
const current = (await captureManagedFileState(directory, path.basename(plan.path))).state;
|
|
487
|
+
const expectedMode = plan.snapshot.exists ? plan.snapshot.mode : 0o644;
|
|
488
|
+
const expectedIdentity = context.authenticatedFiles.get(managedFileKey(plan));
|
|
489
|
+
if (!current.exists ||
|
|
490
|
+
current.sha256 !== plan.postimageSha256 ||
|
|
491
|
+
current.mode !== expectedMode ||
|
|
492
|
+
expectedIdentity === undefined ||
|
|
493
|
+
current.dev !== expectedIdentity.dev ||
|
|
494
|
+
current.ino !== expectedIdentity.ino) {
|
|
495
|
+
throw new Error(`Managed file postimage is no longer current: ${plan.path}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
finally {
|
|
499
|
+
await directory.close();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
export async function applyManagedFilePlan(plan, context = createManagedFileApplyContext(), hooks = {}) {
|
|
503
|
+
if (plan.status === "unchanged") {
|
|
504
|
+
await revalidateManagedFilePlan(plan);
|
|
505
|
+
if (!plan.snapshot.exists) {
|
|
506
|
+
throw new Error(`Managed unchanged file is missing from its snapshot: ${plan.path}`);
|
|
507
|
+
}
|
|
508
|
+
context.authenticatedFiles.set(managedFileKey(plan), {
|
|
509
|
+
dev: plan.snapshot.dev,
|
|
510
|
+
ino: plan.snapshot.ino,
|
|
511
|
+
});
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const directory = await openBoundDirectory(plan.snapshot.directory, true, context);
|
|
515
|
+
const basename = path.basename(plan.path);
|
|
516
|
+
const temporaryBasename = `.${basename}.${process.pid}.${randomUUID()}.tmp`;
|
|
517
|
+
const destination = path.join(directoryDescriptorPath(directory), basename);
|
|
518
|
+
const temporary = path.join(directoryDescriptorPath(directory), temporaryBasename);
|
|
519
|
+
const mode = plan.snapshot.exists ? plan.snapshot.mode : 0o644;
|
|
520
|
+
let temporaryHandle;
|
|
521
|
+
let preimageHandle;
|
|
522
|
+
let temporaryIdentity;
|
|
523
|
+
let publicationState;
|
|
524
|
+
let operationError;
|
|
525
|
+
try {
|
|
526
|
+
const current = (await captureManagedFileState(directory, basename)).state;
|
|
527
|
+
if (!stateMatchesSnapshot(current, plan.snapshot)) {
|
|
528
|
+
throw new Error(`Managed file changed after preflight: ${plan.path}`);
|
|
529
|
+
}
|
|
530
|
+
if (plan.snapshot.exists) {
|
|
531
|
+
preimageHandle = await openAuthenticatedPreimage(directory, basename, plan.snapshot);
|
|
532
|
+
}
|
|
533
|
+
temporaryHandle = await open(temporary, "wx", mode);
|
|
534
|
+
await temporaryHandle.writeFile(plan.postimage);
|
|
535
|
+
await temporaryHandle.chmod(mode);
|
|
536
|
+
await temporaryHandle.sync();
|
|
537
|
+
temporaryIdentity = await assertTemporaryPathIdentity(temporaryHandle, temporary);
|
|
538
|
+
const hookContext = { plan, temporaryPath: temporary, destinationPath: destination };
|
|
539
|
+
await hooks.afterTemporaryReady?.(hookContext);
|
|
540
|
+
if (plan.status === "installed") {
|
|
541
|
+
const beforeLink = (await captureManagedFileState(directory, basename)).state;
|
|
542
|
+
if (!stateMatchesSnapshot(beforeLink, plan.snapshot)) {
|
|
543
|
+
throw new Error(`Managed file changed after preflight: ${plan.path}`);
|
|
544
|
+
}
|
|
545
|
+
await hooks.afterDestinationRevalidated?.(hookContext);
|
|
546
|
+
let linkError;
|
|
547
|
+
try {
|
|
548
|
+
await linkHeldFile(temporaryHandle, directory, basename);
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
linkError = error;
|
|
552
|
+
}
|
|
553
|
+
const linkedIdentity = await capturePathIdentity(directory, basename);
|
|
554
|
+
if (!identitiesEqual(linkedIdentity, temporaryIdentity)) {
|
|
555
|
+
if (linkError === undefined) {
|
|
556
|
+
publicationState = "possibly_committed";
|
|
557
|
+
throw publicationError(plan, publicationState, "Managed file publication became uncertain after descriptor-bound link");
|
|
558
|
+
}
|
|
559
|
+
if (linkedIdentity !== undefined) {
|
|
560
|
+
throw new Error(`Managed file appeared concurrently at ${plan.path}; no file was replaced.`, { cause: linkError });
|
|
561
|
+
}
|
|
562
|
+
throw linkError;
|
|
563
|
+
}
|
|
564
|
+
publicationState = "committed";
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
const beforeExchange = (await captureManagedFileState(directory, basename)).state;
|
|
568
|
+
if (!stateMatchesSnapshot(beforeExchange, plan.snapshot)) {
|
|
569
|
+
throw new Error(`Managed file changed after preflight: ${plan.path}`);
|
|
570
|
+
}
|
|
571
|
+
const preimageIdentity = stateIdentity(beforeExchange);
|
|
572
|
+
await hooks.afterDestinationRevalidated?.(hookContext);
|
|
573
|
+
let exchangeError;
|
|
574
|
+
try {
|
|
575
|
+
await exchangeDirectoryEntries(directory, temporaryBasename, basename);
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
exchangeError = error;
|
|
579
|
+
}
|
|
580
|
+
const exchangedDestination = await capturePathIdentity(directory, basename);
|
|
581
|
+
const exchangedTemporary = await capturePathIdentity(directory, temporaryBasename);
|
|
582
|
+
if (identitiesEqual(exchangedDestination, temporaryIdentity) &&
|
|
583
|
+
identitiesEqual(exchangedTemporary, preimageIdentity)) {
|
|
584
|
+
let preimageValidationError;
|
|
585
|
+
try {
|
|
586
|
+
const exchangedPreimage = await captureHeldFileState(preimageHandle);
|
|
587
|
+
if (!stateMatchesSnapshot(exchangedPreimage, plan.snapshot)) {
|
|
588
|
+
preimageValidationError = new Error(`Managed file preimage changed during atomic publication: ${plan.path}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
preimageValidationError = error;
|
|
593
|
+
}
|
|
594
|
+
if (preimageValidationError !== undefined) {
|
|
595
|
+
await rollbackExchangedPair(plan, directory, temporaryBasename, basename, temporaryIdentity, preimageIdentity, hooks, hookContext, preimageValidationError);
|
|
596
|
+
}
|
|
597
|
+
publicationState = "committed";
|
|
598
|
+
}
|
|
599
|
+
else if (identitiesEqual(exchangedDestination, preimageIdentity) &&
|
|
600
|
+
identitiesEqual(exchangedTemporary, temporaryIdentity)) {
|
|
601
|
+
throw (exchangeError ?? new Error(`Managed file atomic exchange did not commit: ${plan.path}`));
|
|
602
|
+
}
|
|
603
|
+
else if (exchangedDestination !== undefined && exchangedTemporary !== undefined) {
|
|
604
|
+
await rollbackExchangedPair(plan, directory, temporaryBasename, basename, exchangedDestination, exchangedTemporary, hooks, hookContext, exchangeError);
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
publicationState = "possibly_committed";
|
|
608
|
+
throw publicationError(plan, publicationState, "Managed file publication lost an exchange entry and is possibly committed", exchangeError);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
await hooks.afterCommit?.(hookContext);
|
|
612
|
+
let installed;
|
|
613
|
+
try {
|
|
614
|
+
installed = (await captureManagedFileState(directory, basename)).state;
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
const installedIdentity = await capturePathIdentity(directory, basename);
|
|
618
|
+
publicationState = identitiesEqual(installedIdentity, temporaryIdentity)
|
|
619
|
+
? "committed"
|
|
620
|
+
: "possibly_committed";
|
|
621
|
+
throw publicationError(plan, publicationState, "Managed file post-commit verification failed", error);
|
|
622
|
+
}
|
|
623
|
+
if (!installed.exists ||
|
|
624
|
+
installed.sha256 !== plan.postimageSha256 ||
|
|
625
|
+
installed.mode !== mode ||
|
|
626
|
+
!identitiesEqual(stateIdentity(installed), temporaryIdentity)) {
|
|
627
|
+
publicationState = identitiesEqual(stateIdentity(installed), temporaryIdentity)
|
|
628
|
+
? "committed"
|
|
629
|
+
: "possibly_committed";
|
|
630
|
+
throw publicationError(plan, publicationState, "Managed file post-commit verification found a different postimage");
|
|
631
|
+
}
|
|
632
|
+
await hooks.beforeCleanup?.(hookContext);
|
|
633
|
+
if (plan.snapshot.exists) {
|
|
634
|
+
let cleanupPreimageError;
|
|
635
|
+
try {
|
|
636
|
+
const cleanupPreimage = await captureHeldFileState(preimageHandle);
|
|
637
|
+
if (!stateMatchesSnapshot(cleanupPreimage, plan.snapshot)) {
|
|
638
|
+
cleanupPreimageError = new Error(`Managed file preimage changed before publication cleanup: ${plan.path}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
cleanupPreimageError = error;
|
|
643
|
+
}
|
|
644
|
+
if (cleanupPreimageError !== undefined) {
|
|
645
|
+
publicationState = undefined;
|
|
646
|
+
await rollbackExchangedPair(plan, directory, temporaryBasename, basename, temporaryIdentity, { dev: plan.snapshot.dev, ino: plan.snapshot.ino }, hooks, hookContext, cleanupPreimageError);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const cleanupIdentity = plan.snapshot.exists
|
|
650
|
+
? { dev: plan.snapshot.dev, ino: plan.snapshot.ino }
|
|
651
|
+
: temporaryIdentity;
|
|
652
|
+
const cleanupHandle = plan.snapshot.exists ? preimageHandle : temporaryHandle;
|
|
653
|
+
const linksBeforeCleanup = (await cleanupHandle.stat({ bigint: true })).nlink;
|
|
654
|
+
const cleanup = await removePathIfOwned(directory, temporaryBasename, cleanupIdentity);
|
|
655
|
+
const linksAfterCleanup = (await cleanupHandle.stat({ bigint: true })).nlink;
|
|
656
|
+
if (cleanup !== "removed" || linksAfterCleanup + 1n !== linksBeforeCleanup) {
|
|
657
|
+
publicationState = "committed";
|
|
658
|
+
throw publicationError(plan, publicationState, `Managed file committed but temporary cleanup was ${cleanup} with an unexpected link count`);
|
|
659
|
+
}
|
|
660
|
+
const finalState = (await captureManagedFileState(directory, basename)).state;
|
|
661
|
+
if (!finalState.exists ||
|
|
662
|
+
finalState.sha256 !== plan.postimageSha256 ||
|
|
663
|
+
finalState.mode !== mode ||
|
|
664
|
+
!identitiesEqual(stateIdentity(finalState), temporaryIdentity)) {
|
|
665
|
+
publicationState = identitiesEqual(stateIdentity(finalState), temporaryIdentity)
|
|
666
|
+
? "committed"
|
|
667
|
+
: "possibly_committed";
|
|
668
|
+
throw publicationError(plan, publicationState, "Managed file postimage changed during publication cleanup");
|
|
669
|
+
}
|
|
670
|
+
context.authenticatedFiles.set(managedFileKey(plan), {
|
|
671
|
+
dev: finalState.dev,
|
|
672
|
+
ino: finalState.ino,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
operationError = error;
|
|
677
|
+
if (error instanceof ManagedFileApplyError) {
|
|
678
|
+
publicationState = error.commitState;
|
|
679
|
+
}
|
|
680
|
+
else if (error instanceof ManagedFileRollbackError) {
|
|
681
|
+
publicationState = undefined;
|
|
682
|
+
}
|
|
683
|
+
else if (publicationState !== undefined) {
|
|
684
|
+
operationError = publicationError(plan, publicationState, "Managed file publication failed after commit", error);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
finally {
|
|
688
|
+
if (publicationState === undefined && temporaryIdentity !== undefined) {
|
|
689
|
+
await removePathIfOwned(directory, temporaryBasename, temporaryIdentity).catch(() => undefined);
|
|
690
|
+
}
|
|
691
|
+
const closeResults = await Promise.allSettled([
|
|
692
|
+
...(temporaryHandle === undefined ? [] : [temporaryHandle.close()]),
|
|
693
|
+
...(preimageHandle === undefined ? [] : [preimageHandle.close()]),
|
|
694
|
+
directory.close(),
|
|
695
|
+
]);
|
|
696
|
+
const closeFailure = closeResults.find((result) => result.status === "rejected");
|
|
697
|
+
if (closeFailure !== undefined && operationError === undefined) {
|
|
698
|
+
if (publicationState !== undefined) {
|
|
699
|
+
operationError = publicationError(plan, publicationState, "Managed file publication completed but descriptor cleanup failed", closeFailure.reason);
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
operationError = closeFailure.reason;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (operationError !== undefined) {
|
|
707
|
+
throw operationError;
|
|
708
|
+
}
|
|
709
|
+
}
|