@hoardodile/host 0.0.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/LICENSE +18 -0
- package/README.md +73 -0
- package/dist/chunks/worker-entry.mjs +422 -0
- package/dist/container-fKxefO7p.d.ts +58 -0
- package/dist/contract/index.d.ts +49 -0
- package/dist/contract/index.js +187 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/hoard/index.d.ts +754 -0
- package/dist/hoard/index.js +1939 -0
- package/dist/hoard/index.js.map +1 -0
- package/dist/image-Dx2Be3Bj.d.ts +168 -0
- package/dist/index.d.ts +788 -0
- package/dist/index.js +3311 -0
- package/dist/index.js.map +1 -0
- package/dist/media/index.d.ts +96 -0
- package/dist/media/index.js +152 -0
- package/dist/media/index.js.map +1 -0
- package/dist/probe/index.d.ts +113 -0
- package/dist/probe/index.js +454 -0
- package/dist/probe/index.js.map +1 -0
- package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
- package/dist/render/index.d.ts +125 -0
- package/dist/render/index.js +430 -0
- package/dist/render/index.js.map +1 -0
- package/package.json +103 -0
- package/src/__testutils__/zip-fixtures.ts +139 -0
- package/src/activation.ts +116 -0
- package/src/api-types.ts +60 -0
- package/src/api.test.ts +564 -0
- package/src/api.ts +501 -0
- package/src/archive/7z.test.ts +222 -0
- package/src/archive/7z.ts +261 -0
- package/src/archive/archive-ops.test.ts +654 -0
- package/src/archive/extract-archive.test.ts +237 -0
- package/src/archive/extract-archive.ts +534 -0
- package/src/archive/extract.ts +693 -0
- package/src/archive/format.ts +49 -0
- package/src/archive/index.ts +116 -0
- package/src/archive/listing.ts +121 -0
- package/src/archive/materialize.ts +88 -0
- package/src/archive/name-decode.test.ts +34 -0
- package/src/archive/name-decode.ts +287 -0
- package/src/archive/nested-cd-cache.ts +55 -0
- package/src/archive/nested-entry.test.ts +194 -0
- package/src/archive/nested-entry.ts +254 -0
- package/src/archive/pack.ts +40 -0
- package/src/archive/zip-entries.test.ts +56 -0
- package/src/archive/zip-entries.ts +336 -0
- package/src/capability-guard.ts +73 -0
- package/src/container.ts +62 -0
- package/src/contract/container-fixture.ts +74 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/suite.test.ts +101 -0
- package/src/contract/suite.ts +205 -0
- package/src/define-plugin.test.ts +53 -0
- package/src/define-plugin.ts +12 -0
- package/src/dev-runner.test.ts +98 -0
- package/src/dev-runner.ts +73 -0
- package/src/directory-api.test.ts +229 -0
- package/src/directory-api.ts +42 -0
- package/src/directory-container.ts +141 -0
- package/src/discovery.ts +295 -0
- package/src/errors.ts +74 -0
- package/src/fixtures.ts +8 -0
- package/src/hash.test.ts +301 -0
- package/src/hash.ts +167 -0
- package/src/hoard/dir-size.ts +58 -0
- package/src/hoard/index.ts +84 -0
- package/src/hoard/migrate-script.test.ts +539 -0
- package/src/hoard/order-manifest.test.ts +115 -0
- package/src/hoard/order-manifest.ts +131 -0
- package/src/hoard/paths.test.ts +226 -0
- package/src/hoard/paths.ts +569 -0
- package/src/hoard/plugin-vault.test.ts +160 -0
- package/src/hoard/plugin-vault.ts +233 -0
- package/src/hoard/sanitize.test.ts +149 -0
- package/src/hoard/sanitize.ts +230 -0
- package/src/hoard/staging-dir.ts +133 -0
- package/src/hoard/version.test.ts +285 -0
- package/src/hoard/version.ts +216 -0
- package/src/hoard/versioned-folder-ops.ts +152 -0
- package/src/hoard/write-versioned.test.ts +67 -0
- package/src/hoard/write-versioned.ts +39 -0
- package/src/hooks.test.ts +528 -0
- package/src/hooks.ts +428 -0
- package/src/index.ts +135 -0
- package/src/loader.test.ts +442 -0
- package/src/loader.ts +213 -0
- package/src/media/index.ts +16 -0
- package/src/media/render-cache.ts +27 -0
- package/src/media/seekable.ts +35 -0
- package/src/media/thumb-input.test.ts +109 -0
- package/src/media/thumb-input.ts +194 -0
- package/src/nested-view.test.ts +223 -0
- package/src/nested-view.ts +239 -0
- package/src/probe/audio.test.ts +135 -0
- package/src/probe/audio.ts +140 -0
- package/src/probe/av.ts +84 -0
- package/src/probe/ffprobe.ts +197 -0
- package/src/probe/image.test.ts +251 -0
- package/src/probe/image.ts +336 -0
- package/src/probe/index.ts +54 -0
- package/src/probe/sniff.test.ts +72 -0
- package/src/probe/sniff.ts +67 -0
- package/src/probe/video.test.ts +41 -0
- package/src/probe/video.ts +116 -0
- package/src/probe-cache.test.ts +89 -0
- package/src/probe-cache.ts +65 -0
- package/src/render/ffmpeg.ts +78 -0
- package/src/render/index.ts +34 -0
- package/src/render/pipeline.test.ts +187 -0
- package/src/render/pipeline.ts +479 -0
- package/src/sandbox/call-timers.ts +68 -0
- package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
- package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
- package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
- package/src/sandbox/fixtures/container-plugin.mjs +8 -0
- package/src/sandbox/fixtures/context-plugin.mjs +19 -0
- package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
- package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
- package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
- package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
- package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
- package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
- package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
- package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
- package/src/sandbox/fixtures/range-plugin.mjs +9 -0
- package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
- package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
- package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
- package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
- package/src/sandbox/host.test.ts +619 -0
- package/src/sandbox/host.ts +933 -0
- package/src/sandbox/host.unit.test.ts +330 -0
- package/src/sandbox/protocol.test.ts +85 -0
- package/src/sandbox/protocol.ts +147 -0
- package/src/sandbox/sandboxed-plugin.ts +30 -0
- package/src/sandbox/worker-entry.mjs +422 -0
- package/src/seed.test.ts +102 -0
- package/src/seed.ts +160 -0
- package/src/settings-store.ts +26 -0
- package/src/types.ts +21 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3311 @@
|
|
|
1
|
+
import { createWriteStream, createReadStream, existsSync, readFileSync, renameSync, rmSync, mkdirSync, readdirSync, cpSync, statSync, realpathSync } from 'fs';
|
|
2
|
+
import { dirname, isAbsolute, normalize, resolve, sep, join } from 'path';
|
|
3
|
+
import { pluginAssetError, fileTypeFromName, createFailingPlugin, definePlugin } from '@hoardodile/sdk-types';
|
|
4
|
+
export { HOOK_NAMES, assertPluginShape, createFailingPlugin, createResourceAPIFixture, definePlugin, isDetected, isMissed, stubLogger } from '@hoardodile/sdk-types';
|
|
5
|
+
import { mimeToKind, MIME_FFMPEG_INPUT_FORMAT } from '@hoardodile/sdk-types/media-exts';
|
|
6
|
+
import { PLUGIN_READ_FILE_MAX_BYTES } from '@hoardodile/sdk-types/plugin';
|
|
7
|
+
import { createHash, randomUUID } from 'crypto';
|
|
8
|
+
import { mkdir, writeFile, stat, rename, rm, open, readdir, readFile, chmod, lstat } from 'fs/promises';
|
|
9
|
+
import { tmpdir } from 'os';
|
|
10
|
+
import { Readable } from 'stream';
|
|
11
|
+
import { pipeline } from 'stream/promises';
|
|
12
|
+
import yauzl from 'yauzl';
|
|
13
|
+
import { fork, spawn } from 'child_process';
|
|
14
|
+
import { createRequire } from 'module';
|
|
15
|
+
import { buffer } from 'stream/consumers';
|
|
16
|
+
import { createInflateRaw } from 'zlib';
|
|
17
|
+
import { CAPABILITY_BY_METHOD } from '@hoardodile/sdk-types/plugin-capabilities';
|
|
18
|
+
import { pathToFileURL, fileURLToPath } from 'url';
|
|
19
|
+
import { pluginManifest } from '@hoardodile/sdk-types/schema';
|
|
20
|
+
import { PLUGIN_ASSET_ERROR_NAMES } from '@hoardodile/sdk-types/plugin-asset-limits';
|
|
21
|
+
|
|
22
|
+
// src/activation.ts
|
|
23
|
+
|
|
24
|
+
// src/activation.ts
|
|
25
|
+
function createPluginActivation(deps) {
|
|
26
|
+
async function activateAll(found) {
|
|
27
|
+
return Promise.all(
|
|
28
|
+
found.map(async (candidate) => {
|
|
29
|
+
const plugin = await loadDiskPlugin(deps.sandbox, candidate);
|
|
30
|
+
return {
|
|
31
|
+
id: candidate.id,
|
|
32
|
+
manifest: candidate.manifest,
|
|
33
|
+
enabled: candidate.enabled,
|
|
34
|
+
priority: candidate.priority,
|
|
35
|
+
pinned: candidate.pinned,
|
|
36
|
+
color: candidate.color,
|
|
37
|
+
missing: false,
|
|
38
|
+
builtin: candidate.source === "builtin",
|
|
39
|
+
dev: candidate.source === "dev",
|
|
40
|
+
plugin,
|
|
41
|
+
diskPath: candidate.diskPath
|
|
42
|
+
};
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
function createFailingEntries(missing) {
|
|
47
|
+
return missing.map((m) => ({
|
|
48
|
+
id: m.id,
|
|
49
|
+
manifest: m.manifest,
|
|
50
|
+
enabled: m.enabled,
|
|
51
|
+
priority: m.priority,
|
|
52
|
+
pinned: m.pinned,
|
|
53
|
+
color: m.color,
|
|
54
|
+
missing: true,
|
|
55
|
+
builtin: false,
|
|
56
|
+
dev: false,
|
|
57
|
+
plugin: createFailingPlugin(["plugin directory not found"])
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
return { activateAll, createFailingEntries };
|
|
61
|
+
}
|
|
62
|
+
async function loadDiskPlugin(sandbox, candidate) {
|
|
63
|
+
const mainJsPath = join(candidate.diskPath, "main.js");
|
|
64
|
+
if (!existsSync(mainJsPath)) {
|
|
65
|
+
console.warn(`[plugin-activation] ${candidate.id}: no main.js, not loaded`);
|
|
66
|
+
return createFailingPlugin(["main.js not found"]);
|
|
67
|
+
}
|
|
68
|
+
const plugin = await sandbox.loadPlugin({
|
|
69
|
+
id: candidate.id,
|
|
70
|
+
mainPath: mainJsPath,
|
|
71
|
+
eager: candidate.enabled,
|
|
72
|
+
// The sandbox enforces manifest-gated API surface (container).
|
|
73
|
+
permissions: candidate.manifest.permissions
|
|
74
|
+
});
|
|
75
|
+
if (plugin === void 0) {
|
|
76
|
+
return createFailingPlugin(["main.js load error"]);
|
|
77
|
+
}
|
|
78
|
+
return plugin;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/errors.ts
|
|
82
|
+
var DomainError = class extends Error {
|
|
83
|
+
code;
|
|
84
|
+
kind;
|
|
85
|
+
details;
|
|
86
|
+
constructor(code, kind, message, details) {
|
|
87
|
+
super(message);
|
|
88
|
+
this.name = "DomainError";
|
|
89
|
+
this.code = code;
|
|
90
|
+
this.kind = kind;
|
|
91
|
+
if (details !== void 0) this.details = details;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
function invalid(kind, message, details) {
|
|
95
|
+
return new DomainError("VALIDATION", kind, message, details);
|
|
96
|
+
}
|
|
97
|
+
function conflict(kind, message, details) {
|
|
98
|
+
return new DomainError("CONFLICT", kind, message, details);
|
|
99
|
+
}
|
|
100
|
+
function notFound(kind, message, details) {
|
|
101
|
+
return new DomainError("NOT_FOUND", kind, message, details);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/archive/format.ts
|
|
105
|
+
var SNIFF_WINDOW_BYTES = 4100;
|
|
106
|
+
function hasMagic(head, magic) {
|
|
107
|
+
if (head.byteLength < magic.length) return false;
|
|
108
|
+
for (let i = 0; i < magic.length; i++) {
|
|
109
|
+
if (head[i] !== magic[i]) return false;
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
function sniffContainerFormat(head) {
|
|
114
|
+
if (hasMagic(head, [80, 75, 3, 4])) return "zip";
|
|
115
|
+
if (hasMagic(head, [80, 75, 5, 6])) return "zip";
|
|
116
|
+
if (head.byteLength >= 262) {
|
|
117
|
+
const magic = String.fromCharCode(
|
|
118
|
+
head[257],
|
|
119
|
+
head[258],
|
|
120
|
+
head[259],
|
|
121
|
+
head[260],
|
|
122
|
+
head[261]
|
|
123
|
+
);
|
|
124
|
+
if (magic === "ustar") return "tar";
|
|
125
|
+
}
|
|
126
|
+
if (hasMagic(head, [55, 122, 188, 175, 39, 28])) return "7z";
|
|
127
|
+
if (hasMagic(head, [82, 97, 114, 33, 26, 7])) return "rar";
|
|
128
|
+
if (hasMagic(head, [253, 55, 122, 88, 90, 0])) return "xz";
|
|
129
|
+
if (hasMagic(head, [31, 139, 8])) return "gzip";
|
|
130
|
+
return void 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/archive/name-decode.ts
|
|
134
|
+
var CP437 = String.fromCharCode(
|
|
135
|
+
0,
|
|
136
|
+
9786,
|
|
137
|
+
9787,
|
|
138
|
+
9829,
|
|
139
|
+
9830,
|
|
140
|
+
9827,
|
|
141
|
+
9824,
|
|
142
|
+
8226,
|
|
143
|
+
9688,
|
|
144
|
+
9675,
|
|
145
|
+
9689,
|
|
146
|
+
9794,
|
|
147
|
+
9792,
|
|
148
|
+
9834,
|
|
149
|
+
9835,
|
|
150
|
+
9788,
|
|
151
|
+
9658,
|
|
152
|
+
9668,
|
|
153
|
+
8597,
|
|
154
|
+
8252,
|
|
155
|
+
182,
|
|
156
|
+
167,
|
|
157
|
+
9644,
|
|
158
|
+
8616,
|
|
159
|
+
8593,
|
|
160
|
+
8595,
|
|
161
|
+
8594,
|
|
162
|
+
8592,
|
|
163
|
+
8735,
|
|
164
|
+
8596,
|
|
165
|
+
9650,
|
|
166
|
+
9660,
|
|
167
|
+
32,
|
|
168
|
+
33,
|
|
169
|
+
34,
|
|
170
|
+
35,
|
|
171
|
+
36,
|
|
172
|
+
37,
|
|
173
|
+
38,
|
|
174
|
+
39,
|
|
175
|
+
40,
|
|
176
|
+
41,
|
|
177
|
+
42,
|
|
178
|
+
43,
|
|
179
|
+
44,
|
|
180
|
+
45,
|
|
181
|
+
46,
|
|
182
|
+
47,
|
|
183
|
+
48,
|
|
184
|
+
49,
|
|
185
|
+
50,
|
|
186
|
+
51,
|
|
187
|
+
52,
|
|
188
|
+
53,
|
|
189
|
+
54,
|
|
190
|
+
55,
|
|
191
|
+
56,
|
|
192
|
+
57,
|
|
193
|
+
58,
|
|
194
|
+
59,
|
|
195
|
+
60,
|
|
196
|
+
61,
|
|
197
|
+
62,
|
|
198
|
+
63,
|
|
199
|
+
64,
|
|
200
|
+
65,
|
|
201
|
+
66,
|
|
202
|
+
67,
|
|
203
|
+
68,
|
|
204
|
+
69,
|
|
205
|
+
70,
|
|
206
|
+
71,
|
|
207
|
+
72,
|
|
208
|
+
73,
|
|
209
|
+
74,
|
|
210
|
+
75,
|
|
211
|
+
76,
|
|
212
|
+
77,
|
|
213
|
+
78,
|
|
214
|
+
79,
|
|
215
|
+
80,
|
|
216
|
+
81,
|
|
217
|
+
82,
|
|
218
|
+
83,
|
|
219
|
+
84,
|
|
220
|
+
85,
|
|
221
|
+
86,
|
|
222
|
+
87,
|
|
223
|
+
88,
|
|
224
|
+
89,
|
|
225
|
+
90,
|
|
226
|
+
91,
|
|
227
|
+
92,
|
|
228
|
+
93,
|
|
229
|
+
94,
|
|
230
|
+
95,
|
|
231
|
+
96,
|
|
232
|
+
97,
|
|
233
|
+
98,
|
|
234
|
+
99,
|
|
235
|
+
100,
|
|
236
|
+
101,
|
|
237
|
+
102,
|
|
238
|
+
103,
|
|
239
|
+
104,
|
|
240
|
+
105,
|
|
241
|
+
106,
|
|
242
|
+
107,
|
|
243
|
+
108,
|
|
244
|
+
109,
|
|
245
|
+
110,
|
|
246
|
+
111,
|
|
247
|
+
112,
|
|
248
|
+
113,
|
|
249
|
+
114,
|
|
250
|
+
115,
|
|
251
|
+
116,
|
|
252
|
+
117,
|
|
253
|
+
118,
|
|
254
|
+
119,
|
|
255
|
+
120,
|
|
256
|
+
121,
|
|
257
|
+
122,
|
|
258
|
+
123,
|
|
259
|
+
124,
|
|
260
|
+
125,
|
|
261
|
+
126,
|
|
262
|
+
8962,
|
|
263
|
+
199,
|
|
264
|
+
252,
|
|
265
|
+
233,
|
|
266
|
+
226,
|
|
267
|
+
228,
|
|
268
|
+
224,
|
|
269
|
+
229,
|
|
270
|
+
231,
|
|
271
|
+
234,
|
|
272
|
+
235,
|
|
273
|
+
232,
|
|
274
|
+
239,
|
|
275
|
+
238,
|
|
276
|
+
236,
|
|
277
|
+
196,
|
|
278
|
+
197,
|
|
279
|
+
201,
|
|
280
|
+
230,
|
|
281
|
+
198,
|
|
282
|
+
244,
|
|
283
|
+
246,
|
|
284
|
+
242,
|
|
285
|
+
251,
|
|
286
|
+
249,
|
|
287
|
+
255,
|
|
288
|
+
214,
|
|
289
|
+
220,
|
|
290
|
+
162,
|
|
291
|
+
163,
|
|
292
|
+
165,
|
|
293
|
+
8359,
|
|
294
|
+
402,
|
|
295
|
+
225,
|
|
296
|
+
237,
|
|
297
|
+
243,
|
|
298
|
+
250,
|
|
299
|
+
241,
|
|
300
|
+
209,
|
|
301
|
+
170,
|
|
302
|
+
186,
|
|
303
|
+
191,
|
|
304
|
+
8976,
|
|
305
|
+
172,
|
|
306
|
+
189,
|
|
307
|
+
188,
|
|
308
|
+
161,
|
|
309
|
+
171,
|
|
310
|
+
187,
|
|
311
|
+
9617,
|
|
312
|
+
9618,
|
|
313
|
+
9619,
|
|
314
|
+
9474,
|
|
315
|
+
9508,
|
|
316
|
+
9569,
|
|
317
|
+
9570,
|
|
318
|
+
9558,
|
|
319
|
+
9557,
|
|
320
|
+
9571,
|
|
321
|
+
9553,
|
|
322
|
+
9559,
|
|
323
|
+
9565,
|
|
324
|
+
9564,
|
|
325
|
+
9563,
|
|
326
|
+
9488,
|
|
327
|
+
9492,
|
|
328
|
+
9524,
|
|
329
|
+
9516,
|
|
330
|
+
9500,
|
|
331
|
+
9472,
|
|
332
|
+
9532,
|
|
333
|
+
9566,
|
|
334
|
+
9567,
|
|
335
|
+
9562,
|
|
336
|
+
9556,
|
|
337
|
+
9577,
|
|
338
|
+
9574,
|
|
339
|
+
9568,
|
|
340
|
+
9552,
|
|
341
|
+
9580,
|
|
342
|
+
9575,
|
|
343
|
+
9576,
|
|
344
|
+
9572,
|
|
345
|
+
9573,
|
|
346
|
+
9561,
|
|
347
|
+
9560,
|
|
348
|
+
9554,
|
|
349
|
+
9555,
|
|
350
|
+
9579,
|
|
351
|
+
9578,
|
|
352
|
+
9496,
|
|
353
|
+
9484,
|
|
354
|
+
9608,
|
|
355
|
+
9604,
|
|
356
|
+
9612,
|
|
357
|
+
9616,
|
|
358
|
+
9600,
|
|
359
|
+
945,
|
|
360
|
+
223,
|
|
361
|
+
915,
|
|
362
|
+
960,
|
|
363
|
+
931,
|
|
364
|
+
963,
|
|
365
|
+
181,
|
|
366
|
+
964,
|
|
367
|
+
934,
|
|
368
|
+
920,
|
|
369
|
+
937,
|
|
370
|
+
948,
|
|
371
|
+
8734,
|
|
372
|
+
966,
|
|
373
|
+
949,
|
|
374
|
+
8745,
|
|
375
|
+
8801,
|
|
376
|
+
177,
|
|
377
|
+
8805,
|
|
378
|
+
8804,
|
|
379
|
+
8992,
|
|
380
|
+
8993,
|
|
381
|
+
247,
|
|
382
|
+
8776,
|
|
383
|
+
176,
|
|
384
|
+
8729,
|
|
385
|
+
183,
|
|
386
|
+
8730,
|
|
387
|
+
8319,
|
|
388
|
+
178,
|
|
389
|
+
9632,
|
|
390
|
+
160
|
|
391
|
+
);
|
|
392
|
+
function decodeLegacyZipName(bytes) {
|
|
393
|
+
try {
|
|
394
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
395
|
+
} catch {
|
|
396
|
+
let out = "";
|
|
397
|
+
for (const b of bytes) out += CP437[b] ?? "\uFFFD";
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
var RANGE_READ_CHUNK_BYTES = 256 * 1024;
|
|
402
|
+
async function createFileArchiveSource(zipPath) {
|
|
403
|
+
const info = await stat(zipPath);
|
|
404
|
+
return {
|
|
405
|
+
size: info.size,
|
|
406
|
+
readRange: (start, end) => readFileRange(zipPath, start, end)
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
async function listZipEntriesFromSource(source, opts = {}) {
|
|
410
|
+
const zipfile = await openZipFromSource(source);
|
|
411
|
+
const records = [];
|
|
412
|
+
try {
|
|
413
|
+
for await (const entry of zipfile.eachEntry()) {
|
|
414
|
+
if (entry.fileName.endsWith("/")) continue;
|
|
415
|
+
const dataOffset = opts.dataOffsets === false ? 0 : await resolveDataOffsetFromSource(
|
|
416
|
+
source,
|
|
417
|
+
entry.relativeOffsetOfLocalHeader
|
|
418
|
+
);
|
|
419
|
+
records.push({
|
|
420
|
+
name: entry.fileName,
|
|
421
|
+
compressionMethod: entry.compressionMethod,
|
|
422
|
+
encrypted: entry.isEncrypted(),
|
|
423
|
+
uncompressedSize: entry.uncompressedSize,
|
|
424
|
+
compressedSize: entry.compressedSize,
|
|
425
|
+
crc32: entry.crc32,
|
|
426
|
+
localHeaderOffset: entry.relativeOffsetOfLocalHeader,
|
|
427
|
+
dataOffset,
|
|
428
|
+
dataSize: entry.compressedSize,
|
|
429
|
+
modifiedAt: entry.getLastModDate().getTime()
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
} catch (err) {
|
|
433
|
+
throw invalid(
|
|
434
|
+
"resource.archive_open_failed",
|
|
435
|
+
err instanceof Error ? err.message : "could not read archive entries",
|
|
436
|
+
{ size: source.size }
|
|
437
|
+
);
|
|
438
|
+
} finally {
|
|
439
|
+
zipfile.close();
|
|
440
|
+
}
|
|
441
|
+
return records;
|
|
442
|
+
}
|
|
443
|
+
async function listZipEntries(zipPath, opts = {}) {
|
|
444
|
+
return listZipEntriesFromSource(await createFileArchiveSource(zipPath), opts);
|
|
445
|
+
}
|
|
446
|
+
function openZipEntryStream(source, record) {
|
|
447
|
+
if (record.encrypted) {
|
|
448
|
+
throw invalid(
|
|
449
|
+
"resource.archive_open_failed",
|
|
450
|
+
`zip entry "${record.name}" is encrypted \u2014 password-protected archives are not supported`,
|
|
451
|
+
{ name: record.name }
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
switch (record.compressionMethod) {
|
|
455
|
+
case 0:
|
|
456
|
+
return streamRange(
|
|
457
|
+
source,
|
|
458
|
+
record.dataOffset,
|
|
459
|
+
record.dataOffset + record.dataSize - 1
|
|
460
|
+
);
|
|
461
|
+
case 8: {
|
|
462
|
+
const raw = streamRange(
|
|
463
|
+
source,
|
|
464
|
+
record.dataOffset,
|
|
465
|
+
record.dataOffset + record.dataSize - 1
|
|
466
|
+
);
|
|
467
|
+
const inflater = createInflateRaw();
|
|
468
|
+
raw.on("error", (err) => inflater.destroy(err));
|
|
469
|
+
return raw.pipe(inflater);
|
|
470
|
+
}
|
|
471
|
+
default:
|
|
472
|
+
throw invalid(
|
|
473
|
+
"resource.archive_open_failed",
|
|
474
|
+
`unsupported zip entry compression method ${record.compressionMethod} for "${record.name}"`,
|
|
475
|
+
{ method: record.compressionMethod, name: record.name }
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function streamRange(source, start, end) {
|
|
480
|
+
return Readable.from(
|
|
481
|
+
(async function* rangeChunks() {
|
|
482
|
+
let pos = start;
|
|
483
|
+
while (pos <= end) {
|
|
484
|
+
const to = Math.min(end, pos + RANGE_READ_CHUNK_BYTES - 1);
|
|
485
|
+
const chunk = await source.readRange(pos, to);
|
|
486
|
+
if (chunk.length === 0) return;
|
|
487
|
+
yield chunk;
|
|
488
|
+
pos += chunk.length;
|
|
489
|
+
}
|
|
490
|
+
})()
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
function readZipRange(zipPath, start, end) {
|
|
494
|
+
return createReadStream(zipPath, { start, end });
|
|
495
|
+
}
|
|
496
|
+
async function readFileRange(path, start, end) {
|
|
497
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || !Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) {
|
|
498
|
+
throw invalid(
|
|
499
|
+
"resource.file_read_failed",
|
|
500
|
+
`invalid byte range ${start}..${end}`,
|
|
501
|
+
{ path, start, end }
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
const length = end - start + 1;
|
|
505
|
+
if (length <= 0) return Buffer.alloc(0);
|
|
506
|
+
return buffer(readZipRange(path, start, end));
|
|
507
|
+
}
|
|
508
|
+
function openZipFromSource(source) {
|
|
509
|
+
const reader = new RangeReader(source);
|
|
510
|
+
return yauzl.fromRandomAccessReaderPromise(reader, source.size, {
|
|
511
|
+
lazyEntries: true,
|
|
512
|
+
decodeStrings: true
|
|
513
|
+
}).catch((err) => {
|
|
514
|
+
reader.close(() => {
|
|
515
|
+
});
|
|
516
|
+
throw invalid(
|
|
517
|
+
"resource.archive_open_failed",
|
|
518
|
+
err instanceof Error ? err.message : "could not open archive",
|
|
519
|
+
{ size: source.size }
|
|
520
|
+
);
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
var RangeReader = class extends yauzl.RandomAccessReader {
|
|
524
|
+
// Explicit field + assignment: this package ships source to Node's
|
|
525
|
+
// type-stripping loader (no build step), which rejects parameter
|
|
526
|
+
// properties.
|
|
527
|
+
source;
|
|
528
|
+
constructor(source) {
|
|
529
|
+
super();
|
|
530
|
+
this.source = source;
|
|
531
|
+
}
|
|
532
|
+
_readStreamForRange(start, end) {
|
|
533
|
+
return streamRange(this.source, start, end - 1);
|
|
534
|
+
}
|
|
535
|
+
// yauzl's runtime contract calls `_close` even though the shipped
|
|
536
|
+
// types only expose the public `close` — no `override` here.
|
|
537
|
+
_close(callback) {
|
|
538
|
+
callback(null);
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
async function resolveDataOffsetFromSource(source, localHeaderOffset) {
|
|
542
|
+
if (localHeaderOffset + 30 > source.size) {
|
|
543
|
+
throw invalid(
|
|
544
|
+
"resource.archive_open_failed",
|
|
545
|
+
`truncated local file header at offset ${localHeaderOffset}`,
|
|
546
|
+
{ localHeaderOffset }
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
const head = await source.readRange(localHeaderOffset, localHeaderOffset + 29);
|
|
550
|
+
if (head.length < 30) {
|
|
551
|
+
throw invalid(
|
|
552
|
+
"resource.archive_open_failed",
|
|
553
|
+
`truncated local file header at offset ${localHeaderOffset}`,
|
|
554
|
+
{ localHeaderOffset }
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
const sig = head.readUInt32LE(0);
|
|
558
|
+
if (sig !== 67324752) {
|
|
559
|
+
throw invalid(
|
|
560
|
+
"resource.archive_open_failed",
|
|
561
|
+
`bad local file header signature at offset ${localHeaderOffset}`,
|
|
562
|
+
{ localHeaderOffset, sig: sig.toString(16) }
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
const nameLen = head.readUInt16LE(26);
|
|
566
|
+
const extraLen = head.readUInt16LE(28);
|
|
567
|
+
return localHeaderOffset + 30 + nameLen + extraLen;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// src/archive/7z.ts
|
|
571
|
+
var requireCjs = createRequire(import.meta.url);
|
|
572
|
+
var SEVEN_ZIP_PATH_ENV = "7Z_BIN_PATH";
|
|
573
|
+
function resolveSevenZipPath() {
|
|
574
|
+
const fromEnv = process.env[SEVEN_ZIP_PATH_ENV];
|
|
575
|
+
if (fromEnv !== void 0 && fromEnv.length > 0) return fromEnv;
|
|
576
|
+
try {
|
|
577
|
+
const mod = requireCjs("@hoardodile/7z-bin");
|
|
578
|
+
if (typeof mod === "string" && mod.length > 0) return mod;
|
|
579
|
+
return void 0;
|
|
580
|
+
} catch {
|
|
581
|
+
return void 0;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function sevenZipUnavailable() {
|
|
585
|
+
return invalid(
|
|
586
|
+
"resource.archive_open_failed",
|
|
587
|
+
"7-Zip is not installed \u2014 install @hoardodile/7z-bin or set 7Z_BIN_PATH to enable archive extraction",
|
|
588
|
+
{}
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
function run7z(bin, args, timeoutMs) {
|
|
592
|
+
return new Promise((resolveDone, rejectDone) => {
|
|
593
|
+
const child = spawn(bin, [...args], {
|
|
594
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
595
|
+
windowsHide: true
|
|
596
|
+
});
|
|
597
|
+
const stdout = [];
|
|
598
|
+
const stderr = [];
|
|
599
|
+
let settled = false;
|
|
600
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
601
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
602
|
+
const fail = (err) => {
|
|
603
|
+
if (settled) return;
|
|
604
|
+
settled = true;
|
|
605
|
+
rejectDone(err);
|
|
606
|
+
};
|
|
607
|
+
const timer = setTimeout(() => {
|
|
608
|
+
child.kill();
|
|
609
|
+
fail(
|
|
610
|
+
invalid(
|
|
611
|
+
"resource.archive_open_failed",
|
|
612
|
+
`7-Zip timed out after ${timeoutMs}ms`,
|
|
613
|
+
{ timeoutMs }
|
|
614
|
+
)
|
|
615
|
+
);
|
|
616
|
+
}, timeoutMs);
|
|
617
|
+
timer.unref();
|
|
618
|
+
child.on("error", (err) => {
|
|
619
|
+
fail(
|
|
620
|
+
invalid(
|
|
621
|
+
"resource.archive_open_failed",
|
|
622
|
+
`could not start 7-Zip: ${err.message}`,
|
|
623
|
+
{}
|
|
624
|
+
)
|
|
625
|
+
);
|
|
626
|
+
});
|
|
627
|
+
child.on("close", (code, signal) => {
|
|
628
|
+
clearTimeout(timer);
|
|
629
|
+
if (settled) return;
|
|
630
|
+
if (code === 0) {
|
|
631
|
+
settled = true;
|
|
632
|
+
resolveDone({ stdout: Buffer.concat(stdout) });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const detail = signal !== null ? `killed by ${signal}` : `exit ${code ?? "?"}`;
|
|
636
|
+
const tail = Buffer.concat(stderr).toString("utf8").trim().split("\n").at(-1);
|
|
637
|
+
fail(
|
|
638
|
+
invalid(
|
|
639
|
+
"resource.archive_open_failed",
|
|
640
|
+
`7-Zip ${detail}${tail !== void 0 && tail.length > 0 ? `: ${tail}` : ""}`,
|
|
641
|
+
{ code }
|
|
642
|
+
)
|
|
643
|
+
);
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
function isEncryptedField(value) {
|
|
648
|
+
return value === "+";
|
|
649
|
+
}
|
|
650
|
+
function parseSltListing(stdout, legacyNames) {
|
|
651
|
+
const entries = [];
|
|
652
|
+
let name;
|
|
653
|
+
let sizeBytes = 0;
|
|
654
|
+
let folder = false;
|
|
655
|
+
let encrypted = false;
|
|
656
|
+
for (const line of stdout.toString("latin1").split(/\r?\n/)) {
|
|
657
|
+
if (line.length === 0) {
|
|
658
|
+
if (name !== void 0) {
|
|
659
|
+
entries.push({ name, sizeBytes, folder, encrypted });
|
|
660
|
+
name = void 0;
|
|
661
|
+
}
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
const sep4 = line.indexOf(" = ");
|
|
665
|
+
if (sep4 < 0) continue;
|
|
666
|
+
const key = line.slice(0, sep4);
|
|
667
|
+
if (key === "Path") {
|
|
668
|
+
const raw = Buffer.from(line.slice(sep4 + 3), "latin1");
|
|
669
|
+
const decoded = legacyNames ? decodeLegacyZipName(raw) : raw.toString("utf8");
|
|
670
|
+
name = decoded.replace(/\\/g, "/");
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const value = line.slice(sep4 + 3);
|
|
674
|
+
switch (key) {
|
|
675
|
+
case "Size":
|
|
676
|
+
sizeBytes = Number.parseInt(value, 10) || 0;
|
|
677
|
+
break;
|
|
678
|
+
case "Attributes":
|
|
679
|
+
folder = value.includes("D");
|
|
680
|
+
break;
|
|
681
|
+
case "Encrypted":
|
|
682
|
+
encrypted = isEncryptedField(value);
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (name !== void 0) {
|
|
687
|
+
entries.push({ name, sizeBytes, folder, encrypted });
|
|
688
|
+
}
|
|
689
|
+
return entries;
|
|
690
|
+
}
|
|
691
|
+
async function isZipArchive(archivePath) {
|
|
692
|
+
const head = await readFileRange(
|
|
693
|
+
archivePath,
|
|
694
|
+
0,
|
|
695
|
+
SNIFF_WINDOW_BYTES - 1
|
|
696
|
+
).catch(() => void 0);
|
|
697
|
+
if (head === void 0 || head.length === 0) return false;
|
|
698
|
+
return sniffContainerFormat(head) === "zip";
|
|
699
|
+
}
|
|
700
|
+
async function listSevenZipEntries(archivePath, opts = {}) {
|
|
701
|
+
const bin = resolveSevenZipPath();
|
|
702
|
+
if (bin === void 0) throw sevenZipUnavailable();
|
|
703
|
+
const args = ["l", "-slt", "-ba", "-sccUTF-8"];
|
|
704
|
+
const legacyNames = await isZipArchive(archivePath);
|
|
705
|
+
args.push(archivePath);
|
|
706
|
+
const { stdout } = await run7z(bin, args, opts.timeoutMs ?? 12e4);
|
|
707
|
+
return parseSltListing(stdout, legacyNames);
|
|
708
|
+
}
|
|
709
|
+
async function extractSevenZipInto(archivePath, destDir, opts = {}) {
|
|
710
|
+
const bin = resolveSevenZipPath();
|
|
711
|
+
if (bin === void 0) throw sevenZipUnavailable();
|
|
712
|
+
const args = ["x", archivePath, `-o${destDir}`, "-y", "-bd"];
|
|
713
|
+
await run7z(bin, args, opts.timeoutMs ?? 10 * 6e4);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// src/archive/extract.ts
|
|
717
|
+
async function assertExtractedTree(root, maxBytes) {
|
|
718
|
+
let total = 0;
|
|
719
|
+
async function walk(dir) {
|
|
720
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
721
|
+
for (const entry of entries) {
|
|
722
|
+
const full = join(dir, entry.name);
|
|
723
|
+
const info = await lstat(full);
|
|
724
|
+
if (info.isSymbolicLink()) {
|
|
725
|
+
throw invalid(
|
|
726
|
+
"resource.archive_invalid_entry",
|
|
727
|
+
`extracted entry is a symlink: ${entry.name}`,
|
|
728
|
+
{ name: entry.name }
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
if (info.isDirectory()) {
|
|
732
|
+
await walk(full);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (!info.isFile()) {
|
|
736
|
+
throw invalid(
|
|
737
|
+
"resource.archive_invalid_entry",
|
|
738
|
+
`extracted entry is not a regular file: ${entry.name}`,
|
|
739
|
+
{ name: entry.name }
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
total += info.size;
|
|
743
|
+
if (total > maxBytes) {
|
|
744
|
+
throw invalid(
|
|
745
|
+
"resource.archive_too_large",
|
|
746
|
+
`archive extracts to more than ${maxBytes} bytes`,
|
|
747
|
+
{ maxExtractedBytes: maxBytes }
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
await walk(root);
|
|
753
|
+
}
|
|
754
|
+
async function normalizeExtractedTree(root, opts) {
|
|
755
|
+
if (opts.legacyZipNames) {
|
|
756
|
+
const expected = opts.expectedNames ? new Set(opts.expectedNames.map((n) => n.replace(/\/+$/, ""))) : void 0;
|
|
757
|
+
await renameLegacyZipNames(Buffer.from(root), root, root, expected);
|
|
758
|
+
}
|
|
759
|
+
await makeReadable(root);
|
|
760
|
+
}
|
|
761
|
+
async function decodeZipNames(zipPath, fallback) {
|
|
762
|
+
try {
|
|
763
|
+
const records = await listZipEntries(zipPath, { dataOffsets: false });
|
|
764
|
+
const files = records.map((e) => ({
|
|
765
|
+
name: e.name,
|
|
766
|
+
sizeBytes: e.uncompressedSize
|
|
767
|
+
}));
|
|
768
|
+
const paths = /* @__PURE__ */ new Set();
|
|
769
|
+
for (const file of files) {
|
|
770
|
+
const segments = file.name.split("/");
|
|
771
|
+
for (let i = 1; i < segments.length; i++) {
|
|
772
|
+
paths.add(segments.slice(0, i).join("/"));
|
|
773
|
+
}
|
|
774
|
+
paths.add(file.name);
|
|
775
|
+
}
|
|
776
|
+
return { files, paths: [...paths] };
|
|
777
|
+
} catch {
|
|
778
|
+
return { files: fallback, paths: fallback.map((e) => e.name) };
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async function renameLegacyZipNames(rawRoot, decodedRoot, root, expected) {
|
|
782
|
+
const entries = await readdir(rawRoot, {
|
|
783
|
+
withFileTypes: true,
|
|
784
|
+
encoding: "buffer"
|
|
785
|
+
});
|
|
786
|
+
for (const entry of entries) {
|
|
787
|
+
const childRaw = Buffer.concat([rawRoot, Buffer.from(sep), entry.name]);
|
|
788
|
+
const decoded = decodeLegacyZipName(entry.name);
|
|
789
|
+
const target = legacyRenameTarget(
|
|
790
|
+
entry.name,
|
|
791
|
+
decoded,
|
|
792
|
+
decodedRoot,
|
|
793
|
+
root,
|
|
794
|
+
expected
|
|
795
|
+
);
|
|
796
|
+
if (entry.isDirectory()) {
|
|
797
|
+
if (target !== void 0 && !existsSync(target)) {
|
|
798
|
+
await rename(childRaw, target);
|
|
799
|
+
await renameLegacyZipNames(Buffer.from(target), target, root, expected);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
await renameLegacyZipNames(
|
|
803
|
+
childRaw,
|
|
804
|
+
target ?? `${decodedRoot}/${decoded}`,
|
|
805
|
+
root,
|
|
806
|
+
expected
|
|
807
|
+
);
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
if (target === void 0 || existsSync(target)) continue;
|
|
811
|
+
await rename(childRaw, target);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
function legacyRenameTarget(rawName, decoded, decodedRoot, root, expected) {
|
|
815
|
+
const utf8Name = rawName.toString("utf8");
|
|
816
|
+
if (decoded !== utf8Name) {
|
|
817
|
+
return `${decodedRoot}/${decoded}`;
|
|
818
|
+
}
|
|
819
|
+
const raw = recoverRawLegacyName(rawName);
|
|
820
|
+
if (raw === void 0) return void 0;
|
|
821
|
+
const recovered = decodeLegacyZipName(raw);
|
|
822
|
+
if (recovered === utf8Name || expected === void 0) return void 0;
|
|
823
|
+
const rel = decodedRoot === root ? recovered : `${decodedRoot.slice(root.length + 1)}/${recovered}`;
|
|
824
|
+
return expected.has(rel) ? `${decodedRoot}/${recovered}` : void 0;
|
|
825
|
+
}
|
|
826
|
+
function recoverRawLegacyName(name) {
|
|
827
|
+
const escaped = unescapePercentEscapes(name);
|
|
828
|
+
if (escaped !== void 0) return escaped;
|
|
829
|
+
const text = name.toString("utf8");
|
|
830
|
+
const out = [];
|
|
831
|
+
for (const ch of text) {
|
|
832
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
833
|
+
if (code >= 61312 && code <= 61439) {
|
|
834
|
+
out.push(code - 61184);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
if (code > 127) return void 0;
|
|
838
|
+
out.push(code);
|
|
839
|
+
}
|
|
840
|
+
return out.length === text.length ? Buffer.from(out) : void 0;
|
|
841
|
+
}
|
|
842
|
+
function unescapePercentEscapes(name) {
|
|
843
|
+
const text = name.toString("latin1");
|
|
844
|
+
if (!text.includes("%")) return void 0;
|
|
845
|
+
const out = [];
|
|
846
|
+
let changed = false;
|
|
847
|
+
for (let i = 0; i < text.length; i++) {
|
|
848
|
+
const hex = i + 2 < text.length ? text.slice(i + 1, i + 3) : void 0;
|
|
849
|
+
if (text[i] === "%" && hex !== void 0 && /^[0-9a-fA-F]{2}$/.test(hex)) {
|
|
850
|
+
out.push(Number.parseInt(hex, 16));
|
|
851
|
+
changed = true;
|
|
852
|
+
i += 2;
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
out.push(text.charCodeAt(i));
|
|
856
|
+
}
|
|
857
|
+
return changed ? Buffer.from(out) : void 0;
|
|
858
|
+
}
|
|
859
|
+
async function makeReadable(root) {
|
|
860
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
861
|
+
for (const entry of entries) {
|
|
862
|
+
const full = join(root, entry.name);
|
|
863
|
+
if (entry.isDirectory()) {
|
|
864
|
+
await chmod(full, (await lstat(full)).mode | 448);
|
|
865
|
+
await makeReadable(full);
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (entry.isFile()) {
|
|
869
|
+
await chmod(full, (await lstat(full)).mode | 384);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async function materializeFile(opts) {
|
|
874
|
+
const { key } = opts;
|
|
875
|
+
if (key !== void 0) {
|
|
876
|
+
return withSingleFlight(key, () => materializeFileWithoutFlight(opts));
|
|
877
|
+
}
|
|
878
|
+
return materializeFileWithoutFlight(opts);
|
|
879
|
+
}
|
|
880
|
+
async function materializeFileWithoutFlight(opts) {
|
|
881
|
+
const { openStream, target, expectedSize } = opts;
|
|
882
|
+
await mkdir(dirname(target), { recursive: true });
|
|
883
|
+
const partial = `${target}.partial-${process.pid}-${Date.now()}`;
|
|
884
|
+
try {
|
|
885
|
+
if (expectedSize === 0) {
|
|
886
|
+
await writeFile(partial, Buffer.alloc(0));
|
|
887
|
+
} else {
|
|
888
|
+
await pipeline(await openStream(), createWriteStream(partial));
|
|
889
|
+
}
|
|
890
|
+
const written = await stat(partial);
|
|
891
|
+
if (written.size !== expectedSize) {
|
|
892
|
+
throw new Error(
|
|
893
|
+
`materialized ${target} size mismatch: expected ${expectedSize}, got ${written.size}`
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
await rename(partial, target);
|
|
897
|
+
} catch (err) {
|
|
898
|
+
await rm(partial, { force: true }).catch(() => {
|
|
899
|
+
});
|
|
900
|
+
throw err;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
function withSingleFlight(key, run, inflight = globalMaterializeInflight) {
|
|
904
|
+
const pending = inflight.get(key);
|
|
905
|
+
if (pending !== void 0) return pending;
|
|
906
|
+
const work = run();
|
|
907
|
+
inflight.set(key, work);
|
|
908
|
+
void work.then(
|
|
909
|
+
() => inflight.delete(key),
|
|
910
|
+
() => inflight.delete(key)
|
|
911
|
+
);
|
|
912
|
+
return work;
|
|
913
|
+
}
|
|
914
|
+
var globalMaterializeInflight = /* @__PURE__ */ new Map();
|
|
915
|
+
|
|
916
|
+
// src/archive/nested-entry.ts
|
|
917
|
+
var VIRTUAL_PATH_SEPARATOR = "!";
|
|
918
|
+
function splitVirtualPath(path) {
|
|
919
|
+
const sep4 = path.indexOf(VIRTUAL_PATH_SEPARATOR);
|
|
920
|
+
if (sep4 <= 0) return void 0;
|
|
921
|
+
const inner = path.slice(sep4 + 1);
|
|
922
|
+
if (inner.length === 0) return void 0;
|
|
923
|
+
return { outer: path.slice(0, sep4), inner };
|
|
924
|
+
}
|
|
925
|
+
function createOuterArchiveSource(outer, outerName, size) {
|
|
926
|
+
return {
|
|
927
|
+
size,
|
|
928
|
+
readRange: (start, end) => outer.readSlice(outerName, start, end + 1)
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
function createNestedResolver(outer, opts = {}) {
|
|
932
|
+
const cdCache = opts.cdCache;
|
|
933
|
+
const scope = opts.scope;
|
|
934
|
+
const cacheKey = (outerName) => scope === void 0 ? outerName : `${scope}:${outerName}`;
|
|
935
|
+
const listingMemo = /* @__PURE__ */ new Map();
|
|
936
|
+
const listedMemo = /* @__PURE__ */ new Map();
|
|
937
|
+
function listOuter(outerName) {
|
|
938
|
+
const key = cacheKey(outerName);
|
|
939
|
+
const pending = listingMemo.get(key);
|
|
940
|
+
if (pending !== void 0) return pending;
|
|
941
|
+
const shared = cdCache?.get(key);
|
|
942
|
+
if (shared !== void 0) {
|
|
943
|
+
listingMemo.set(key, shared);
|
|
944
|
+
return shared;
|
|
945
|
+
}
|
|
946
|
+
const work = listOuterOnce(outerName);
|
|
947
|
+
listingMemo.set(key, work);
|
|
948
|
+
cdCache?.set(key, work);
|
|
949
|
+
void work.then(
|
|
950
|
+
() => void 0,
|
|
951
|
+
() => void 0
|
|
952
|
+
);
|
|
953
|
+
return work;
|
|
954
|
+
}
|
|
955
|
+
async function listOuterOnce(outerName) {
|
|
956
|
+
const size = await outer.sizeOf(outerName);
|
|
957
|
+
if (size === void 0) return void 0;
|
|
958
|
+
if (size === 0) return void 0;
|
|
959
|
+
const head = await outer.readSlice(
|
|
960
|
+
outerName,
|
|
961
|
+
0,
|
|
962
|
+
Math.min(size, SNIFF_WINDOW_BYTES)
|
|
963
|
+
);
|
|
964
|
+
const format = sniffContainerFormat(head);
|
|
965
|
+
if (format !== "zip") return void 0;
|
|
966
|
+
const source = createOuterArchiveSource(outer, outerName, size);
|
|
967
|
+
const records = await listZipEntriesFromSource(source);
|
|
968
|
+
return records.map((r) => ({
|
|
969
|
+
name: r.name,
|
|
970
|
+
sizeBytes: r.uncompressedSize,
|
|
971
|
+
outerSize: size,
|
|
972
|
+
zip: r
|
|
973
|
+
}));
|
|
974
|
+
}
|
|
975
|
+
async function resolve5(path) {
|
|
976
|
+
const parts = splitVirtualPath(path);
|
|
977
|
+
if (parts === void 0) return { kind: "literal" };
|
|
978
|
+
const { outer: outerName, inner } = parts;
|
|
979
|
+
const entries = await listOuter(outerName);
|
|
980
|
+
const found = entries?.find((e) => e.name === inner);
|
|
981
|
+
if (found === void 0) return { kind: "literal" };
|
|
982
|
+
return {
|
|
983
|
+
kind: "container",
|
|
984
|
+
outer: outerName,
|
|
985
|
+
entry: { name: found.name, sizeBytes: found.sizeBytes },
|
|
986
|
+
openStream: () => openListedEntry(outer, outerName, found)
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
async function list(outerName) {
|
|
990
|
+
const pending = listedMemo.get(outerName);
|
|
991
|
+
if (pending !== void 0) return pending;
|
|
992
|
+
const work = listOuter(outerName).then(
|
|
993
|
+
(entries) => entries === void 0 ? void 0 : entries.map((e) => listedWithStream(outer, outerName, e))
|
|
994
|
+
);
|
|
995
|
+
listedMemo.set(outerName, work);
|
|
996
|
+
void work.then(
|
|
997
|
+
() => void 0,
|
|
998
|
+
() => void 0
|
|
999
|
+
);
|
|
1000
|
+
return work;
|
|
1001
|
+
}
|
|
1002
|
+
return { resolve: resolve5, list };
|
|
1003
|
+
}
|
|
1004
|
+
function listedWithStream(outer, outerName, entry) {
|
|
1005
|
+
return {
|
|
1006
|
+
name: entry.name,
|
|
1007
|
+
sizeBytes: entry.sizeBytes,
|
|
1008
|
+
openStream: () => openListedEntry(outer, outerName, entry)
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
function openListedEntry(outer, outerName, entry) {
|
|
1012
|
+
const source = createOuterArchiveSource(outer, outerName, entry.outerSize);
|
|
1013
|
+
if (entry.zip !== void 0) return openZipEntryStream(source, entry.zip);
|
|
1014
|
+
throw invalid(
|
|
1015
|
+
"resource.archive_open_failed",
|
|
1016
|
+
`entry "${entry.name}" has no stream backend`,
|
|
1017
|
+
{ name: entry.name }
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// src/archive/extract-archive.ts
|
|
1022
|
+
var PROBE_HEADER_BYTES = 256 * 1024;
|
|
1023
|
+
var EXTRACT_INDEX_VERSION = 1;
|
|
1024
|
+
function createArchiveExtractor(deps) {
|
|
1025
|
+
const resolver = createNestedResolver(deps.outer, {
|
|
1026
|
+
cdCache: deps.nestedCdCache
|
|
1027
|
+
});
|
|
1028
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
1029
|
+
function extract(archiveName) {
|
|
1030
|
+
const pending = inflight.get(archiveName);
|
|
1031
|
+
if (pending !== void 0) return pending;
|
|
1032
|
+
const work = extractOnce(archiveName);
|
|
1033
|
+
inflight.set(archiveName, work);
|
|
1034
|
+
void work.then(
|
|
1035
|
+
() => void 0,
|
|
1036
|
+
() => void 0
|
|
1037
|
+
);
|
|
1038
|
+
return work;
|
|
1039
|
+
}
|
|
1040
|
+
async function extractOnce(archiveName) {
|
|
1041
|
+
const size = await deps.outer.sizeOf(archiveName);
|
|
1042
|
+
if (size === void 0) {
|
|
1043
|
+
throw invalid(
|
|
1044
|
+
"plugin.container_extract_failed",
|
|
1045
|
+
`"${archiveName}" does not exist`,
|
|
1046
|
+
{ archiveName }
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
const markerPath = join(deps.cacheDir, archiveName, "index.json");
|
|
1050
|
+
const existing = await readExistingManifest(markerPath, archiveName);
|
|
1051
|
+
if (existing !== void 0) return { entries: existing };
|
|
1052
|
+
if (size > 0) {
|
|
1053
|
+
const head = await deps.outer.readSlice(
|
|
1054
|
+
archiveName,
|
|
1055
|
+
0,
|
|
1056
|
+
Math.min(size, SNIFF_WINDOW_BYTES)
|
|
1057
|
+
);
|
|
1058
|
+
const format = sniffContainerFormat(head);
|
|
1059
|
+
if (format !== void 0) {
|
|
1060
|
+
return extractWithSevenZip(archiveName, size, markerPath, format);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
throw invalid(
|
|
1064
|
+
"plugin.container_extract_failed",
|
|
1065
|
+
`"${archiveName}" is not a supported archive (zip/tar/7z/rar/xz/gzip)`,
|
|
1066
|
+
{ archiveName }
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
async function extractWithSevenZip(archiveName, size, markerPath, format) {
|
|
1070
|
+
const source = createOuterArchiveSource(deps.outer, archiveName, size);
|
|
1071
|
+
const tempPath = join(
|
|
1072
|
+
deps.cacheDir,
|
|
1073
|
+
`${archiveName}.7z-partial-${process.pid}-${randomUUID().slice(0, 8)}`
|
|
1074
|
+
);
|
|
1075
|
+
await mkdir(deps.cacheDir, { recursive: true });
|
|
1076
|
+
try {
|
|
1077
|
+
await materializeFile({
|
|
1078
|
+
openStream: () => streamRange(source, 0, size - 1),
|
|
1079
|
+
target: tempPath,
|
|
1080
|
+
expectedSize: size
|
|
1081
|
+
});
|
|
1082
|
+
const entries = await listSevenZipEntries(tempPath);
|
|
1083
|
+
const files = entries.filter((e) => !e.folder);
|
|
1084
|
+
checkExtractionBudgets(archiveName, files, deps.maxBytes, deps.maxEntries);
|
|
1085
|
+
if (entries.some((e) => e.encrypted)) {
|
|
1086
|
+
throw invalid(
|
|
1087
|
+
"plugin.container_extract_failed",
|
|
1088
|
+
`archive "${archiveName}" is password-protected \xE2\u20AC\u201D encrypted archives are not supported`,
|
|
1089
|
+
{ archiveName }
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
const decoded = format === "zip" ? await decodeZipNames(
|
|
1093
|
+
tempPath,
|
|
1094
|
+
files.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))
|
|
1095
|
+
) : void 0;
|
|
1096
|
+
const materialized = decoded?.files ?? files;
|
|
1097
|
+
for (const entry of materialized) sanitizeExtractPath(entry.name);
|
|
1098
|
+
const root = join(deps.cacheDir, archiveName);
|
|
1099
|
+
await mkdir(root, { recursive: true });
|
|
1100
|
+
await extractSevenZipInto(tempPath, root);
|
|
1101
|
+
await normalizeExtractedTree(root, {
|
|
1102
|
+
legacyZipNames: format === "zip",
|
|
1103
|
+
expectedNames: decoded?.paths
|
|
1104
|
+
});
|
|
1105
|
+
await assertExtractedTree(root, deps.maxBytes);
|
|
1106
|
+
deps.onProgress?.({ done: files.length, total: files.length });
|
|
1107
|
+
const manifest = await buildManifest(
|
|
1108
|
+
archiveName,
|
|
1109
|
+
materialized.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))
|
|
1110
|
+
);
|
|
1111
|
+
await writeManifest(markerPath, archiveName, manifest);
|
|
1112
|
+
await rm(join(deps.cacheDir, `${archiveName}.7z-list`), {
|
|
1113
|
+
force: true
|
|
1114
|
+
}).catch(() => {
|
|
1115
|
+
});
|
|
1116
|
+
await rm(join(deps.cacheDir, `${archiveName}.7z-list.json`), {
|
|
1117
|
+
force: true
|
|
1118
|
+
}).catch(() => {
|
|
1119
|
+
});
|
|
1120
|
+
return { entries: manifest };
|
|
1121
|
+
} finally {
|
|
1122
|
+
await rm(tempPath, { force: true }).catch(() => {
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
function checkExtractionBudgets(archiveName, entries, maxBytes, maxEntries) {
|
|
1127
|
+
if (entries.length > maxEntries) {
|
|
1128
|
+
throw invalid(
|
|
1129
|
+
"plugin.container_extract_failed",
|
|
1130
|
+
`archive "${archiveName}" has ${entries.length} entries, exceeding the limit of ${maxEntries}`,
|
|
1131
|
+
{ archiveName, entries: entries.length, maxEntries }
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
let totalBytes = 0;
|
|
1135
|
+
for (const entry of entries) totalBytes += entry.sizeBytes;
|
|
1136
|
+
if (totalBytes > maxBytes) {
|
|
1137
|
+
throw invalid(
|
|
1138
|
+
"plugin.container_extract_failed",
|
|
1139
|
+
`archive "${archiveName}" unpacks to ${totalBytes} bytes, exceeding the limit of ${maxBytes}`,
|
|
1140
|
+
{ archiveName, totalBytes, maxBytes }
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
async function buildManifest(archiveName, entries) {
|
|
1145
|
+
const manifest = [];
|
|
1146
|
+
for (const entry of entries) {
|
|
1147
|
+
const kind = fileTypeFromName(entry.name)?.kind ?? "other";
|
|
1148
|
+
const row = {
|
|
1149
|
+
path: entry.name,
|
|
1150
|
+
sizeBytes: entry.sizeBytes,
|
|
1151
|
+
kind
|
|
1152
|
+
};
|
|
1153
|
+
if (kind === "image" && deps.probeImage !== void 0) {
|
|
1154
|
+
const rel = sanitizeExtractPath(entry.name);
|
|
1155
|
+
const filePath = join(deps.cacheDir, archiveName, rel);
|
|
1156
|
+
const probe = await probeImageHead(
|
|
1157
|
+
deps.probeImage,
|
|
1158
|
+
filePath,
|
|
1159
|
+
entry.name
|
|
1160
|
+
);
|
|
1161
|
+
if (probe !== void 0) {
|
|
1162
|
+
row.width = probe.width;
|
|
1163
|
+
row.height = probe.height;
|
|
1164
|
+
row.animated = probe.animated;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
manifest.push(row);
|
|
1168
|
+
}
|
|
1169
|
+
return manifest;
|
|
1170
|
+
}
|
|
1171
|
+
async function list(archiveName) {
|
|
1172
|
+
const size = await deps.outer.sizeOf(archiveName);
|
|
1173
|
+
if (size === void 0) return void 0;
|
|
1174
|
+
const markerPath = join(deps.cacheDir, archiveName, "index.json");
|
|
1175
|
+
const extracted = await readExistingManifest(markerPath, archiveName);
|
|
1176
|
+
if (extracted !== void 0) {
|
|
1177
|
+
return extracted.map((e) => ({ name: e.path, sizeBytes: e.sizeBytes }));
|
|
1178
|
+
}
|
|
1179
|
+
if (size === 0) return void 0;
|
|
1180
|
+
const head = await deps.outer.readSlice(
|
|
1181
|
+
archiveName,
|
|
1182
|
+
0,
|
|
1183
|
+
Math.min(size, SNIFF_WINDOW_BYTES)
|
|
1184
|
+
);
|
|
1185
|
+
const format = sniffContainerFormat(head);
|
|
1186
|
+
if (format === "zip") {
|
|
1187
|
+
const entries = await resolver.list(archiveName);
|
|
1188
|
+
if (entries === void 0) return void 0;
|
|
1189
|
+
return entries.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }));
|
|
1190
|
+
}
|
|
1191
|
+
if (format === "tar" || format === "7z" || format === "rar" || format === "xz") {
|
|
1192
|
+
return listViaSevenZip(archiveName, size);
|
|
1193
|
+
}
|
|
1194
|
+
return void 0;
|
|
1195
|
+
}
|
|
1196
|
+
async function listViaSevenZip(archiveName, size) {
|
|
1197
|
+
if (resolveSevenZipPath() === void 0) return void 0;
|
|
1198
|
+
await mkdir(deps.cacheDir, { recursive: true });
|
|
1199
|
+
const cachePath = join(deps.cacheDir, `${archiveName}.7z-list`);
|
|
1200
|
+
const entriesPath = join(deps.cacheDir, `${archiveName}.7z-list.json`);
|
|
1201
|
+
const cached = await stat(cachePath).catch(() => void 0);
|
|
1202
|
+
if (cached?.size === size) {
|
|
1203
|
+
const raw = await readFile(entriesPath, "utf8").catch(() => void 0);
|
|
1204
|
+
if (raw !== void 0) {
|
|
1205
|
+
try {
|
|
1206
|
+
return JSON.parse(raw);
|
|
1207
|
+
} catch {
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
try {
|
|
1212
|
+
const source = createOuterArchiveSource(deps.outer, archiveName, size);
|
|
1213
|
+
await materializeFile({
|
|
1214
|
+
openStream: () => streamRange(source, 0, size - 1),
|
|
1215
|
+
target: cachePath,
|
|
1216
|
+
expectedSize: size
|
|
1217
|
+
});
|
|
1218
|
+
const listed = await listSevenZipEntries(cachePath);
|
|
1219
|
+
const files = listed.filter((e) => !e.folder).map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }));
|
|
1220
|
+
await writeFile(entriesPath, JSON.stringify(files));
|
|
1221
|
+
return files;
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
await rm(cachePath, { force: true }).catch(() => {
|
|
1224
|
+
});
|
|
1225
|
+
await rm(entriesPath, { force: true }).catch(() => {
|
|
1226
|
+
});
|
|
1227
|
+
throw err;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return { extract, list };
|
|
1231
|
+
}
|
|
1232
|
+
async function readExistingManifest(markerPath, archiveName) {
|
|
1233
|
+
const raw = await readFile(markerPath, "utf8").catch(() => void 0);
|
|
1234
|
+
if (raw === void 0) return void 0;
|
|
1235
|
+
try {
|
|
1236
|
+
const parsed = JSON.parse(raw);
|
|
1237
|
+
if (parsed.v !== EXTRACT_INDEX_VERSION || parsed.archiveName !== archiveName || !Array.isArray(parsed.entries)) {
|
|
1238
|
+
return void 0;
|
|
1239
|
+
}
|
|
1240
|
+
return parsed.entries;
|
|
1241
|
+
} catch {
|
|
1242
|
+
return void 0;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
async function writeManifest(markerPath, archiveName, entries) {
|
|
1246
|
+
const payload = JSON.stringify({
|
|
1247
|
+
v: EXTRACT_INDEX_VERSION,
|
|
1248
|
+
archiveName,
|
|
1249
|
+
entries
|
|
1250
|
+
});
|
|
1251
|
+
const partial = `${markerPath}.partial-${process.pid}`;
|
|
1252
|
+
await writeFile(partial, payload);
|
|
1253
|
+
await rename(partial, markerPath);
|
|
1254
|
+
}
|
|
1255
|
+
function sanitizeExtractPath(name) {
|
|
1256
|
+
const normalized = name.replace(/\\/g, "/");
|
|
1257
|
+
if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) {
|
|
1258
|
+
throw invalid(
|
|
1259
|
+
"plugin.container_extract_failed",
|
|
1260
|
+
`archive entry has an absolute path: "${name}"`,
|
|
1261
|
+
{ name }
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
const segments = normalized.split("/");
|
|
1265
|
+
for (const seg of segments) {
|
|
1266
|
+
if (seg.length === 0 || seg === "." || seg === "..") {
|
|
1267
|
+
throw invalid(
|
|
1268
|
+
"plugin.container_extract_failed",
|
|
1269
|
+
`archive entry has an unsafe path: "${name}"`,
|
|
1270
|
+
{ name }
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
if (hasControlChar(seg)) {
|
|
1274
|
+
throw invalid(
|
|
1275
|
+
"plugin.container_extract_failed",
|
|
1276
|
+
`archive entry contains control characters: "${name}"`,
|
|
1277
|
+
{ name }
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return segments.join("/");
|
|
1282
|
+
}
|
|
1283
|
+
function hasControlChar(value) {
|
|
1284
|
+
for (let i = 0; i < value.length; i++) {
|
|
1285
|
+
const code = value.charCodeAt(i);
|
|
1286
|
+
if (code < 32 || code === 127) return true;
|
|
1287
|
+
}
|
|
1288
|
+
return false;
|
|
1289
|
+
}
|
|
1290
|
+
async function probeImageHead(probeImage, filePath, name) {
|
|
1291
|
+
const head = await readFile(filePath).then(
|
|
1292
|
+
(b) => b.subarray(0, PROBE_HEADER_BYTES)
|
|
1293
|
+
);
|
|
1294
|
+
if (head.length === 0) return void 0;
|
|
1295
|
+
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
1296
|
+
return probeImage(head, ext);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// src/archive/nested-cd-cache.ts
|
|
1300
|
+
var DEFAULT_MAX_ENTRIES = 512;
|
|
1301
|
+
function createNestedCdCache(opts) {
|
|
1302
|
+
const maxEntries = opts?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
1303
|
+
const lru = /* @__PURE__ */ new Map();
|
|
1304
|
+
function get(key) {
|
|
1305
|
+
const value = lru.get(key);
|
|
1306
|
+
if (value === void 0) return void 0;
|
|
1307
|
+
lru.delete(key);
|
|
1308
|
+
lru.set(key, value);
|
|
1309
|
+
return value;
|
|
1310
|
+
}
|
|
1311
|
+
function set(key, value) {
|
|
1312
|
+
lru.set(key, value);
|
|
1313
|
+
while (lru.size > maxEntries) {
|
|
1314
|
+
const oldest = lru.keys().next().value;
|
|
1315
|
+
if (oldest === void 0) break;
|
|
1316
|
+
lru.delete(oldest);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
function clear() {
|
|
1320
|
+
lru.clear();
|
|
1321
|
+
}
|
|
1322
|
+
return { get, set, clear };
|
|
1323
|
+
}
|
|
1324
|
+
var PHASH_GRID = 32;
|
|
1325
|
+
var MIN_PERCEPTUAL_STDDEV = 8;
|
|
1326
|
+
function hashStream(stream, algo) {
|
|
1327
|
+
return new Promise((resolve5, reject) => {
|
|
1328
|
+
const hash = createHash(algo);
|
|
1329
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
1330
|
+
stream.on("end", () => resolve5(hash.digest("hex")));
|
|
1331
|
+
stream.on("error", reject);
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
function computeDHash(gray, grid = PHASH_GRID) {
|
|
1335
|
+
let bits = 0n;
|
|
1336
|
+
for (let row = 0; row < 8; row++) {
|
|
1337
|
+
for (let col = 0; col < 8; col++) {
|
|
1338
|
+
const left = sampleAt(gray, grid, row, col);
|
|
1339
|
+
const right = sampleAt(gray, grid, row, col + 1);
|
|
1340
|
+
bits = bits << 1n | BigInt(left > right ? 1 : 0);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
return bits.toString(16).padStart(16, "0");
|
|
1344
|
+
}
|
|
1345
|
+
function sampleAt(gray, grid, row, col) {
|
|
1346
|
+
const x = Math.round(col * (grid - 1) / 8);
|
|
1347
|
+
return gray[row * grid + x] ?? 0;
|
|
1348
|
+
}
|
|
1349
|
+
function computePHash(gray, grid = PHASH_GRID) {
|
|
1350
|
+
const n = 8;
|
|
1351
|
+
const small = meanPool(gray, grid, n);
|
|
1352
|
+
const cos = precomputeCosines(n, n);
|
|
1353
|
+
const coefficients = [];
|
|
1354
|
+
for (let v = 0; v < n; v++) {
|
|
1355
|
+
for (let u = 0; u < n; u++) {
|
|
1356
|
+
let sum = 0;
|
|
1357
|
+
for (let y = 0; y < n; y++) {
|
|
1358
|
+
const cosYv = cos[v]?.[y] ?? 0;
|
|
1359
|
+
const cosXu = cos[u] ?? [];
|
|
1360
|
+
let rowSum = 0;
|
|
1361
|
+
for (let x = 0; x < n; x++) {
|
|
1362
|
+
rowSum += (small[y * n + x] ?? 0) * (cosXu[x] ?? 0);
|
|
1363
|
+
}
|
|
1364
|
+
sum += rowSum * cosYv;
|
|
1365
|
+
}
|
|
1366
|
+
const cu = u === 0 ? 1 / Math.SQRT2 : 1;
|
|
1367
|
+
const cv = v === 0 ? 1 / Math.SQRT2 : 1;
|
|
1368
|
+
coefficients.push(0.25 * cu * cv * sum);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
const median = medianOf(coefficients);
|
|
1372
|
+
let bits = 0n;
|
|
1373
|
+
for (const coefficient of coefficients) {
|
|
1374
|
+
bits = bits << 1n | BigInt(coefficient > median ? 1 : 0);
|
|
1375
|
+
}
|
|
1376
|
+
return bits.toString(16).padStart(16, "0");
|
|
1377
|
+
}
|
|
1378
|
+
function meanPool(gray, grid, n) {
|
|
1379
|
+
const step = grid / n;
|
|
1380
|
+
const pooled = [];
|
|
1381
|
+
for (let by = 0; by < n; by++) {
|
|
1382
|
+
for (let bx = 0; bx < n; bx++) {
|
|
1383
|
+
let sum = 0;
|
|
1384
|
+
for (let y = 0; y < step; y++) {
|
|
1385
|
+
for (let x = 0; x < step; x++) {
|
|
1386
|
+
sum += gray[(by * step + y) * grid + bx * step + x] ?? 0;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
pooled.push(sum / (step * step));
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
return pooled;
|
|
1393
|
+
}
|
|
1394
|
+
function grayStddev(gray) {
|
|
1395
|
+
if (gray.length === 0) return 0;
|
|
1396
|
+
let mean = 0;
|
|
1397
|
+
for (const value of gray) mean += value;
|
|
1398
|
+
mean /= gray.length;
|
|
1399
|
+
let squared = 0;
|
|
1400
|
+
for (const value of gray) {
|
|
1401
|
+
const delta = value - mean;
|
|
1402
|
+
squared += delta * delta;
|
|
1403
|
+
}
|
|
1404
|
+
return Math.sqrt(squared / gray.length);
|
|
1405
|
+
}
|
|
1406
|
+
function precomputeCosines(grid, n) {
|
|
1407
|
+
const cosX = [];
|
|
1408
|
+
for (let k = 0; k < n; k++) {
|
|
1409
|
+
const row = [];
|
|
1410
|
+
for (let x = 0; x < grid; x++) {
|
|
1411
|
+
row.push(Math.cos((2 * x + 1) * k * Math.PI / (2 * grid)));
|
|
1412
|
+
}
|
|
1413
|
+
cosX.push(row);
|
|
1414
|
+
}
|
|
1415
|
+
return cosX;
|
|
1416
|
+
}
|
|
1417
|
+
function medianOf(values) {
|
|
1418
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
1419
|
+
return sorted[Math.floor(sorted.length / 2)] ?? 0;
|
|
1420
|
+
}
|
|
1421
|
+
var PERCEPTUAL_HASH_KINDS = [
|
|
1422
|
+
"dhash",
|
|
1423
|
+
"phash"
|
|
1424
|
+
];
|
|
1425
|
+
function createNestedAwareContainer(base, nestedCdCache, scope, extractCacheDir) {
|
|
1426
|
+
const resolver = createNestedResolver(
|
|
1427
|
+
{
|
|
1428
|
+
sizeOf: (rel) => base.resolveByteRange(rel).then((r) => r?.size),
|
|
1429
|
+
readSlice: (rel, start, end) => base.readEntrySlice(rel, start, end)
|
|
1430
|
+
},
|
|
1431
|
+
{ cdCache: nestedCdCache, scope }
|
|
1432
|
+
);
|
|
1433
|
+
const manifestMemo = /* @__PURE__ */ new Map();
|
|
1434
|
+
function readManifest(outer) {
|
|
1435
|
+
const key = `${extractCacheDir}:${outer}`;
|
|
1436
|
+
const pending = manifestMemo.get(key);
|
|
1437
|
+
if (pending !== void 0) return pending;
|
|
1438
|
+
const work = readExistingManifest(
|
|
1439
|
+
join(extractCacheDir, outer, "index.json"),
|
|
1440
|
+
outer
|
|
1441
|
+
).catch(() => void 0);
|
|
1442
|
+
manifestMemo.set(key, work);
|
|
1443
|
+
return work;
|
|
1444
|
+
}
|
|
1445
|
+
async function resolveMaterialized(rel) {
|
|
1446
|
+
if (extractCacheDir === void 0) return void 0;
|
|
1447
|
+
const parts = splitVirtualPath(rel);
|
|
1448
|
+
if (parts === void 0) return void 0;
|
|
1449
|
+
const { outer, inner } = parts;
|
|
1450
|
+
const manifest = await readManifest(outer);
|
|
1451
|
+
const entry = manifest?.find((e) => e.path === inner);
|
|
1452
|
+
if (entry === void 0) return void 0;
|
|
1453
|
+
return {
|
|
1454
|
+
kind: "materialized",
|
|
1455
|
+
absPath: join(extractCacheDir, outer, inner),
|
|
1456
|
+
sizeBytes: entry.sizeBytes
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
async function resolve5(rel) {
|
|
1460
|
+
const nested = await resolver.resolve(rel);
|
|
1461
|
+
if (nested.kind === "container") return nested;
|
|
1462
|
+
const materialized = await resolveMaterialized(rel);
|
|
1463
|
+
return materialized ?? nested;
|
|
1464
|
+
}
|
|
1465
|
+
async function readEntrySlice(rel, start, end) {
|
|
1466
|
+
const resolved = await resolve5(rel);
|
|
1467
|
+
if (resolved.kind === "literal") {
|
|
1468
|
+
return base.readEntrySlice(rel, start, end);
|
|
1469
|
+
}
|
|
1470
|
+
if (resolved.kind === "materialized") {
|
|
1471
|
+
return readFileRange(resolved.absPath, start, end - 1);
|
|
1472
|
+
}
|
|
1473
|
+
return readVirtualSlice(() => resolved.openStream(), start, end);
|
|
1474
|
+
}
|
|
1475
|
+
async function openEntryStream(rel) {
|
|
1476
|
+
const resolved = await resolve5(rel);
|
|
1477
|
+
if (resolved.kind === "literal") {
|
|
1478
|
+
return base.openEntryStream(rel);
|
|
1479
|
+
}
|
|
1480
|
+
if (resolved.kind === "materialized") {
|
|
1481
|
+
return {
|
|
1482
|
+
stream: createReadStream(resolved.absPath),
|
|
1483
|
+
size: resolved.sizeBytes,
|
|
1484
|
+
path: resolved.absPath
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
return { stream: resolved.openStream(), size: resolved.entry.sizeBytes };
|
|
1488
|
+
}
|
|
1489
|
+
async function readEntry(rel) {
|
|
1490
|
+
const resolved = await resolve5(rel);
|
|
1491
|
+
if (resolved.kind === "literal") {
|
|
1492
|
+
return base.readEntry(rel);
|
|
1493
|
+
}
|
|
1494
|
+
if (resolved.kind === "materialized") {
|
|
1495
|
+
return readFile(resolved.absPath);
|
|
1496
|
+
}
|
|
1497
|
+
return buffer(resolved.openStream());
|
|
1498
|
+
}
|
|
1499
|
+
async function resolveByteRange(rel) {
|
|
1500
|
+
const resolved = await resolve5(rel);
|
|
1501
|
+
if (resolved.kind === "literal") {
|
|
1502
|
+
return base.resolveByteRange(rel);
|
|
1503
|
+
}
|
|
1504
|
+
if (resolved.kind === "materialized") {
|
|
1505
|
+
return { size: resolved.sizeBytes };
|
|
1506
|
+
}
|
|
1507
|
+
return { size: resolved.entry.sizeBytes };
|
|
1508
|
+
}
|
|
1509
|
+
async function resolveSeekablePath(rel) {
|
|
1510
|
+
const resolved = await resolve5(rel);
|
|
1511
|
+
if (resolved.kind === "literal") {
|
|
1512
|
+
return base.resolveSeekablePath?.(rel);
|
|
1513
|
+
}
|
|
1514
|
+
if (resolved.kind === "materialized") {
|
|
1515
|
+
return resolved.absPath;
|
|
1516
|
+
}
|
|
1517
|
+
return void 0;
|
|
1518
|
+
}
|
|
1519
|
+
return {
|
|
1520
|
+
listEntries: base.listEntries,
|
|
1521
|
+
readEntry,
|
|
1522
|
+
readEntrySlice,
|
|
1523
|
+
openEntryStream,
|
|
1524
|
+
resolveByteRange,
|
|
1525
|
+
resolveSeekablePath
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
async function readVirtualSlice(openStream, start, end) {
|
|
1529
|
+
if (end <= start) return Buffer.alloc(0);
|
|
1530
|
+
const chunks = [];
|
|
1531
|
+
let collected = 0;
|
|
1532
|
+
await new Promise((resolveDone, rejectDone) => {
|
|
1533
|
+
const stream = openStream();
|
|
1534
|
+
stream.on("data", (chunk) => {
|
|
1535
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
1536
|
+
const need = end - collected;
|
|
1537
|
+
if (need <= 0) {
|
|
1538
|
+
stream.destroy();
|
|
1539
|
+
resolveDone();
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
const take = bytes.subarray(0, Math.min(need, bytes.length));
|
|
1543
|
+
chunks.push(take);
|
|
1544
|
+
collected += take.length;
|
|
1545
|
+
if (collected >= end) {
|
|
1546
|
+
stream.destroy();
|
|
1547
|
+
resolveDone();
|
|
1548
|
+
}
|
|
1549
|
+
});
|
|
1550
|
+
stream.on("end", () => resolveDone());
|
|
1551
|
+
stream.on("error", (err) => rejectDone(err));
|
|
1552
|
+
});
|
|
1553
|
+
const full = Buffer.concat(chunks, collected);
|
|
1554
|
+
const sliceStart = Math.min(start, full.length);
|
|
1555
|
+
return full.subarray(sliceStart);
|
|
1556
|
+
}
|
|
1557
|
+
var sharpPromise;
|
|
1558
|
+
function loadSharp() {
|
|
1559
|
+
sharpPromise ??= import('sharp').catch((err) => {
|
|
1560
|
+
sharpPromise = void 0;
|
|
1561
|
+
throw new Error(
|
|
1562
|
+
`sharp is not installed \u2014 add "sharp" to your dependencies to enable image probing: ${err instanceof Error ? err.message : String(err)}`
|
|
1563
|
+
);
|
|
1564
|
+
});
|
|
1565
|
+
return sharpPromise;
|
|
1566
|
+
}
|
|
1567
|
+
var ANIMATED_EXT_HINTS = /* @__PURE__ */ new Set([".gif", ".webp", ".avif"]);
|
|
1568
|
+
function isAnimatedCandidateExt(ext) {
|
|
1569
|
+
return ANIMATED_EXT_HINTS.has(ext);
|
|
1570
|
+
}
|
|
1571
|
+
async function sharpFromReadable(stream, options) {
|
|
1572
|
+
if (typeof stream.read !== "function") {
|
|
1573
|
+
throw new Error("expected a readable stream");
|
|
1574
|
+
}
|
|
1575
|
+
const { default: sharp } = await loadSharp();
|
|
1576
|
+
const descriptor = options === void 0 || Object.keys(options).length === 0 ? void 0 : { ...options };
|
|
1577
|
+
const instance = descriptor === void 0 ? sharp() : sharp(descriptor);
|
|
1578
|
+
stream.pipe(instance);
|
|
1579
|
+
return instance;
|
|
1580
|
+
}
|
|
1581
|
+
var SNIFF_HEADER_BYTES = 4100;
|
|
1582
|
+
var fileTypePromise;
|
|
1583
|
+
function loadFileType() {
|
|
1584
|
+
fileTypePromise ??= import('file-type');
|
|
1585
|
+
return fileTypePromise;
|
|
1586
|
+
}
|
|
1587
|
+
async function sniffBytes(head, path) {
|
|
1588
|
+
if (head.byteLength > 0) {
|
|
1589
|
+
try {
|
|
1590
|
+
const { fileTypeFromBuffer } = await loadFileType();
|
|
1591
|
+
const detected = await fileTypeFromBuffer(head);
|
|
1592
|
+
if (detected !== void 0) {
|
|
1593
|
+
return {
|
|
1594
|
+
mime: detected.mime,
|
|
1595
|
+
ext: `.${detected.ext}`,
|
|
1596
|
+
kind: mimeToKind(detected.mime),
|
|
1597
|
+
source: "magic"
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
} catch {
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return fileTypeFromName(path);
|
|
1604
|
+
}
|
|
1605
|
+
var ORDER_MANIFEST_NAME = ".order";
|
|
1606
|
+
function orderManifestPath(dataDir) {
|
|
1607
|
+
return join(dataDir, ORDER_MANIFEST_NAME);
|
|
1608
|
+
}
|
|
1609
|
+
async function readOrderManifest(dataDir) {
|
|
1610
|
+
let raw;
|
|
1611
|
+
try {
|
|
1612
|
+
raw = await readFile(orderManifestPath(dataDir), "utf8");
|
|
1613
|
+
} catch {
|
|
1614
|
+
return void 0;
|
|
1615
|
+
}
|
|
1616
|
+
return parseOrderManifest(raw);
|
|
1617
|
+
}
|
|
1618
|
+
function parseOrderManifest(raw) {
|
|
1619
|
+
let parsed;
|
|
1620
|
+
try {
|
|
1621
|
+
parsed = JSON.parse(raw);
|
|
1622
|
+
} catch {
|
|
1623
|
+
return void 0;
|
|
1624
|
+
}
|
|
1625
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return void 0;
|
|
1626
|
+
const names = [];
|
|
1627
|
+
for (const entry of parsed) {
|
|
1628
|
+
if (typeof entry !== "string") return void 0;
|
|
1629
|
+
if (!isManifestPathSafe(entry)) return void 0;
|
|
1630
|
+
names.push(entry);
|
|
1631
|
+
}
|
|
1632
|
+
if (names.length === 0) return void 0;
|
|
1633
|
+
return names;
|
|
1634
|
+
}
|
|
1635
|
+
function orderEntries(manifest, listed) {
|
|
1636
|
+
const available = new Set(listed);
|
|
1637
|
+
const uniqueManifest = /* @__PURE__ */ new Set();
|
|
1638
|
+
for (const name of manifest) {
|
|
1639
|
+
if (uniqueManifest.has(name)) return void 0;
|
|
1640
|
+
uniqueManifest.add(name);
|
|
1641
|
+
if (!available.has(name)) return void 0;
|
|
1642
|
+
}
|
|
1643
|
+
const ordered = manifest.filter((name) => available.has(name));
|
|
1644
|
+
const rest = naturalSort(listed.filter((name) => !uniqueManifest.has(name)));
|
|
1645
|
+
return [...ordered, ...rest];
|
|
1646
|
+
}
|
|
1647
|
+
function naturalSort(names) {
|
|
1648
|
+
return [...names].sort(
|
|
1649
|
+
(a, b) => a.localeCompare(b, void 0, { sensitivity: "base", numeric: true })
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
function isManifestPathSafe(entry) {
|
|
1653
|
+
if (entry.length === 0 || entry.includes("\0") || entry.includes("\\")) {
|
|
1654
|
+
return false;
|
|
1655
|
+
}
|
|
1656
|
+
const segments = entry.split("/");
|
|
1657
|
+
for (const segment of segments) {
|
|
1658
|
+
if (segment.length === 0 || segment === "." || segment === "..") {
|
|
1659
|
+
return false;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
return true;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// src/directory-container.ts
|
|
1666
|
+
function createDirectoryContainer(dir) {
|
|
1667
|
+
async function resolvePath(relPath) {
|
|
1668
|
+
return resolveSafeImportPath(dir, relPath);
|
|
1669
|
+
}
|
|
1670
|
+
return {
|
|
1671
|
+
async listEntries() {
|
|
1672
|
+
const out = [];
|
|
1673
|
+
async function collect(current, prefix) {
|
|
1674
|
+
const entries = await readdir(join(dir, current), {
|
|
1675
|
+
withFileTypes: true
|
|
1676
|
+
}).catch(() => []);
|
|
1677
|
+
for (const e of entries) {
|
|
1678
|
+
if (e.name.startsWith(".")) continue;
|
|
1679
|
+
if (e.name.includes(".uploading-")) continue;
|
|
1680
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
1681
|
+
if (e.isDirectory()) {
|
|
1682
|
+
await collect(join(current, e.name), rel);
|
|
1683
|
+
} else if (e.isFile()) {
|
|
1684
|
+
out.push(rel);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
await collect(".", "");
|
|
1689
|
+
const manifest = await readOrderManifest(dir);
|
|
1690
|
+
if (manifest !== void 0) {
|
|
1691
|
+
const ordered = orderEntries(manifest, out);
|
|
1692
|
+
if (ordered !== void 0) return ordered;
|
|
1693
|
+
}
|
|
1694
|
+
return naturalSort(out);
|
|
1695
|
+
},
|
|
1696
|
+
async readEntry(relPath) {
|
|
1697
|
+
const safe = await resolvePath(relPath);
|
|
1698
|
+
const handle = await open(safe, "r");
|
|
1699
|
+
try {
|
|
1700
|
+
return await handle.readFile();
|
|
1701
|
+
} finally {
|
|
1702
|
+
await handle.close();
|
|
1703
|
+
}
|
|
1704
|
+
},
|
|
1705
|
+
async readEntrySlice(relPath, start, end) {
|
|
1706
|
+
const safe = await resolvePath(relPath);
|
|
1707
|
+
const handle = await open(safe, "r");
|
|
1708
|
+
try {
|
|
1709
|
+
const { size } = await handle.stat();
|
|
1710
|
+
const clampedStart = Math.min(Math.max(0, start), size);
|
|
1711
|
+
const clampedEnd = Math.min(Math.max(clampedStart, end), size);
|
|
1712
|
+
const length = Math.max(0, clampedEnd - clampedStart);
|
|
1713
|
+
if (length === 0) return Buffer.alloc(0);
|
|
1714
|
+
const buf = Buffer.alloc(length);
|
|
1715
|
+
await handle.read(buf, 0, length, clampedStart);
|
|
1716
|
+
return buf;
|
|
1717
|
+
} finally {
|
|
1718
|
+
await handle.close();
|
|
1719
|
+
}
|
|
1720
|
+
},
|
|
1721
|
+
async openEntryStream(relPath) {
|
|
1722
|
+
const safe = await resolvePath(relPath);
|
|
1723
|
+
const info = await stat(safe);
|
|
1724
|
+
return {
|
|
1725
|
+
stream: createReadStream(safe),
|
|
1726
|
+
size: info.size,
|
|
1727
|
+
mtimeMs: info.mtimeMs,
|
|
1728
|
+
path: safe
|
|
1729
|
+
};
|
|
1730
|
+
},
|
|
1731
|
+
async resolveByteRange(relPath) {
|
|
1732
|
+
const safe = await resolvePath(relPath);
|
|
1733
|
+
const info = await stat(safe).catch(() => void 0);
|
|
1734
|
+
return info === void 0 ? void 0 : { size: info.size };
|
|
1735
|
+
},
|
|
1736
|
+
async resolveSeekablePath(relPath) {
|
|
1737
|
+
const safe = await resolvePath(relPath);
|
|
1738
|
+
const info = await stat(safe).catch(() => void 0);
|
|
1739
|
+
return info?.isFile() === true ? safe : void 0;
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
function resolveSafeImportPath(dir, relPath) {
|
|
1744
|
+
if (relPath.length === 0) {
|
|
1745
|
+
throw new Error("path is empty");
|
|
1746
|
+
}
|
|
1747
|
+
if (relPath.includes("\0")) {
|
|
1748
|
+
throw new Error("path contains null byte");
|
|
1749
|
+
}
|
|
1750
|
+
if (isAbsolute(relPath)) {
|
|
1751
|
+
throw new Error("absolute paths are not allowed");
|
|
1752
|
+
}
|
|
1753
|
+
const normalized = normalize(relPath);
|
|
1754
|
+
if (normalized.startsWith("..") || normalized === "..") {
|
|
1755
|
+
throw new Error("path escapes import directory");
|
|
1756
|
+
}
|
|
1757
|
+
const root = resolve(dir);
|
|
1758
|
+
const candidate = resolve(root, normalized);
|
|
1759
|
+
if (candidate !== root && !candidate.startsWith(root + sep)) {
|
|
1760
|
+
throw new Error("path escapes import directory");
|
|
1761
|
+
}
|
|
1762
|
+
return candidate;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// src/directory-api.ts
|
|
1766
|
+
function createDirectoryResourceAPI(dir, opts = {}) {
|
|
1767
|
+
return createPluginResourceAPI({
|
|
1768
|
+
view: createDirectoryContainer(dir),
|
|
1769
|
+
maxReadFileBytes: opts.maxReadFileBytes ?? PLUGIN_READ_FILE_MAX_BYTES,
|
|
1770
|
+
extractCacheDir: opts.extractCacheDir,
|
|
1771
|
+
detectContext: opts.detectContext
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// src/api.ts
|
|
1776
|
+
var DEFAULT_PLUGIN_EXTRACT_MAX_BYTES = 8 * 1024 * 1024 * 1024;
|
|
1777
|
+
var DEFAULT_PLUGIN_EXTRACT_MAX_ENTRIES = 2e5;
|
|
1778
|
+
function createPluginResourceAPI(deps) {
|
|
1779
|
+
const view = createNestedAwareContainer(
|
|
1780
|
+
deps.view,
|
|
1781
|
+
deps.nestedCdCache,
|
|
1782
|
+
deps.cacheScope,
|
|
1783
|
+
deps.extractCacheDir
|
|
1784
|
+
);
|
|
1785
|
+
const maxReadFileBytes = deps.maxReadFileBytes ?? PLUGIN_READ_FILE_MAX_BYTES;
|
|
1786
|
+
const extractor = deps.extractCacheDir === void 0 ? void 0 : createArchiveExtractor({
|
|
1787
|
+
outer: {
|
|
1788
|
+
sizeOf: (rel) => deps.view.resolveByteRange(rel).then((r) => r?.size),
|
|
1789
|
+
readSlice: (rel, start, end) => deps.view.readEntrySlice(rel, start, end)
|
|
1790
|
+
},
|
|
1791
|
+
cacheDir: deps.extractCacheDir,
|
|
1792
|
+
maxBytes: deps.maxExtractBytes ?? DEFAULT_PLUGIN_EXTRACT_MAX_BYTES,
|
|
1793
|
+
maxEntries: deps.maxExtractEntries ?? DEFAULT_PLUGIN_EXTRACT_MAX_ENTRIES,
|
|
1794
|
+
probeImage: deps.probeImage,
|
|
1795
|
+
onProgress: deps.onExtractProgress,
|
|
1796
|
+
nestedCdCache: deps.nestedCdCache
|
|
1797
|
+
});
|
|
1798
|
+
async function readFileScoped(path, range) {
|
|
1799
|
+
if (range === void 0) {
|
|
1800
|
+
const size2 = (await view.resolveByteRange(path))?.size;
|
|
1801
|
+
if (size2 !== void 0) assertReadSize(path, size2, maxReadFileBytes);
|
|
1802
|
+
const buf = await view.readEntry(path);
|
|
1803
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
1804
|
+
}
|
|
1805
|
+
const start = Math.max(0, range.start ?? 0);
|
|
1806
|
+
const size = (await view.resolveByteRange(path))?.size;
|
|
1807
|
+
if (size === void 0) {
|
|
1808
|
+
return toUint8Array(
|
|
1809
|
+
await view.readEntrySlice(path, start, range.end ?? start)
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
const end = Math.min(range.end ?? size, size);
|
|
1813
|
+
assertReadSize(path, Math.max(0, end - start), maxReadFileBytes);
|
|
1814
|
+
return toUint8Array(await view.readEntrySlice(path, start, end));
|
|
1815
|
+
}
|
|
1816
|
+
async function listFileNamesScoped() {
|
|
1817
|
+
return view.listEntries();
|
|
1818
|
+
}
|
|
1819
|
+
function cached(kind, path, compute) {
|
|
1820
|
+
if (deps.probeCache === void 0 || deps.cacheScope === void 0) {
|
|
1821
|
+
return compute();
|
|
1822
|
+
}
|
|
1823
|
+
return deps.probeCache.getOrCompute(
|
|
1824
|
+
`${deps.cacheScope}:${kind}:${path}`,
|
|
1825
|
+
compute
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
function sniffScoped(path) {
|
|
1829
|
+
return cached("sniff", path, async () => {
|
|
1830
|
+
const head = await view.readEntrySlice(path, 0, SNIFF_HEADER_BYTES).catch(() => void 0);
|
|
1831
|
+
if (head === void 0) return void 0;
|
|
1832
|
+
return sniffBytes(head, path);
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
async function probeImageEntry(path, type) {
|
|
1836
|
+
const probeImage = deps.probeImage;
|
|
1837
|
+
if (probeImage === void 0) {
|
|
1838
|
+
return { kind: "unknown", reason: "unavailable" };
|
|
1839
|
+
}
|
|
1840
|
+
if (!isAnimatedCandidateExt(type.ext)) {
|
|
1841
|
+
const head = await view.readEntrySlice(path, 0, PROBE_HEADER_BYTES);
|
|
1842
|
+
if (head.length > 0) {
|
|
1843
|
+
const probed = await probeImage(head, type.ext).catch(() => void 0);
|
|
1844
|
+
if (probed !== void 0) return imageResult(type.mime, probed);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
try {
|
|
1848
|
+
const { stream } = await view.openEntryStream(path);
|
|
1849
|
+
const probed = await probeImage(stream, type.ext);
|
|
1850
|
+
if (probed !== void 0) return imageResult(type.mime, probed);
|
|
1851
|
+
} catch (err) {
|
|
1852
|
+
console.warn(
|
|
1853
|
+
`[probe] path=${path} image probe threw: ${err instanceof Error ? err.message : String(err)}`
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
return { kind: "unknown", reason: "failed" };
|
|
1857
|
+
}
|
|
1858
|
+
async function probeAvEntry(path, type) {
|
|
1859
|
+
const probeAv = deps.probeAv;
|
|
1860
|
+
if (probeAv === void 0) {
|
|
1861
|
+
return { kind: "unknown", reason: "unavailable" };
|
|
1862
|
+
}
|
|
1863
|
+
const inputFormat = MIME_FFMPEG_INPUT_FORMAT[type.mime];
|
|
1864
|
+
if (inputFormat === void 0) {
|
|
1865
|
+
return { kind: "unknown", reason: "unsupported" };
|
|
1866
|
+
}
|
|
1867
|
+
try {
|
|
1868
|
+
const { stream } = await view.openEntryStream(path);
|
|
1869
|
+
return await probeAv(stream, { mime: type.mime, inputFormat });
|
|
1870
|
+
} catch {
|
|
1871
|
+
return { kind: "unknown", reason: "failed" };
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
function probeScoped(path) {
|
|
1875
|
+
return cached("probe", path, async () => {
|
|
1876
|
+
const type = await sniffScoped(path);
|
|
1877
|
+
if (type === void 0) {
|
|
1878
|
+
return { kind: "unknown", reason: "unsupported" };
|
|
1879
|
+
}
|
|
1880
|
+
switch (type.kind) {
|
|
1881
|
+
case "image":
|
|
1882
|
+
return probeImageEntry(path, type);
|
|
1883
|
+
case "video":
|
|
1884
|
+
case "audio":
|
|
1885
|
+
return probeAvEntry(path, type);
|
|
1886
|
+
default:
|
|
1887
|
+
return { kind: "other", mime: type.mime };
|
|
1888
|
+
}
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
async function statFileScoped(path) {
|
|
1892
|
+
const range = await view.resolveByteRange(path);
|
|
1893
|
+
if (range === void 0) return void 0;
|
|
1894
|
+
return { sizeBytes: range.size };
|
|
1895
|
+
}
|
|
1896
|
+
async function statFilesScoped(paths) {
|
|
1897
|
+
return Promise.all(paths.map(statFileScoped));
|
|
1898
|
+
}
|
|
1899
|
+
async function hashBytesScoped(path, algo) {
|
|
1900
|
+
const { stream } = await view.openEntryStream(path);
|
|
1901
|
+
return hashStream(stream, algo);
|
|
1902
|
+
}
|
|
1903
|
+
async function computeImageHashesScoped(path, kinds) {
|
|
1904
|
+
const type = await sniffScoped(path);
|
|
1905
|
+
if (type?.kind !== "image") return void 0;
|
|
1906
|
+
const result = {};
|
|
1907
|
+
if (kinds.includes("sha256")) {
|
|
1908
|
+
const { stream } = await view.openEntryStream(path);
|
|
1909
|
+
result.sha256 = await hashStream(stream, "sha256");
|
|
1910
|
+
}
|
|
1911
|
+
const perceptual = kinds.filter(
|
|
1912
|
+
(kind) => PERCEPTUAL_HASH_KINDS.includes(kind)
|
|
1913
|
+
);
|
|
1914
|
+
if (perceptual.length > 0) {
|
|
1915
|
+
const { stream } = await view.openEntryStream(path);
|
|
1916
|
+
const gray = await decodeGrayGrid(stream);
|
|
1917
|
+
if (gray !== void 0 && grayStddev(gray) >= MIN_PERCEPTUAL_STDDEV) {
|
|
1918
|
+
if (perceptual.includes("dhash")) result.dhash = computeDHash(gray);
|
|
1919
|
+
if (perceptual.includes("phash")) result.phash = computePHash(gray);
|
|
1920
|
+
} else if (result.sha256 === void 0) {
|
|
1921
|
+
return void 0;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
return result;
|
|
1925
|
+
}
|
|
1926
|
+
async function decodeGrayGrid(stream) {
|
|
1927
|
+
try {
|
|
1928
|
+
const instance = await sharpFromReadable(stream, { pages: 1 });
|
|
1929
|
+
const { data } = await instance.resize(PHASH_GRID, PHASH_GRID, { fit: "fill" }).grayscale().raw().toBuffer({ resolveWithObject: true });
|
|
1930
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
1931
|
+
} catch {
|
|
1932
|
+
return void 0;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
return {
|
|
1936
|
+
// No-ops: the sandbox host is the plugin log sink — it alone knows
|
|
1937
|
+
// which plugin emitted the line (see dispatchLog in sandbox/host.ts).
|
|
1938
|
+
logInfo() {
|
|
1939
|
+
},
|
|
1940
|
+
logWarn() {
|
|
1941
|
+
},
|
|
1942
|
+
logError() {
|
|
1943
|
+
},
|
|
1944
|
+
// The payload is opaque to this factory (hosts pass `unknown`);
|
|
1945
|
+
// the schema generic only types it for schema-aware consumers.
|
|
1946
|
+
context: { detect: deps.detectContext },
|
|
1947
|
+
listFileNames: listFileNamesScoped,
|
|
1948
|
+
readFile: readFileScoped,
|
|
1949
|
+
statFile: statFileScoped,
|
|
1950
|
+
statFiles: statFilesScoped,
|
|
1951
|
+
sniff: sniffScoped,
|
|
1952
|
+
probe: probeScoped,
|
|
1953
|
+
hashBytes: hashBytesScoped,
|
|
1954
|
+
computeImageHashes: computeImageHashesScoped,
|
|
1955
|
+
listContainer: (filename) => listContainerScoped(filename, extractor),
|
|
1956
|
+
extractArchive: (filename) => extractArchiveScoped(filename, extractor),
|
|
1957
|
+
// The plugin vault is a sandbox-level capability: the sandbox host
|
|
1958
|
+
// intercepts these method names and routes them to the wired
|
|
1959
|
+
// plugin-asset service with the owning plugin id (see
|
|
1960
|
+
// sandbox/host.ts). In-process hosts (directory API, contract
|
|
1961
|
+
// fixtures, dev runner) have no consent channel, so the methods
|
|
1962
|
+
// answer UNAVAILABLE — exactly like the CLI and the workbench.
|
|
1963
|
+
download: async () => {
|
|
1964
|
+
throw pluginAssetError(
|
|
1965
|
+
"UNAVAILABLE",
|
|
1966
|
+
"download() \u2014 this host has no plugin asset service; only the app server host can download into the plugin vault"
|
|
1967
|
+
);
|
|
1968
|
+
},
|
|
1969
|
+
statAsset: async () => {
|
|
1970
|
+
throw unavailableAsset("statAsset");
|
|
1971
|
+
},
|
|
1972
|
+
readAsset: async () => {
|
|
1973
|
+
throw unavailableAsset("readAsset");
|
|
1974
|
+
},
|
|
1975
|
+
deleteAsset: async () => {
|
|
1976
|
+
throw unavailableAsset("deleteAsset");
|
|
1977
|
+
}
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
function unavailableAsset(method) {
|
|
1981
|
+
return pluginAssetError(
|
|
1982
|
+
"UNAVAILABLE",
|
|
1983
|
+
`${method}() \u2014 this host has no plugin asset vault; only the app server host manages vault files`
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1986
|
+
async function listContainerScoped(filename, extractor) {
|
|
1987
|
+
if (extractor === void 0) {
|
|
1988
|
+
throw new Error(
|
|
1989
|
+
`listContainer("${filename}") \u2014 this host has no container support; only the archive-backed server host lists containers`
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
const entries = await extractor.list(filename);
|
|
1993
|
+
if (entries === void 0) {
|
|
1994
|
+
throw new Error(
|
|
1995
|
+
`listContainer("${filename}") \u2014 not a supported archive (zip/tar/7z/rar/xz), or no 7-Zip binary for non-zip formats`
|
|
1996
|
+
);
|
|
1997
|
+
}
|
|
1998
|
+
return { entries: entries.map(bareContainerEntry) };
|
|
1999
|
+
}
|
|
2000
|
+
function bareContainerEntry(entry) {
|
|
2001
|
+
return {
|
|
2002
|
+
path: entry.name,
|
|
2003
|
+
sizeBytes: entry.sizeBytes,
|
|
2004
|
+
kind: fileTypeFromName(entry.name)?.kind ?? "other"
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
async function extractArchiveScoped(filename, extractor) {
|
|
2008
|
+
if (extractor === void 0) {
|
|
2009
|
+
throw new Error(
|
|
2010
|
+
`extractArchive("${filename}") \u2014 this host has no extraction cache; only the archive-backed server host materializes containers`
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
return extractor.extract(filename);
|
|
2014
|
+
}
|
|
2015
|
+
function imageResult(mime, probed) {
|
|
2016
|
+
return {
|
|
2017
|
+
kind: "image",
|
|
2018
|
+
mime,
|
|
2019
|
+
width: probed.width,
|
|
2020
|
+
height: probed.height,
|
|
2021
|
+
animated: probed.animated
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
function assertReadSize(path, sizeBytes, max) {
|
|
2025
|
+
if (sizeBytes > max) {
|
|
2026
|
+
throw new Error(
|
|
2027
|
+
`readFile("${path}") requests ${sizeBytes} bytes, exceeding the per-call limit of ${max} bytes \u2014 pass a byte range or use readFileChunks()`
|
|
2028
|
+
);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
function toUint8Array(buf) {
|
|
2032
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
2033
|
+
}
|
|
2034
|
+
function createCapabilityGuard() {
|
|
2035
|
+
function check(manifest, capability) {
|
|
2036
|
+
return manifest.permissions[capability] === true;
|
|
2037
|
+
}
|
|
2038
|
+
function require2(manifest, capability) {
|
|
2039
|
+
if (!check(manifest, capability)) {
|
|
2040
|
+
throw new Error(
|
|
2041
|
+
`${capability} permission denied for plugin ${manifest.id}`
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
function filter(entries, capability) {
|
|
2046
|
+
return entries.filter((e) => check(e.manifest, capability));
|
|
2047
|
+
}
|
|
2048
|
+
return { check, require: require2, filter };
|
|
2049
|
+
}
|
|
2050
|
+
function isRecord(value) {
|
|
2051
|
+
return typeof value === "object" && value !== null;
|
|
2052
|
+
}
|
|
2053
|
+
async function runPluginHook(opts) {
|
|
2054
|
+
const mod = await import(pathToFileURL(opts.mainPath).href);
|
|
2055
|
+
if (!isRecord(mod) || !isRecord(mod.default)) {
|
|
2056
|
+
throw new Error(
|
|
2057
|
+
`Plugin at ${opts.mainPath} must default-export a plugin definition`
|
|
2058
|
+
);
|
|
2059
|
+
}
|
|
2060
|
+
const hook = mod.default[opts.hook];
|
|
2061
|
+
if (typeof hook !== "function") {
|
|
2062
|
+
throw new Error(
|
|
2063
|
+
`Plugin at ${opts.mainPath} has no "${opts.hook}" hook (expected a function on the default export)`
|
|
2064
|
+
);
|
|
2065
|
+
}
|
|
2066
|
+
let detectContext;
|
|
2067
|
+
if (opts.hook !== "detect" && typeof mod.default.detect === "function") {
|
|
2068
|
+
try {
|
|
2069
|
+
const detected = await mod.default.detect(
|
|
2070
|
+
createDirectoryResourceAPI(opts.dir, {
|
|
2071
|
+
extractCacheDir: opts.extractCacheDir
|
|
2072
|
+
})
|
|
2073
|
+
);
|
|
2074
|
+
if (isRecord(detected) && detected.ok === true) {
|
|
2075
|
+
const { ok: _ok, ...payload } = detected;
|
|
2076
|
+
if (Object.keys(payload).length > 0) detectContext = payload;
|
|
2077
|
+
}
|
|
2078
|
+
} catch {
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
const api = createDirectoryResourceAPI(opts.dir, {
|
|
2082
|
+
extractCacheDir: opts.extractCacheDir,
|
|
2083
|
+
detectContext
|
|
2084
|
+
});
|
|
2085
|
+
const started = performance.now();
|
|
2086
|
+
const result = await hook(api);
|
|
2087
|
+
return { result, durationMs: performance.now() - started };
|
|
2088
|
+
}
|
|
2089
|
+
var DEFAULT_PLUGIN_PINNED = true;
|
|
2090
|
+
var DEFAULT_PLUGIN_PRIORITY = 100;
|
|
2091
|
+
function createPluginDiscovery(deps) {
|
|
2092
|
+
let firstLoad = true;
|
|
2093
|
+
async function discover() {
|
|
2094
|
+
const found = [];
|
|
2095
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2096
|
+
if (deps.builtinDir !== void 0) {
|
|
2097
|
+
const builtin = discoverBuiltin(deps.builtinDir, firstLoad);
|
|
2098
|
+
if (builtin !== void 0) {
|
|
2099
|
+
found.push(builtin);
|
|
2100
|
+
seen.add(builtin.id);
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
firstLoad = false;
|
|
2104
|
+
const dev = discoverDevPlugins(
|
|
2105
|
+
deps.devPluginDirs,
|
|
2106
|
+
deps.settings,
|
|
2107
|
+
deps.disableDevPlugins ?? false
|
|
2108
|
+
);
|
|
2109
|
+
for (const plugin of dev) {
|
|
2110
|
+
if (seen.has(plugin.id)) {
|
|
2111
|
+
const index = found.findIndex((f) => f.id === plugin.id);
|
|
2112
|
+
if (index !== -1 && found[index]?.source === "dev") {
|
|
2113
|
+
found[index] = plugin;
|
|
2114
|
+
console.warn(
|
|
2115
|
+
`[plugin-discovery] dev plugin ${plugin.id} (${plugin.manifest.name ?? plugin.id}) overrides an earlier dev dir entry`
|
|
2116
|
+
);
|
|
2117
|
+
} else {
|
|
2118
|
+
console.warn(
|
|
2119
|
+
`[plugin-discovery] skipping dev plugin ${plugin.id}: id is reserved by the builtin plugin`
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
continue;
|
|
2123
|
+
}
|
|
2124
|
+
found.push(plugin);
|
|
2125
|
+
seen.add(plugin.id);
|
|
2126
|
+
}
|
|
2127
|
+
const disk = discoverDiskPlugins(deps.pluginsDir, deps.settings);
|
|
2128
|
+
for (const plugin of disk) {
|
|
2129
|
+
if (seen.has(plugin.id)) {
|
|
2130
|
+
console.warn(
|
|
2131
|
+
`[plugin-discovery] skipping disk plugin ${plugin.id}: id already registered (builtin or dev plugin)`
|
|
2132
|
+
);
|
|
2133
|
+
continue;
|
|
2134
|
+
}
|
|
2135
|
+
found.push(plugin);
|
|
2136
|
+
seen.add(plugin.id);
|
|
2137
|
+
}
|
|
2138
|
+
const missing = discoverMissingPlugins(deps.settings, seen);
|
|
2139
|
+
return { found, missing };
|
|
2140
|
+
}
|
|
2141
|
+
return { discover };
|
|
2142
|
+
}
|
|
2143
|
+
function discoverBuiltin(builtinDir, failFast) {
|
|
2144
|
+
const resolved = resolve(builtinDir);
|
|
2145
|
+
const manifest = parseManifest(resolved, "builtin");
|
|
2146
|
+
if (manifest === void 0) {
|
|
2147
|
+
const message = `Builtin plugin not found or invalid at ${resolved}. Set BUILTIN_PATH env to a valid plugin directory.`;
|
|
2148
|
+
if (failFast) {
|
|
2149
|
+
throw new Error(message);
|
|
2150
|
+
}
|
|
2151
|
+
console.error(`[plugin-discovery] ${message}`);
|
|
2152
|
+
return void 0;
|
|
2153
|
+
}
|
|
2154
|
+
return {
|
|
2155
|
+
id: manifest.id,
|
|
2156
|
+
manifest,
|
|
2157
|
+
diskPath: resolved,
|
|
2158
|
+
source: "builtin",
|
|
2159
|
+
enabled: true,
|
|
2160
|
+
priority: Number.MAX_SAFE_INTEGER,
|
|
2161
|
+
pinned: DEFAULT_PLUGIN_PINNED,
|
|
2162
|
+
color: ""
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
function discoverDevPlugins(devPluginDirs, settings, disableDevPlugins) {
|
|
2166
|
+
if (disableDevPlugins) {
|
|
2167
|
+
console.info("[plugin-discovery] dev plugins disabled by configuration");
|
|
2168
|
+
return [];
|
|
2169
|
+
}
|
|
2170
|
+
if (devPluginDirs === void 0 || devPluginDirs.length === 0) return [];
|
|
2171
|
+
const results = [];
|
|
2172
|
+
for (const dir of devPluginDirs) {
|
|
2173
|
+
const resolved = resolve(dir);
|
|
2174
|
+
const manifest = parseManifest(resolved, "dev");
|
|
2175
|
+
if (manifest === void 0) continue;
|
|
2176
|
+
const row = settings.get(manifest.id);
|
|
2177
|
+
results.push({
|
|
2178
|
+
id: manifest.id,
|
|
2179
|
+
manifest,
|
|
2180
|
+
diskPath: resolved,
|
|
2181
|
+
source: "dev",
|
|
2182
|
+
enabled: true,
|
|
2183
|
+
priority: row?.priority ?? DEFAULT_PLUGIN_PRIORITY,
|
|
2184
|
+
pinned: row?.pinned ?? DEFAULT_PLUGIN_PINNED,
|
|
2185
|
+
color: row?.color ?? ""
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
return results;
|
|
2189
|
+
}
|
|
2190
|
+
function discoverDiskPlugins(pluginsDir, settings) {
|
|
2191
|
+
if (!existsSync(pluginsDir)) return [];
|
|
2192
|
+
const dirents = readdirSync(pluginsDir, { withFileTypes: true });
|
|
2193
|
+
if (dirents.length === 0) return [];
|
|
2194
|
+
const results = [];
|
|
2195
|
+
for (const dirent of dirents) {
|
|
2196
|
+
if (!dirent.isDirectory()) continue;
|
|
2197
|
+
const dirPath = join(pluginsDir, dirent.name);
|
|
2198
|
+
const manifest = parseManifest(dirPath, dirent.name);
|
|
2199
|
+
if (manifest === void 0) continue;
|
|
2200
|
+
const row = settings.get(manifest.id);
|
|
2201
|
+
const enabled = row?.enabled ?? true;
|
|
2202
|
+
const priority = row?.priority ?? DEFAULT_PLUGIN_PRIORITY;
|
|
2203
|
+
const pinned = row?.pinned ?? DEFAULT_PLUGIN_PINNED;
|
|
2204
|
+
const color = row?.color ?? "";
|
|
2205
|
+
results.push({
|
|
2206
|
+
id: manifest.id,
|
|
2207
|
+
manifest,
|
|
2208
|
+
diskPath: dirPath,
|
|
2209
|
+
source: "disk",
|
|
2210
|
+
enabled,
|
|
2211
|
+
priority,
|
|
2212
|
+
pinned,
|
|
2213
|
+
color
|
|
2214
|
+
});
|
|
2215
|
+
}
|
|
2216
|
+
return results;
|
|
2217
|
+
}
|
|
2218
|
+
function discoverMissingPlugins(settings, loadedIds) {
|
|
2219
|
+
const results = [];
|
|
2220
|
+
for (const row of settings.all()) {
|
|
2221
|
+
if (loadedIds.has(row.id)) continue;
|
|
2222
|
+
let manifest;
|
|
2223
|
+
try {
|
|
2224
|
+
manifest = JSON.parse(row.manifest);
|
|
2225
|
+
} catch {
|
|
2226
|
+
continue;
|
|
2227
|
+
}
|
|
2228
|
+
results.push({
|
|
2229
|
+
id: row.id,
|
|
2230
|
+
manifest,
|
|
2231
|
+
enabled: row.enabled,
|
|
2232
|
+
priority: row.priority,
|
|
2233
|
+
pinned: row.pinned,
|
|
2234
|
+
color: row.color
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
return results;
|
|
2238
|
+
}
|
|
2239
|
+
function parseManifest(dirPath, dirName) {
|
|
2240
|
+
const manifestPath = join(dirPath, "manifest.json");
|
|
2241
|
+
if (!existsSync(manifestPath)) {
|
|
2242
|
+
console.warn(`[plugin-discovery] skipping ${dirName}: no manifest.json`);
|
|
2243
|
+
return void 0;
|
|
2244
|
+
}
|
|
2245
|
+
let raw;
|
|
2246
|
+
try {
|
|
2247
|
+
raw = readFileSync(manifestPath, "utf-8");
|
|
2248
|
+
} catch {
|
|
2249
|
+
console.warn(
|
|
2250
|
+
`[plugin-discovery] skipping ${dirName}: cannot read manifest.json`
|
|
2251
|
+
);
|
|
2252
|
+
return void 0;
|
|
2253
|
+
}
|
|
2254
|
+
let parsed;
|
|
2255
|
+
try {
|
|
2256
|
+
parsed = JSON.parse(raw);
|
|
2257
|
+
} catch {
|
|
2258
|
+
console.warn(
|
|
2259
|
+
`[plugin-discovery] skipping ${dirName}: manifest is not valid JSON`
|
|
2260
|
+
);
|
|
2261
|
+
return void 0;
|
|
2262
|
+
}
|
|
2263
|
+
const result = pluginManifest.safeParse(parsed);
|
|
2264
|
+
if (!result.success) {
|
|
2265
|
+
console.warn(
|
|
2266
|
+
`[plugin-discovery] skipping ${dirName}: invalid manifest`,
|
|
2267
|
+
result.error
|
|
2268
|
+
);
|
|
2269
|
+
return void 0;
|
|
2270
|
+
}
|
|
2271
|
+
return result.data;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// src/hooks.ts
|
|
2275
|
+
var MAX_IMAGE_HASHES_PER_RESOURCE = 2e3;
|
|
2276
|
+
async function runDetectors(entries, api, action) {
|
|
2277
|
+
for (const entry of entries) {
|
|
2278
|
+
const result = await invokeHook(entry, entry.plugin.detect, api, action);
|
|
2279
|
+
if (result?.ok) return entry.id;
|
|
2280
|
+
}
|
|
2281
|
+
return void 0;
|
|
2282
|
+
}
|
|
2283
|
+
async function invokeHook(entry, hook, api, action, level = "error") {
|
|
2284
|
+
if (hook === void 0) return void 0;
|
|
2285
|
+
try {
|
|
2286
|
+
return await hook(api);
|
|
2287
|
+
} catch (err) {
|
|
2288
|
+
const log = level === "warn" ? console.warn : console.error;
|
|
2289
|
+
log(hookFailureText(action, entry.id, err));
|
|
2290
|
+
return void 0;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
function errorText(err) {
|
|
2294
|
+
return err instanceof Error ? err.message : String(err);
|
|
2295
|
+
}
|
|
2296
|
+
function hookFailureText(action, pluginId, err) {
|
|
2297
|
+
return `[plugin-hooks] ${action} failed for plugin ${pluginId}: ${errorText(err)}`;
|
|
2298
|
+
}
|
|
2299
|
+
function createPluginHooks(deps) {
|
|
2300
|
+
const { getRegistry } = deps;
|
|
2301
|
+
const guard = createCapabilityGuard();
|
|
2302
|
+
function defaultPluginId() {
|
|
2303
|
+
const builtin = getRegistry().getBuiltin();
|
|
2304
|
+
if (builtin === void 0) {
|
|
2305
|
+
throw new Error(
|
|
2306
|
+
"No builtin plugin in registry \u2014 cannot determine default plugin"
|
|
2307
|
+
);
|
|
2308
|
+
}
|
|
2309
|
+
return builtin.id;
|
|
2310
|
+
}
|
|
2311
|
+
function getEffectiveEntry(resPluginId) {
|
|
2312
|
+
if (resPluginId !== null) {
|
|
2313
|
+
const entry = getRegistry().getById(resPluginId);
|
|
2314
|
+
if (entry?.enabled === true && !entry.missing) {
|
|
2315
|
+
return entry;
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
const builtin = getRegistry().getBuiltin();
|
|
2319
|
+
if (builtin === void 0) {
|
|
2320
|
+
throw new Error(
|
|
2321
|
+
"No builtin plugin in registry \u2014 cannot determine effective plugin"
|
|
2322
|
+
);
|
|
2323
|
+
}
|
|
2324
|
+
return builtin;
|
|
2325
|
+
}
|
|
2326
|
+
async function detectFirstMatch(api) {
|
|
2327
|
+
const matched = await runDetectors(
|
|
2328
|
+
getRegistry().getEnabled(),
|
|
2329
|
+
api,
|
|
2330
|
+
"detect"
|
|
2331
|
+
);
|
|
2332
|
+
if (matched !== void 0) return matched;
|
|
2333
|
+
const builtin = getRegistry().getBuiltin();
|
|
2334
|
+
throw new Error(
|
|
2335
|
+
`No plugin matched resource. Builtin plugin ${builtin?.id ?? "unknown"} should have matched but did not.`
|
|
2336
|
+
);
|
|
2337
|
+
}
|
|
2338
|
+
async function revalidate(api, currentPluginId) {
|
|
2339
|
+
const registry = getRegistry();
|
|
2340
|
+
const builtin = registry.getBuiltin();
|
|
2341
|
+
if (builtin === void 0) {
|
|
2342
|
+
throw new Error("No builtin plugin available for fallback");
|
|
2343
|
+
}
|
|
2344
|
+
const enabled = registry.getEnabled();
|
|
2345
|
+
const startIndex = enabled.findIndex((e) => e.id === currentPluginId);
|
|
2346
|
+
if (startIndex < 0) {
|
|
2347
|
+
return builtin.id;
|
|
2348
|
+
}
|
|
2349
|
+
const matched = await runDetectors(
|
|
2350
|
+
enabled.slice(startIndex),
|
|
2351
|
+
api,
|
|
2352
|
+
"revalidate detect"
|
|
2353
|
+
);
|
|
2354
|
+
return matched ?? builtin.id;
|
|
2355
|
+
}
|
|
2356
|
+
async function detectForPlugin(api, pluginId) {
|
|
2357
|
+
const entry = getRegistry().getById(pluginId);
|
|
2358
|
+
if (entry === void 0) {
|
|
2359
|
+
return { ok: false, reasons: [`unknown plugin: ${pluginId}`] };
|
|
2360
|
+
}
|
|
2361
|
+
const result = await invokeHook(
|
|
2362
|
+
entry,
|
|
2363
|
+
entry.plugin.detect,
|
|
2364
|
+
api,
|
|
2365
|
+
"detectForPlugin"
|
|
2366
|
+
);
|
|
2367
|
+
if (result === void 0) {
|
|
2368
|
+
return { ok: false, reasons: ["detect threw an exception"] };
|
|
2369
|
+
}
|
|
2370
|
+
if (result.ok) return { ok: true };
|
|
2371
|
+
return { ok: false, reasons: result.reasons.slice() };
|
|
2372
|
+
}
|
|
2373
|
+
async function detectForImportDir(api) {
|
|
2374
|
+
const fallback = defaultPluginId();
|
|
2375
|
+
const detectors = getRegistry().getEnabled().filter((e) => !e.builtin).sort((a, b) => a.priority - b.priority);
|
|
2376
|
+
const matched = await runDetectors(detectors, api, "import detect");
|
|
2377
|
+
return matched ?? fallback;
|
|
2378
|
+
}
|
|
2379
|
+
async function buildFileList(api, pluginId) {
|
|
2380
|
+
const entry = getRegistry().getById(pluginId);
|
|
2381
|
+
if (entry === void 0) return void 0;
|
|
2382
|
+
const pluginResult = await invokeHook(
|
|
2383
|
+
entry,
|
|
2384
|
+
entry.plugin.listFiles,
|
|
2385
|
+
api,
|
|
2386
|
+
"listFiles"
|
|
2387
|
+
);
|
|
2388
|
+
if (pluginResult === void 0) return void 0;
|
|
2389
|
+
for (const item of pluginResult) {
|
|
2390
|
+
if (typeof item === "string") continue;
|
|
2391
|
+
if (typeof item === "object" && item !== null && !Array.isArray(item)) {
|
|
2392
|
+
for (const value of Object.values(item)) {
|
|
2393
|
+
if (value === void 0) continue;
|
|
2394
|
+
const t = typeof value;
|
|
2395
|
+
if (t === "string" || t === "number" || t === "boolean") continue;
|
|
2396
|
+
throw new Error(
|
|
2397
|
+
`Plugin ${String(pluginId)} returned an invalid file list item value type: ${t}`
|
|
2398
|
+
);
|
|
2399
|
+
}
|
|
2400
|
+
continue;
|
|
2401
|
+
}
|
|
2402
|
+
throw new Error(
|
|
2403
|
+
`Plugin ${String(pluginId)} returned an invalid file list item`
|
|
2404
|
+
);
|
|
2405
|
+
}
|
|
2406
|
+
return pluginResult;
|
|
2407
|
+
}
|
|
2408
|
+
async function resolveLocalCoverSource(api, pluginId) {
|
|
2409
|
+
const entry = getRegistry().getById(pluginId);
|
|
2410
|
+
if (entry === void 0) return void 0;
|
|
2411
|
+
return invokeHook(entry, entry.plugin.coverLocal, api, "coverLocal", "warn");
|
|
2412
|
+
}
|
|
2413
|
+
async function runMetaHooks(api, pluginId) {
|
|
2414
|
+
const entry = getRegistry().getById(pluginId);
|
|
2415
|
+
if (entry === void 0) return {};
|
|
2416
|
+
const { manifest, plugin } = entry;
|
|
2417
|
+
const results = {};
|
|
2418
|
+
async function runMetaHook(name) {
|
|
2419
|
+
const hook = plugin[name];
|
|
2420
|
+
if (!guard.check(manifest, name) || hook === void 0) return;
|
|
2421
|
+
try {
|
|
2422
|
+
results[name] = { value: await hook(api) };
|
|
2423
|
+
} catch (err) {
|
|
2424
|
+
console.error(hookFailureText(name, pluginId, err));
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
await runMetaHook("sourceMeta");
|
|
2428
|
+
await runMetaHook("searchMeta");
|
|
2429
|
+
return results;
|
|
2430
|
+
}
|
|
2431
|
+
function entrySupportsImageHashes(entry) {
|
|
2432
|
+
return guard.check(entry.manifest, "imageHashes") && entry.plugin.imageHashes !== void 0;
|
|
2433
|
+
}
|
|
2434
|
+
function supportsImageHashes(pluginId) {
|
|
2435
|
+
const entry = getRegistry().getById(pluginId);
|
|
2436
|
+
return entry !== void 0 && entrySupportsImageHashes(entry);
|
|
2437
|
+
}
|
|
2438
|
+
async function runImageHashes(api, pluginId) {
|
|
2439
|
+
const entry = getRegistry().getById(pluginId);
|
|
2440
|
+
if (entry === void 0 || !entrySupportsImageHashes(entry))
|
|
2441
|
+
return void 0;
|
|
2442
|
+
return sanitizeImageHashes(
|
|
2443
|
+
await invokeHook(entry, entry.plugin.imageHashes, api, "imageHashes"),
|
|
2444
|
+
pluginId
|
|
2445
|
+
);
|
|
2446
|
+
}
|
|
2447
|
+
return {
|
|
2448
|
+
defaultPluginId,
|
|
2449
|
+
getEffectiveEntry,
|
|
2450
|
+
detectFirstMatch,
|
|
2451
|
+
revalidate,
|
|
2452
|
+
detectForPlugin,
|
|
2453
|
+
detectForImportDir,
|
|
2454
|
+
buildFileList,
|
|
2455
|
+
resolveLocalCoverSource,
|
|
2456
|
+
runMetaHooks,
|
|
2457
|
+
supportsImageHashes,
|
|
2458
|
+
runImageHashes
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
var HEX_VALUE = /^[0-9a-f]+$/;
|
|
2462
|
+
function sanitizeImageHashes(value, pluginId) {
|
|
2463
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
2464
|
+
const hashes = value.hashes;
|
|
2465
|
+
if (!Array.isArray(hashes)) return void 0;
|
|
2466
|
+
const valid = [];
|
|
2467
|
+
for (const item of hashes) {
|
|
2468
|
+
if (typeof item !== "object" || item === null) continue;
|
|
2469
|
+
const { scope, type, value: hex, bits } = item;
|
|
2470
|
+
if (typeof scope === "string" && scope.length > 0 && typeof type === "string" && type.length > 0 && typeof hex === "string" && HEX_VALUE.test(hex)) {
|
|
2471
|
+
valid.push({
|
|
2472
|
+
scope,
|
|
2473
|
+
type,
|
|
2474
|
+
value: hex,
|
|
2475
|
+
bits: typeof bits === "number" && Number.isInteger(bits) && bits > 0 ? bits : hex.length * 4
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
if (valid.length > MAX_IMAGE_HASHES_PER_RESOURCE) {
|
|
2480
|
+
console.warn(
|
|
2481
|
+
`[plugin-hooks] imageHashes for plugin ${pluginId} produced ${valid.length} entries \u2014 capped at ${MAX_IMAGE_HASHES_PER_RESOURCE}`
|
|
2482
|
+
);
|
|
2483
|
+
valid.length = MAX_IMAGE_HASHES_PER_RESOURCE;
|
|
2484
|
+
}
|
|
2485
|
+
return { hashes: valid };
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
// src/sandbox/call-timers.ts
|
|
2489
|
+
function createCallTimers(opts) {
|
|
2490
|
+
function armWatchdog(call, onExpire) {
|
|
2491
|
+
if (call.watchdog !== void 0) clearTimeout(call.watchdog);
|
|
2492
|
+
call.watchdog = setTimeout(onExpire, opts.watchdogMs);
|
|
2493
|
+
call.watchdog.unref();
|
|
2494
|
+
}
|
|
2495
|
+
function armHardTimer(call, onExpire) {
|
|
2496
|
+
if (call.hardTimer !== void 0) clearTimeout(call.hardTimer);
|
|
2497
|
+
call.hardTimer = setTimeout(onExpire, opts.hardTimeoutMs);
|
|
2498
|
+
call.hardTimer.unref();
|
|
2499
|
+
}
|
|
2500
|
+
function pauseWatchdog(call) {
|
|
2501
|
+
if (call.watchdog === void 0) return;
|
|
2502
|
+
clearTimeout(call.watchdog);
|
|
2503
|
+
call.watchdog = void 0;
|
|
2504
|
+
}
|
|
2505
|
+
function clearCallTimers(call) {
|
|
2506
|
+
if (call.watchdog !== void 0) clearTimeout(call.watchdog);
|
|
2507
|
+
if (call.hardTimer !== void 0) clearTimeout(call.hardTimer);
|
|
2508
|
+
}
|
|
2509
|
+
return { armWatchdog, armHardTimer, pauseWatchdog, clearCallTimers };
|
|
2510
|
+
}
|
|
2511
|
+
var API_METHOD_NAMES = [
|
|
2512
|
+
"logInfo",
|
|
2513
|
+
"logWarn",
|
|
2514
|
+
"logError",
|
|
2515
|
+
"listFileNames",
|
|
2516
|
+
"readFile",
|
|
2517
|
+
"statFile",
|
|
2518
|
+
"statFiles",
|
|
2519
|
+
"sniff",
|
|
2520
|
+
"probe",
|
|
2521
|
+
"hashBytes",
|
|
2522
|
+
"computeImageHashes",
|
|
2523
|
+
"listContainer",
|
|
2524
|
+
"extractArchive",
|
|
2525
|
+
"download",
|
|
2526
|
+
"statAsset",
|
|
2527
|
+
"readAsset",
|
|
2528
|
+
"deleteAsset"
|
|
2529
|
+
];
|
|
2530
|
+
var LOG_METHOD_NAMES = /* @__PURE__ */ new Set([
|
|
2531
|
+
"logInfo",
|
|
2532
|
+
"logWarn",
|
|
2533
|
+
"logError"
|
|
2534
|
+
]);
|
|
2535
|
+
function serializeError(err) {
|
|
2536
|
+
if (err instanceof Error) {
|
|
2537
|
+
return {
|
|
2538
|
+
name: err.name,
|
|
2539
|
+
message: err.message,
|
|
2540
|
+
stack: err.stack,
|
|
2541
|
+
// The vocabulary errors carry their code in `name`; the wire
|
|
2542
|
+
// keeps an explicit `code` field so the receiving side never
|
|
2543
|
+
// re-parses or re-derives it.
|
|
2544
|
+
code: isAssetErrorName(err.name) ? err.name : void 0
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
return { name: "Error", message: String(err) };
|
|
2548
|
+
}
|
|
2549
|
+
function deserializeError(err) {
|
|
2550
|
+
const e = new Error(err.message);
|
|
2551
|
+
e.name = err.code ?? err.name;
|
|
2552
|
+
if (err.stack !== void 0) e.stack = err.stack;
|
|
2553
|
+
return e;
|
|
2554
|
+
}
|
|
2555
|
+
function isAssetErrorName(name) {
|
|
2556
|
+
return PLUGIN_ASSET_ERROR_NAMES.includes(name);
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
// src/sandbox/sandboxed-plugin.ts
|
|
2560
|
+
function createSandboxedPlugin(hooks, invoke) {
|
|
2561
|
+
const definition = {};
|
|
2562
|
+
for (const hook of hooks) {
|
|
2563
|
+
definition[hook] = async (api) => invoke(hook, api);
|
|
2564
|
+
}
|
|
2565
|
+
if (definition.detect === void 0) {
|
|
2566
|
+
definition.detect = async () => ({
|
|
2567
|
+
ok: false,
|
|
2568
|
+
reasons: ["plugin does not implement detect()"]
|
|
2569
|
+
});
|
|
2570
|
+
}
|
|
2571
|
+
return definePlugin(definition);
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
// src/sandbox/host.ts
|
|
2575
|
+
var PLUGIN_WATCHDOG_TIMEOUT_MS = 6e4;
|
|
2576
|
+
var PLUGIN_HOOK_HARD_TIMEOUT_MS = 30 * 6e4;
|
|
2577
|
+
var PLUGIN_WORKER_MAX_OLD_SPACE_MB = 512;
|
|
2578
|
+
var PLUGIN_MAX_RESULT_BYTES = 256 * 1024 * 1024;
|
|
2579
|
+
var PLUGIN_MAX_LOGS_PER_HOOK = 1e3;
|
|
2580
|
+
var PLUGIN_MAX_API_CALLS_PER_HOOK = 1e5;
|
|
2581
|
+
var ASSET_METHODS = /* @__PURE__ */ new Set([
|
|
2582
|
+
"download",
|
|
2583
|
+
"statAsset",
|
|
2584
|
+
"readAsset",
|
|
2585
|
+
"deleteAsset"
|
|
2586
|
+
]);
|
|
2587
|
+
var PLUGIN_WORKER_MAX_RESPAWNS = 3;
|
|
2588
|
+
var PLUGIN_WORKER_RESPAWN_WINDOW_MS = 6e4;
|
|
2589
|
+
var DEFAULT_SANDBOX_CONFIG = {
|
|
2590
|
+
watchdogMs: PLUGIN_WATCHDOG_TIMEOUT_MS,
|
|
2591
|
+
hardTimeoutMs: PLUGIN_HOOK_HARD_TIMEOUT_MS,
|
|
2592
|
+
maxOldSpaceMb: PLUGIN_WORKER_MAX_OLD_SPACE_MB,
|
|
2593
|
+
maxRespawns: PLUGIN_WORKER_MAX_RESPAWNS,
|
|
2594
|
+
respawnWindowMs: PLUGIN_WORKER_RESPAWN_WINDOW_MS,
|
|
2595
|
+
maxResultBytes: PLUGIN_MAX_RESULT_BYTES,
|
|
2596
|
+
maxLogsPerHook: PLUGIN_MAX_LOGS_PER_HOOK,
|
|
2597
|
+
maxApiCallsPerHook: PLUGIN_MAX_API_CALLS_PER_HOOK
|
|
2598
|
+
};
|
|
2599
|
+
var PERMISSION_FLAG_CANDIDATES = ["--permission", "--experimental-permission"];
|
|
2600
|
+
var permissionFlagPromise;
|
|
2601
|
+
async function resolvePermissionFlag() {
|
|
2602
|
+
permissionFlagPromise ??= probePermissionFlag();
|
|
2603
|
+
return permissionFlagPromise;
|
|
2604
|
+
}
|
|
2605
|
+
async function probePermissionFlag() {
|
|
2606
|
+
for (const flag of PERMISSION_FLAG_CANDIDATES) {
|
|
2607
|
+
if (await acceptsFlag(flag)) return flag;
|
|
2608
|
+
}
|
|
2609
|
+
return void 0;
|
|
2610
|
+
}
|
|
2611
|
+
function acceptsFlag(flag) {
|
|
2612
|
+
return new Promise((resolveProbe) => {
|
|
2613
|
+
const probe = spawn(
|
|
2614
|
+
process.execPath,
|
|
2615
|
+
[flag, `--allow-fs-read=${tmpdir()}`, "-e", "process.exit(0)"],
|
|
2616
|
+
{ stdio: "ignore", timeout: 1e4 }
|
|
2617
|
+
);
|
|
2618
|
+
probe.on("exit", (code) => resolveProbe(code === 0));
|
|
2619
|
+
probe.on("error", () => resolveProbe(false));
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
function createPluginSandbox(config = DEFAULT_SANDBOX_CONFIG) {
|
|
2623
|
+
const states = /* @__PURE__ */ new Map();
|
|
2624
|
+
let nextCallId = 1;
|
|
2625
|
+
const timers = createCallTimers({
|
|
2626
|
+
watchdogMs: config.watchdogMs,
|
|
2627
|
+
hardTimeoutMs: config.hardTimeoutMs
|
|
2628
|
+
});
|
|
2629
|
+
async function loadPlugin(opts) {
|
|
2630
|
+
const previous = states.get(opts.id);
|
|
2631
|
+
const state = {
|
|
2632
|
+
id: opts.id,
|
|
2633
|
+
mainPath: opts.mainPath,
|
|
2634
|
+
permissions: opts.permissions,
|
|
2635
|
+
child: void 0,
|
|
2636
|
+
hooks: void 0,
|
|
2637
|
+
loading: void 0,
|
|
2638
|
+
loadWaiter: void 0,
|
|
2639
|
+
pending: /* @__PURE__ */ new Map(),
|
|
2640
|
+
respawnTimes: [],
|
|
2641
|
+
degraded: false,
|
|
2642
|
+
disposed: false
|
|
2643
|
+
};
|
|
2644
|
+
states.set(opts.id, state);
|
|
2645
|
+
try {
|
|
2646
|
+
await ensureLoaded(state);
|
|
2647
|
+
} catch (err) {
|
|
2648
|
+
console.error(`[plugin-sandbox] ${opts.id}: failed to load main.js`, err);
|
|
2649
|
+
state.disposed = true;
|
|
2650
|
+
if (states.get(opts.id) !== state) return void 0;
|
|
2651
|
+
if (previous !== void 0) {
|
|
2652
|
+
previous.disposed = false;
|
|
2653
|
+
states.set(opts.id, previous);
|
|
2654
|
+
return createSandboxedPlugin(
|
|
2655
|
+
previous.hooks ?? ["detect"],
|
|
2656
|
+
(hook, api) => invoke(previous, hook, api)
|
|
2657
|
+
);
|
|
2658
|
+
}
|
|
2659
|
+
states.delete(opts.id);
|
|
2660
|
+
return void 0;
|
|
2661
|
+
}
|
|
2662
|
+
if (previous !== void 0) {
|
|
2663
|
+
previous.disposed = true;
|
|
2664
|
+
await teardownChild(previous);
|
|
2665
|
+
}
|
|
2666
|
+
if (!opts.eager) {
|
|
2667
|
+
void teardownChild(state);
|
|
2668
|
+
}
|
|
2669
|
+
return createSandboxedPlugin(
|
|
2670
|
+
state.hooks ?? ["detect"],
|
|
2671
|
+
(hook, api) => invoke(state, hook, api)
|
|
2672
|
+
);
|
|
2673
|
+
}
|
|
2674
|
+
function unloadPlugin(id) {
|
|
2675
|
+
const state = states.get(id);
|
|
2676
|
+
if (state === void 0) return;
|
|
2677
|
+
teardownChild(state);
|
|
2678
|
+
state.respawnTimes = [];
|
|
2679
|
+
state.degraded = false;
|
|
2680
|
+
}
|
|
2681
|
+
async function disposeAll() {
|
|
2682
|
+
const tasks = [];
|
|
2683
|
+
for (const state of states.values()) {
|
|
2684
|
+
state.disposed = true;
|
|
2685
|
+
tasks.push(teardownChild(state));
|
|
2686
|
+
}
|
|
2687
|
+
states.clear();
|
|
2688
|
+
await Promise.all(tasks);
|
|
2689
|
+
}
|
|
2690
|
+
async function disposeExcept(keepIds) {
|
|
2691
|
+
const tasks = [];
|
|
2692
|
+
for (const [id, state] of states) {
|
|
2693
|
+
if (keepIds.has(id)) continue;
|
|
2694
|
+
state.disposed = true;
|
|
2695
|
+
states.delete(id);
|
|
2696
|
+
tasks.push(teardownChild(state));
|
|
2697
|
+
}
|
|
2698
|
+
await Promise.all(tasks);
|
|
2699
|
+
}
|
|
2700
|
+
function syncRef(state) {
|
|
2701
|
+
const child = state.child;
|
|
2702
|
+
if (child === void 0) return;
|
|
2703
|
+
if (state.loading !== void 0 || state.loadWaiter !== void 0 || state.pending.size > 0) {
|
|
2704
|
+
child.ref();
|
|
2705
|
+
} else {
|
|
2706
|
+
child.unref();
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
function ensureLoaded(state) {
|
|
2710
|
+
if (state.child !== void 0) return Promise.resolve();
|
|
2711
|
+
state.loading ??= spawnAndLoad(state).finally(() => {
|
|
2712
|
+
state.loading = void 0;
|
|
2713
|
+
});
|
|
2714
|
+
return state.loading;
|
|
2715
|
+
}
|
|
2716
|
+
async function spawnAndLoad(state) {
|
|
2717
|
+
const now = Date.now();
|
|
2718
|
+
state.respawnTimes = state.respawnTimes.filter(
|
|
2719
|
+
(t) => now - t < config.respawnWindowMs
|
|
2720
|
+
);
|
|
2721
|
+
if (state.respawnTimes.length >= config.maxRespawns) {
|
|
2722
|
+
state.degraded = true;
|
|
2723
|
+
throw new Error(
|
|
2724
|
+
`plugin ${state.id} unavailable: sandbox respawned ${config.maxRespawns} times within ${config.respawnWindowMs}ms`
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
state.respawnTimes.push(now);
|
|
2728
|
+
const permissionFlag = config.permissionFlag ?? await resolvePermissionFlag();
|
|
2729
|
+
if (permissionFlag === void 0) {
|
|
2730
|
+
throw new Error(
|
|
2731
|
+
`plugin ${state.id} unavailable: this Node build has no permission-model flag (tried ${PERMISSION_FLAG_CANDIDATES.map((f) => `"${f}"`).join(", ")}); refusing to run plugin code unsandboxed`
|
|
2732
|
+
);
|
|
2733
|
+
}
|
|
2734
|
+
const entryPath = fileURLToPath(resolveWorkerEntryUrl());
|
|
2735
|
+
const pluginDir = dirname(state.mainPath);
|
|
2736
|
+
const realEntryPath = realPathOrResolve(entryPath);
|
|
2737
|
+
const realPluginDir = realPathOrResolve(pluginDir);
|
|
2738
|
+
const realMainPath = realPathOrResolve(state.mainPath);
|
|
2739
|
+
const fsReadGrants = [`${realPluginDir}${sep}`, realEntryPath];
|
|
2740
|
+
const assetVaultDir = typeof config.assetVaultDir === "function" ? config.assetVaultDir(state.id) : config.assetVaultDir;
|
|
2741
|
+
const realVaultDir = assetVaultDir === void 0 ? void 0 : realPathOrResolve(assetVaultDir);
|
|
2742
|
+
if (realVaultDir !== void 0) {
|
|
2743
|
+
fsReadGrants.push(`${realVaultDir}${sep}`);
|
|
2744
|
+
}
|
|
2745
|
+
const child = fork(
|
|
2746
|
+
realEntryPath,
|
|
2747
|
+
[realPluginDir, realEntryPath, realVaultDir].filter(
|
|
2748
|
+
(v) => v !== void 0
|
|
2749
|
+
),
|
|
2750
|
+
{
|
|
2751
|
+
execArgv: [
|
|
2752
|
+
permissionFlag,
|
|
2753
|
+
...fsReadGrants.map((p) => `--allow-fs-read=${p}`),
|
|
2754
|
+
`--max-old-space-size=${config.maxOldSpaceMb}`
|
|
2755
|
+
],
|
|
2756
|
+
serialization: "advanced",
|
|
2757
|
+
// Child stderr is inherited (self-check failures must surface);
|
|
2758
|
+
// plugin console output is dropped — logs flow over the RPC.
|
|
2759
|
+
stdio: ["ignore", "ignore", "inherit", "ipc"],
|
|
2760
|
+
env: {
|
|
2761
|
+
...process.env,
|
|
2762
|
+
HOARDODILE_PLUGIN_MAX_RESULT_BYTES: String(config.maxResultBytes),
|
|
2763
|
+
HOARDODILE_PLUGIN_MAX_LOGS_PER_HOOK: String(config.maxLogsPerHook),
|
|
2764
|
+
HOARDODILE_PLUGIN_MAX_API_CALLS_PER_HOOK: String(
|
|
2765
|
+
config.maxApiCallsPerHook
|
|
2766
|
+
)
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
);
|
|
2770
|
+
child.unref();
|
|
2771
|
+
state.child = child;
|
|
2772
|
+
syncRef(state);
|
|
2773
|
+
child.on(
|
|
2774
|
+
"message",
|
|
2775
|
+
(msg) => handleMessage(state, child, msg)
|
|
2776
|
+
);
|
|
2777
|
+
child.on("error", (err) => failChild(state, child, asError(err)));
|
|
2778
|
+
child.on("exit", (code) => {
|
|
2779
|
+
if (state.child === child) {
|
|
2780
|
+
failChild(
|
|
2781
|
+
state,
|
|
2782
|
+
child,
|
|
2783
|
+
new Error(`plugin ${state.id} worker exited (code ${code})`)
|
|
2784
|
+
);
|
|
2785
|
+
}
|
|
2786
|
+
});
|
|
2787
|
+
const loaded = new Promise((resolveLoaded, reject) => {
|
|
2788
|
+
state.loadWaiter = { resolve: resolveLoaded, reject };
|
|
2789
|
+
});
|
|
2790
|
+
try {
|
|
2791
|
+
child.send({ type: "load", mainPath: realMainPath });
|
|
2792
|
+
} catch (err) {
|
|
2793
|
+
await teardownChild(state, asError(err));
|
|
2794
|
+
throw err;
|
|
2795
|
+
}
|
|
2796
|
+
try {
|
|
2797
|
+
await loaded;
|
|
2798
|
+
} catch (err) {
|
|
2799
|
+
await teardownChild(state);
|
|
2800
|
+
throw err;
|
|
2801
|
+
} finally {
|
|
2802
|
+
state.loadWaiter = void 0;
|
|
2803
|
+
}
|
|
2804
|
+
syncRef(state);
|
|
2805
|
+
}
|
|
2806
|
+
function teardownChild(state, err) {
|
|
2807
|
+
const child = state.child;
|
|
2808
|
+
state.child = void 0;
|
|
2809
|
+
state.loading = void 0;
|
|
2810
|
+
const stopErr = err ?? new Error(`plugin ${state.id} worker stopped`);
|
|
2811
|
+
state.loadWaiter?.reject(stopErr);
|
|
2812
|
+
state.loadWaiter = void 0;
|
|
2813
|
+
rejectAllPending(state, stopErr);
|
|
2814
|
+
if (child === void 0) return Promise.resolve();
|
|
2815
|
+
child.kill();
|
|
2816
|
+
return Promise.resolve();
|
|
2817
|
+
}
|
|
2818
|
+
function failChild(state, child, err) {
|
|
2819
|
+
if (state.child !== child) return;
|
|
2820
|
+
void teardownChild(state, err);
|
|
2821
|
+
}
|
|
2822
|
+
function rejectAllPending(state, err) {
|
|
2823
|
+
for (const call of state.pending.values()) {
|
|
2824
|
+
timers.clearCallTimers(call);
|
|
2825
|
+
call.reject(err);
|
|
2826
|
+
}
|
|
2827
|
+
state.pending.clear();
|
|
2828
|
+
}
|
|
2829
|
+
async function invoke(state, hook, api) {
|
|
2830
|
+
if (state.disposed) {
|
|
2831
|
+
throw new Error(`plugin ${state.id} sandbox disposed`);
|
|
2832
|
+
}
|
|
2833
|
+
if (state.degraded) {
|
|
2834
|
+
const now = Date.now();
|
|
2835
|
+
state.respawnTimes = state.respawnTimes.filter(
|
|
2836
|
+
(t) => now - t < config.respawnWindowMs
|
|
2837
|
+
);
|
|
2838
|
+
if (state.respawnTimes.length === 0) {
|
|
2839
|
+
state.degraded = false;
|
|
2840
|
+
} else {
|
|
2841
|
+
throw new Error(
|
|
2842
|
+
`plugin ${state.id} unavailable: sandbox crashed repeatedly`
|
|
2843
|
+
);
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
await ensureLoaded(state);
|
|
2847
|
+
const child = state.child;
|
|
2848
|
+
if (child === void 0) {
|
|
2849
|
+
throw new Error(`plugin ${state.id} sandbox unavailable`);
|
|
2850
|
+
}
|
|
2851
|
+
const callId = nextCallId++;
|
|
2852
|
+
return new Promise((resolveCall, reject) => {
|
|
2853
|
+
const call = {
|
|
2854
|
+
api,
|
|
2855
|
+
resolve: resolveCall,
|
|
2856
|
+
reject,
|
|
2857
|
+
apiInFlight: 0
|
|
2858
|
+
};
|
|
2859
|
+
state.pending.set(callId, call);
|
|
2860
|
+
syncRef(state);
|
|
2861
|
+
timers.armWatchdog(
|
|
2862
|
+
call,
|
|
2863
|
+
failCall(
|
|
2864
|
+
state,
|
|
2865
|
+
child,
|
|
2866
|
+
callId,
|
|
2867
|
+
`hung: no activity for ${config.watchdogMs}ms`
|
|
2868
|
+
)
|
|
2869
|
+
);
|
|
2870
|
+
timers.armHardTimer(
|
|
2871
|
+
call,
|
|
2872
|
+
failCall(
|
|
2873
|
+
state,
|
|
2874
|
+
child,
|
|
2875
|
+
callId,
|
|
2876
|
+
`exceeded hard timeout ${config.hardTimeoutMs}ms`
|
|
2877
|
+
)
|
|
2878
|
+
);
|
|
2879
|
+
try {
|
|
2880
|
+
child.send({
|
|
2881
|
+
type: "invoke",
|
|
2882
|
+
callId,
|
|
2883
|
+
hook
|
|
2884
|
+
});
|
|
2885
|
+
} catch (err) {
|
|
2886
|
+
state.pending.delete(callId);
|
|
2887
|
+
syncRef(state);
|
|
2888
|
+
timers.clearCallTimers(call);
|
|
2889
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
2890
|
+
}
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2893
|
+
function failCall(state, child, callId, reason) {
|
|
2894
|
+
return () => failChild(
|
|
2895
|
+
state,
|
|
2896
|
+
child,
|
|
2897
|
+
new Error(`plugin ${state.id} ${reason} (call ${callId})`)
|
|
2898
|
+
);
|
|
2899
|
+
}
|
|
2900
|
+
function handleMessage(state, child, msg) {
|
|
2901
|
+
if (state.child !== child) return;
|
|
2902
|
+
if (msg === null || typeof msg !== "object") return;
|
|
2903
|
+
switch (msg.type) {
|
|
2904
|
+
case "loaded": {
|
|
2905
|
+
const waiter = state.loadWaiter;
|
|
2906
|
+
state.loadWaiter = void 0;
|
|
2907
|
+
if (msg.ok) {
|
|
2908
|
+
state.hooks = msg.hooks ?? ["detect"];
|
|
2909
|
+
waiter?.resolve();
|
|
2910
|
+
} else {
|
|
2911
|
+
waiter?.reject(
|
|
2912
|
+
deserializeError(
|
|
2913
|
+
msg.error ?? { name: "Error", message: "plugin load failed" }
|
|
2914
|
+
)
|
|
2915
|
+
);
|
|
2916
|
+
}
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
case "result": {
|
|
2920
|
+
const call = state.pending.get(msg.callId);
|
|
2921
|
+
if (call === void 0) return;
|
|
2922
|
+
state.pending.delete(msg.callId);
|
|
2923
|
+
syncRef(state);
|
|
2924
|
+
timers.clearCallTimers(call);
|
|
2925
|
+
if (msg.ok) {
|
|
2926
|
+
call.resolve(msg.value);
|
|
2927
|
+
} else {
|
|
2928
|
+
call.reject(
|
|
2929
|
+
deserializeError(
|
|
2930
|
+
msg.error ?? { name: "Error", message: "hook failed" }
|
|
2931
|
+
)
|
|
2932
|
+
);
|
|
2933
|
+
}
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
2936
|
+
case "api": {
|
|
2937
|
+
const call = state.pending.get(msg.callId);
|
|
2938
|
+
if (call === void 0) return;
|
|
2939
|
+
call.apiInFlight += 1;
|
|
2940
|
+
timers.pauseWatchdog(call);
|
|
2941
|
+
void dispatchApi(
|
|
2942
|
+
child,
|
|
2943
|
+
state,
|
|
2944
|
+
msg.callId,
|
|
2945
|
+
msg.apiCallId,
|
|
2946
|
+
call,
|
|
2947
|
+
msg.method,
|
|
2948
|
+
msg.args
|
|
2949
|
+
);
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
case "log": {
|
|
2953
|
+
const call = state.pending.get(msg.callId);
|
|
2954
|
+
if (call === void 0) return;
|
|
2955
|
+
if (call.apiInFlight === 0) {
|
|
2956
|
+
timers.armWatchdog(
|
|
2957
|
+
call,
|
|
2958
|
+
failCall(
|
|
2959
|
+
state,
|
|
2960
|
+
child,
|
|
2961
|
+
msg.callId,
|
|
2962
|
+
`hung: no activity for ${config.watchdogMs}ms`
|
|
2963
|
+
)
|
|
2964
|
+
);
|
|
2965
|
+
}
|
|
2966
|
+
dispatchLog(state.id, msg.method, msg.args);
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
async function dispatchApi(child, state, callId, apiCallId, call, method, args) {
|
|
2972
|
+
const respond = (ok, value, error) => {
|
|
2973
|
+
if (state.child !== child) return;
|
|
2974
|
+
try {
|
|
2975
|
+
child.send({ type: "apiResult", apiCallId, ok, value, error });
|
|
2976
|
+
} catch {
|
|
2977
|
+
}
|
|
2978
|
+
};
|
|
2979
|
+
try {
|
|
2980
|
+
if (!isApiMethod(method) || LOG_METHOD_NAMES.has(method)) {
|
|
2981
|
+
respond(false, void 0, {
|
|
2982
|
+
name: "Error",
|
|
2983
|
+
message: `unknown API method: ${String(method)}`
|
|
2984
|
+
});
|
|
2985
|
+
return;
|
|
2986
|
+
}
|
|
2987
|
+
const capability = CAPABILITY_BY_METHOD.get(method);
|
|
2988
|
+
if (capability !== void 0 && state.permissions?.[capability] !== true) {
|
|
2989
|
+
respond(false, void 0, {
|
|
2990
|
+
name: "POLICY",
|
|
2991
|
+
code: "POLICY",
|
|
2992
|
+
message: `${capability} permission denied for plugin ${state.id} \u2014 declare "${capability}": true in the manifest to use ${method}()`
|
|
2993
|
+
});
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
if (ASSET_METHODS.has(method)) {
|
|
2997
|
+
const handler = config.pluginAssets;
|
|
2998
|
+
if (handler === void 0) {
|
|
2999
|
+
respond(false, void 0, {
|
|
3000
|
+
name: "UNAVAILABLE",
|
|
3001
|
+
code: "UNAVAILABLE",
|
|
3002
|
+
message: `${method}() is unavailable on this host \u2014 plugin asset downloads need the app server runtime`
|
|
3003
|
+
});
|
|
3004
|
+
return;
|
|
3005
|
+
}
|
|
3006
|
+
if (method === "download") {
|
|
3007
|
+
respond(
|
|
3008
|
+
true,
|
|
3009
|
+
await handler.download(state.id, args[0])
|
|
3010
|
+
);
|
|
3011
|
+
} else if (method === "statAsset") {
|
|
3012
|
+
respond(true, await handler.statAsset(state.id, args[0]));
|
|
3013
|
+
} else if (method === "readAsset") {
|
|
3014
|
+
respond(true, await handler.readAsset(state.id, args[0]));
|
|
3015
|
+
} else {
|
|
3016
|
+
respond(true, await handler.deleteAsset(state.id, args[0]));
|
|
3017
|
+
}
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
const fn = call.api[method];
|
|
3021
|
+
respond(true, await fn(...args));
|
|
3022
|
+
} catch (err) {
|
|
3023
|
+
respond(false, void 0, serializeError(err));
|
|
3024
|
+
} finally {
|
|
3025
|
+
call.apiInFlight -= 1;
|
|
3026
|
+
if (call.apiInFlight === 0 && state.pending.get(callId) === call) {
|
|
3027
|
+
timers.armWatchdog(
|
|
3028
|
+
call,
|
|
3029
|
+
failCall(
|
|
3030
|
+
state,
|
|
3031
|
+
child,
|
|
3032
|
+
callId,
|
|
3033
|
+
`hung: no activity for ${config.watchdogMs}ms`
|
|
3034
|
+
)
|
|
3035
|
+
);
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
function dispatchLog(pluginId, method, args) {
|
|
3040
|
+
const message = typeof args[0] === "string" ? args[0] : String(args[0]);
|
|
3041
|
+
const data = isPlainRecord(args[1]) ? args[1] : void 0;
|
|
3042
|
+
const line = `[plugin:${pluginId}] ${message}`;
|
|
3043
|
+
const extra = data === void 0 ? [] : [data];
|
|
3044
|
+
try {
|
|
3045
|
+
if (method === "logInfo") console.log(line, ...extra);
|
|
3046
|
+
else if (method === "logWarn") console.warn(line, ...extra);
|
|
3047
|
+
else console.error(line, ...extra);
|
|
3048
|
+
} catch {
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
return { loadPlugin, unloadPlugin, disposeExcept, disposeAll };
|
|
3052
|
+
}
|
|
3053
|
+
function isApiMethod(name) {
|
|
3054
|
+
return typeof name === "string" && API_METHOD_NAMES.includes(name);
|
|
3055
|
+
}
|
|
3056
|
+
function asError(value) {
|
|
3057
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
3058
|
+
}
|
|
3059
|
+
function realPathOrResolve(path) {
|
|
3060
|
+
try {
|
|
3061
|
+
return realpathSync(path);
|
|
3062
|
+
} catch {
|
|
3063
|
+
return resolve(path);
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
function resolveWorkerEntryUrl() {
|
|
3067
|
+
try {
|
|
3068
|
+
if (typeof import.meta.resolve === "function") {
|
|
3069
|
+
return import.meta.resolve("@hoardodile/host/worker-entry");
|
|
3070
|
+
}
|
|
3071
|
+
} catch {
|
|
3072
|
+
}
|
|
3073
|
+
return workerEntryUrlFromModule(import.meta.url);
|
|
3074
|
+
}
|
|
3075
|
+
function workerEntryUrlFromModule(moduleUrl) {
|
|
3076
|
+
const distIdx = moduleUrl.lastIndexOf("/dist/");
|
|
3077
|
+
if (distIdx >= 0) {
|
|
3078
|
+
const distRoot = moduleUrl.slice(0, distIdx + "/dist/".length);
|
|
3079
|
+
return new URL("chunks/worker-entry.mjs", distRoot).href;
|
|
3080
|
+
}
|
|
3081
|
+
return new URL("worker-entry.mjs", moduleUrl).href;
|
|
3082
|
+
}
|
|
3083
|
+
function isPlainRecord(value) {
|
|
3084
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3085
|
+
}
|
|
3086
|
+
var VAULT_DIR_NAME = "vault";
|
|
3087
|
+
function seedPlugins(pluginsDir, seedPluginDirs) {
|
|
3088
|
+
if (seedPluginDirs === void 0 || seedPluginDirs.length === 0) {
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
for (const dir of seedPluginDirs) {
|
|
3092
|
+
try {
|
|
3093
|
+
copyPluginDir(dir, pluginsDir);
|
|
3094
|
+
} catch (err) {
|
|
3095
|
+
console.warn(
|
|
3096
|
+
`[plugin-loader] failed to seed plugin dir ${dir}: ${err instanceof Error ? err.message : String(err)}`
|
|
3097
|
+
);
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
function copyPluginDir(srcDir, pluginsDir) {
|
|
3102
|
+
const id = readManifestId(srcDir);
|
|
3103
|
+
if (id === void 0) return false;
|
|
3104
|
+
const dstDir = join(pluginsDir, id);
|
|
3105
|
+
if (existsSync(dstDir) && treesMatch(srcDir, dstDir)) {
|
|
3106
|
+
return true;
|
|
3107
|
+
}
|
|
3108
|
+
if (existsSync(dstDir)) {
|
|
3109
|
+
const vaultDir = join(dstDir, VAULT_DIR_NAME);
|
|
3110
|
+
const stashDir = join(pluginsDir, `.vault-${id}-${Date.now()}`);
|
|
3111
|
+
const hasVault = existsSync(vaultDir);
|
|
3112
|
+
if (hasVault) {
|
|
3113
|
+
renameSync(vaultDir, stashDir);
|
|
3114
|
+
}
|
|
3115
|
+
rmSync(dstDir, { recursive: true, force: true });
|
|
3116
|
+
mkdirSync(dstDir, { recursive: true });
|
|
3117
|
+
for (const f of readdirSync(srcDir)) {
|
|
3118
|
+
if (f === VAULT_DIR_NAME) continue;
|
|
3119
|
+
cpSync(join(srcDir, f), join(dstDir, f), { recursive: true });
|
|
3120
|
+
}
|
|
3121
|
+
if (existsSync(stashDir)) {
|
|
3122
|
+
renameSync(stashDir, join(dstDir, VAULT_DIR_NAME));
|
|
3123
|
+
}
|
|
3124
|
+
return true;
|
|
3125
|
+
}
|
|
3126
|
+
mkdirSync(dstDir, { recursive: true });
|
|
3127
|
+
for (const f of readdirSync(srcDir)) {
|
|
3128
|
+
if (f === VAULT_DIR_NAME) continue;
|
|
3129
|
+
cpSync(join(srcDir, f), join(dstDir, f), { recursive: true });
|
|
3130
|
+
}
|
|
3131
|
+
return true;
|
|
3132
|
+
}
|
|
3133
|
+
function readManifestId(dir) {
|
|
3134
|
+
const manifestPath = join(dir, "manifest.json");
|
|
3135
|
+
if (!existsSync(manifestPath)) return void 0;
|
|
3136
|
+
let manifest;
|
|
3137
|
+
try {
|
|
3138
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
3139
|
+
} catch {
|
|
3140
|
+
return void 0;
|
|
3141
|
+
}
|
|
3142
|
+
if (typeof manifest.id !== "string" || manifest.id.length === 0) {
|
|
3143
|
+
return void 0;
|
|
3144
|
+
}
|
|
3145
|
+
return manifest.id;
|
|
3146
|
+
}
|
|
3147
|
+
function treesMatch(a, b) {
|
|
3148
|
+
const left = fingerprintTree(a);
|
|
3149
|
+
const right = fingerprintTree(b);
|
|
3150
|
+
if (left.size !== right.size) return false;
|
|
3151
|
+
for (const [path, fingerprint] of left) {
|
|
3152
|
+
if (right.get(path) !== fingerprint) return false;
|
|
3153
|
+
}
|
|
3154
|
+
return true;
|
|
3155
|
+
}
|
|
3156
|
+
function fingerprintTree(root) {
|
|
3157
|
+
const out = /* @__PURE__ */ new Map();
|
|
3158
|
+
if (!existsSync(root)) return out;
|
|
3159
|
+
walkFiles(root, "", out);
|
|
3160
|
+
return out;
|
|
3161
|
+
}
|
|
3162
|
+
function walkFiles(absDir, relDir, out) {
|
|
3163
|
+
const entries = readdirSync(absDir, { withFileTypes: true });
|
|
3164
|
+
for (const entry of entries) {
|
|
3165
|
+
if (relDir.length === 0 && entry.name === VAULT_DIR_NAME) continue;
|
|
3166
|
+
const rel = relDir.length === 0 ? entry.name : `${relDir}/${entry.name}`;
|
|
3167
|
+
const abs = join(absDir, entry.name);
|
|
3168
|
+
if (entry.isDirectory()) {
|
|
3169
|
+
walkFiles(abs, rel, out);
|
|
3170
|
+
continue;
|
|
3171
|
+
}
|
|
3172
|
+
if (!entry.isFile()) continue;
|
|
3173
|
+
const st = statSync(abs);
|
|
3174
|
+
const hash = createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
3175
|
+
out.set(rel.replaceAll("\\", "/"), `${st.size}:${hash}`);
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
// src/loader.ts
|
|
3180
|
+
function createPluginLoader(deps) {
|
|
3181
|
+
let registry;
|
|
3182
|
+
let chain = Promise.resolve();
|
|
3183
|
+
const discovery = createPluginDiscovery(deps);
|
|
3184
|
+
const sandbox = deps.sandbox ?? createPluginSandbox();
|
|
3185
|
+
const activation = createPluginActivation({ sandbox });
|
|
3186
|
+
function loadAll() {
|
|
3187
|
+
const run = chain.then(doLoadAll);
|
|
3188
|
+
chain = run.catch(() => {
|
|
3189
|
+
});
|
|
3190
|
+
return run;
|
|
3191
|
+
}
|
|
3192
|
+
async function doLoadAll() {
|
|
3193
|
+
const seedStart = performance.now();
|
|
3194
|
+
seedPlugins(deps.pluginsDir, deps.seedPluginDirs);
|
|
3195
|
+
deps.onTiming?.("seed", Math.round(performance.now() - seedStart));
|
|
3196
|
+
const discoverStart = performance.now();
|
|
3197
|
+
const { found, missing } = await discovery.discover();
|
|
3198
|
+
deps.onTiming?.("discover", Math.round(performance.now() - discoverStart));
|
|
3199
|
+
const activateStart = performance.now();
|
|
3200
|
+
const activated = await activation.activateAll(found);
|
|
3201
|
+
deps.onTiming?.("activate", Math.round(performance.now() - activateStart));
|
|
3202
|
+
const failing = activation.createFailingEntries(missing);
|
|
3203
|
+
const entries = [...activated, ...failing];
|
|
3204
|
+
entries.sort((a, b) => a.priority - b.priority);
|
|
3205
|
+
const next = buildRegistry(entries);
|
|
3206
|
+
await sandbox.disposeExcept(new Set(next.getAll().map((e) => e.id)));
|
|
3207
|
+
registry = next;
|
|
3208
|
+
return registry;
|
|
3209
|
+
}
|
|
3210
|
+
async function rescan() {
|
|
3211
|
+
return loadAll();
|
|
3212
|
+
}
|
|
3213
|
+
function getRegistry() {
|
|
3214
|
+
if (registry === void 0) {
|
|
3215
|
+
throw new Error("Plugin registry not initialised \u2014 call loadAll() first");
|
|
3216
|
+
}
|
|
3217
|
+
return registry;
|
|
3218
|
+
}
|
|
3219
|
+
return { loadAll, rescan, getRegistry };
|
|
3220
|
+
}
|
|
3221
|
+
function buildRegistry(entries) {
|
|
3222
|
+
const byId = /* @__PURE__ */ new Map();
|
|
3223
|
+
const deduped = [];
|
|
3224
|
+
for (const entry of entries) {
|
|
3225
|
+
const existing = byId.get(entry.id);
|
|
3226
|
+
if (existing !== void 0) {
|
|
3227
|
+
console.warn(
|
|
3228
|
+
`[plugin-loader] skipping ${entry.id}: UUID conflicts with already registered plugin (${existing.manifest.name ?? existing.id}), keeping first`
|
|
3229
|
+
);
|
|
3230
|
+
continue;
|
|
3231
|
+
}
|
|
3232
|
+
byId.set(entry.id, entry);
|
|
3233
|
+
deduped.push(entry);
|
|
3234
|
+
}
|
|
3235
|
+
let sortedEntries = deduped;
|
|
3236
|
+
return {
|
|
3237
|
+
getAll() {
|
|
3238
|
+
return sortedEntries;
|
|
3239
|
+
},
|
|
3240
|
+
getEnabled() {
|
|
3241
|
+
return sortedEntries.filter((e) => e.enabled);
|
|
3242
|
+
},
|
|
3243
|
+
getById(id) {
|
|
3244
|
+
return byId.get(id);
|
|
3245
|
+
},
|
|
3246
|
+
getBuiltin() {
|
|
3247
|
+
return sortedEntries.find((e) => e.builtin);
|
|
3248
|
+
},
|
|
3249
|
+
getForResource(resPluginId) {
|
|
3250
|
+
const entry = byId.get(resPluginId);
|
|
3251
|
+
if (entry === void 0 || !entry.enabled) return void 0;
|
|
3252
|
+
return entry;
|
|
3253
|
+
},
|
|
3254
|
+
updateEntry(id, patch) {
|
|
3255
|
+
const index = sortedEntries.findIndex((e) => e.id === id);
|
|
3256
|
+
if (index === -1) return;
|
|
3257
|
+
const old = sortedEntries[index];
|
|
3258
|
+
if (old === void 0) return;
|
|
3259
|
+
const updated = {
|
|
3260
|
+
id: old.id,
|
|
3261
|
+
manifest: old.manifest,
|
|
3262
|
+
plugin: old.plugin,
|
|
3263
|
+
diskPath: old.diskPath,
|
|
3264
|
+
enabled: patch.enabled ?? old.enabled,
|
|
3265
|
+
priority: patch.priority ?? old.priority,
|
|
3266
|
+
pinned: patch.pinned ?? old.pinned,
|
|
3267
|
+
color: patch.color ?? old.color,
|
|
3268
|
+
missing: patch.missing ?? old.missing,
|
|
3269
|
+
builtin: old.builtin,
|
|
3270
|
+
dev: old.dev
|
|
3271
|
+
};
|
|
3272
|
+
sortedEntries = [
|
|
3273
|
+
...sortedEntries.slice(0, index),
|
|
3274
|
+
updated,
|
|
3275
|
+
...sortedEntries.slice(index + 1)
|
|
3276
|
+
];
|
|
3277
|
+
sortedEntries.sort((a, b) => a.priority - b.priority);
|
|
3278
|
+
byId.set(id, updated);
|
|
3279
|
+
}
|
|
3280
|
+
};
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
// src/probe-cache.ts
|
|
3284
|
+
var PLUGIN_PROBE_CACHE_MAX_ENTRIES = 512;
|
|
3285
|
+
function createProbeCache(maxEntries = PLUGIN_PROBE_CACHE_MAX_ENTRIES) {
|
|
3286
|
+
const cells = /* @__PURE__ */ new Map();
|
|
3287
|
+
return {
|
|
3288
|
+
getOrCompute(key, compute) {
|
|
3289
|
+
const existing = cells.get(key);
|
|
3290
|
+
if (existing !== void 0) {
|
|
3291
|
+
cells.delete(key);
|
|
3292
|
+
cells.set(key, existing);
|
|
3293
|
+
return existing;
|
|
3294
|
+
}
|
|
3295
|
+
const promise = compute();
|
|
3296
|
+
cells.set(key, promise);
|
|
3297
|
+
promise.catch(() => {
|
|
3298
|
+
if (cells.get(key) === promise) cells.delete(key);
|
|
3299
|
+
});
|
|
3300
|
+
if (cells.size > maxEntries) {
|
|
3301
|
+
const oldest = cells.keys().next().value;
|
|
3302
|
+
if (oldest !== void 0) cells.delete(oldest);
|
|
3303
|
+
}
|
|
3304
|
+
return promise;
|
|
3305
|
+
}
|
|
3306
|
+
};
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
export { DEFAULT_PLUGIN_EXTRACT_MAX_BYTES, DEFAULT_PLUGIN_EXTRACT_MAX_ENTRIES, DEFAULT_SANDBOX_CONFIG, DomainError, MIN_PERCEPTUAL_STDDEV, PERCEPTUAL_HASH_KINDS, PHASH_GRID, PLUGIN_HOOK_HARD_TIMEOUT_MS, PLUGIN_MAX_API_CALLS_PER_HOOK, PLUGIN_MAX_LOGS_PER_HOOK, PLUGIN_MAX_RESULT_BYTES, PLUGIN_PROBE_CACHE_MAX_ENTRIES, PLUGIN_WATCHDOG_TIMEOUT_MS, PLUGIN_WORKER_MAX_OLD_SPACE_MB, PLUGIN_WORKER_MAX_RESPAWNS, PLUGIN_WORKER_RESPAWN_WINDOW_MS, buildRegistry, computeDHash, computePHash, conflict, createCapabilityGuard, createDirectoryContainer, createDirectoryResourceAPI, createDirectoryResourceAPI as createImportResourceAPI, createNestedAwareContainer, createNestedCdCache, createPluginActivation, createPluginDiscovery, createPluginHooks, createPluginLoader, createPluginResourceAPI, createPluginSandbox, createProbeCache, grayStddev, invalid, listZipEntries, materializeFile, notFound, parseManifest, resolveSafeImportPath, runPluginHook, seedPlugins };
|
|
3310
|
+
//# sourceMappingURL=index.js.map
|
|
3311
|
+
//# sourceMappingURL=index.js.map
|