ast-mcp-server 0.8.0 → 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/CHANGELOG.md +39 -4
- package/README.md +13 -4
- package/SECURITY.md +1 -1
- package/dist/cli.js +21 -0
- package/dist/services/opencode-config.js +15 -2
- package/dist/services/project.js +12 -2
- package/dist/services/symbol-index-cache.js +847 -0
- package/dist/services/symbol-index-policy.js +48 -15
- package/dist/services/symbol-index-sqlite.js +266 -7
- package/docs/support.md +31 -16
- package/package.json +4 -3
- package/skills/structural-code-editing/SKILL.md +3 -3
- package/skills/structural-code-editing/releases.json +12 -2
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, realpathSync, unlinkSync, } from "node:fs";
|
|
2
|
+
import { access, lstat, open, opendir, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readSymbolIndexPersistencePolicy, } from "./symbol-index-policy.js";
|
|
5
|
+
const DEFAULT_MAX_RECORDS = 4_096;
|
|
6
|
+
const DEFAULT_MAX_BYTES = 512 * 1024 * 1024;
|
|
7
|
+
const MAX_REASON_COUNT = 1_000_000;
|
|
8
|
+
const SQLITE_HEADER = Buffer.from("SQLite format 3\u0000", "utf8");
|
|
9
|
+
const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
10
|
+
const SUPPORTS_OWNER_CHECK = process.platform === "linux" && typeof process.getuid === "function";
|
|
11
|
+
const STICKY_DIRECTORY_MODE = 0o1000;
|
|
12
|
+
const GROUP_OR_OTHER_WRITE_MODE = 0o022;
|
|
13
|
+
const GROUP_OR_OTHER_ACCESS_MODE = 0o077;
|
|
14
|
+
export class SymbolIndexCacheError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
details;
|
|
17
|
+
constructor(code, message, details) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.details = details;
|
|
21
|
+
this.name = "SymbolIndexCacheError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function boundedIncrement(value, increment = 1) {
|
|
25
|
+
return Math.min(MAX_REASON_COUNT, value + increment);
|
|
26
|
+
}
|
|
27
|
+
function incrementReason(counts, reason) {
|
|
28
|
+
counts[reason] = boundedIncrement(counts[reason] ?? 0);
|
|
29
|
+
}
|
|
30
|
+
function effectiveLimit(candidate, maximum) {
|
|
31
|
+
return Number.isSafeInteger(candidate) && candidate !== undefined && candidate > 0
|
|
32
|
+
? Math.min(candidate, maximum)
|
|
33
|
+
: maximum;
|
|
34
|
+
}
|
|
35
|
+
function limitsFrom(options) {
|
|
36
|
+
return {
|
|
37
|
+
maxRecords: effectiveLimit(options.limits?.maxRecords, DEFAULT_MAX_RECORDS),
|
|
38
|
+
maxBytes: effectiveLimit(options.limits?.maxBytes, DEFAULT_MAX_BYTES),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function snapshot(stats) {
|
|
42
|
+
return {
|
|
43
|
+
dev: stats.dev,
|
|
44
|
+
ino: stats.ino,
|
|
45
|
+
size: stats.size,
|
|
46
|
+
mtimeMs: stats.mtimeMs,
|
|
47
|
+
nlink: stats.nlink,
|
|
48
|
+
mode: stats.mode,
|
|
49
|
+
uid: stats.uid,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function sameIdentity(left, right) {
|
|
53
|
+
return (left.dev === right.dev &&
|
|
54
|
+
left.ino === right.ino &&
|
|
55
|
+
left.size === right.size &&
|
|
56
|
+
left.mtimeMs === right.mtimeMs &&
|
|
57
|
+
left.nlink === right.nlink &&
|
|
58
|
+
left.mode === right.mode &&
|
|
59
|
+
left.uid === right.uid);
|
|
60
|
+
}
|
|
61
|
+
function sameDirectoryIdentity(left, right) {
|
|
62
|
+
return (left.dev === right.dev &&
|
|
63
|
+
left.ino === right.ino &&
|
|
64
|
+
left.nlink === right.nlink &&
|
|
65
|
+
left.mode === right.mode &&
|
|
66
|
+
left.uid === right.uid);
|
|
67
|
+
}
|
|
68
|
+
function classifyArtifact(name) {
|
|
69
|
+
if (/^symbol-index-[0-9a-f]{64}\.sqlite$/u.test(name))
|
|
70
|
+
return "active_database";
|
|
71
|
+
if (/^symbol-index-[0-9a-f]{64}\.sqlite-wal$/u.test(name))
|
|
72
|
+
return "wal";
|
|
73
|
+
if (/^symbol-index-[0-9a-f]{64}\.sqlite-shm$/u.test(name))
|
|
74
|
+
return "shm";
|
|
75
|
+
if (/^symbol-index-[0-9a-f]{64}\.sqlite\.corrupt-[0-9]+-[0-9]+$/u.test(name)) {
|
|
76
|
+
return "quarantine";
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
function lexicalCompare(left, right) {
|
|
81
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
function artifactGroup(entry) {
|
|
84
|
+
if (entry.artifact === "wal" || entry.artifact === "shm") {
|
|
85
|
+
return entry.relativeName.slice(0, -4);
|
|
86
|
+
}
|
|
87
|
+
if (entry.artifact === "quarantine") {
|
|
88
|
+
return entry.relativeName.replace(/\.corrupt-[0-9]+-[0-9]+$/u, "");
|
|
89
|
+
}
|
|
90
|
+
return entry.relativeName;
|
|
91
|
+
}
|
|
92
|
+
function deletionCompare(left, right) {
|
|
93
|
+
const groupOrder = lexicalCompare(artifactGroup(left), artifactGroup(right));
|
|
94
|
+
if (groupOrder !== 0)
|
|
95
|
+
return groupOrder;
|
|
96
|
+
if (left.artifact === "active_database" && right.artifact !== "active_database")
|
|
97
|
+
return 1;
|
|
98
|
+
if (right.artifact === "active_database" && left.artifact !== "active_database")
|
|
99
|
+
return -1;
|
|
100
|
+
return lexicalCompare(left.relativeName, right.relativeName);
|
|
101
|
+
}
|
|
102
|
+
function emptyFailureDetails(reasonCounts = {}) {
|
|
103
|
+
return {
|
|
104
|
+
removed_artifact_count: 0,
|
|
105
|
+
not_removed_artifact_count: 0,
|
|
106
|
+
unrecognized_regular_file_count: 0,
|
|
107
|
+
reason_counts: reasonCounts,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function cacheError(code, message, details = emptyFailureDetails()) {
|
|
111
|
+
return new SymbolIndexCacheError(code, message, details);
|
|
112
|
+
}
|
|
113
|
+
function rootSafetyError(reason = "non_regular") {
|
|
114
|
+
return cacheError("CACHE_UNSAFE_ROOT", "Cache root failed physical safety validation.", {
|
|
115
|
+
...emptyFailureDetails(),
|
|
116
|
+
reason_counts: { [reason]: 1 },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function directoryTrustReason(stats, selectedRoot) {
|
|
120
|
+
if (!SUPPORTS_OWNER_CHECK)
|
|
121
|
+
return null;
|
|
122
|
+
const currentUid = process.getuid();
|
|
123
|
+
if (selectedRoot) {
|
|
124
|
+
if (stats.uid !== currentUid)
|
|
125
|
+
return "wrong_owner";
|
|
126
|
+
return (stats.mode & GROUP_OR_OTHER_ACCESS_MODE) === 0 ? null : "unsafe_permissions";
|
|
127
|
+
}
|
|
128
|
+
if (stats.uid !== currentUid && stats.uid !== 0)
|
|
129
|
+
return "wrong_owner";
|
|
130
|
+
if ((stats.mode & GROUP_OR_OTHER_WRITE_MODE) !== 0 &&
|
|
131
|
+
(stats.mode & STICKY_DIRECTORY_MODE) === 0) {
|
|
132
|
+
return "unsafe_permissions";
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
async function validateRoot(root) {
|
|
137
|
+
const parsed = path.parse(root);
|
|
138
|
+
const relative = path.relative(parsed.root, root);
|
|
139
|
+
const segments = relative === "" ? [] : relative.split(path.sep);
|
|
140
|
+
let candidate = parsed.root;
|
|
141
|
+
for (const segment of segments) {
|
|
142
|
+
candidate = path.join(candidate, segment);
|
|
143
|
+
let stats;
|
|
144
|
+
try {
|
|
145
|
+
stats = await lstat(candidate);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error.code === "ENOENT")
|
|
149
|
+
return "missing";
|
|
150
|
+
throw rootSafetyError();
|
|
151
|
+
}
|
|
152
|
+
if (stats.isSymbolicLink() || !stats.isDirectory())
|
|
153
|
+
throw rootSafetyError();
|
|
154
|
+
const trustReason = directoryTrustReason(stats, candidate === root);
|
|
155
|
+
if (trustReason)
|
|
156
|
+
throw rootSafetyError(trustReason);
|
|
157
|
+
try {
|
|
158
|
+
if ((await realpath(candidate)) !== candidate)
|
|
159
|
+
throw rootSafetyError();
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (error instanceof SymbolIndexCacheError)
|
|
163
|
+
throw error;
|
|
164
|
+
throw rootSafetyError();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return "ready";
|
|
168
|
+
}
|
|
169
|
+
function assertContained(root, candidate) {
|
|
170
|
+
const relative = path.relative(root, candidate);
|
|
171
|
+
if (relative === "" ||
|
|
172
|
+
relative === ".." ||
|
|
173
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
174
|
+
path.isAbsolute(relative)) {
|
|
175
|
+
throw rootSafetyError("changed");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function markUnsafe(state, relativeName, reason) {
|
|
179
|
+
state.unsafeEntries.add(relativeName);
|
|
180
|
+
incrementReason(state.reasonCounts, reason);
|
|
181
|
+
}
|
|
182
|
+
function ownerIsUnsafe(stats) {
|
|
183
|
+
return SUPPORTS_OWNER_CHECK && stats.uid !== process.getuid();
|
|
184
|
+
}
|
|
185
|
+
async function isReadable(filePath, directory) {
|
|
186
|
+
try {
|
|
187
|
+
await access(filePath, directory ? constants.R_OK | constants.X_OK : constants.R_OK);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function classifyNode(stats) {
|
|
195
|
+
if (stats.isSymbolicLink())
|
|
196
|
+
return "symlink";
|
|
197
|
+
if (stats.isDirectory())
|
|
198
|
+
return "directory";
|
|
199
|
+
if (stats.isFile())
|
|
200
|
+
return "regular";
|
|
201
|
+
return "other";
|
|
202
|
+
}
|
|
203
|
+
function inventoryFailureDetails(inventory, removedArtifactCount = 0, reasonCounts = inventory.reasonCounts) {
|
|
204
|
+
const recognizedCount = inventory.entries.filter((entry) => entry.artifact !== null).length;
|
|
205
|
+
return {
|
|
206
|
+
removed_artifact_count: removedArtifactCount,
|
|
207
|
+
not_removed_artifact_count: Math.max(0, recognizedCount - removedArtifactCount),
|
|
208
|
+
unrecognized_regular_file_count: inventory.unrecognizedRegularFileCount,
|
|
209
|
+
reason_counts: reasonCounts,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
async function inventoryRoot(root, limits) {
|
|
213
|
+
let rootStats;
|
|
214
|
+
try {
|
|
215
|
+
rootStats = await lstat(root);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
throw rootSafetyError("changed");
|
|
219
|
+
}
|
|
220
|
+
if (rootStats.isSymbolicLink() || !rootStats.isDirectory())
|
|
221
|
+
throw rootSafetyError();
|
|
222
|
+
const rootTrustReason = directoryTrustReason(rootStats, true);
|
|
223
|
+
if (rootTrustReason)
|
|
224
|
+
throw rootSafetyError(rootTrustReason);
|
|
225
|
+
try {
|
|
226
|
+
if ((await realpath(root)) !== root)
|
|
227
|
+
throw rootSafetyError();
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (error instanceof SymbolIndexCacheError)
|
|
231
|
+
throw error;
|
|
232
|
+
throw rootSafetyError();
|
|
233
|
+
}
|
|
234
|
+
const state = {
|
|
235
|
+
recordCount: 0,
|
|
236
|
+
totalBytes: 0,
|
|
237
|
+
regularFileCount: 0,
|
|
238
|
+
activeDatabaseCount: 0,
|
|
239
|
+
sidecarCount: 0,
|
|
240
|
+
quarantineCount: 0,
|
|
241
|
+
unrecognizedRegularFileCount: 0,
|
|
242
|
+
entries: [],
|
|
243
|
+
unsafeEntries: new Set(),
|
|
244
|
+
reasonCounts: {},
|
|
245
|
+
};
|
|
246
|
+
if (ownerIsUnsafe(rootStats))
|
|
247
|
+
markUnsafe(state, "", "wrong_owner");
|
|
248
|
+
if (!(await isReadable(root, true)))
|
|
249
|
+
throw rootSafetyError();
|
|
250
|
+
const visit = async (directoryPath) => {
|
|
251
|
+
let directoryRealPath;
|
|
252
|
+
try {
|
|
253
|
+
directoryRealPath = await realpath(directoryPath);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
throw rootSafetyError("changed");
|
|
257
|
+
}
|
|
258
|
+
if (directoryRealPath !== directoryPath)
|
|
259
|
+
throw rootSafetyError("changed");
|
|
260
|
+
const names = [];
|
|
261
|
+
try {
|
|
262
|
+
const directory = await opendir(directoryPath);
|
|
263
|
+
for await (const directoryEntry of directory) {
|
|
264
|
+
state.recordCount += 1;
|
|
265
|
+
if (state.recordCount > limits.maxRecords) {
|
|
266
|
+
throw cacheError("CACHE_LIMIT", "Cache inventory exceeded its bounded safety limits.", {
|
|
267
|
+
...emptyFailureDetails(),
|
|
268
|
+
reason_counts: { record_limit: 1 },
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
names.push(directoryEntry.name);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (error instanceof SymbolIndexCacheError)
|
|
276
|
+
throw error;
|
|
277
|
+
throw cacheError("CACHE_UNSAFE", "Cache inventory could not safely read the selected tree.", {
|
|
278
|
+
...emptyFailureDetails(),
|
|
279
|
+
reason_counts: { unreadable: 1 },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
names.sort(lexicalCompare);
|
|
283
|
+
for (const name of names) {
|
|
284
|
+
const absolutePath = path.join(directoryPath, name);
|
|
285
|
+
assertContained(root, absolutePath);
|
|
286
|
+
const relativeName = path.relative(root, absolutePath);
|
|
287
|
+
let stats;
|
|
288
|
+
try {
|
|
289
|
+
stats = await lstat(absolutePath);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
throw cacheError("CACHE_UNSAFE", "Cache inventory changed during inspection.", {
|
|
293
|
+
...emptyFailureDetails(),
|
|
294
|
+
reason_counts: { changed: 1 },
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
const artifact = classifyArtifact(name);
|
|
298
|
+
const nodeType = classifyNode(stats);
|
|
299
|
+
const entry = {
|
|
300
|
+
absolutePath,
|
|
301
|
+
relativeName,
|
|
302
|
+
name,
|
|
303
|
+
identity: snapshot(stats),
|
|
304
|
+
artifact,
|
|
305
|
+
nodeType,
|
|
306
|
+
};
|
|
307
|
+
if (nodeType === "regular") {
|
|
308
|
+
if (!Number.isSafeInteger(stats.size) ||
|
|
309
|
+
stats.size < 0 ||
|
|
310
|
+
state.totalBytes > limits.maxBytes - stats.size) {
|
|
311
|
+
throw cacheError("CACHE_LIMIT", "Cache inventory exceeded its bounded safety limits.", {
|
|
312
|
+
...emptyFailureDetails(),
|
|
313
|
+
reason_counts: { byte_limit: 1 },
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
state.totalBytes += stats.size;
|
|
317
|
+
state.regularFileCount += 1;
|
|
318
|
+
if (artifact === "active_database")
|
|
319
|
+
state.activeDatabaseCount += 1;
|
|
320
|
+
else if (artifact === "wal" || artifact === "shm")
|
|
321
|
+
state.sidecarCount += 1;
|
|
322
|
+
else if (artifact === "quarantine")
|
|
323
|
+
state.quarantineCount += 1;
|
|
324
|
+
else
|
|
325
|
+
state.unrecognizedRegularFileCount += 1;
|
|
326
|
+
if (stats.nlink !== 1)
|
|
327
|
+
markUnsafe(state, relativeName, "multiple_links");
|
|
328
|
+
if (ownerIsUnsafe(stats))
|
|
329
|
+
markUnsafe(state, relativeName, "wrong_owner");
|
|
330
|
+
if (!(await isReadable(absolutePath, false)))
|
|
331
|
+
markUnsafe(state, relativeName, "unreadable");
|
|
332
|
+
state.entries.push(entry);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (nodeType === "symlink") {
|
|
336
|
+
markUnsafe(state, relativeName, "symlink");
|
|
337
|
+
state.entries.push(entry);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (nodeType === "directory") {
|
|
341
|
+
if (artifact !== null)
|
|
342
|
+
markUnsafe(state, relativeName, "non_regular");
|
|
343
|
+
if (ownerIsUnsafe(stats))
|
|
344
|
+
markUnsafe(state, relativeName, "wrong_owner");
|
|
345
|
+
if ((stats.mode & GROUP_OR_OTHER_ACCESS_MODE) !== 0) {
|
|
346
|
+
markUnsafe(state, relativeName, "unsafe_permissions");
|
|
347
|
+
}
|
|
348
|
+
if (!(await isReadable(absolutePath, true))) {
|
|
349
|
+
markUnsafe(state, relativeName, "unreadable");
|
|
350
|
+
state.entries.push(entry);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
state.entries.push(entry);
|
|
354
|
+
await visit(absolutePath);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
markUnsafe(state, relativeName, "non_regular");
|
|
358
|
+
if (ownerIsUnsafe(stats))
|
|
359
|
+
markUnsafe(state, relativeName, "wrong_owner");
|
|
360
|
+
state.entries.push(entry);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
await visit(root);
|
|
364
|
+
return {
|
|
365
|
+
root,
|
|
366
|
+
rootIdentity: snapshot(rootStats),
|
|
367
|
+
entries: state.entries,
|
|
368
|
+
regularFileCount: state.regularFileCount,
|
|
369
|
+
totalBytes: state.totalBytes,
|
|
370
|
+
activeDatabaseCount: state.activeDatabaseCount,
|
|
371
|
+
sidecarCount: state.sidecarCount,
|
|
372
|
+
quarantineCount: state.quarantineCount,
|
|
373
|
+
unrecognizedRegularFileCount: state.unrecognizedRegularFileCount,
|
|
374
|
+
unsafeEntryCount: state.unsafeEntries.size,
|
|
375
|
+
reasonCounts: state.reasonCounts,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
async function identityIsCurrent(filePath, expected) {
|
|
379
|
+
try {
|
|
380
|
+
return sameIdentity(snapshot(await lstat(filePath)), expected);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function rootIsCurrent(inventory) {
|
|
387
|
+
let stats;
|
|
388
|
+
try {
|
|
389
|
+
stats = await lstat(inventory.root);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
if (stats.isSymbolicLink() ||
|
|
395
|
+
!stats.isDirectory() ||
|
|
396
|
+
stats.dev !== inventory.rootIdentity.dev ||
|
|
397
|
+
stats.ino !== inventory.rootIdentity.ino ||
|
|
398
|
+
stats.mode !== inventory.rootIdentity.mode) {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
return (await realpath(inventory.root)) === inventory.root;
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
async function inventoryIsCurrent(inventory) {
|
|
409
|
+
if (!(await rootIsCurrent(inventory)))
|
|
410
|
+
return false;
|
|
411
|
+
for (const entry of inventory.entries) {
|
|
412
|
+
if (!(await identityIsCurrent(entry.absolutePath, entry.identity)))
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
function identityIsCurrentSync(filePath, expected) {
|
|
418
|
+
try {
|
|
419
|
+
return sameIdentity(snapshot(lstatSync(filePath)), expected);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function directoryChainIsCurrentSync(inventory, targetPath) {
|
|
426
|
+
let directory = path.dirname(targetPath);
|
|
427
|
+
for (;;) {
|
|
428
|
+
const expected = directory === inventory.root
|
|
429
|
+
? inventory.rootIdentity
|
|
430
|
+
: inventory.entries.find((entry) => entry.absolutePath === directory && entry.nodeType === "directory")?.identity;
|
|
431
|
+
if (!expected)
|
|
432
|
+
return false;
|
|
433
|
+
try {
|
|
434
|
+
const stats = lstatSync(directory);
|
|
435
|
+
if (stats.isSymbolicLink() ||
|
|
436
|
+
!stats.isDirectory() ||
|
|
437
|
+
!sameDirectoryIdentity(snapshot(stats), expected) ||
|
|
438
|
+
realpathSync(directory) !== directory) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
if (directory === inventory.root)
|
|
446
|
+
return true;
|
|
447
|
+
directory = path.dirname(directory);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async function hasSQLiteHeader(entry) {
|
|
451
|
+
let handle;
|
|
452
|
+
try {
|
|
453
|
+
handle = await open(entry.absolutePath, constants.O_RDONLY | NO_FOLLOW);
|
|
454
|
+
const header = Buffer.alloc(SQLITE_HEADER.length);
|
|
455
|
+
const { bytesRead } = await handle.read(header, 0, header.length, 0);
|
|
456
|
+
if (!(await identityIsCurrent(entry.absolutePath, entry.identity))) {
|
|
457
|
+
throw cacheError("CACHE_UNSAFE", "Cache inventory changed during preflight.", {
|
|
458
|
+
...emptyFailureDetails(),
|
|
459
|
+
reason_counts: { changed: 1 },
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return bytesRead === SQLITE_HEADER.length && header.equals(SQLITE_HEADER);
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
if (error instanceof SymbolIndexCacheError)
|
|
466
|
+
throw error;
|
|
467
|
+
throw cacheError("CACHE_UNSAFE", "Cache database could not be checked safely.", {
|
|
468
|
+
...emptyFailureDetails(),
|
|
469
|
+
reason_counts: { lock_check_failed: 1 },
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
await handle?.close().catch(() => undefined);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function isLockError(error) {
|
|
477
|
+
const code = error.code;
|
|
478
|
+
if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED")
|
|
479
|
+
return true;
|
|
480
|
+
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
481
|
+
return message.includes("busy") || message.includes("locked");
|
|
482
|
+
}
|
|
483
|
+
function isCorruptDatabaseError(error) {
|
|
484
|
+
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
485
|
+
return (message.includes("malformed") ||
|
|
486
|
+
message.includes("not a database") ||
|
|
487
|
+
message.includes("database disk image"));
|
|
488
|
+
}
|
|
489
|
+
function changedDatabaseArtifactError(inventory, removedArtifactCount) {
|
|
490
|
+
return cacheError("CACHE_UNSAFE", "Cache database identity changed before activity guarding.", inventoryFailureDetails(inventory, removedArtifactCount, { changed: 1 }));
|
|
491
|
+
}
|
|
492
|
+
function openBoundDatabaseArtifact(entry, inventory, removedArtifactCount) {
|
|
493
|
+
let descriptor;
|
|
494
|
+
try {
|
|
495
|
+
descriptor = openSync(entry.absolutePath, constants.O_RDWR | NO_FOLLOW);
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
throw changedDatabaseArtifactError(inventory, removedArtifactCount);
|
|
499
|
+
}
|
|
500
|
+
let transferred = false;
|
|
501
|
+
try {
|
|
502
|
+
const stats = fstatSync(descriptor);
|
|
503
|
+
if (!stats.isFile() || !sameIdentity(snapshot(stats), entry.identity)) {
|
|
504
|
+
throw changedDatabaseArtifactError(inventory, removedArtifactCount);
|
|
505
|
+
}
|
|
506
|
+
const openPath = `/proc/self/fd/${descriptor}`;
|
|
507
|
+
let resolvedPath;
|
|
508
|
+
try {
|
|
509
|
+
resolvedPath = realpathSync(openPath);
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
throw cacheError("CACHE_UNSAFE", "Cache database descriptor binding is unavailable.", inventoryFailureDetails(inventory, removedArtifactCount, { lock_check_failed: 1 }));
|
|
513
|
+
}
|
|
514
|
+
if (resolvedPath !== entry.absolutePath) {
|
|
515
|
+
throw changedDatabaseArtifactError(inventory, removedArtifactCount);
|
|
516
|
+
}
|
|
517
|
+
transferred = true;
|
|
518
|
+
return { descriptor, openPath };
|
|
519
|
+
}
|
|
520
|
+
finally {
|
|
521
|
+
if (!transferred)
|
|
522
|
+
closeSync(descriptor);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
function openBoundDatabase(Database, entry, inventory, removedArtifactCount) {
|
|
526
|
+
const artifact = openBoundDatabaseArtifact(entry, inventory, removedArtifactCount);
|
|
527
|
+
try {
|
|
528
|
+
return new Database(artifact.openPath);
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
closeSync(artifact.descriptor);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function assertDatabaseInactive(Database, entry, inventory) {
|
|
535
|
+
let database;
|
|
536
|
+
let transactionStarted = false;
|
|
537
|
+
try {
|
|
538
|
+
database = openBoundDatabase(Database, entry, inventory, 0);
|
|
539
|
+
database.exec("PRAGMA busy_timeout = 0");
|
|
540
|
+
database.exec("BEGIN IMMEDIATE");
|
|
541
|
+
transactionStarted = true;
|
|
542
|
+
database.exec("ROLLBACK");
|
|
543
|
+
transactionStarted = false;
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
if (error instanceof SymbolIndexCacheError)
|
|
547
|
+
throw error;
|
|
548
|
+
if (isLockError(error)) {
|
|
549
|
+
throw cacheError("CACHE_ACTIVE", "Cache clear refused a concurrently active database.", inventoryFailureDetails(inventory, 0, { active_database: 1 }));
|
|
550
|
+
}
|
|
551
|
+
if (!isCorruptDatabaseError(error)) {
|
|
552
|
+
throw cacheError("CACHE_UNSAFE", "Cache database activity could not be checked safely.", inventoryFailureDetails(inventory, 0, { lock_check_failed: 1 }));
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
finally {
|
|
556
|
+
if (transactionStarted) {
|
|
557
|
+
try {
|
|
558
|
+
database?.exec("ROLLBACK");
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
// Closing the private probe remains the fail-closed cleanup path.
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
try {
|
|
565
|
+
database?.close();
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
// The clear operation has already failed closed if this probe did not complete.
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function acquireExclusiveDatabaseGuard(Database, entry, inventory, removedArtifactCount) {
|
|
573
|
+
let database;
|
|
574
|
+
let transactionStarted = false;
|
|
575
|
+
try {
|
|
576
|
+
database = openBoundDatabase(Database, entry, inventory, removedArtifactCount);
|
|
577
|
+
database.exec("PRAGMA busy_timeout = 0");
|
|
578
|
+
database.exec("PRAGMA locking_mode = EXCLUSIVE");
|
|
579
|
+
database.exec("BEGIN EXCLUSIVE");
|
|
580
|
+
transactionStarted = true;
|
|
581
|
+
return { database, transactionStarted, released: false };
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
if (transactionStarted) {
|
|
585
|
+
try {
|
|
586
|
+
database?.exec("ROLLBACK");
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
// Closing the failed private guard remains the cleanup path.
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
database?.close();
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
// The group remains untouched when guard acquisition fails.
|
|
597
|
+
}
|
|
598
|
+
if (error instanceof SymbolIndexCacheError)
|
|
599
|
+
throw error;
|
|
600
|
+
if (isLockError(error)) {
|
|
601
|
+
throw cacheError("CACHE_ACTIVE", "Cache clear refused a concurrently active database.", inventoryFailureDetails(inventory, removedArtifactCount, { active_database: 1 }));
|
|
602
|
+
}
|
|
603
|
+
throw cacheError("CACHE_UNSAFE", "Cache database activity could not be guarded safely.", inventoryFailureDetails(inventory, removedArtifactCount, { lock_check_failed: 1 }));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function releaseExclusiveDatabaseGuard(guard) {
|
|
607
|
+
if (guard.released)
|
|
608
|
+
return true;
|
|
609
|
+
guard.released = true;
|
|
610
|
+
let released = true;
|
|
611
|
+
if (guard.transactionStarted) {
|
|
612
|
+
try {
|
|
613
|
+
guard.database.exec("ROLLBACK");
|
|
614
|
+
guard.transactionStarted = false;
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
released = false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
guard.database.close();
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
released = false;
|
|
625
|
+
}
|
|
626
|
+
return released;
|
|
627
|
+
}
|
|
628
|
+
async function assertDatabasesInactive(inventory, DatabaseOverride) {
|
|
629
|
+
const databases = inventory.entries.filter((entry) => entry.nodeType === "regular" && entry.artifact === "active_database");
|
|
630
|
+
if (databases.length === 0)
|
|
631
|
+
return null;
|
|
632
|
+
let Database = DatabaseOverride;
|
|
633
|
+
if (!Database) {
|
|
634
|
+
try {
|
|
635
|
+
({ DatabaseSync: Database } = await import("node:sqlite"));
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
throw cacheError("CACHE_UNSAFE", "Cache database activity could not be checked on this runtime.", inventoryFailureDetails(inventory, 0, { lock_check_failed: databases.length }));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const sqlitePaths = new Set();
|
|
642
|
+
for (const entry of databases) {
|
|
643
|
+
if (!(await hasSQLiteHeader(entry)))
|
|
644
|
+
continue;
|
|
645
|
+
assertDatabaseInactive(Database, entry, inventory);
|
|
646
|
+
sqlitePaths.add(entry.absolutePath);
|
|
647
|
+
}
|
|
648
|
+
return { Database, sqlitePaths };
|
|
649
|
+
}
|
|
650
|
+
function policyFrom(options) {
|
|
651
|
+
return readSymbolIndexPersistencePolicy(options.environment, options.resolveHomeDirectory);
|
|
652
|
+
}
|
|
653
|
+
function emptyInspectResult(policy, state) {
|
|
654
|
+
return {
|
|
655
|
+
status: "ok",
|
|
656
|
+
command: "cache inspect",
|
|
657
|
+
policy: policy.mode,
|
|
658
|
+
backend: policy.backend,
|
|
659
|
+
state,
|
|
660
|
+
regular_file_count: 0,
|
|
661
|
+
total_bytes: 0,
|
|
662
|
+
active_database_count: 0,
|
|
663
|
+
sidecar_count: 0,
|
|
664
|
+
quarantine_count: 0,
|
|
665
|
+
unrecognized_regular_file_count: 0,
|
|
666
|
+
unsafe_entry_count: 0,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
function emptyClearResult(policy, state) {
|
|
670
|
+
return {
|
|
671
|
+
status: "ok",
|
|
672
|
+
command: "cache clear",
|
|
673
|
+
policy: policy.mode,
|
|
674
|
+
backend: policy.backend,
|
|
675
|
+
state,
|
|
676
|
+
deleted_artifact_count: 0,
|
|
677
|
+
deleted_active_database_count: 0,
|
|
678
|
+
deleted_sidecar_count: 0,
|
|
679
|
+
deleted_quarantine_count: 0,
|
|
680
|
+
unrecognized_regular_file_count: 0,
|
|
681
|
+
not_removed_artifact_count: 0,
|
|
682
|
+
reason_counts: {},
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
export async function inspectSymbolIndexCache(options = {}) {
|
|
686
|
+
const policy = policyFrom(options);
|
|
687
|
+
if (policy.backend !== "sqlite" || !policy.cache_root)
|
|
688
|
+
return emptyInspectResult(policy, "disabled");
|
|
689
|
+
if ((await validateRoot(policy.cache_root)) === "missing")
|
|
690
|
+
return emptyInspectResult(policy, "missing");
|
|
691
|
+
const inventory = await inventoryRoot(policy.cache_root, limitsFrom(options));
|
|
692
|
+
return {
|
|
693
|
+
status: "ok",
|
|
694
|
+
command: "cache inspect",
|
|
695
|
+
policy: policy.mode,
|
|
696
|
+
backend: policy.backend,
|
|
697
|
+
state: "ready",
|
|
698
|
+
regular_file_count: inventory.regularFileCount,
|
|
699
|
+
total_bytes: inventory.totalBytes,
|
|
700
|
+
active_database_count: inventory.activeDatabaseCount,
|
|
701
|
+
sidecar_count: inventory.sidecarCount,
|
|
702
|
+
quarantine_count: inventory.quarantineCount,
|
|
703
|
+
unrecognized_regular_file_count: inventory.unrecognizedRegularFileCount,
|
|
704
|
+
unsafe_entry_count: inventory.unsafeEntryCount,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
export async function clearSymbolIndexCache(options = {}) {
|
|
708
|
+
const policy = policyFrom(options);
|
|
709
|
+
if (policy.backend !== "sqlite" || !policy.cache_root)
|
|
710
|
+
return emptyClearResult(policy, "disabled");
|
|
711
|
+
if ((await validateRoot(policy.cache_root)) === "missing")
|
|
712
|
+
return emptyClearResult(policy, "missing");
|
|
713
|
+
const limits = limitsFrom(options);
|
|
714
|
+
const initialInventory = await inventoryRoot(policy.cache_root, limits);
|
|
715
|
+
if (initialInventory.unsafeEntryCount > 0) {
|
|
716
|
+
throw cacheError("CACHE_UNSAFE", "Cache clear refused unsafe selected-tree entries.", inventoryFailureDetails(initialInventory));
|
|
717
|
+
}
|
|
718
|
+
if (!(await inventoryIsCurrent(initialInventory))) {
|
|
719
|
+
throw cacheError("CACHE_UNSAFE", "Cache inventory changed during preflight.", inventoryFailureDetails(initialInventory, 0, { changed: 1 }));
|
|
720
|
+
}
|
|
721
|
+
const inventory = await inventoryRoot(policy.cache_root, limits);
|
|
722
|
+
if (inventory.unsafeEntryCount > 0) {
|
|
723
|
+
throw cacheError("CACHE_UNSAFE", "Cache clear refused unsafe selected-tree entries.", inventoryFailureDetails(inventory));
|
|
724
|
+
}
|
|
725
|
+
if (!(await inventoryIsCurrent(inventory))) {
|
|
726
|
+
throw cacheError("CACHE_UNSAFE", "Cache inventory changed during preflight.", inventoryFailureDetails(inventory, 0, { changed: 1 }));
|
|
727
|
+
}
|
|
728
|
+
const activityProbe = await assertDatabasesInactive(inventory, options.DatabaseSync);
|
|
729
|
+
const targets = inventory.entries
|
|
730
|
+
.filter((entry) => entry.nodeType === "regular" && entry.artifact !== null)
|
|
731
|
+
.sort(deletionCompare);
|
|
732
|
+
const deleted = {
|
|
733
|
+
active_database: 0,
|
|
734
|
+
wal: 0,
|
|
735
|
+
shm: 0,
|
|
736
|
+
quarantine: 0,
|
|
737
|
+
};
|
|
738
|
+
const guardedGroups = new Map();
|
|
739
|
+
if (activityProbe) {
|
|
740
|
+
for (const target of targets) {
|
|
741
|
+
if (target.artifact === "active_database" &&
|
|
742
|
+
activityProbe.sqlitePaths.has(target.absolutePath)) {
|
|
743
|
+
guardedGroups.set(artifactGroup(target), { main: target, indexes: [] });
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
for (const [index, target] of targets.entries()) {
|
|
747
|
+
const group = guardedGroups.get(artifactGroup(target));
|
|
748
|
+
if (group)
|
|
749
|
+
group.indexes.push(index);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const preflightedHooks = new Set();
|
|
753
|
+
let activeGuard;
|
|
754
|
+
const partialFailure = (reason) => {
|
|
755
|
+
const removedArtifactCount = Object.values(deleted).reduce((total, count) => total + count, 0);
|
|
756
|
+
const reasonCounts = { [reason]: 1 };
|
|
757
|
+
const notAttempted = targets.length - removedArtifactCount - 1;
|
|
758
|
+
if (notAttempted > 0)
|
|
759
|
+
reasonCounts.not_attempted = notAttempted;
|
|
760
|
+
return cacheError("CACHE_PARTIAL", "Cache clear stopped before every target was removed.", {
|
|
761
|
+
removed_artifact_count: removedArtifactCount,
|
|
762
|
+
not_removed_artifact_count: targets.length - removedArtifactCount,
|
|
763
|
+
unrecognized_regular_file_count: inventory.unrecognizedRegularFileCount,
|
|
764
|
+
reason_counts: reasonCounts,
|
|
765
|
+
});
|
|
766
|
+
};
|
|
767
|
+
try {
|
|
768
|
+
for (const [index, target] of targets.entries()) {
|
|
769
|
+
const guardedGroup = guardedGroups.get(artifactGroup(target));
|
|
770
|
+
if (guardedGroup?.indexes[0] === index) {
|
|
771
|
+
for (const hookIndex of guardedGroup.indexes) {
|
|
772
|
+
const hookTarget = targets[hookIndex];
|
|
773
|
+
try {
|
|
774
|
+
await options.testHooks?.beforeUnlink?.({
|
|
775
|
+
artifact: hookTarget.artifact,
|
|
776
|
+
name: hookTarget.name,
|
|
777
|
+
ordinal: hookIndex,
|
|
778
|
+
});
|
|
779
|
+
preflightedHooks.add(hookIndex);
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
throw partialFailure("delete_failed");
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
const removedArtifactCount = Object.values(deleted).reduce((total, count) => total + count, 0);
|
|
786
|
+
activeGuard = acquireExclusiveDatabaseGuard(activityProbe.Database, guardedGroup.main, inventory, removedArtifactCount);
|
|
787
|
+
}
|
|
788
|
+
let reason = null;
|
|
789
|
+
if (!preflightedHooks.has(index)) {
|
|
790
|
+
try {
|
|
791
|
+
await options.testHooks?.beforeUnlink?.({
|
|
792
|
+
artifact: target.artifact,
|
|
793
|
+
name: target.name,
|
|
794
|
+
ordinal: index,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
reason = "delete_failed";
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (reason === null) {
|
|
802
|
+
if (!directoryChainIsCurrentSync(inventory, target.absolutePath) ||
|
|
803
|
+
!identityIsCurrentSync(target.absolutePath, target.identity)) {
|
|
804
|
+
reason = "changed";
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
if (reason === null) {
|
|
808
|
+
try {
|
|
809
|
+
unlinkSync(target.absolutePath);
|
|
810
|
+
deleted[target.artifact] += 1;
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
reason = "delete_failed";
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (reason !== null)
|
|
817
|
+
throw partialFailure(reason);
|
|
818
|
+
if (guardedGroup &&
|
|
819
|
+
guardedGroup.indexes[guardedGroup.indexes.length - 1] === index &&
|
|
820
|
+
activeGuard) {
|
|
821
|
+
const released = releaseExclusiveDatabaseGuard(activeGuard);
|
|
822
|
+
activeGuard = undefined;
|
|
823
|
+
if (!released)
|
|
824
|
+
throw partialFailure("lock_check_failed");
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
finally {
|
|
829
|
+
if (activeGuard)
|
|
830
|
+
releaseExclusiveDatabaseGuard(activeGuard);
|
|
831
|
+
}
|
|
832
|
+
const deletedArtifactCount = Object.values(deleted).reduce((total, count) => total + count, 0);
|
|
833
|
+
return {
|
|
834
|
+
status: "ok",
|
|
835
|
+
command: "cache clear",
|
|
836
|
+
policy: policy.mode,
|
|
837
|
+
backend: policy.backend,
|
|
838
|
+
state: "cleared",
|
|
839
|
+
deleted_artifact_count: deletedArtifactCount,
|
|
840
|
+
deleted_active_database_count: deleted.active_database,
|
|
841
|
+
deleted_sidecar_count: deleted.wal + deleted.shm,
|
|
842
|
+
deleted_quarantine_count: deleted.quarantine,
|
|
843
|
+
unrecognized_regular_file_count: inventory.unrecognizedRegularFileCount,
|
|
844
|
+
not_removed_artifact_count: 0,
|
|
845
|
+
reason_counts: {},
|
|
846
|
+
};
|
|
847
|
+
}
|