@milpa/comal 0.2.6 → 0.2.8

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.
@@ -0,0 +1,675 @@
1
+ import { open, mkdir, writeFile, chmod, lstat, stat, readdir, readlink, realpath, rm, rmdir, symlink, constants } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { inspectPluginModule, pluginId, MAX_CODE_FILES, PLUGIN_SPECIFIERS } from './plugin-config.mjs';
5
+ import { defaultStateDir } from './compat.mjs';
6
+
7
+ // The plugin workbench's candidate: a private copy of a plugin under
8
+ // <stateDir>/plugin-candidates/<id>/<candidateId>/tree, where a repair is made and tested.
9
+ // The installed files and the user's originals are only ever read.
10
+ //
11
+ // Own files and host modules. Of the files the inspector covers, the plugin's OWN files are
12
+ // those inside the directory of its entry module (recursively): they are copied, and they are
13
+ // all a repair may change or add. Every other covered file is a HOST MODULE (the house's own
14
+ // modules a bundled plugin imports, a workspace file shared with other code): it appears in
15
+ // tree at its relative place as a symbolic link to the installed file's real path, recorded
16
+ // in candidate.json and the seal. Node resolves a linked module to its real path, so the
17
+ // repaired plugin imports the same module instances as the host (its protected paths, its
18
+ // catalogs), never private copies of them.
19
+ //
20
+ // The model writes every byte inside tree, so nothing in the candidate directory is
21
+ // trusted on its own. What later tasks must hold:
22
+ // - the writable root given to the model is exactly `tree`, never `dir` or above, and the file
23
+ // tools refuse a path under it whose real path leaves it (a write through a host link);
24
+ // - the caller keeps `seal` (from openCandidate) in the session journal and passes it
25
+ // to readCandidate: candidate.json is only metadata until it hashes to that seal;
26
+ // - verifyCandidate runs immediately before every load of the candidate and again at
27
+ // promotion, and promotion copies only the files (and hashes) that verification returned
28
+ // and links exactly the recorded host modules (verifyPlacement checks the result).
29
+
30
+ const CANDIDATES = 'plugin-candidates';
31
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
32
+ const SHA = /^[0-9a-f]{64}$/;
33
+ const MAX_FILE_BYTES = 1024 * 1024; // the inspector's per-file bound
34
+ const MAX_READ_BYTES = 64 * 1024 * 1024; // all bytes one diff or one verification may read
35
+ const PATCH_LIMIT = 200 * 1024;
36
+ const PATCH_MARKER = '[patch truncated at 200 KB]\n';
37
+ const DIFF_CELLS = 4_000_000; // above this, a file is summarized instead of diffed
38
+ const CONTEXT = 3;
39
+ // Files Node may load as code: refused when opaque, and scanned for specifiers.
40
+ const CODE = /\.(?:m?js|cjs)$/, OPAQUE_CHECKED = /\.(?:m?js|cjs|json)$/;
41
+ const isCode = file => CODE.test(file) || !path.basename(file).includes('.');
42
+ const isOpaqueChecked = file => OPAQUE_CHECKED.test(file) || !path.basename(file).includes('.');
43
+ // The inspector's literal specifiers, plus CommonJS require: every one must stay inside tree.
44
+ // A module specifier may name no URL scheme but node:; a new URL(…, import.meta.url) only no file:.
45
+ const SPECIFIERS = [/\bfrom\s*['"]([^'"\n]+)['"]/g, /\bimport\s*['"]([^'"\n]+)['"]/g, /\bimport\s*\(\s*['"]([^'"\n]+)['"]\s*[,)]/g,
46
+ /(?<![.\w$])require\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g].map(pattern => [pattern, true])
47
+ .concat([[/\bnew\s+URL\s*\(\s*['"]([^'"\n]+)['"]\s*,\s*import\.meta\.url\s*\)/g, false]]);
48
+ // Constructs static inspection cannot follow. Listed for changed files; promotion decides.
49
+ const DYNAMIC = [
50
+ ['import()', /\bimport\s*\(\s*(?!(['"])[^'"\n]*\1\s*[,)])/],
51
+ ['createRequire', /\bcreateRequire\b/],
52
+ ['import.meta.resolve', /\bimport\s*\.\s*meta\s*\.\s*resolve\b/],
53
+ ['require()', /(?<![.\w$])require\s*\(/],
54
+ ['process.binding', /\bprocess\s*\.\s*binding\b/],
55
+ ['eval()', /(?<![.\w$])eval\s*\(/],
56
+ ['new Function', /(?<![.\w$])Function\s*\(/],
57
+ ];
58
+
59
+ const fail = (code, message) => Object.assign(new Error(message), { code });
60
+ const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
61
+ const posix = relative => relative.split(path.sep).join('/');
62
+ const sorted = values => [...values].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
63
+ const plainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
64
+
65
+ function inside(root, file) {
66
+ const relative = path.relative(root, file);
67
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
68
+ }
69
+
70
+ const stateRoot = (cwd, stateDir) => path.resolve(cwd, stateDir ?? defaultStateDir(cwd));
71
+
72
+ /**
73
+ * A reader with a total byte budget. Each read allocates the file's size plus one byte
74
+ * (to notice growth), refuses non-regular files, and can refuse to follow a final symlink.
75
+ */
76
+ function reader(total = MAX_READ_BYTES) {
77
+ let remaining = total;
78
+ return async function read(file, { limit = Infinity, nofollow = false } = {}) {
79
+ const handle = await open(file, nofollow ? constants.O_RDONLY | constants.O_NOFOLLOW : 'r');
80
+ try {
81
+ const info = await handle.stat();
82
+ if (!info.isFile()) throw new Error(`Not a regular file: ${file}`);
83
+ if (info.size > limit) throw new Error(`File larger than ${limit} bytes: ${file}`);
84
+ if (info.size > remaining) throw fail('PLUGIN_CANDIDATE_TOO_LARGE', `The candidate holds more than ${total} bytes to read (at ${file})`);
85
+ const buffer = Buffer.alloc(info.size + 1);
86
+ let offset = 0;
87
+ while (offset < buffer.length) {
88
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null);
89
+ if (!bytesRead) break;
90
+ offset += bytesRead;
91
+ }
92
+ if (offset > info.size) throw new Error(`File grew while it was read: ${file}`);
93
+ remaining -= offset;
94
+ return { bytes: buffer.subarray(0, offset), nlink: info.nlink };
95
+ } finally { await handle.close(); }
96
+ };
97
+ }
98
+
99
+ const boundedRead = async file => (await reader(MAX_FILE_BYTES)(file, { limit: MAX_FILE_BYTES })).bytes;
100
+
101
+ // A directory of ours: created 0700, or already there as a real directory (never a symlink).
102
+ async function ownDirectory(dir) {
103
+ try { await mkdir(dir, { mode: 0o700 }); }
104
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
105
+ const info = await lstat(dir);
106
+ if (info.isSymbolicLink() || !info.isDirectory()) throw fail('PLUGIN_CANDIDATE_REFUSED', `Not a plain directory: ${dir}`);
107
+ await chmod(dir, 0o700);
108
+ }
109
+
110
+ async function writePrivate(file, bytes) {
111
+ await writeFile(file, bytes, { flag: 'wx', mode: 0o600 });
112
+ await chmod(file, 0o600);
113
+ }
114
+
115
+ function commonDirectory(directories) {
116
+ const split = directories.map(dir => dir.split(path.sep));
117
+ let length = split[0].length;
118
+ for (const parts of split) {
119
+ let i = 0;
120
+ while (i < length && i < parts.length && parts[i] === split[0][i]) i++;
121
+ length = i;
122
+ }
123
+ const common = split[0].slice(0, length).join(path.sep);
124
+ if (!common) {
125
+ if (split[0][0] === '') return path.sep;
126
+ throw new Error('The plugin files share no common directory');
127
+ }
128
+ return common;
129
+ }
130
+
131
+ // Node resolves bare specifiers from the first node_modules found walking up from the importer.
132
+ async function nearestNodeModules(start) {
133
+ for (let dir = start; ; dir = path.dirname(dir)) {
134
+ if (path.basename(dir) !== 'node_modules') {
135
+ const candidate = path.join(dir, 'node_modules');
136
+ try { if ((await stat(candidate)).isDirectory()) return await realpath(candidate); } catch {}
137
+ }
138
+ if (path.dirname(dir) === dir) return null;
139
+ }
140
+ }
141
+
142
+ // The `type` of the nearest package.json: it decides whether a .js file is ESM.
143
+ async function packageType(start) {
144
+ for (let dir = start; ; dir = path.dirname(dir)) {
145
+ let bytes = null;
146
+ try { bytes = await boundedRead(path.join(dir, 'package.json')); } catch {}
147
+ if (bytes) {
148
+ try { const type = JSON.parse(bytes.toString('utf8')).type; return typeof type === 'string' ? type : null; }
149
+ catch { return null; }
150
+ }
151
+ if (path.dirname(dir) === dir) return null;
152
+ }
153
+ }
154
+
155
+ function requestedModule(cwd, module) {
156
+ if (typeof module !== 'string' || !module || path.isAbsolute(module) || /^[a-z][a-z0-9+.-]*:/i.test(module)) return module;
157
+ return path.resolve(cwd, module);
158
+ }
159
+
160
+ function canonical(value) {
161
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
162
+ if (plainObject(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
163
+ return JSON.stringify(value ?? null);
164
+ }
165
+
166
+ /**
167
+ * SHA-256 over the canonical JSON of what a candidate's trust rests on: its ids, its entry
168
+ * inside tree, the base it was copied from, and what open placed beside tree.
169
+ */
170
+ export function candidateSeal(candidate) {
171
+ const base = candidate?.base ?? {};
172
+ return sha256(canonical({
173
+ id: candidate?.id, candidateId: candidate?.candidateId,
174
+ entry: typeof candidate?.tree === 'string' && typeof candidate?.module === 'string' ? posix(path.relative(candidate.tree, candidate.module)) : null,
175
+ base: { module: base.module, fingerprint: base.fingerprint, logical: base.logical ?? null, files: base.files, external: base.external ?? null },
176
+ host_modules: candidate?.host_modules ?? {},
177
+ node_modules: candidate?.node_modules ?? null, package_json: candidate?.package_json ?? null,
178
+ }));
179
+ }
180
+
181
+ // The inspector's fingerprint formula over a list of { path, sha256 }.
182
+ const fingerprintOf = list => sha256(Buffer.from(JSON.stringify(list.map(({ path: file, sha256: hash }) => ({ path: file, sha256: hash })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0))));
183
+
184
+ /**
185
+ * Where each file the inspector covers sits in the plugin's layout: real path → logical path.
186
+ * They differ only for a file reached through a link (a host module of a placement that links
187
+ * them): Node loads the real file, and that file's own relative imports resolve from its real
188
+ * place, while its logical place is where the importer's specifier points.
189
+ */
190
+ async function logicalPlaces(entry) {
191
+ const places = new Map([[entry, entry]]), pending = [entry];
192
+ while (pending.length) {
193
+ const real = pending.shift(), at = places.get(real);
194
+ if (!/\.(?:mjs|js|cjs)$/.test(real)) continue;
195
+ let source;
196
+ try { source = (await boundedRead(real)).toString('utf8'); } catch { continue; }
197
+ for (const pattern of PLUGIN_SPECIFIERS) for (const match of source.matchAll(pattern)) {
198
+ const specifier = match[1];
199
+ if (!specifier.startsWith('./') && !specifier.startsWith('../')) continue;
200
+ const clean = specifier.split(/[?#]/)[0], target = path.resolve(path.dirname(real), clean);
201
+ let resolved; try { resolved = await realpath(target); } catch (error) { if (error.code !== 'ENOENT') throw error; resolved = target; }
202
+ if (places.has(resolved)) continue;
203
+ if (places.size >= MAX_CODE_FILES * 2) throw new Error(`The plugin reaches too many local files: ${entry}`);
204
+ places.set(resolved, path.resolve(path.dirname(at), clean)); pending.push(resolved);
205
+ }
206
+ }
207
+ return places;
208
+ }
209
+
210
+ // The recorded host links of a placement: relative place in tree → { target, sha256 }.
211
+ function hostModules(place) {
212
+ const recorded = place?.host_modules ?? {};
213
+ if (!plainObject(recorded)) throw fail('PLUGIN_CANDIDATE_INVALID', 'host_modules is not an object');
214
+ const out = new Map();
215
+ for (const relative of sorted(Object.keys(recorded))) {
216
+ const item = recorded[relative];
217
+ if (!relative || relative.split('/').some(part => !part || part === '.' || part === '..') || !plainObject(item) || typeof item.target !== 'string' || !path.isAbsolute(item.target) || typeof item.sha256 !== 'string' || !SHA.test(item.sha256)) {
218
+ throw fail('PLUGIN_CANDIDATE_INVALID', `Invalid host module record: ${relative}`);
219
+ }
220
+ out.set(relative, { target: item.target, sha256: item.sha256 });
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /**
226
+ * The code fingerprint of a placement (a tree of own files and host links), naming each covered
227
+ * file by its place in the layout: the same value wherever the placement lives, and the
228
+ * inspector's own fingerprint for a plugin that reaches nothing through a link. With `strict`,
229
+ * a covered file that is neither inside tree nor a recorded host module is an escape.
230
+ */
231
+ async function placementFingerprint(place, { strict = false } = {}) {
232
+ const inspection = await inspectPluginModule(place.module), tree = await realpath(place.tree);
233
+ if (strict && !inside(tree, inspection.module)) throw fail('PLUGIN_CANDIDATE_ESCAPES', `The candidate's entry resolves outside its tree: ${inspection.module}`);
234
+ const origin = path.dirname(inspection.module), targets = new Map([...hostModules(place)].map(([relative, item]) => [item.target, relative]));
235
+ const list = [];
236
+ for (const file of inspection.files) {
237
+ const real = path.resolve(origin, file.path);
238
+ if (inside(tree, real)) list.push(file);
239
+ else if (targets.has(real)) list.push({ path: posix(path.relative(origin, path.join(tree, ...targets.get(real).split('/')))), sha256: file.sha256 });
240
+ else if (strict) throw fail('PLUGIN_CANDIDATE_ESCAPES', `The candidate reaches a file outside its tree: ${file.path}`);
241
+ else list.push(file);
242
+ }
243
+ return fingerprintOf(list);
244
+ }
245
+
246
+ /**
247
+ * Open a fresh candidate of the plugin `entry` names: its own files (those inside the directory
248
+ * of its entry module) are copied, and every other file the inspector covers is linked to the
249
+ * installed file as a host module. Each keeps its path relative to the deepest common directory
250
+ * of the covered set, so every relative import resolves inside tree and an untouched candidate
251
+ * has the original's code fingerprint (base.logical; base.fingerprint is the inspector's own
252
+ * fingerprint of the base module, which differs only when the base already links host modules).
253
+ * Returns the candidate with its `seal`, which the caller keeps; candidate.json holds the rest.
254
+ */
255
+ export async function openCandidate({ cwd = process.cwd(), stateDir, entry } = {}) {
256
+ if (!entry || typeof entry !== 'object') throw new TypeError('openCandidate requires the plugins.json entry');
257
+ pluginId(entry.id);
258
+ const state = stateRoot(cwd, stateDir);
259
+ const inspection = await inspectPluginModule(requestedModule(cwd, entry.module));
260
+ const origin = path.dirname(inspection.module);
261
+ const layout = () => fail('PLUGIN_CANDIDATE_LAYOUT', `The copy of plugin ${entry.id} does not reproduce its code; it likely reaches files through a symbolic link.`);
262
+ const places = await logicalPlaces(inspection.module);
263
+ const own = [], host = [], logical = [], directories = [origin];
264
+ for (const item of inspection.files) {
265
+ const real = path.resolve(origin, item.path), at = places.get(real);
266
+ if (!at) throw layout();
267
+ logical.push({ path: posix(path.relative(origin, at)), sha256: item.sha256 });
268
+ const mine = at === real && inside(origin, real);
269
+ // Referenced-but-missing own files count too: a repair may create them, and they must land inside tree.
270
+ if (!item.sha256) { if (mine) directories.push(path.dirname(real)); continue; }
271
+ if (mine) own.push({ file: real, sha256: item.sha256 });
272
+ else {
273
+ // A file link is a host module at that place; a file reached through a linked DIRECTORY has no
274
+ // place of its own in the layout (the inspector records it by real path) and cannot be reproduced.
275
+ if (at !== real && !(await lstat(at).catch(() => null))?.isSymbolicLink()) throw layout();
276
+ host.push({ at, target: real, sha256: item.sha256 });
277
+ }
278
+ directories.push(path.dirname(at));
279
+ }
280
+ const common = commonDirectory(directories);
281
+
282
+ await mkdir(state, { recursive: true, mode: 0o700 });
283
+ const base = path.join(state, CANDIDATES);
284
+ await ownDirectory(base);
285
+ await ownDirectory(path.join(base, entry.id));
286
+ const candidateId = randomUUID();
287
+ const dir = path.join(base, entry.id, candidateId), tree = path.join(dir, 'tree');
288
+ await mkdir(dir, { mode: 0o700 });
289
+ try {
290
+ await chmod(dir, 0o700);
291
+ await ownDirectory(tree);
292
+ const parents = async target => { let parent = tree; for (const part of path.relative(tree, path.dirname(target)).split(path.sep).filter(Boolean)) await ownDirectory(parent = path.join(parent, part)); };
293
+ const byPath = key => (a, b) => a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0;
294
+ const files = {}, hostModulesRecord = {};
295
+ for (const { file, sha256: expected } of own.sort(byPath('file'))) {
296
+ const relative = path.relative(common, file);
297
+ const bytes = await boundedRead(file);
298
+ if (sha256(bytes) !== expected) throw new Error(`The plugin changed while it was being copied: ${file}`);
299
+ const target = path.join(tree, relative);
300
+ await parents(target);
301
+ await writePrivate(target, bytes);
302
+ files[posix(relative)] = expected;
303
+ }
304
+ // Host modules: linked to the installed file's real path, never copied.
305
+ for (const { at, target, sha256: expected } of host.sort(byPath('at'))) {
306
+ const relative = path.relative(common, at), link = path.join(tree, relative);
307
+ await parents(link);
308
+ await symlink(target, link, 'file');
309
+ hostModulesRecord[posix(relative)] = { target, sha256: expected };
310
+ }
311
+ // Beside tree, never inside it: the module type and the packages the original resolves.
312
+ const type = await packageType(origin);
313
+ const packageJson = `${JSON.stringify(type ? { type } : {})}\n`;
314
+ await writePrivate(path.join(dir, 'package.json'), packageJson);
315
+ const modules = await nearestNodeModules(origin);
316
+ if (modules) await symlink(modules, path.join(dir, 'node_modules'), 'dir');
317
+
318
+ const candidate = {
319
+ id: entry.id, candidateId, dir, tree,
320
+ module: path.join(tree, path.relative(common, inspection.module)),
321
+ base: { module: inspection.module, fingerprint: inspection.code_fingerprint, logical: fingerprintOf(logical), files, external: inspection.external },
322
+ host_modules: hostModulesRecord,
323
+ node_modules: modules, package_json: sha256(packageJson),
324
+ created_at: new Date().toISOString(),
325
+ };
326
+ // Same layout, same bytes: anything else means the candidate is not the plugin.
327
+ if (await placementFingerprint(candidate) !== candidate.base.logical) throw layout();
328
+ await writePrivate(path.join(dir, 'candidate.json'), `${JSON.stringify(candidate, null, 2)}\n`);
329
+ return { ...candidate, seal: candidateSeal(candidate) };
330
+ } catch (error) {
331
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
332
+ throw error;
333
+ }
334
+ }
335
+
336
+ /** The candidate as candidate.json describes it, only if that metadata hashes to `seal`. */
337
+ export async function readCandidate({ cwd = process.cwd(), stateDir, id, candidateId, seal } = {}) {
338
+ pluginId(id);
339
+ if (typeof seal !== 'string' || !SHA.test(seal)) throw fail('PLUGIN_CANDIDATE_SEAL_REQUIRED', 'readCandidate requires the seal openCandidate returned');
340
+ const unknown = () => fail('PLUGIN_CANDIDATE_UNKNOWN', `Unknown plugin candidate: ${id}/${candidateId}`);
341
+ if (typeof candidateId !== 'string' || !UUID.test(candidateId)) throw unknown();
342
+ const dir = path.join(stateRoot(cwd, stateDir), CANDIDATES, id, candidateId);
343
+ let stored;
344
+ try { stored = JSON.parse((await boundedRead(path.join(dir, 'candidate.json'))).toString('utf8')); }
345
+ catch (error) {
346
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') throw unknown();
347
+ throw fail('PLUGIN_CANDIDATE_INVALID', `Unreadable plugin candidate ${id}/${candidateId}: ${error.message}`);
348
+ }
349
+ if (!plainObject(stored) || typeof stored.tree !== 'string' || typeof stored.module !== 'string' || !plainObject(stored.base)) {
350
+ throw fail('PLUGIN_CANDIDATE_INVALID', `Plugin candidate ${id}/${candidateId} has an invalid candidate.json`);
351
+ }
352
+ // Paths follow where the candidate is found now (the state directory may have moved).
353
+ const tree = path.join(dir, 'tree');
354
+ const { seal: ignored, ...metadata } = stored;
355
+ const candidate = { ...metadata, dir, tree, module: path.join(tree, path.relative(stored.tree, stored.module)) };
356
+ if (candidateSeal(candidate) !== seal) throw fail('PLUGIN_CANDIDATE_TAMPERED', `Plugin candidate ${id}/${candidateId} does not match its seal`);
357
+ if (candidate.id !== id || candidate.candidateId !== candidateId || candidate.module === tree || !inside(tree, candidate.module)
358
+ || typeof candidate.base.module !== 'string' || typeof candidate.base.fingerprint !== 'string' || !plainObject(candidate.base.files)) {
359
+ throw fail('PLUGIN_CANDIDATE_INVALID', `Plugin candidate ${id}/${candidateId} has an invalid candidate.json`);
360
+ }
361
+ hostModules(candidate);
362
+ return { ...candidate, seal };
363
+ }
364
+
365
+ function sealed(candidate) {
366
+ if (!candidate || typeof candidate.tree !== 'string' || typeof candidate.module !== 'string' || !plainObject(candidate.base)) throw new TypeError('A plugin candidate is required');
367
+ if (candidateSeal(candidate) !== candidate.seal) throw fail('PLUGIN_CANDIDATE_TAMPERED', `Plugin candidate ${candidate.id}/${candidate.candidateId} does not match its seal`);
368
+ }
369
+
370
+ // Every entry of tree, by lstat. More files than the inspector covers is an escape.
371
+ async function walkTree(tree) {
372
+ const files = [], links = [], others = [], modules = [], pending = [tree];
373
+ let entries = 0;
374
+ while (pending.length) {
375
+ const dir = pending.pop();
376
+ for (const name of await readdir(dir)) {
377
+ const full = path.join(dir, name);
378
+ if (++entries > MAX_CODE_FILES * 16) throw fail('PLUGIN_CANDIDATE_ESCAPES', `The candidate tree holds too many entries: ${tree}`);
379
+ const info = await lstat(full);
380
+ if (name === 'node_modules') modules.push(full);
381
+ if (info.isSymbolicLink()) links.push(full);
382
+ else if (info.isDirectory()) pending.push(full);
383
+ else if (info.isFile()) {
384
+ files.push(full);
385
+ if (files.length > MAX_CODE_FILES) throw fail('PLUGIN_CANDIDATE_ESCAPES', `The candidate tree holds more than ${MAX_CODE_FILES} files: ${tree}`);
386
+ } else others.push(full);
387
+ }
388
+ }
389
+ return { files: sorted(files), links, others, modules };
390
+ }
391
+
392
+ function isText(bytes) {
393
+ if (bytes.includes(0)) return false;
394
+ try { new TextDecoder('utf-8', { fatal: true }).decode(bytes); return true; } catch { return false; }
395
+ }
396
+
397
+ // Lines keep their '\n', so a missing final newline is a difference like any other.
398
+ function splitLines(text) {
399
+ if (!text) return [];
400
+ const parts = text.split('\n'), last = parts.pop();
401
+ const lines = parts.map(line => `${line}\n`);
402
+ if (last !== '') lines.push(last);
403
+ return lines;
404
+ }
405
+
406
+ // Edit script by longest common subsequence over the lines between the common prefix and suffix.
407
+ function editScript(a, b) {
408
+ let start = 0;
409
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
410
+ let endA = a.length, endB = b.length;
411
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
412
+ const n = endA - start, m = endB - start;
413
+ if (n * m > DIFF_CELLS) return null;
414
+ const ops = [];
415
+ for (let i = 0; i < start; i++) ops.push([' ', a[i]]);
416
+ const width = m + 1, table = new Uint32Array((n + 1) * width);
417
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
418
+ table[i * width + j] = a[start + i] === b[start + j] ? table[(i + 1) * width + j + 1] + 1 : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
419
+ }
420
+ let i = 0, j = 0;
421
+ while (i < n || j < m) {
422
+ if (i < n && j < m && a[start + i] === b[start + j]) { ops.push([' ', a[start + i]]); i++; j++; }
423
+ else if (j >= m || (i < n && table[(i + 1) * width + j] >= table[i * width + j + 1])) { ops.push(['-', a[start + i]]); i++; }
424
+ else { ops.push(['+', b[start + j]]); j++; }
425
+ }
426
+ for (let k = endA; k < a.length; k++) ops.push([' ', a[k]]);
427
+ return ops;
428
+ }
429
+
430
+ // The unified diff of one file, '' when equal, or null when too large to diff.
431
+ function unifiedDiff(relative, before, after) {
432
+ const header = `${before === null ? '--- /dev/null' : `--- a/${relative}`}\n+++ b/${relative}\n`;
433
+ const ops = editScript(splitLines(before), splitLines(after));
434
+ if (!ops) return null;
435
+ const oldAt = [], newAt = [];
436
+ let oldLine = 1, newLine = 1;
437
+ for (const [kind] of ops) { oldAt.push(oldLine); newAt.push(newLine); if (kind !== '+') oldLine++; if (kind !== '-') newLine++; }
438
+ const changes = ops.flatMap(([kind], index) => kind === ' ' ? [] : [index]);
439
+ if (!changes.length) return '';
440
+ let out = header;
441
+ for (let c = 0; c < changes.length;) {
442
+ let last = c;
443
+ while (last + 1 < changes.length && changes[last + 1] - changes[last] - 1 <= 2 * CONTEXT) last++;
444
+ const from = Math.max(0, changes[c] - CONTEXT), to = Math.min(ops.length - 1, changes[last] + CONTEXT);
445
+ const slice = ops.slice(from, to + 1);
446
+ const oldCount = slice.filter(([kind]) => kind !== '+').length, newCount = slice.filter(([kind]) => kind !== '-').length;
447
+ out += `@@ -${oldCount ? oldAt[from] : oldAt[from] - 1},${oldCount} +${newCount ? newAt[from] : newAt[from] - 1},${newCount} @@\n`;
448
+ for (const [kind, line] of slice) out += line.endsWith('\n') ? `${kind}${line}` : `${kind}${line}\n\\n`;
449
+ c = last + 1;
450
+ }
451
+ return out;
452
+ }
453
+
454
+ /**
455
+ * What the candidate changed against the own files it was copied from: lists by hash, the
456
+ * candidate's code fingerprint, and a unified diff of the text files (at most 200 KB). Host
457
+ * modules are links to the installed files, unchanged by construction: listed apart in
458
+ * `host_modules` with the hash recorded at open, never in the patch.
459
+ * Every changed or added file without its full text in `patch` is named there with the hash
460
+ * of the bytes that will load, and listed in `unreviewed`; `review_complete` is true only
461
+ * when there is none.
462
+ */
463
+ export async function candidateDiff(candidate) {
464
+ sealed(candidate);
465
+ const read = reader();
466
+ const baseFiles = candidate.base.files;
467
+ const current = new Map();
468
+ for (const file of (await walkTree(candidate.tree)).files) {
469
+ current.set(posix(path.relative(candidate.tree, file)), (await read(file, { nofollow: true })).bytes);
470
+ }
471
+ const changed = [], added = [], removed = [];
472
+ for (const [relative, bytes] of current) {
473
+ if (!Object.hasOwn(baseFiles, relative)) added.push(relative);
474
+ else if (sha256(bytes) !== baseFiles[relative]) changed.push(relative);
475
+ }
476
+ for (const relative of Object.keys(baseFiles)) if (!current.has(relative)) removed.push(relative);
477
+ const fingerprint = await placementFingerprint(candidate);
478
+ const host_modules = [...hostModules(candidate)].map(([file, item]) => ({ file, sha256: item.sha256 }));
479
+
480
+ // The originals sit at the same paths relative to base.module as the copies relative to module.
481
+ const originRoot = path.resolve(path.dirname(candidate.base.module), path.relative(path.dirname(candidate.module), candidate.tree));
482
+ const sections = [];
483
+ let size = 0;
484
+ for (const relative of sorted([...changed, ...added])) {
485
+ if (size > PATCH_LIMIT) { sections.push({ file: relative, text: null }); continue; }
486
+ const after = current.get(relative);
487
+ let before = null;
488
+ if (Object.hasOwn(baseFiles, relative)) {
489
+ try { before = (await read(path.join(originRoot, ...relative.split('/')), { limit: MAX_FILE_BYTES })).bytes; }
490
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
491
+ }
492
+ let section;
493
+ if (!isText(after) || (before && !isText(before))) section = { file: relative, text: `Binary or non-UTF-8 file ${relative} sha256 ${sha256(after)}\n`, reason: 'binary' };
494
+ else {
495
+ const text = unifiedDiff(relative, before === null ? null : before.toString('utf8'), after.toString('utf8'));
496
+ section = text === null
497
+ ? { file: relative, text: `Too large to diff: ${relative} (${splitLines(after.toString('utf8')).length} lines) sha256 ${sha256(after)}\n`, reason: 'too_large_to_diff' }
498
+ : { file: relative, text };
499
+ }
500
+ sections.push(section);
501
+ size += Buffer.byteLength(section.text);
502
+ }
503
+ let patch = '';
504
+ const unreviewed = [];
505
+ if (size <= PATCH_LIMIT) {
506
+ for (const section of sections) { patch += section.text; if (section.reason) unreviewed.push({ file: section.file, reason: section.reason }); }
507
+ } else {
508
+ const room = PATCH_LIMIT - Buffer.byteLength(PATCH_MARKER);
509
+ let used = 0, full = true;
510
+ for (const section of sections) {
511
+ const length = section.text === null ? Infinity : Buffer.byteLength(section.text);
512
+ if (full && used + length <= room) {
513
+ patch += section.text; used += length;
514
+ if (section.reason) unreviewed.push({ file: section.file, reason: section.reason });
515
+ continue;
516
+ }
517
+ if (full && section.text !== null) {
518
+ // The first section that does not fit is cut at a line boundary.
519
+ const bytes = Buffer.from(section.text).subarray(0, room - used);
520
+ patch += bytes.subarray(0, bytes.lastIndexOf(10) + 1).toString('utf8');
521
+ }
522
+ full = false;
523
+ unreviewed.push({ file: section.file, reason: 'patch_truncated' });
524
+ }
525
+ patch += PATCH_MARKER;
526
+ }
527
+ return { changed: sorted(changed), added: sorted(added), removed: sorted(removed), host_modules, fingerprint, patch, review_complete: unreviewed.length === 0, unreviewed };
528
+ }
529
+
530
+ // What a placement (a candidate, or an overlay built from one) must be. `baseFiles` are the hashes a
531
+ // file is "unchanged" against; `external` the packages the base already imported (null: not asked).
532
+ async function checkPlacement(place, baseFiles, external) {
533
+ const escapes = message => fail('PLUGIN_CANDIDATE_ESCAPES', message);
534
+ const root = await lstat(place.tree);
535
+ if (root.isSymbolicLink() || !root.isDirectory()) throw escapes(`The candidate tree is not a plain directory: ${place.tree}`);
536
+ const { files, links, others, modules } = await walkTree(place.tree);
537
+ const name = file => posix(path.relative(place.tree, file));
538
+ if (others.length) throw escapes(`The candidate tree holds a special file: ${name(others[0])}`);
539
+ if (modules.length) throw escapes(`The candidate tree holds node_modules: ${name(modules[0])}`);
540
+ const tree = await realpath(place.tree), read = reader(), hashes = {}, dynamic = [], packages = [];
541
+ // Exactly the recorded host links, each still pointing at the installed file recorded at open.
542
+ const host = hostModules(place), linked = new Set();
543
+ for (const link of links) {
544
+ const relative = name(link), recorded = host.get(relative);
545
+ if (!recorded) throw escapes(`The candidate tree holds a symbolic link: ${relative}`);
546
+ let target = null, real = null;
547
+ try { target = await readlink(link); real = await realpath(link); } catch {}
548
+ if (target !== recorded.target || real !== recorded.target) throw escapes(`The link to a host module was changed: ${relative}`);
549
+ linked.add(relative);
550
+ }
551
+ for (const [relative, recorded] of host) {
552
+ if (!linked.has(relative)) throw escapes(`A host module is no longer the link to the installed file: ${relative}`);
553
+ let bytes;
554
+ try { bytes = (await read(recorded.target, { nofollow: true, limit: MAX_FILE_BYTES })).bytes; }
555
+ catch (error) { if (error.code === 'PLUGIN_CANDIDATE_TOO_LARGE') throw error; throw fail('PLUGIN_CANDIDATE_BASE_MOVED', `A host module can no longer be read as it was when the candidate was opened: ${relative}`); }
556
+ if (sha256(bytes) !== recorded.sha256) throw fail('PLUGIN_CANDIDATE_BASE_MOVED', `A host module changed since the candidate was opened: ${relative}`);
557
+ }
558
+ // Own files: only inside the directory of the entry module. Anything else in tree would be a private
559
+ // copy of a host module, or code the plugin does not own.
560
+ const own = posix(path.relative(place.tree, path.dirname(place.module)));
561
+ const known = external ? new Set(external) : null;
562
+ for (const file of files) {
563
+ const relative = name(file);
564
+ if (own !== '' && !relative.startsWith(`${own}/`)) throw escapes(`A file outside the plugin's own directory (${own}); host modules are linked, never copied, replaced or added: ${relative}`);
565
+ const { bytes, nlink } = await read(file, { nofollow: true });
566
+ if (nlink > 1) throw escapes(`The candidate tree holds a hard link: ${relative}`);
567
+ const hash = hashes[relative] = sha256(bytes);
568
+ const changed = baseFiles[relative] !== hash;
569
+ if (path.basename(file) === 'package.json' && changed) throw escapes(`A package.json in the candidate tree is new or changed: ${relative}`);
570
+ if (!isOpaqueChecked(file)) continue;
571
+ if (!isText(bytes)) throw fail('PLUGIN_CANDIDATE_OPAQUE', `A code file in the candidate is not valid UTF-8 text: ${relative}`);
572
+ if (!isCode(file)) continue;
573
+ const source = bytes.toString('utf8');
574
+ for (const [pattern, module] of SPECIFIERS) for (const match of source.matchAll(pattern)) {
575
+ const specifier = match[1];
576
+ const scheme = /^[a-z][a-z0-9+.-]*:/i.test(specifier) && (module ? !specifier.startsWith('node:') : /^file:/i.test(specifier));
577
+ if (path.isAbsolute(specifier) || specifier.startsWith('\\') || scheme) {
578
+ throw escapes(`The candidate names code outside its tree: ${specifier} in ${relative}`);
579
+ }
580
+ const local = specifier.startsWith('./') || specifier.startsWith('../');
581
+ if (local && !inside(tree, path.resolve(path.dirname(path.join(tree, relative)), specifier.split(/[?#]/)[0]))) {
582
+ throw escapes(`The candidate reaches outside its tree: ${specifier} in ${relative}`);
583
+ }
584
+ // A package the base did not import: code outside the fingerprint that review did not see loading before.
585
+ if (module && changed && known && !local && !/^[a-z][a-z0-9+.-]*:/i.test(specifier) && !known.has(specifier) && !packages.some(item => item.file === relative && item.specifier === specifier)) packages.push({ file: relative, specifier });
586
+ }
587
+ if (changed) for (const [construct, pattern] of DYNAMIC) if (pattern.test(source)) dynamic.push({ file: relative, construct });
588
+ }
589
+ // What open placed beside tree, exactly.
590
+ const besideModules = path.join(place.dir, 'node_modules');
591
+ let beside = null;
592
+ try { beside = await lstat(besideModules); } catch (error) { if (error.code !== 'ENOENT') throw error; }
593
+ if (place.node_modules ? !beside?.isSymbolicLink() || await realpath(besideModules).catch(() => null) !== place.node_modules : beside) {
594
+ throw escapes(`node_modules beside the candidate tree is not the one recorded at open: ${besideModules}`);
595
+ }
596
+ let packageJson = null;
597
+ try { packageJson = (await read(path.join(place.dir, 'package.json'), { nofollow: true, limit: MAX_FILE_BYTES })).bytes; } catch {}
598
+ if (!packageJson || sha256(packageJson) !== place.package_json) throw escapes(`package.json beside the candidate tree is not the one written at open: ${place.dir}`);
599
+ // Everything the inspector reaches from the entry: inside tree, or a recorded host module.
600
+ const fingerprint = await placementFingerprint(place, { strict: true });
601
+ return { files: hashes, fingerprint, dynamic, packages, host_modules: [...host].map(([file, item]) => ({ file, sha256: item.sha256 })) };
602
+ }
603
+
604
+ /**
605
+ * The candidate is contained and legible. Refused (PLUGIN_CANDIDATE_ESCAPES unless noted):
606
+ * a symbolic link that is not a recorded host module, or a host link that was removed,
607
+ * retargeted or replaced by a file; a file outside the plugin's own directory; hard links,
608
+ * special files or node_modules in tree; more files than the inspector covers; a package.json
609
+ * in tree that is new or changed; a specifier that is absolute, a URL other than node:, or
610
+ * relative and leaving tree; code that is not valid UTF-8 text (PLUGIN_CANDIDATE_OPAQUE); a
611
+ * host module whose installed bytes changed since open (PLUGIN_CANDIDATE_BASE_MOVED);
612
+ * dir/node_modules or dir/package.json not as open left them; any file the inspector reaches
613
+ * from the entry that is neither inside tree nor a recorded host module. Returns the hash of
614
+ * every own file, the candidate's code fingerprint, the host modules, and what static
615
+ * inspection cannot follow in changed files: `dynamic` constructs and `packages` (bare
616
+ * specifiers the base did not import).
617
+ */
618
+ export async function verifyCandidate(candidate) {
619
+ if (!candidate || typeof candidate.tree !== 'string' || typeof candidate.module !== 'string') throw new TypeError('verifyCandidate requires a candidate');
620
+ if (candidate.module === candidate.tree || !inside(candidate.tree, candidate.module)) throw fail('PLUGIN_CANDIDATE_ESCAPES', `The candidate's entry is outside its tree: ${candidate.module}`);
621
+ sealed(candidate);
622
+ return checkPlacement(candidate, candidate.base.files, Array.isArray(candidate.base.external) ? candidate.base.external : []);
623
+ }
624
+
625
+ /**
626
+ * An overlay built from a verified candidate (or found from an earlier repair) is that candidate, whole:
627
+ * the same own files byte for byte and no other, exactly its host links, the same package.json and
628
+ * node_modules link beside tree, and the same code fingerprint. Throws PLUGIN_PLACEMENT_MISMATCH otherwise.
629
+ * place: { dir, tree, module, host_modules, node_modules, package_json }; expected: { files, fingerprint }.
630
+ */
631
+ export async function verifyPlacement(place, expected) {
632
+ const mismatch = message => fail('PLUGIN_PLACEMENT_MISMATCH', message);
633
+ let checked;
634
+ try {
635
+ if (!place || typeof place.tree !== 'string' || typeof place.module !== 'string' || place.module === place.tree || !inside(place.tree, place.module)) throw new Error('the entry is outside the tree');
636
+ checked = await checkPlacement(place, expected.files, null);
637
+ } catch (error) { throw mismatch(error.message); }
638
+ for (const relative of sorted(new Set([...Object.keys(expected.files), ...Object.keys(checked.files)]))) {
639
+ if (checked.files[relative] !== expected.files[relative]) throw mismatch(`${relative} ${Object.hasOwn(checked.files, relative) ? Object.hasOwn(expected.files, relative) ? 'differs' : 'was not verified' : 'is missing'}`);
640
+ }
641
+ if (checked.fingerprint !== expected.fingerprint) throw mismatch(`fingerprint ${checked.fingerprint} instead of ${expected.fingerprint}`);
642
+ return checked;
643
+ }
644
+
645
+ /**
646
+ * Remove a candidate directory. Only <stateDir>/plugin-candidates/<id>/<uuid>, reached
647
+ * without symbolic links below plugin-candidates, is ever removed, by its verified real path.
648
+ */
649
+ export async function discardCandidate(candidate, { cwd = process.cwd(), stateDir } = {}) {
650
+ const refuse = message => fail('PLUGIN_CANDIDATE_REFUSED', message);
651
+ if (!candidate || typeof candidate.dir !== 'string') throw refuse('discardCandidate requires a candidate with its dir');
652
+ try { pluginId(candidate.id); } catch { throw refuse(`Invalid plugin id: ${candidate.id}`); }
653
+ if (typeof candidate.candidateId !== 'string' || !UUID.test(candidate.candidateId)) throw refuse(`Invalid candidate id: ${candidate.candidateId}`);
654
+ const base = path.join(stateRoot(cwd, stateDir), CANDIDATES);
655
+ const expected = path.join(base, candidate.id, candidate.candidateId);
656
+ const outside = () => refuse(`Not a candidate directory under ${base}: ${candidate.dir}`);
657
+ let real;
658
+ try { real = await realpath(candidate.dir); }
659
+ catch (error) {
660
+ if (error.code === 'ENOENT' && path.resolve(candidate.dir) === expected) return; // already gone
661
+ throw outside();
662
+ }
663
+ for (const dir of [base, path.dirname(expected), expected]) {
664
+ let info;
665
+ try { info = await lstat(dir); } catch { throw outside(); }
666
+ if (info.isSymbolicLink() || !info.isDirectory()) throw refuse(`A symbolic link or non-directory on the way to the candidate: ${dir}`);
667
+ }
668
+ const target = path.join(await realpath(base), candidate.id, candidate.candidateId);
669
+ if (real !== target) throw outside();
670
+ // Once more, on the very path about to be removed.
671
+ const final = await lstat(target);
672
+ if (final.isSymbolicLink() || !final.isDirectory() || await realpath(target) !== target) throw outside();
673
+ await rm(target, { recursive: true });
674
+ await rmdir(path.dirname(target)).catch(() => {}); // the plugin's folder, once it holds no candidates
675
+ }