@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
|
@@ -0,0 +1,1939 @@
|
|
|
1
|
+
import { randomUUID, createHash } from 'crypto';
|
|
2
|
+
import { existsSync, createWriteStream, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync, cpSync, createReadStream } from 'fs';
|
|
3
|
+
import { writeFile, mkdir, rm, readdir, rename, chmod, lstat, stat, readFile } from 'fs/promises';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { resolve, join, sep, dirname, normalize, isAbsolute, extname, basename } from 'path';
|
|
6
|
+
import { Readable, Transform } from 'stream';
|
|
7
|
+
import { pipeline } from 'stream/promises';
|
|
8
|
+
import yauzl from 'yauzl';
|
|
9
|
+
import { spawn } from 'child_process';
|
|
10
|
+
import { createRequire } from 'module';
|
|
11
|
+
import { buffer } from 'stream/consumers';
|
|
12
|
+
import 'zlib';
|
|
13
|
+
import yazl from 'yazl';
|
|
14
|
+
import { pluginAssetError } from '@hoardodile/sdk-types';
|
|
15
|
+
|
|
16
|
+
// src/archive/extract.ts
|
|
17
|
+
|
|
18
|
+
// src/errors.ts
|
|
19
|
+
var DomainError = class extends Error {
|
|
20
|
+
code;
|
|
21
|
+
kind;
|
|
22
|
+
details;
|
|
23
|
+
constructor(code, kind, message, details) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "DomainError";
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.kind = kind;
|
|
28
|
+
if (details !== void 0) this.details = details;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
function invalid(kind, message, details) {
|
|
32
|
+
return new DomainError("VALIDATION", kind, message, details);
|
|
33
|
+
}
|
|
34
|
+
function conflict(kind, message, details) {
|
|
35
|
+
return new DomainError("CONFLICT", kind, message, details);
|
|
36
|
+
}
|
|
37
|
+
function notFound(kind, message, details) {
|
|
38
|
+
return new DomainError("NOT_FOUND", kind, message, details);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/archive/format.ts
|
|
42
|
+
var SNIFF_WINDOW_BYTES = 4100;
|
|
43
|
+
function hasMagic(head, magic) {
|
|
44
|
+
if (head.byteLength < magic.length) return false;
|
|
45
|
+
for (let i = 0; i < magic.length; i++) {
|
|
46
|
+
if (head[i] !== magic[i]) return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
function sniffContainerFormat(head) {
|
|
51
|
+
if (hasMagic(head, [80, 75, 3, 4])) return "zip";
|
|
52
|
+
if (hasMagic(head, [80, 75, 5, 6])) return "zip";
|
|
53
|
+
if (head.byteLength >= 262) {
|
|
54
|
+
const magic = String.fromCharCode(
|
|
55
|
+
head[257],
|
|
56
|
+
head[258],
|
|
57
|
+
head[259],
|
|
58
|
+
head[260],
|
|
59
|
+
head[261]
|
|
60
|
+
);
|
|
61
|
+
if (magic === "ustar") return "tar";
|
|
62
|
+
}
|
|
63
|
+
if (hasMagic(head, [55, 122, 188, 175, 39, 28])) return "7z";
|
|
64
|
+
if (hasMagic(head, [82, 97, 114, 33, 26, 7])) return "rar";
|
|
65
|
+
if (hasMagic(head, [253, 55, 122, 88, 90, 0])) return "xz";
|
|
66
|
+
if (hasMagic(head, [31, 139, 8])) return "gzip";
|
|
67
|
+
return void 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/archive/name-decode.ts
|
|
71
|
+
var CP437 = String.fromCharCode(
|
|
72
|
+
0,
|
|
73
|
+
9786,
|
|
74
|
+
9787,
|
|
75
|
+
9829,
|
|
76
|
+
9830,
|
|
77
|
+
9827,
|
|
78
|
+
9824,
|
|
79
|
+
8226,
|
|
80
|
+
9688,
|
|
81
|
+
9675,
|
|
82
|
+
9689,
|
|
83
|
+
9794,
|
|
84
|
+
9792,
|
|
85
|
+
9834,
|
|
86
|
+
9835,
|
|
87
|
+
9788,
|
|
88
|
+
9658,
|
|
89
|
+
9668,
|
|
90
|
+
8597,
|
|
91
|
+
8252,
|
|
92
|
+
182,
|
|
93
|
+
167,
|
|
94
|
+
9644,
|
|
95
|
+
8616,
|
|
96
|
+
8593,
|
|
97
|
+
8595,
|
|
98
|
+
8594,
|
|
99
|
+
8592,
|
|
100
|
+
8735,
|
|
101
|
+
8596,
|
|
102
|
+
9650,
|
|
103
|
+
9660,
|
|
104
|
+
32,
|
|
105
|
+
33,
|
|
106
|
+
34,
|
|
107
|
+
35,
|
|
108
|
+
36,
|
|
109
|
+
37,
|
|
110
|
+
38,
|
|
111
|
+
39,
|
|
112
|
+
40,
|
|
113
|
+
41,
|
|
114
|
+
42,
|
|
115
|
+
43,
|
|
116
|
+
44,
|
|
117
|
+
45,
|
|
118
|
+
46,
|
|
119
|
+
47,
|
|
120
|
+
48,
|
|
121
|
+
49,
|
|
122
|
+
50,
|
|
123
|
+
51,
|
|
124
|
+
52,
|
|
125
|
+
53,
|
|
126
|
+
54,
|
|
127
|
+
55,
|
|
128
|
+
56,
|
|
129
|
+
57,
|
|
130
|
+
58,
|
|
131
|
+
59,
|
|
132
|
+
60,
|
|
133
|
+
61,
|
|
134
|
+
62,
|
|
135
|
+
63,
|
|
136
|
+
64,
|
|
137
|
+
65,
|
|
138
|
+
66,
|
|
139
|
+
67,
|
|
140
|
+
68,
|
|
141
|
+
69,
|
|
142
|
+
70,
|
|
143
|
+
71,
|
|
144
|
+
72,
|
|
145
|
+
73,
|
|
146
|
+
74,
|
|
147
|
+
75,
|
|
148
|
+
76,
|
|
149
|
+
77,
|
|
150
|
+
78,
|
|
151
|
+
79,
|
|
152
|
+
80,
|
|
153
|
+
81,
|
|
154
|
+
82,
|
|
155
|
+
83,
|
|
156
|
+
84,
|
|
157
|
+
85,
|
|
158
|
+
86,
|
|
159
|
+
87,
|
|
160
|
+
88,
|
|
161
|
+
89,
|
|
162
|
+
90,
|
|
163
|
+
91,
|
|
164
|
+
92,
|
|
165
|
+
93,
|
|
166
|
+
94,
|
|
167
|
+
95,
|
|
168
|
+
96,
|
|
169
|
+
97,
|
|
170
|
+
98,
|
|
171
|
+
99,
|
|
172
|
+
100,
|
|
173
|
+
101,
|
|
174
|
+
102,
|
|
175
|
+
103,
|
|
176
|
+
104,
|
|
177
|
+
105,
|
|
178
|
+
106,
|
|
179
|
+
107,
|
|
180
|
+
108,
|
|
181
|
+
109,
|
|
182
|
+
110,
|
|
183
|
+
111,
|
|
184
|
+
112,
|
|
185
|
+
113,
|
|
186
|
+
114,
|
|
187
|
+
115,
|
|
188
|
+
116,
|
|
189
|
+
117,
|
|
190
|
+
118,
|
|
191
|
+
119,
|
|
192
|
+
120,
|
|
193
|
+
121,
|
|
194
|
+
122,
|
|
195
|
+
123,
|
|
196
|
+
124,
|
|
197
|
+
125,
|
|
198
|
+
126,
|
|
199
|
+
8962,
|
|
200
|
+
199,
|
|
201
|
+
252,
|
|
202
|
+
233,
|
|
203
|
+
226,
|
|
204
|
+
228,
|
|
205
|
+
224,
|
|
206
|
+
229,
|
|
207
|
+
231,
|
|
208
|
+
234,
|
|
209
|
+
235,
|
|
210
|
+
232,
|
|
211
|
+
239,
|
|
212
|
+
238,
|
|
213
|
+
236,
|
|
214
|
+
196,
|
|
215
|
+
197,
|
|
216
|
+
201,
|
|
217
|
+
230,
|
|
218
|
+
198,
|
|
219
|
+
244,
|
|
220
|
+
246,
|
|
221
|
+
242,
|
|
222
|
+
251,
|
|
223
|
+
249,
|
|
224
|
+
255,
|
|
225
|
+
214,
|
|
226
|
+
220,
|
|
227
|
+
162,
|
|
228
|
+
163,
|
|
229
|
+
165,
|
|
230
|
+
8359,
|
|
231
|
+
402,
|
|
232
|
+
225,
|
|
233
|
+
237,
|
|
234
|
+
243,
|
|
235
|
+
250,
|
|
236
|
+
241,
|
|
237
|
+
209,
|
|
238
|
+
170,
|
|
239
|
+
186,
|
|
240
|
+
191,
|
|
241
|
+
8976,
|
|
242
|
+
172,
|
|
243
|
+
189,
|
|
244
|
+
188,
|
|
245
|
+
161,
|
|
246
|
+
171,
|
|
247
|
+
187,
|
|
248
|
+
9617,
|
|
249
|
+
9618,
|
|
250
|
+
9619,
|
|
251
|
+
9474,
|
|
252
|
+
9508,
|
|
253
|
+
9569,
|
|
254
|
+
9570,
|
|
255
|
+
9558,
|
|
256
|
+
9557,
|
|
257
|
+
9571,
|
|
258
|
+
9553,
|
|
259
|
+
9559,
|
|
260
|
+
9565,
|
|
261
|
+
9564,
|
|
262
|
+
9563,
|
|
263
|
+
9488,
|
|
264
|
+
9492,
|
|
265
|
+
9524,
|
|
266
|
+
9516,
|
|
267
|
+
9500,
|
|
268
|
+
9472,
|
|
269
|
+
9532,
|
|
270
|
+
9566,
|
|
271
|
+
9567,
|
|
272
|
+
9562,
|
|
273
|
+
9556,
|
|
274
|
+
9577,
|
|
275
|
+
9574,
|
|
276
|
+
9568,
|
|
277
|
+
9552,
|
|
278
|
+
9580,
|
|
279
|
+
9575,
|
|
280
|
+
9576,
|
|
281
|
+
9572,
|
|
282
|
+
9573,
|
|
283
|
+
9561,
|
|
284
|
+
9560,
|
|
285
|
+
9554,
|
|
286
|
+
9555,
|
|
287
|
+
9579,
|
|
288
|
+
9578,
|
|
289
|
+
9496,
|
|
290
|
+
9484,
|
|
291
|
+
9608,
|
|
292
|
+
9604,
|
|
293
|
+
9612,
|
|
294
|
+
9616,
|
|
295
|
+
9600,
|
|
296
|
+
945,
|
|
297
|
+
223,
|
|
298
|
+
915,
|
|
299
|
+
960,
|
|
300
|
+
931,
|
|
301
|
+
963,
|
|
302
|
+
181,
|
|
303
|
+
964,
|
|
304
|
+
934,
|
|
305
|
+
920,
|
|
306
|
+
937,
|
|
307
|
+
948,
|
|
308
|
+
8734,
|
|
309
|
+
966,
|
|
310
|
+
949,
|
|
311
|
+
8745,
|
|
312
|
+
8801,
|
|
313
|
+
177,
|
|
314
|
+
8805,
|
|
315
|
+
8804,
|
|
316
|
+
8992,
|
|
317
|
+
8993,
|
|
318
|
+
247,
|
|
319
|
+
8776,
|
|
320
|
+
176,
|
|
321
|
+
8729,
|
|
322
|
+
183,
|
|
323
|
+
8730,
|
|
324
|
+
8319,
|
|
325
|
+
178,
|
|
326
|
+
9632,
|
|
327
|
+
160
|
|
328
|
+
);
|
|
329
|
+
function decodeLegacyZipName(bytes) {
|
|
330
|
+
try {
|
|
331
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
332
|
+
} catch {
|
|
333
|
+
let out = "";
|
|
334
|
+
for (const b of bytes) out += CP437[b] ?? "\uFFFD";
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
var RANGE_READ_CHUNK_BYTES = 256 * 1024;
|
|
339
|
+
async function createFileArchiveSource(zipPath) {
|
|
340
|
+
const info = await stat(zipPath);
|
|
341
|
+
return {
|
|
342
|
+
size: info.size,
|
|
343
|
+
readRange: (start, end) => readFileRange(zipPath, start, end)
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
async function listZipEntriesFromSource(source, opts = {}) {
|
|
347
|
+
const zipfile = await openZipFromSource(source);
|
|
348
|
+
const records = [];
|
|
349
|
+
try {
|
|
350
|
+
for await (const entry of zipfile.eachEntry()) {
|
|
351
|
+
if (entry.fileName.endsWith("/")) continue;
|
|
352
|
+
const dataOffset = opts.dataOffsets === false ? 0 : await resolveDataOffsetFromSource(
|
|
353
|
+
source,
|
|
354
|
+
entry.relativeOffsetOfLocalHeader
|
|
355
|
+
);
|
|
356
|
+
records.push({
|
|
357
|
+
name: entry.fileName,
|
|
358
|
+
compressionMethod: entry.compressionMethod,
|
|
359
|
+
encrypted: entry.isEncrypted(),
|
|
360
|
+
uncompressedSize: entry.uncompressedSize,
|
|
361
|
+
compressedSize: entry.compressedSize,
|
|
362
|
+
crc32: entry.crc32,
|
|
363
|
+
localHeaderOffset: entry.relativeOffsetOfLocalHeader,
|
|
364
|
+
dataOffset,
|
|
365
|
+
dataSize: entry.compressedSize,
|
|
366
|
+
modifiedAt: entry.getLastModDate().getTime()
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
} catch (err) {
|
|
370
|
+
throw invalid(
|
|
371
|
+
"resource.archive_open_failed",
|
|
372
|
+
err instanceof Error ? err.message : "could not read archive entries",
|
|
373
|
+
{ size: source.size }
|
|
374
|
+
);
|
|
375
|
+
} finally {
|
|
376
|
+
zipfile.close();
|
|
377
|
+
}
|
|
378
|
+
return records;
|
|
379
|
+
}
|
|
380
|
+
async function listZipEntries(zipPath, opts = {}) {
|
|
381
|
+
return listZipEntriesFromSource(await createFileArchiveSource(zipPath), opts);
|
|
382
|
+
}
|
|
383
|
+
function streamRange(source, start, end) {
|
|
384
|
+
return Readable.from(
|
|
385
|
+
(async function* rangeChunks() {
|
|
386
|
+
let pos = start;
|
|
387
|
+
while (pos <= end) {
|
|
388
|
+
const to = Math.min(end, pos + RANGE_READ_CHUNK_BYTES - 1);
|
|
389
|
+
const chunk = await source.readRange(pos, to);
|
|
390
|
+
if (chunk.length === 0) return;
|
|
391
|
+
yield chunk;
|
|
392
|
+
pos += chunk.length;
|
|
393
|
+
}
|
|
394
|
+
})()
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
function readZipRange(zipPath, start, end) {
|
|
398
|
+
return createReadStream(zipPath, { start, end });
|
|
399
|
+
}
|
|
400
|
+
async function readFileRange(path, start, end) {
|
|
401
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || !Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) {
|
|
402
|
+
throw invalid(
|
|
403
|
+
"resource.file_read_failed",
|
|
404
|
+
`invalid byte range ${start}..${end}`,
|
|
405
|
+
{ path, start, end }
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const length = end - start + 1;
|
|
409
|
+
if (length <= 0) return Buffer.alloc(0);
|
|
410
|
+
return buffer(readZipRange(path, start, end));
|
|
411
|
+
}
|
|
412
|
+
function openZipFromSource(source) {
|
|
413
|
+
const reader = new RangeReader(source);
|
|
414
|
+
return yauzl.fromRandomAccessReaderPromise(reader, source.size, {
|
|
415
|
+
lazyEntries: true,
|
|
416
|
+
decodeStrings: true
|
|
417
|
+
}).catch((err) => {
|
|
418
|
+
reader.close(() => {
|
|
419
|
+
});
|
|
420
|
+
throw invalid(
|
|
421
|
+
"resource.archive_open_failed",
|
|
422
|
+
err instanceof Error ? err.message : "could not open archive",
|
|
423
|
+
{ size: source.size }
|
|
424
|
+
);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
var RangeReader = class extends yauzl.RandomAccessReader {
|
|
428
|
+
// Explicit field + assignment: this package ships source to Node's
|
|
429
|
+
// type-stripping loader (no build step), which rejects parameter
|
|
430
|
+
// properties.
|
|
431
|
+
source;
|
|
432
|
+
constructor(source) {
|
|
433
|
+
super();
|
|
434
|
+
this.source = source;
|
|
435
|
+
}
|
|
436
|
+
_readStreamForRange(start, end) {
|
|
437
|
+
return streamRange(this.source, start, end - 1);
|
|
438
|
+
}
|
|
439
|
+
// yauzl's runtime contract calls `_close` even though the shipped
|
|
440
|
+
// types only expose the public `close` — no `override` here.
|
|
441
|
+
_close(callback) {
|
|
442
|
+
callback(null);
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
async function resolveDataOffsetFromSource(source, localHeaderOffset) {
|
|
446
|
+
if (localHeaderOffset + 30 > source.size) {
|
|
447
|
+
throw invalid(
|
|
448
|
+
"resource.archive_open_failed",
|
|
449
|
+
`truncated local file header at offset ${localHeaderOffset}`,
|
|
450
|
+
{ localHeaderOffset }
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
const head = await source.readRange(localHeaderOffset, localHeaderOffset + 29);
|
|
454
|
+
if (head.length < 30) {
|
|
455
|
+
throw invalid(
|
|
456
|
+
"resource.archive_open_failed",
|
|
457
|
+
`truncated local file header at offset ${localHeaderOffset}`,
|
|
458
|
+
{ localHeaderOffset }
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
const sig = head.readUInt32LE(0);
|
|
462
|
+
if (sig !== 67324752) {
|
|
463
|
+
throw invalid(
|
|
464
|
+
"resource.archive_open_failed",
|
|
465
|
+
`bad local file header signature at offset ${localHeaderOffset}`,
|
|
466
|
+
{ localHeaderOffset, sig: sig.toString(16) }
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
const nameLen = head.readUInt16LE(26);
|
|
470
|
+
const extraLen = head.readUInt16LE(28);
|
|
471
|
+
return localHeaderOffset + 30 + nameLen + extraLen;
|
|
472
|
+
}
|
|
473
|
+
function normalizeZipError(err) {
|
|
474
|
+
if (!(err instanceof Error)) return err;
|
|
475
|
+
const message = err.message;
|
|
476
|
+
if (message.startsWith("invalid relative path: ") || message.startsWith("absolute path: ") || message.startsWith("invalid characters in fileName: ")) {
|
|
477
|
+
return invalid("resource.archive_invalid_entry", message, {});
|
|
478
|
+
}
|
|
479
|
+
return err;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// src/archive/7z.ts
|
|
483
|
+
var requireCjs = createRequire(import.meta.url);
|
|
484
|
+
var SEVEN_ZIP_PATH_ENV = "7Z_BIN_PATH";
|
|
485
|
+
function resolveSevenZipPath() {
|
|
486
|
+
const fromEnv = process.env[SEVEN_ZIP_PATH_ENV];
|
|
487
|
+
if (fromEnv !== void 0 && fromEnv.length > 0) return fromEnv;
|
|
488
|
+
try {
|
|
489
|
+
const mod = requireCjs("@hoardodile/7z-bin");
|
|
490
|
+
if (typeof mod === "string" && mod.length > 0) return mod;
|
|
491
|
+
return void 0;
|
|
492
|
+
} catch {
|
|
493
|
+
return void 0;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function sevenZipUnavailable() {
|
|
497
|
+
return invalid(
|
|
498
|
+
"resource.archive_open_failed",
|
|
499
|
+
"7-Zip is not installed \u2014 install @hoardodile/7z-bin or set 7Z_BIN_PATH to enable archive extraction",
|
|
500
|
+
{}
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
function run7z(bin, args, timeoutMs) {
|
|
504
|
+
return new Promise((resolveDone, rejectDone) => {
|
|
505
|
+
const child = spawn(bin, [...args], {
|
|
506
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
507
|
+
windowsHide: true
|
|
508
|
+
});
|
|
509
|
+
const stdout = [];
|
|
510
|
+
const stderr = [];
|
|
511
|
+
let settled = false;
|
|
512
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
513
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
514
|
+
const fail = (err) => {
|
|
515
|
+
if (settled) return;
|
|
516
|
+
settled = true;
|
|
517
|
+
rejectDone(err);
|
|
518
|
+
};
|
|
519
|
+
const timer = setTimeout(() => {
|
|
520
|
+
child.kill();
|
|
521
|
+
fail(
|
|
522
|
+
invalid(
|
|
523
|
+
"resource.archive_open_failed",
|
|
524
|
+
`7-Zip timed out after ${timeoutMs}ms`,
|
|
525
|
+
{ timeoutMs }
|
|
526
|
+
)
|
|
527
|
+
);
|
|
528
|
+
}, timeoutMs);
|
|
529
|
+
timer.unref();
|
|
530
|
+
child.on("error", (err) => {
|
|
531
|
+
fail(
|
|
532
|
+
invalid(
|
|
533
|
+
"resource.archive_open_failed",
|
|
534
|
+
`could not start 7-Zip: ${err.message}`,
|
|
535
|
+
{}
|
|
536
|
+
)
|
|
537
|
+
);
|
|
538
|
+
});
|
|
539
|
+
child.on("close", (code, signal) => {
|
|
540
|
+
clearTimeout(timer);
|
|
541
|
+
if (settled) return;
|
|
542
|
+
if (code === 0) {
|
|
543
|
+
settled = true;
|
|
544
|
+
resolveDone({ stdout: Buffer.concat(stdout) });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const detail = signal !== null ? `killed by ${signal}` : `exit ${code ?? "?"}`;
|
|
548
|
+
const tail = Buffer.concat(stderr).toString("utf8").trim().split("\n").at(-1);
|
|
549
|
+
fail(
|
|
550
|
+
invalid(
|
|
551
|
+
"resource.archive_open_failed",
|
|
552
|
+
`7-Zip ${detail}${tail !== void 0 && tail.length > 0 ? `: ${tail}` : ""}`,
|
|
553
|
+
{ code }
|
|
554
|
+
)
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
function isEncryptedField(value) {
|
|
560
|
+
return value === "+";
|
|
561
|
+
}
|
|
562
|
+
function parseSltListing(stdout, legacyNames) {
|
|
563
|
+
const entries = [];
|
|
564
|
+
let name;
|
|
565
|
+
let sizeBytes = 0;
|
|
566
|
+
let folder = false;
|
|
567
|
+
let encrypted = false;
|
|
568
|
+
for (const line of stdout.toString("latin1").split(/\r?\n/)) {
|
|
569
|
+
if (line.length === 0) {
|
|
570
|
+
if (name !== void 0) {
|
|
571
|
+
entries.push({ name, sizeBytes, folder, encrypted });
|
|
572
|
+
name = void 0;
|
|
573
|
+
}
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const sep3 = line.indexOf(" = ");
|
|
577
|
+
if (sep3 < 0) continue;
|
|
578
|
+
const key = line.slice(0, sep3);
|
|
579
|
+
if (key === "Path") {
|
|
580
|
+
const raw = Buffer.from(line.slice(sep3 + 3), "latin1");
|
|
581
|
+
const decoded = legacyNames ? decodeLegacyZipName(raw) : raw.toString("utf8");
|
|
582
|
+
name = decoded.replace(/\\/g, "/");
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
const value = line.slice(sep3 + 3);
|
|
586
|
+
switch (key) {
|
|
587
|
+
case "Size":
|
|
588
|
+
sizeBytes = Number.parseInt(value, 10) || 0;
|
|
589
|
+
break;
|
|
590
|
+
case "Attributes":
|
|
591
|
+
folder = value.includes("D");
|
|
592
|
+
break;
|
|
593
|
+
case "Encrypted":
|
|
594
|
+
encrypted = isEncryptedField(value);
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (name !== void 0) {
|
|
599
|
+
entries.push({ name, sizeBytes, folder, encrypted });
|
|
600
|
+
}
|
|
601
|
+
return entries;
|
|
602
|
+
}
|
|
603
|
+
async function isZipArchive(archivePath) {
|
|
604
|
+
const head = await readFileRange(
|
|
605
|
+
archivePath,
|
|
606
|
+
0,
|
|
607
|
+
SNIFF_WINDOW_BYTES - 1
|
|
608
|
+
).catch(() => void 0);
|
|
609
|
+
if (head === void 0 || head.length === 0) return false;
|
|
610
|
+
return sniffContainerFormat(head) === "zip";
|
|
611
|
+
}
|
|
612
|
+
async function listSevenZipEntries(archivePath, opts = {}) {
|
|
613
|
+
const bin = resolveSevenZipPath();
|
|
614
|
+
if (bin === void 0) throw sevenZipUnavailable();
|
|
615
|
+
const args = ["l", "-slt", "-ba", "-sccUTF-8"];
|
|
616
|
+
const legacyNames = await isZipArchive(archivePath);
|
|
617
|
+
args.push(archivePath);
|
|
618
|
+
const { stdout } = await run7z(bin, args, opts.timeoutMs ?? 12e4);
|
|
619
|
+
return parseSltListing(stdout, legacyNames);
|
|
620
|
+
}
|
|
621
|
+
async function extractSevenZipInto(archivePath, destDir, opts = {}) {
|
|
622
|
+
const bin = resolveSevenZipPath();
|
|
623
|
+
if (bin === void 0) throw sevenZipUnavailable();
|
|
624
|
+
const args = ["x", archivePath, `-o${destDir}`, "-y", "-bd"];
|
|
625
|
+
await run7z(bin, args, opts.timeoutMs ?? 10 * 6e4);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// src/archive/listing.ts
|
|
629
|
+
async function listArchiveEntries(archivePath) {
|
|
630
|
+
const head = await readFileRange(archivePath, 0, SNIFF_WINDOW_BYTES - 1);
|
|
631
|
+
const format = sniffContainerFormat(head);
|
|
632
|
+
if (format === "zip") {
|
|
633
|
+
const records = await listZipEntries(archivePath);
|
|
634
|
+
return records.map((r) => ({ name: r.name, sizeBytes: r.uncompressedSize }));
|
|
635
|
+
}
|
|
636
|
+
if (format === void 0) {
|
|
637
|
+
throw invalid(
|
|
638
|
+
"resource.archive_open_failed",
|
|
639
|
+
"not a supported archive (zip/tar/7z/rar/xz/gzip)",
|
|
640
|
+
{}
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
if (resolveSevenZipPath() === void 0) {
|
|
644
|
+
throw invalid(
|
|
645
|
+
"resource.archive_open_failed",
|
|
646
|
+
"7-Zip is not installed \u2014 cannot list tar/7z/rar/xz/gzip archives",
|
|
647
|
+
{}
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const entries = await listSevenZipEntries(archivePath);
|
|
651
|
+
return entries.filter((e) => !e.folder).map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }));
|
|
652
|
+
}
|
|
653
|
+
function listingBudgetExceeded(entries, opts) {
|
|
654
|
+
if (opts.maxEntries !== void 0 && entries.length > opts.maxEntries) {
|
|
655
|
+
return {
|
|
656
|
+
kind: "entries",
|
|
657
|
+
count: entries.length,
|
|
658
|
+
maxEntries: opts.maxEntries
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
let totalBytes = 0;
|
|
662
|
+
for (const entry of entries) {
|
|
663
|
+
totalBytes += entry.sizeBytes;
|
|
664
|
+
if (totalBytes > opts.maxBytes) {
|
|
665
|
+
return {
|
|
666
|
+
kind: "bytes",
|
|
667
|
+
totalBytes,
|
|
668
|
+
maxBytes: opts.maxBytes
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return void 0;
|
|
673
|
+
}
|
|
674
|
+
async function validateArchiveBudget(archivePath, maxBytes) {
|
|
675
|
+
const entries = await listArchiveEntries(archivePath);
|
|
676
|
+
const exceeded = listingBudgetExceeded(entries, { maxBytes });
|
|
677
|
+
if (exceeded?.kind === "bytes") {
|
|
678
|
+
throw invalid(
|
|
679
|
+
"resource.archive_too_large",
|
|
680
|
+
`archive uncompressed size exceeds ${maxBytes} bytes`,
|
|
681
|
+
{ maxBytes }
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// src/archive/extract.ts
|
|
687
|
+
async function extractArchiveInto(source, destDir, opts) {
|
|
688
|
+
const buffer2 = await readToBuffer(source);
|
|
689
|
+
const format = sniffContainerFormat(buffer2.subarray(0, SNIFF_WINDOW_BYTES));
|
|
690
|
+
if (format === void 0) {
|
|
691
|
+
throw invalid(
|
|
692
|
+
"resource.archive_open_failed",
|
|
693
|
+
"not a supported archive (zip/tar/7z/rar/xz/gzip)",
|
|
694
|
+
{}
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
if (format === "zip" && resolveSevenZipPath() === void 0) {
|
|
698
|
+
return extractZipBuffer(buffer2, destDir, opts);
|
|
699
|
+
}
|
|
700
|
+
return extractViaSevenZip(buffer2, destDir, opts, format);
|
|
701
|
+
}
|
|
702
|
+
async function extractZipBuffer(buffer2, destDir, opts) {
|
|
703
|
+
const { maxBytes, maxEntries, onProgress } = opts;
|
|
704
|
+
const zipfile = await openZipFromBuffer(buffer2);
|
|
705
|
+
if (maxEntries !== void 0 && zipfile.entryCount > maxEntries) {
|
|
706
|
+
zipfile.close();
|
|
707
|
+
throw invalid(
|
|
708
|
+
"resource.archive_too_large",
|
|
709
|
+
`archive has ${zipfile.entryCount} entries, exceeding the limit of ${maxEntries}`,
|
|
710
|
+
{ maxEntries }
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
const root = resolve(destDir);
|
|
714
|
+
const budget = { remaining: maxBytes, max: maxBytes };
|
|
715
|
+
const totalEntries = zipfile.entryCount;
|
|
716
|
+
const counters = { entriesDone: 0, bytesDone: 0, totalBytes: 0 };
|
|
717
|
+
if (onProgress !== void 0) {
|
|
718
|
+
onProgress({ phase: "started", totalEntries, totalBytes: 0 });
|
|
719
|
+
}
|
|
720
|
+
try {
|
|
721
|
+
await new Promise((resolveDone, rejectDone) => {
|
|
722
|
+
zipfile.on("error", (err) => rejectDone(normalizeZipError(err)));
|
|
723
|
+
zipfile.on("end", resolveDone);
|
|
724
|
+
zipfile.on("entry", (entry) => {
|
|
725
|
+
handleExtractEntry(zipfile, entry, root, budget).then(() => {
|
|
726
|
+
counters.entriesDone += 1;
|
|
727
|
+
counters.bytesDone += entry.uncompressedSize;
|
|
728
|
+
counters.totalBytes += entry.uncompressedSize;
|
|
729
|
+
if (onProgress !== void 0) {
|
|
730
|
+
onProgress({
|
|
731
|
+
phase: "entry",
|
|
732
|
+
entriesDone: counters.entriesDone,
|
|
733
|
+
totalEntries,
|
|
734
|
+
bytesDone: counters.bytesDone,
|
|
735
|
+
totalBytes: counters.totalBytes
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
zipfile.readEntry();
|
|
739
|
+
}, rejectDone);
|
|
740
|
+
});
|
|
741
|
+
zipfile.readEntry();
|
|
742
|
+
});
|
|
743
|
+
} finally {
|
|
744
|
+
zipfile.close();
|
|
745
|
+
}
|
|
746
|
+
if (onProgress !== void 0) {
|
|
747
|
+
onProgress({ phase: "done" });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
async function extractViaSevenZip(buffer2, destDir, opts, format) {
|
|
751
|
+
const { maxBytes, maxEntries, onProgress } = opts;
|
|
752
|
+
const tempPath = join(
|
|
753
|
+
tmpdir(),
|
|
754
|
+
`hoardodile-7z-${process.pid}-${randomUUID().slice(0, 8)}`
|
|
755
|
+
);
|
|
756
|
+
await writeFile(tempPath, buffer2);
|
|
757
|
+
try {
|
|
758
|
+
const entries = await listSevenZipEntries(tempPath);
|
|
759
|
+
const files = entries.filter((e) => !e.folder);
|
|
760
|
+
const exceeded = listingBudgetExceeded(files, { maxBytes, maxEntries });
|
|
761
|
+
if (exceeded?.kind === "bytes") {
|
|
762
|
+
throw invalid(
|
|
763
|
+
"resource.archive_too_large",
|
|
764
|
+
`archive extracts to more than ${maxBytes} bytes`,
|
|
765
|
+
{ maxExtractedBytes: maxBytes }
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
if (exceeded?.kind === "entries") {
|
|
769
|
+
throw invalid(
|
|
770
|
+
"resource.archive_too_large",
|
|
771
|
+
`archive has ${files.length} entries, exceeding the limit of ${maxEntries}`,
|
|
772
|
+
{ maxEntries }
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
if (entries.some((e) => e.encrypted)) {
|
|
776
|
+
throw invalid(
|
|
777
|
+
"resource.archive_invalid_entry",
|
|
778
|
+
"archive is password-protected \u2014 encrypted archives are not supported",
|
|
779
|
+
{}
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
for (const entry of files) assertSafeEntryPath(entry.name);
|
|
783
|
+
const root = resolve(destDir);
|
|
784
|
+
await mkdir(root, { recursive: true });
|
|
785
|
+
const totalBytes = files.reduce((acc, e) => acc + e.sizeBytes, 0);
|
|
786
|
+
onProgress?.({ phase: "started", totalEntries: files.length, totalBytes });
|
|
787
|
+
await extractSevenZipInto(tempPath, root);
|
|
788
|
+
const decoded = format === "zip" ? await decodeZipNames(
|
|
789
|
+
tempPath,
|
|
790
|
+
files.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))
|
|
791
|
+
) : void 0;
|
|
792
|
+
await normalizeExtractedTree(root, {
|
|
793
|
+
legacyZipNames: format === "zip",
|
|
794
|
+
expectedNames: decoded?.paths
|
|
795
|
+
});
|
|
796
|
+
await assertExtractedTree(root, maxBytes);
|
|
797
|
+
onProgress?.({ phase: "done" });
|
|
798
|
+
} finally {
|
|
799
|
+
await rm(tempPath, { force: true }).catch(() => {
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
async function assertExtractedTree(root, maxBytes) {
|
|
804
|
+
let total = 0;
|
|
805
|
+
async function walk(dir) {
|
|
806
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
807
|
+
for (const entry of entries) {
|
|
808
|
+
const full = join(dir, entry.name);
|
|
809
|
+
const info = await lstat(full);
|
|
810
|
+
if (info.isSymbolicLink()) {
|
|
811
|
+
throw invalid(
|
|
812
|
+
"resource.archive_invalid_entry",
|
|
813
|
+
`extracted entry is a symlink: ${entry.name}`,
|
|
814
|
+
{ name: entry.name }
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
if (info.isDirectory()) {
|
|
818
|
+
await walk(full);
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (!info.isFile()) {
|
|
822
|
+
throw invalid(
|
|
823
|
+
"resource.archive_invalid_entry",
|
|
824
|
+
`extracted entry is not a regular file: ${entry.name}`,
|
|
825
|
+
{ name: entry.name }
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
total += info.size;
|
|
829
|
+
if (total > maxBytes) {
|
|
830
|
+
throw invalid(
|
|
831
|
+
"resource.archive_too_large",
|
|
832
|
+
`archive extracts to more than ${maxBytes} bytes`,
|
|
833
|
+
{ maxExtractedBytes: maxBytes }
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
await walk(root);
|
|
839
|
+
}
|
|
840
|
+
async function normalizeExtractedTree(root, opts) {
|
|
841
|
+
if (opts.legacyZipNames) {
|
|
842
|
+
const expected = opts.expectedNames ? new Set(opts.expectedNames.map((n) => n.replace(/\/+$/, ""))) : void 0;
|
|
843
|
+
await renameLegacyZipNames(Buffer.from(root), root, root, expected);
|
|
844
|
+
}
|
|
845
|
+
await makeReadable(root);
|
|
846
|
+
}
|
|
847
|
+
async function decodeZipNames(zipPath, fallback) {
|
|
848
|
+
try {
|
|
849
|
+
const records = await listZipEntries(zipPath, { dataOffsets: false });
|
|
850
|
+
const files = records.map((e) => ({
|
|
851
|
+
name: e.name,
|
|
852
|
+
sizeBytes: e.uncompressedSize
|
|
853
|
+
}));
|
|
854
|
+
const paths = /* @__PURE__ */ new Set();
|
|
855
|
+
for (const file of files) {
|
|
856
|
+
const segments = file.name.split("/");
|
|
857
|
+
for (let i = 1; i < segments.length; i++) {
|
|
858
|
+
paths.add(segments.slice(0, i).join("/"));
|
|
859
|
+
}
|
|
860
|
+
paths.add(file.name);
|
|
861
|
+
}
|
|
862
|
+
return { files, paths: [...paths] };
|
|
863
|
+
} catch {
|
|
864
|
+
return { files: fallback, paths: fallback.map((e) => e.name) };
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async function renameLegacyZipNames(rawRoot, decodedRoot, root, expected) {
|
|
868
|
+
const entries = await readdir(rawRoot, {
|
|
869
|
+
withFileTypes: true,
|
|
870
|
+
encoding: "buffer"
|
|
871
|
+
});
|
|
872
|
+
for (const entry of entries) {
|
|
873
|
+
const childRaw = Buffer.concat([rawRoot, Buffer.from(sep), entry.name]);
|
|
874
|
+
const decoded = decodeLegacyZipName(entry.name);
|
|
875
|
+
const target = legacyRenameTarget(
|
|
876
|
+
entry.name,
|
|
877
|
+
decoded,
|
|
878
|
+
decodedRoot,
|
|
879
|
+
root,
|
|
880
|
+
expected
|
|
881
|
+
);
|
|
882
|
+
if (entry.isDirectory()) {
|
|
883
|
+
if (target !== void 0 && !existsSync(target)) {
|
|
884
|
+
await rename(childRaw, target);
|
|
885
|
+
await renameLegacyZipNames(Buffer.from(target), target, root, expected);
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
await renameLegacyZipNames(
|
|
889
|
+
childRaw,
|
|
890
|
+
target ?? `${decodedRoot}/${decoded}`,
|
|
891
|
+
root,
|
|
892
|
+
expected
|
|
893
|
+
);
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
if (target === void 0 || existsSync(target)) continue;
|
|
897
|
+
await rename(childRaw, target);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
function legacyRenameTarget(rawName, decoded, decodedRoot, root, expected) {
|
|
901
|
+
const utf8Name = rawName.toString("utf8");
|
|
902
|
+
if (decoded !== utf8Name) {
|
|
903
|
+
return `${decodedRoot}/${decoded}`;
|
|
904
|
+
}
|
|
905
|
+
const raw = recoverRawLegacyName(rawName);
|
|
906
|
+
if (raw === void 0) return void 0;
|
|
907
|
+
const recovered = decodeLegacyZipName(raw);
|
|
908
|
+
if (recovered === utf8Name || expected === void 0) return void 0;
|
|
909
|
+
const rel = decodedRoot === root ? recovered : `${decodedRoot.slice(root.length + 1)}/${recovered}`;
|
|
910
|
+
return expected.has(rel) ? `${decodedRoot}/${recovered}` : void 0;
|
|
911
|
+
}
|
|
912
|
+
function recoverRawLegacyName(name) {
|
|
913
|
+
const escaped = unescapePercentEscapes(name);
|
|
914
|
+
if (escaped !== void 0) return escaped;
|
|
915
|
+
const text = name.toString("utf8");
|
|
916
|
+
const out = [];
|
|
917
|
+
for (const ch of text) {
|
|
918
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
919
|
+
if (code >= 61312 && code <= 61439) {
|
|
920
|
+
out.push(code - 61184);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if (code > 127) return void 0;
|
|
924
|
+
out.push(code);
|
|
925
|
+
}
|
|
926
|
+
return out.length === text.length ? Buffer.from(out) : void 0;
|
|
927
|
+
}
|
|
928
|
+
function unescapePercentEscapes(name) {
|
|
929
|
+
const text = name.toString("latin1");
|
|
930
|
+
if (!text.includes("%")) return void 0;
|
|
931
|
+
const out = [];
|
|
932
|
+
let changed = false;
|
|
933
|
+
for (let i = 0; i < text.length; i++) {
|
|
934
|
+
const hex = i + 2 < text.length ? text.slice(i + 1, i + 3) : void 0;
|
|
935
|
+
if (text[i] === "%" && hex !== void 0 && /^[0-9a-fA-F]{2}$/.test(hex)) {
|
|
936
|
+
out.push(Number.parseInt(hex, 16));
|
|
937
|
+
changed = true;
|
|
938
|
+
i += 2;
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
out.push(text.charCodeAt(i));
|
|
942
|
+
}
|
|
943
|
+
return changed ? Buffer.from(out) : void 0;
|
|
944
|
+
}
|
|
945
|
+
async function makeReadable(root) {
|
|
946
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
947
|
+
for (const entry of entries) {
|
|
948
|
+
const full = join(root, entry.name);
|
|
949
|
+
if (entry.isDirectory()) {
|
|
950
|
+
await chmod(full, (await lstat(full)).mode | 448);
|
|
951
|
+
await makeReadable(full);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (entry.isFile()) {
|
|
955
|
+
await chmod(full, (await lstat(full)).mode | 384);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
function assertSafeEntryPath(rawName) {
|
|
960
|
+
if (rawName.length === 0 || /^([a-zA-Z]:)?[\\/]/.test(rawName) || rawName.split("/").includes("..")) {
|
|
961
|
+
throw invalid(
|
|
962
|
+
"resource.archive_invalid_entry",
|
|
963
|
+
`archive entry has an unsafe path: ${rawName}`,
|
|
964
|
+
{ rawName }
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
async function handleExtractEntry(zipfile, entry, root, budget) {
|
|
969
|
+
const safe = safeExtractEntryPath(entry.fileName, root);
|
|
970
|
+
if (entry.fileName.endsWith("/")) {
|
|
971
|
+
await mkdir(safe, { recursive: true });
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
await mkdir(dirname(safe), { recursive: true });
|
|
975
|
+
const stream = await openZipEntryStream(zipfile, entry);
|
|
976
|
+
await pipeline(stream, makeByteBudgetCounter(budget), createWriteStream(safe));
|
|
977
|
+
}
|
|
978
|
+
function safeExtractEntryPath(rawName, root) {
|
|
979
|
+
if (rawName.length === 0) {
|
|
980
|
+
throw invalid(
|
|
981
|
+
"resource.archive_invalid_entry",
|
|
982
|
+
"archive entry has empty name",
|
|
983
|
+
{ rawName }
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
if (/^([a-zA-Z]:)?[\\/]/.test(rawName)) {
|
|
987
|
+
throw invalid(
|
|
988
|
+
"resource.archive_invalid_entry",
|
|
989
|
+
`archive entry has absolute path: ${rawName}`,
|
|
990
|
+
{ rawName }
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
const normalised = normalize(rawName).replace(/\\/g, "/");
|
|
994
|
+
if (normalised.startsWith("../") || normalised === "..") {
|
|
995
|
+
throw invalid(
|
|
996
|
+
"resource.archive_invalid_entry",
|
|
997
|
+
`archive entry escapes destination: ${rawName}`,
|
|
998
|
+
{ rawName }
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
const candidate = resolve(root, normalised);
|
|
1002
|
+
if (candidate !== root && !candidate.startsWith(root + sep)) {
|
|
1003
|
+
throw invalid(
|
|
1004
|
+
"resource.archive_invalid_entry",
|
|
1005
|
+
`archive entry escapes destination: ${rawName}`,
|
|
1006
|
+
{ rawName }
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
return candidate;
|
|
1010
|
+
}
|
|
1011
|
+
function makeByteBudgetCounter(budget) {
|
|
1012
|
+
return new Transform({
|
|
1013
|
+
transform(chunk, _enc, cb) {
|
|
1014
|
+
const len = Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
|
|
1015
|
+
budget.remaining -= len;
|
|
1016
|
+
if (budget.remaining < 0) {
|
|
1017
|
+
cb(
|
|
1018
|
+
invalid(
|
|
1019
|
+
"resource.archive_too_large",
|
|
1020
|
+
`archive extracts to more than ${budget.max} bytes`,
|
|
1021
|
+
{ maxExtractedBytes: budget.max }
|
|
1022
|
+
)
|
|
1023
|
+
);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
cb(void 0, chunk);
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
async function readToBuffer(source) {
|
|
1031
|
+
const chunks = [];
|
|
1032
|
+
for await (const chunk of source) {
|
|
1033
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1034
|
+
}
|
|
1035
|
+
return Buffer.concat(chunks);
|
|
1036
|
+
}
|
|
1037
|
+
function openZipFromBuffer(buffer2) {
|
|
1038
|
+
return new Promise((res, rej) => {
|
|
1039
|
+
yauzl.fromBuffer(
|
|
1040
|
+
buffer2,
|
|
1041
|
+
{ lazyEntries: true },
|
|
1042
|
+
(err, zip) => {
|
|
1043
|
+
if (err !== null || zip === void 0) {
|
|
1044
|
+
rej(
|
|
1045
|
+
invalid(
|
|
1046
|
+
"resource.archive_open_failed",
|
|
1047
|
+
err?.message ?? "could not open archive",
|
|
1048
|
+
{}
|
|
1049
|
+
)
|
|
1050
|
+
);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
res(zip);
|
|
1054
|
+
}
|
|
1055
|
+
);
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
function openZipEntryStream(zipfile, entry) {
|
|
1059
|
+
return new Promise((res, rej) => {
|
|
1060
|
+
zipfile.openReadStream(entry, (err, stream) => {
|
|
1061
|
+
if (err !== null && err !== void 0 || stream === void 0) {
|
|
1062
|
+
rej(
|
|
1063
|
+
invalid(
|
|
1064
|
+
"resource.archive_entry_unreadable",
|
|
1065
|
+
err?.message ?? `could not read archive entry: ${entry.fileName}`,
|
|
1066
|
+
{ entry: entry.fileName }
|
|
1067
|
+
)
|
|
1068
|
+
);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
res(stream);
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
function streamStoredZip(entries) {
|
|
1076
|
+
const zip = new yazl.ZipFile();
|
|
1077
|
+
for (const entry of entries) {
|
|
1078
|
+
if (entry.size === 0) {
|
|
1079
|
+
zip.addBuffer(Buffer.alloc(0), entry.name, { compress: false });
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
zip.addReadStream(entry.openStream(), entry.name, {
|
|
1083
|
+
compress: false,
|
|
1084
|
+
size: entry.size
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
zip.end();
|
|
1088
|
+
return zip.outputStream;
|
|
1089
|
+
}
|
|
1090
|
+
async function sumDirSizes(root, opts = {}) {
|
|
1091
|
+
let total = 0;
|
|
1092
|
+
const stack = [root];
|
|
1093
|
+
while (stack.length > 0) {
|
|
1094
|
+
const dir = stack.pop();
|
|
1095
|
+
if (dir === void 0) continue;
|
|
1096
|
+
let entries;
|
|
1097
|
+
try {
|
|
1098
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1099
|
+
} catch {
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
for (const entry of entries) {
|
|
1103
|
+
if (entry.isSymbolicLink()) continue;
|
|
1104
|
+
const full = join(dir, entry.name);
|
|
1105
|
+
if (entry.isDirectory()) {
|
|
1106
|
+
stack.push(full);
|
|
1107
|
+
} else if (entry.isFile()) {
|
|
1108
|
+
if (opts.excludeDotPrefix === true && entry.name.startsWith(".")) {
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
const info = await stat(full);
|
|
1113
|
+
total += info.size;
|
|
1114
|
+
} catch {
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return total;
|
|
1120
|
+
}
|
|
1121
|
+
var ORDER_MANIFEST_NAME = ".order";
|
|
1122
|
+
function orderManifestPath(dataDir) {
|
|
1123
|
+
return join(dataDir, ORDER_MANIFEST_NAME);
|
|
1124
|
+
}
|
|
1125
|
+
async function writeOrderManifest(dataDir, names) {
|
|
1126
|
+
const dest = orderManifestPath(dataDir);
|
|
1127
|
+
const tmp = `${dest}.writing-${process.pid}-${Date.now()}`;
|
|
1128
|
+
try {
|
|
1129
|
+
await writeFile(tmp, JSON.stringify(names), "utf8");
|
|
1130
|
+
await rename(tmp, dest);
|
|
1131
|
+
} catch (err) {
|
|
1132
|
+
await rm(tmp, { force: true }).catch(() => {
|
|
1133
|
+
});
|
|
1134
|
+
throw err;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
async function readOrderManifest(dataDir) {
|
|
1138
|
+
let raw;
|
|
1139
|
+
try {
|
|
1140
|
+
raw = await readFile(orderManifestPath(dataDir), "utf8");
|
|
1141
|
+
} catch {
|
|
1142
|
+
return void 0;
|
|
1143
|
+
}
|
|
1144
|
+
return parseOrderManifest(raw);
|
|
1145
|
+
}
|
|
1146
|
+
function parseOrderManifest(raw) {
|
|
1147
|
+
let parsed;
|
|
1148
|
+
try {
|
|
1149
|
+
parsed = JSON.parse(raw);
|
|
1150
|
+
} catch {
|
|
1151
|
+
return void 0;
|
|
1152
|
+
}
|
|
1153
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return void 0;
|
|
1154
|
+
const names = [];
|
|
1155
|
+
for (const entry of parsed) {
|
|
1156
|
+
if (typeof entry !== "string") return void 0;
|
|
1157
|
+
if (!isManifestPathSafe(entry)) return void 0;
|
|
1158
|
+
names.push(entry);
|
|
1159
|
+
}
|
|
1160
|
+
if (names.length === 0) return void 0;
|
|
1161
|
+
return names;
|
|
1162
|
+
}
|
|
1163
|
+
function orderEntries(manifest, listed) {
|
|
1164
|
+
const available = new Set(listed);
|
|
1165
|
+
const uniqueManifest = /* @__PURE__ */ new Set();
|
|
1166
|
+
for (const name of manifest) {
|
|
1167
|
+
if (uniqueManifest.has(name)) return void 0;
|
|
1168
|
+
uniqueManifest.add(name);
|
|
1169
|
+
if (!available.has(name)) return void 0;
|
|
1170
|
+
}
|
|
1171
|
+
const ordered = manifest.filter((name) => available.has(name));
|
|
1172
|
+
const rest = naturalSort(listed.filter((name) => !uniqueManifest.has(name)));
|
|
1173
|
+
return [...ordered, ...rest];
|
|
1174
|
+
}
|
|
1175
|
+
function naturalSort(names) {
|
|
1176
|
+
return [...names].sort(
|
|
1177
|
+
(a, b) => a.localeCompare(b, void 0, { sensitivity: "base", numeric: true })
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
function isManifestPathSafe(entry) {
|
|
1181
|
+
if (entry.length === 0 || entry.includes("\0") || entry.includes("\\")) {
|
|
1182
|
+
return false;
|
|
1183
|
+
}
|
|
1184
|
+
const segments = entry.split("/");
|
|
1185
|
+
for (const segment of segments) {
|
|
1186
|
+
if (segment.length === 0 || segment === "." || segment === "..") {
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return true;
|
|
1191
|
+
}
|
|
1192
|
+
var STATE_FILENAME = "version-state.json";
|
|
1193
|
+
function versionsRoot(root) {
|
|
1194
|
+
return resolve(root, "versions");
|
|
1195
|
+
}
|
|
1196
|
+
function stateFile(root) {
|
|
1197
|
+
return resolve(root, "local", STATE_FILENAME);
|
|
1198
|
+
}
|
|
1199
|
+
function listVersions(root) {
|
|
1200
|
+
const dir = versionsRoot(root);
|
|
1201
|
+
if (!existsSync(dir)) return [];
|
|
1202
|
+
const names = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
1203
|
+
const nums = [];
|
|
1204
|
+
for (const n of names) {
|
|
1205
|
+
if (!/^[1-9][0-9]*$/.test(n)) continue;
|
|
1206
|
+
nums.push(Number.parseInt(n, 10));
|
|
1207
|
+
}
|
|
1208
|
+
nums.sort((a, b) => a - b);
|
|
1209
|
+
return nums;
|
|
1210
|
+
}
|
|
1211
|
+
function currentVersion(root) {
|
|
1212
|
+
const all = listVersions(root);
|
|
1213
|
+
return all.length === 0 ? 0 : all[all.length - 1] ?? 0;
|
|
1214
|
+
}
|
|
1215
|
+
function readActiveVersion(root) {
|
|
1216
|
+
const cur = currentVersion(root);
|
|
1217
|
+
const file = stateFile(root);
|
|
1218
|
+
if (!existsSync(file)) return cur;
|
|
1219
|
+
try {
|
|
1220
|
+
const parsed = JSON.parse(
|
|
1221
|
+
readFileSync(file, "utf8")
|
|
1222
|
+
);
|
|
1223
|
+
const active = typeof parsed.active === "number" ? parsed.active : cur;
|
|
1224
|
+
const all = listVersions(root);
|
|
1225
|
+
if (all.includes(active)) return active;
|
|
1226
|
+
return cur;
|
|
1227
|
+
} catch {
|
|
1228
|
+
return cur;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
function writeActiveVersion(root, version) {
|
|
1232
|
+
const all = listVersions(root);
|
|
1233
|
+
if (!all.includes(version)) {
|
|
1234
|
+
throw notFound("version.not_found", `version ${version} does not exist`, {
|
|
1235
|
+
version
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
const file = stateFile(root);
|
|
1239
|
+
mkdirSync(resolve(root, "local"), { recursive: true });
|
|
1240
|
+
const payload = { active: version };
|
|
1241
|
+
writeFileSync(file, JSON.stringify(payload), "utf8");
|
|
1242
|
+
}
|
|
1243
|
+
function ensureBootstrapVersion(root) {
|
|
1244
|
+
const cur = currentVersion(root);
|
|
1245
|
+
if (cur > 0) return cur;
|
|
1246
|
+
const v1 = resolve(versionsRoot(root), "1");
|
|
1247
|
+
mkdirSync(v1, { recursive: true });
|
|
1248
|
+
return 1;
|
|
1249
|
+
}
|
|
1250
|
+
function createNextVersion(root, vacuumInto) {
|
|
1251
|
+
const prev = currentVersion(root);
|
|
1252
|
+
if (prev === 0) {
|
|
1253
|
+
throw conflict(
|
|
1254
|
+
"version.bootstrap_required",
|
|
1255
|
+
"no current version to snapshot from"
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
const next = prev + 1;
|
|
1259
|
+
const prevDir = resolve(versionsRoot(root), String(prev));
|
|
1260
|
+
const nextDir = resolve(versionsRoot(root), String(next));
|
|
1261
|
+
const dest = resolve(prevDir, "app.sqlite");
|
|
1262
|
+
if (existsSync(dest)) {
|
|
1263
|
+
throw conflict(
|
|
1264
|
+
"version.already_exists",
|
|
1265
|
+
`app.sqlite already exists for version ${next}`,
|
|
1266
|
+
{ version: next }
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
mkdirSync(nextDir, { recursive: true });
|
|
1270
|
+
try {
|
|
1271
|
+
copyVersionPlugins(prevDir, nextDir);
|
|
1272
|
+
vacuumInto(dest);
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
rmSync(nextDir, { recursive: true, force: true });
|
|
1275
|
+
throw err;
|
|
1276
|
+
}
|
|
1277
|
+
return { previous: prev, created: next };
|
|
1278
|
+
}
|
|
1279
|
+
function copyVersionPlugins(prevDir, nextDir) {
|
|
1280
|
+
const src = join(prevDir, "plugins");
|
|
1281
|
+
if (!existsSync(src)) return;
|
|
1282
|
+
const entries = readdirSync(src, { withFileTypes: true });
|
|
1283
|
+
for (const entry of entries) {
|
|
1284
|
+
if (!entry.isDirectory()) continue;
|
|
1285
|
+
if (entry.name.startsWith(".")) continue;
|
|
1286
|
+
const from = join(src, entry.name);
|
|
1287
|
+
if (!existsSync(join(from, "manifest.json"))) continue;
|
|
1288
|
+
cpSync(from, join(nextDir, "plugins", entry.name), { recursive: true });
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
function versionedDbFile(root, v) {
|
|
1292
|
+
return resolve(versionsRoot(root), String(v), "app.sqlite");
|
|
1293
|
+
}
|
|
1294
|
+
function versionedPath(root, v) {
|
|
1295
|
+
return resolve(versionsRoot(root), String(v));
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// src/hoard/paths.ts
|
|
1299
|
+
var WINDOWS_RESERVED = /* @__PURE__ */ new Set([
|
|
1300
|
+
"CON",
|
|
1301
|
+
"PRN",
|
|
1302
|
+
"AUX",
|
|
1303
|
+
"NUL",
|
|
1304
|
+
"COM1",
|
|
1305
|
+
"COM2",
|
|
1306
|
+
"COM3",
|
|
1307
|
+
"COM4",
|
|
1308
|
+
"COM5",
|
|
1309
|
+
"COM6",
|
|
1310
|
+
"COM7",
|
|
1311
|
+
"COM8",
|
|
1312
|
+
"COM9",
|
|
1313
|
+
"LPT1",
|
|
1314
|
+
"LPT2",
|
|
1315
|
+
"LPT3",
|
|
1316
|
+
"LPT4",
|
|
1317
|
+
"LPT5",
|
|
1318
|
+
"LPT6",
|
|
1319
|
+
"LPT7",
|
|
1320
|
+
"LPT8",
|
|
1321
|
+
"LPT9"
|
|
1322
|
+
]);
|
|
1323
|
+
var RESOURCE_DATA_DIR_NAME = "data";
|
|
1324
|
+
var FORBIDDEN_VISIBLE_CHARS = '<>:"|?*';
|
|
1325
|
+
function hasControlChar(segment) {
|
|
1326
|
+
for (let i = 0; i < segment.length; i++) {
|
|
1327
|
+
if (segment.charCodeAt(i) < 32) return true;
|
|
1328
|
+
}
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
1331
|
+
function hasForbiddenVisibleChar(segment) {
|
|
1332
|
+
for (let i = 0; i < segment.length; i++) {
|
|
1333
|
+
const ch = segment[i];
|
|
1334
|
+
if (ch !== void 0 && FORBIDDEN_VISIBLE_CHARS.includes(ch)) return true;
|
|
1335
|
+
}
|
|
1336
|
+
return false;
|
|
1337
|
+
}
|
|
1338
|
+
function createStoragePaths(opts) {
|
|
1339
|
+
if (!isAbsolute(opts.root)) {
|
|
1340
|
+
throw new Error(`storage root must be an absolute path: ${opts.root}`);
|
|
1341
|
+
}
|
|
1342
|
+
const root = resolve(opts.root);
|
|
1343
|
+
const versionsRootPath = resolve(root, "versions");
|
|
1344
|
+
const localRoot = resolve(root, "local");
|
|
1345
|
+
const diskMax = currentVersion(root);
|
|
1346
|
+
let latestVersion;
|
|
1347
|
+
if (opts.latestVersion !== void 0) {
|
|
1348
|
+
latestVersion = opts.latestVersion;
|
|
1349
|
+
} else {
|
|
1350
|
+
latestVersion = diskMax > 0 ? diskMax : 1;
|
|
1351
|
+
}
|
|
1352
|
+
let activeVersion;
|
|
1353
|
+
if (opts.activeVersion !== void 0) {
|
|
1354
|
+
activeVersion = opts.activeVersion;
|
|
1355
|
+
} else if (opts.latestVersion !== void 0) {
|
|
1356
|
+
activeVersion = latestVersion;
|
|
1357
|
+
} else {
|
|
1358
|
+
activeVersion = diskMax > 0 ? readActiveVersion(root) : latestVersion;
|
|
1359
|
+
}
|
|
1360
|
+
function versionAt(version) {
|
|
1361
|
+
const vSeg = assertSafeSegment(String(version));
|
|
1362
|
+
const vRoot = join5(versionsRootPath, vSeg);
|
|
1363
|
+
return {
|
|
1364
|
+
root: vRoot,
|
|
1365
|
+
version,
|
|
1366
|
+
versionSnapshotDb: () => join5(vRoot, "app.sqlite"),
|
|
1367
|
+
resource: (id) => join5(vRoot, "resources", assertSafeSegment(id)),
|
|
1368
|
+
resourceData: (id) => join5(vRoot, "resources", assertSafeSegment(id), RESOURCE_DATA_DIR_NAME),
|
|
1369
|
+
resources: () => join5(vRoot, "resources"),
|
|
1370
|
+
characters: () => join5(vRoot, "characters"),
|
|
1371
|
+
documents: () => join5(vRoot, "documents"),
|
|
1372
|
+
character: (id) => join5(vRoot, "characters", assertSafeSegment(id)),
|
|
1373
|
+
dbBackups: () => join5(vRoot, "db-backups"),
|
|
1374
|
+
dbBackup: (name) => join5(vRoot, "db-backups", assertSafeSegment(name)),
|
|
1375
|
+
snapshots: () => join5(vRoot, "snapshots"),
|
|
1376
|
+
snapshot: (name) => join5(vRoot, "snapshots", assertSafeSegment(name)),
|
|
1377
|
+
deletedMarker: (kind, id) => join5(vRoot, kind, assertSafeSegment(id), ".deleted"),
|
|
1378
|
+
document: (id) => join5(vRoot, "documents", assertSafeSegment(id)),
|
|
1379
|
+
plugins: () => join5(vRoot, "plugins"),
|
|
1380
|
+
pluginVaultDir: (id) => join5(vRoot, "plugins", assertSafeSegment(id), "vault")
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
const active = versionAt(activeVersion);
|
|
1384
|
+
const latest = versionAt(latestVersion);
|
|
1385
|
+
const uploadStagingRootPath = join5(localRoot, ".tmp");
|
|
1386
|
+
const cacheRoot = join5(localRoot, "cache");
|
|
1387
|
+
const local = {
|
|
1388
|
+
root: localRoot,
|
|
1389
|
+
cache: () => cacheRoot,
|
|
1390
|
+
logs: () => join5(localRoot, "logs"),
|
|
1391
|
+
localCover: (subjectKind, id, variant, format) => join5(
|
|
1392
|
+
cacheRoot,
|
|
1393
|
+
localCoverSubjectDir(subjectKind),
|
|
1394
|
+
assertSafeSegment(id),
|
|
1395
|
+
`${assertSafeSegment(variant)}.${format ?? "avif"}`
|
|
1396
|
+
),
|
|
1397
|
+
resFileVariant: (id, filename, variantKey, format) => join5(
|
|
1398
|
+
cacheRoot,
|
|
1399
|
+
"resources",
|
|
1400
|
+
assertSafeSegment(id),
|
|
1401
|
+
"file-preview",
|
|
1402
|
+
`${assertSafeSegment(sourceCacheId(filename))}__${assertSafeSegment(variantKey)}.${format ?? "avif"}`
|
|
1403
|
+
),
|
|
1404
|
+
resFilePreviewDir: (id) => join5(cacheRoot, "resources", assertSafeSegment(id), "file-preview"),
|
|
1405
|
+
resFilesCache: (id) => join5(cacheRoot, "resources", assertSafeSegment(id), "files-cache.json"),
|
|
1406
|
+
resource: (id) => join5(cacheRoot, "resources", assertSafeSegment(id)),
|
|
1407
|
+
character: (id) => join5(cacheRoot, "characters", assertSafeSegment(id)),
|
|
1408
|
+
trash: () => join5(localRoot, "trash"),
|
|
1409
|
+
trashItem: (id) => join5(localRoot, "trash", assertSafeSegment(id)),
|
|
1410
|
+
tmp: () => join5(cacheRoot, "tmp"),
|
|
1411
|
+
tmpFile: (name) => join5(cacheRoot, "tmp", assertSafeSegment(name)),
|
|
1412
|
+
sessionKey: () => join5(localRoot, ".session-key"),
|
|
1413
|
+
uploadStagingRoot: () => uploadStagingRootPath,
|
|
1414
|
+
stagingPoolRoot: () => join5(uploadStagingRootPath, "staging"),
|
|
1415
|
+
stagingPoolFile: (fileId, ext) => join5(
|
|
1416
|
+
uploadStagingRootPath,
|
|
1417
|
+
"staging",
|
|
1418
|
+
`${assertSafeSegment(fileId)}${ext}`
|
|
1419
|
+
),
|
|
1420
|
+
resVideoFrame: (id, filename, timeMs) => join5(
|
|
1421
|
+
cacheRoot,
|
|
1422
|
+
"resources",
|
|
1423
|
+
assertSafeSegment(id),
|
|
1424
|
+
"frames",
|
|
1425
|
+
assertSafeSegment(sourceCacheId(filename)),
|
|
1426
|
+
`${timeMs}.avif`
|
|
1427
|
+
),
|
|
1428
|
+
resExtractedDir: (id, fileVersion) => join5(
|
|
1429
|
+
cacheRoot,
|
|
1430
|
+
"resources",
|
|
1431
|
+
assertSafeSegment(id),
|
|
1432
|
+
"extracted",
|
|
1433
|
+
`v${fileVersion}`
|
|
1434
|
+
),
|
|
1435
|
+
resExtractedEntry: (id, fileVersion, entryName) => join5(
|
|
1436
|
+
cacheRoot,
|
|
1437
|
+
"resources",
|
|
1438
|
+
assertSafeSegment(id),
|
|
1439
|
+
"extracted",
|
|
1440
|
+
`v${fileVersion}`,
|
|
1441
|
+
assertSafeSegment(sourceCacheId(entryName))
|
|
1442
|
+
),
|
|
1443
|
+
resExtractedArchivesDir: (id, fileVersion) => join5(
|
|
1444
|
+
cacheRoot,
|
|
1445
|
+
"resources",
|
|
1446
|
+
assertSafeSegment(id),
|
|
1447
|
+
"extracted",
|
|
1448
|
+
`v${fileVersion}`,
|
|
1449
|
+
"archives"
|
|
1450
|
+
)
|
|
1451
|
+
};
|
|
1452
|
+
return {
|
|
1453
|
+
root,
|
|
1454
|
+
activeVersion,
|
|
1455
|
+
latestVersion,
|
|
1456
|
+
active,
|
|
1457
|
+
latest,
|
|
1458
|
+
local,
|
|
1459
|
+
atVersion: (v) => versionAt(v),
|
|
1460
|
+
runtimeDb: () => join5(root, "app.sqlite")
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
function localCoverSubjectDir(subjectKind) {
|
|
1464
|
+
return subjectKind === "resource" ? "resources" : "characters";
|
|
1465
|
+
}
|
|
1466
|
+
function toCacheBasename(filename) {
|
|
1467
|
+
const dot = filename.lastIndexOf(".");
|
|
1468
|
+
if (dot <= 0) return filename.replace(/[/\\]/g, "__");
|
|
1469
|
+
const stem = filename.slice(0, dot).replace(/[/\\]/g, "__");
|
|
1470
|
+
const ext = filename.slice(dot + 1);
|
|
1471
|
+
return `${stem}__${ext}`;
|
|
1472
|
+
}
|
|
1473
|
+
function sourceCacheId(filename) {
|
|
1474
|
+
return `${toCacheBasename(filename)}__${shortHash(filename)}`;
|
|
1475
|
+
}
|
|
1476
|
+
function shortHash(input) {
|
|
1477
|
+
return createHash("sha256").update(input).digest("hex").slice(0, 8);
|
|
1478
|
+
}
|
|
1479
|
+
function imageVariantKey(canonical) {
|
|
1480
|
+
return shortHash(canonical);
|
|
1481
|
+
}
|
|
1482
|
+
function assertSafeSegment(segment) {
|
|
1483
|
+
if (segment.length === 0) throw new Error("path segment must not be empty");
|
|
1484
|
+
if (segment === "." || segment === "..") {
|
|
1485
|
+
throw new Error(`path segment must not be '${segment}'`);
|
|
1486
|
+
}
|
|
1487
|
+
if (segment.includes("/") || segment.includes("\\")) {
|
|
1488
|
+
throw new Error(`path segment must not contain separators: ${segment}`);
|
|
1489
|
+
}
|
|
1490
|
+
if (hasForbiddenVisibleChar(segment) || hasControlChar(segment)) {
|
|
1491
|
+
throw new Error(`path segment contains disallowed characters: ${segment}`);
|
|
1492
|
+
}
|
|
1493
|
+
if (segment.endsWith(".") || segment.endsWith(" ")) {
|
|
1494
|
+
throw new Error(
|
|
1495
|
+
`path segment must not end with dot or space: ${JSON.stringify(segment)}`
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
const base = segment.split(".")[0]?.toUpperCase();
|
|
1499
|
+
if (base !== void 0 && WINDOWS_RESERVED.has(base)) {
|
|
1500
|
+
throw new Error(`path segment is a reserved name: ${segment}`);
|
|
1501
|
+
}
|
|
1502
|
+
return segment;
|
|
1503
|
+
}
|
|
1504
|
+
function join5(...segments) {
|
|
1505
|
+
return resolve(...segments);
|
|
1506
|
+
}
|
|
1507
|
+
function assertInside(ancestor, candidate) {
|
|
1508
|
+
const resolved = resolve(candidate);
|
|
1509
|
+
const base = resolve(ancestor);
|
|
1510
|
+
if (resolved !== base && !resolved.startsWith(base + sep)) {
|
|
1511
|
+
throw new Error(`path ${resolved} escapes ${base}`);
|
|
1512
|
+
}
|
|
1513
|
+
return resolved;
|
|
1514
|
+
}
|
|
1515
|
+
var PluginVaultPathError = class extends Error {
|
|
1516
|
+
constructor(message) {
|
|
1517
|
+
super(message);
|
|
1518
|
+
this.name = "PluginVaultPathError";
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1521
|
+
function parsePluginVaultDest(vaultDir, dest) {
|
|
1522
|
+
if (dest.length === 0) {
|
|
1523
|
+
throw new PluginVaultPathError("plugin vault destination must not be empty");
|
|
1524
|
+
}
|
|
1525
|
+
const segments = dest.split("/");
|
|
1526
|
+
let abs = vaultDir;
|
|
1527
|
+
for (const segment of segments) {
|
|
1528
|
+
if (segment.length === 0) {
|
|
1529
|
+
throw new PluginVaultPathError(
|
|
1530
|
+
`plugin vault destination must not contain empty segments: ${JSON.stringify(dest)}`
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
try {
|
|
1534
|
+
abs = join(abs, assertSafeSegment(segment));
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
throw new PluginVaultPathError(
|
|
1537
|
+
err instanceof Error ? err.message : String(err)
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
try {
|
|
1542
|
+
const resolved = assertInside(vaultDir, abs);
|
|
1543
|
+
return { rel: segments.join("/"), abs: resolved };
|
|
1544
|
+
} catch (err) {
|
|
1545
|
+
throw new PluginVaultPathError(
|
|
1546
|
+
err instanceof Error ? err.message : String(err)
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
async function vaultStatFile(vaultDir, rel) {
|
|
1551
|
+
const { abs } = parsePluginVaultDest(vaultDir, rel);
|
|
1552
|
+
const info = await stat(abs).catch(() => void 0);
|
|
1553
|
+
if (info === void 0 || !info.isFile()) return void 0;
|
|
1554
|
+
return { sizeBytes: info.size };
|
|
1555
|
+
}
|
|
1556
|
+
async function vaultReadFile(vaultDir, rel, maxBytes) {
|
|
1557
|
+
const { abs } = parsePluginVaultDest(vaultDir, rel);
|
|
1558
|
+
const info = await stat(abs).catch(() => void 0);
|
|
1559
|
+
if (info === void 0 || !info.isFile()) {
|
|
1560
|
+
throw pluginAssetError("POLICY", `plugin vault file not found: ${rel}`);
|
|
1561
|
+
}
|
|
1562
|
+
if (info.size > maxBytes) {
|
|
1563
|
+
throw pluginAssetError(
|
|
1564
|
+
"POLICY",
|
|
1565
|
+
`plugin vault file "${rel}" exceeds the ${maxBytes}-byte read cap`
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
const bytes = await readFile(abs);
|
|
1569
|
+
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1570
|
+
}
|
|
1571
|
+
async function vaultRemoveFile(vaultDir, rel) {
|
|
1572
|
+
const { abs } = parsePluginVaultDest(vaultDir, rel);
|
|
1573
|
+
const info = await stat(abs).catch(() => void 0);
|
|
1574
|
+
if (info === void 0) return false;
|
|
1575
|
+
if (!info.isFile()) {
|
|
1576
|
+
throw pluginAssetError(
|
|
1577
|
+
"POLICY",
|
|
1578
|
+
`plugin vault destination is not a file: ${rel}`
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
await rm(abs);
|
|
1582
|
+
return true;
|
|
1583
|
+
}
|
|
1584
|
+
function vaultTempFile(vaultDir) {
|
|
1585
|
+
return join(vaultDir, `.${randomUUID()}.tmp`);
|
|
1586
|
+
}
|
|
1587
|
+
async function discardVaultTempFile(tempPath) {
|
|
1588
|
+
await rm(tempPath, { force: true }).catch(() => {
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
async function commitVaultFile(opts) {
|
|
1592
|
+
const { abs } = parsePluginVaultDest(opts.vaultDir, opts.rel);
|
|
1593
|
+
const existing = await stat(abs).catch(() => void 0);
|
|
1594
|
+
if (existing !== void 0 && !existing.isFile()) {
|
|
1595
|
+
throw pluginAssetError(
|
|
1596
|
+
"POLICY",
|
|
1597
|
+
`plugin vault destination is not a file: ${opts.rel}`
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
const tempInfo = await stat(opts.tempPath).catch(() => void 0);
|
|
1601
|
+
if (tempInfo === void 0 || !tempInfo.isFile()) {
|
|
1602
|
+
throw pluginAssetError(
|
|
1603
|
+
"POLICY",
|
|
1604
|
+
`plugin vault download lost its staging file: ${opts.rel}`
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
if (tempInfo.size > opts.maxFileBytes) {
|
|
1608
|
+
throw pluginAssetError(
|
|
1609
|
+
"POLICY",
|
|
1610
|
+
`plugin vault file "${opts.rel}" exceeds the ${opts.maxFileBytes}-byte download cap`
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
const currentTotal = await vaultTotalSize(opts.vaultDir);
|
|
1614
|
+
const replacedBytes = existing?.isFile() ? existing.size : 0;
|
|
1615
|
+
if (currentTotal - replacedBytes + tempInfo.size > opts.maxTotalBytes) {
|
|
1616
|
+
throw pluginAssetError(
|
|
1617
|
+
"POLICY",
|
|
1618
|
+
`plugin vault "${opts.rel}" would exceed the ${opts.maxTotalBytes}-byte plugin quota (current ${currentTotal})`
|
|
1619
|
+
);
|
|
1620
|
+
}
|
|
1621
|
+
await mkdir(resolve(abs, ".."), { recursive: true });
|
|
1622
|
+
await rename(opts.tempPath, abs);
|
|
1623
|
+
return { sizeBytes: tempInfo.size, sha256: await hashFileAbs(abs) };
|
|
1624
|
+
}
|
|
1625
|
+
async function vaultTotalSize(vaultDir) {
|
|
1626
|
+
return sumDirSizes(vaultDir, { excludeDotPrefix: true });
|
|
1627
|
+
}
|
|
1628
|
+
async function vaultFileSha256(vaultDir, rel) {
|
|
1629
|
+
const { abs } = parsePluginVaultDest(vaultDir, rel);
|
|
1630
|
+
const info = await stat(abs).catch(() => void 0);
|
|
1631
|
+
if (info === void 0 || !info.isFile()) {
|
|
1632
|
+
throw pluginAssetError("POLICY", `plugin vault file not found: ${rel}`);
|
|
1633
|
+
}
|
|
1634
|
+
return hashFileAbs(abs);
|
|
1635
|
+
}
|
|
1636
|
+
async function hashFileAbs(abs) {
|
|
1637
|
+
const hash = createHash("sha256");
|
|
1638
|
+
await new Promise((resolveDone, reject) => {
|
|
1639
|
+
const stream = createReadStream(abs);
|
|
1640
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
1641
|
+
stream.on("end", () => resolveDone());
|
|
1642
|
+
stream.on("error", reject);
|
|
1643
|
+
});
|
|
1644
|
+
return hash.digest("hex");
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// src/hoard/sanitize.ts
|
|
1648
|
+
var FORBIDDEN_VISIBLE_CHARS2 = '<>:"|?*';
|
|
1649
|
+
var MAX_SEGMENT_BYTES = 240;
|
|
1650
|
+
var MAX_REL_PATH_CHARS = 700;
|
|
1651
|
+
var MAX_UNIQUE_RETRIES = 1e4;
|
|
1652
|
+
var WINDOWS_RESERVED2 = /* @__PURE__ */ new Set([
|
|
1653
|
+
"CON",
|
|
1654
|
+
"PRN",
|
|
1655
|
+
"AUX",
|
|
1656
|
+
"NUL",
|
|
1657
|
+
"COM0",
|
|
1658
|
+
"COM1",
|
|
1659
|
+
"COM2",
|
|
1660
|
+
"COM3",
|
|
1661
|
+
"COM4",
|
|
1662
|
+
"COM5",
|
|
1663
|
+
"COM6",
|
|
1664
|
+
"COM7",
|
|
1665
|
+
"COM8",
|
|
1666
|
+
"COM9",
|
|
1667
|
+
"LPT0",
|
|
1668
|
+
"LPT1",
|
|
1669
|
+
"LPT2",
|
|
1670
|
+
"LPT3",
|
|
1671
|
+
"LPT4",
|
|
1672
|
+
"LPT5",
|
|
1673
|
+
"LPT6",
|
|
1674
|
+
"LPT7",
|
|
1675
|
+
"LPT8",
|
|
1676
|
+
"LPT9",
|
|
1677
|
+
"CONIN$",
|
|
1678
|
+
"CONOUT$",
|
|
1679
|
+
"COM\xB9",
|
|
1680
|
+
"COM\xB2",
|
|
1681
|
+
"COM\xB3",
|
|
1682
|
+
"LPT\xB9",
|
|
1683
|
+
"LPT\xB2",
|
|
1684
|
+
"LPT\xB3"
|
|
1685
|
+
]);
|
|
1686
|
+
function sanitizeEntryName(name) {
|
|
1687
|
+
if (name.length === 0 || name.includes("\0")) return void 0;
|
|
1688
|
+
const normalized = name.normalize("NFC");
|
|
1689
|
+
const rawSegments = normalized.replace(/\\/g, "/").split("/");
|
|
1690
|
+
const segments = [];
|
|
1691
|
+
for (const raw of rawSegments) {
|
|
1692
|
+
const cleaned = cleanSegment(raw);
|
|
1693
|
+
if (cleaned === void 0) continue;
|
|
1694
|
+
segments.push(cleaned);
|
|
1695
|
+
}
|
|
1696
|
+
if (segments.length === 0) return void 0;
|
|
1697
|
+
const relPath = segments.join("/");
|
|
1698
|
+
if (relPath.length > MAX_REL_PATH_CHARS) return void 0;
|
|
1699
|
+
return relPath;
|
|
1700
|
+
}
|
|
1701
|
+
function cleanSegment(raw) {
|
|
1702
|
+
if (raw.length === 0 || raw === "." || raw === "..") return void 0;
|
|
1703
|
+
let out = "";
|
|
1704
|
+
for (const ch of raw) {
|
|
1705
|
+
const code = ch.codePointAt(0);
|
|
1706
|
+
if (code !== void 0 && code < 32) continue;
|
|
1707
|
+
if (FORBIDDEN_VISIBLE_CHARS2.includes(ch)) {
|
|
1708
|
+
out += "_";
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
out += ch;
|
|
1712
|
+
}
|
|
1713
|
+
let trimmed = out.replace(/^\.+/, "").replace(/[. ]+$/, "");
|
|
1714
|
+
if (trimmed.length === 0) return void 0;
|
|
1715
|
+
trimmed = truncateUtf8(trimmed, MAX_SEGMENT_BYTES);
|
|
1716
|
+
if (trimmed.length === 0) return void 0;
|
|
1717
|
+
const base = trimmed.split(".")[0]?.toUpperCase();
|
|
1718
|
+
if (base !== void 0 && WINDOWS_RESERVED2.has(base)) {
|
|
1719
|
+
trimmed = `_${trimmed}`;
|
|
1720
|
+
}
|
|
1721
|
+
return trimmed;
|
|
1722
|
+
}
|
|
1723
|
+
function truncateUtf8(value, maxBytes) {
|
|
1724
|
+
let bytes = 0;
|
|
1725
|
+
let out = "";
|
|
1726
|
+
for (const ch of value) {
|
|
1727
|
+
const len = Buffer.byteLength(ch, "utf8");
|
|
1728
|
+
if (bytes + len > maxBytes) break;
|
|
1729
|
+
bytes += len;
|
|
1730
|
+
out += ch;
|
|
1731
|
+
}
|
|
1732
|
+
return out;
|
|
1733
|
+
}
|
|
1734
|
+
function createOccupiedNames(existing) {
|
|
1735
|
+
return {
|
|
1736
|
+
files: new Set(existing?.files ?? []),
|
|
1737
|
+
dirs: new Set(existing?.dirs ?? [])
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
function occupyEntryName(occupied, relPath) {
|
|
1741
|
+
occupied.files.add(relPath);
|
|
1742
|
+
let prefix = "";
|
|
1743
|
+
for (const segment of relPath.split("/").slice(0, -1)) {
|
|
1744
|
+
prefix = prefix.length > 0 ? `${prefix}/${segment}` : segment;
|
|
1745
|
+
occupied.dirs.add(prefix);
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
function uniqueEntryName(occupied, relPath) {
|
|
1749
|
+
const lowerFiles = new Set([...occupied.files].map((p) => p.toLowerCase()));
|
|
1750
|
+
const lowerDirs = new Set([...occupied.dirs].map((p) => p.toLowerCase()));
|
|
1751
|
+
let candidate = relPath;
|
|
1752
|
+
for (let guard = 0; guard < MAX_UNIQUE_RETRIES; guard += 1) {
|
|
1753
|
+
const segIdx = collidingSegment(lowerFiles, lowerDirs, candidate);
|
|
1754
|
+
if (segIdx < 0) return candidate;
|
|
1755
|
+
const segments = candidate.split("/");
|
|
1756
|
+
const target = segments[segIdx];
|
|
1757
|
+
const dot = target.lastIndexOf(".");
|
|
1758
|
+
const stem = dot > 0 ? target.slice(0, dot) : target;
|
|
1759
|
+
const ext = dot > 0 ? target.slice(dot) : "";
|
|
1760
|
+
for (let i = 1; ; i += 1) {
|
|
1761
|
+
const next = `${stem}-${i}${ext}`;
|
|
1762
|
+
const rebuilt = segments.map((seg, idx) => idx === segIdx ? next : seg).join("/");
|
|
1763
|
+
if (collidingSegment(lowerFiles, lowerDirs, rebuilt) < 0) {
|
|
1764
|
+
candidate = rebuilt;
|
|
1765
|
+
break;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
throw new Error(`unable to resolve a unique name for ${relPath}`);
|
|
1770
|
+
}
|
|
1771
|
+
function collidingSegment(lowerFiles, lowerDirs, candidate) {
|
|
1772
|
+
const segments = candidate.split("/");
|
|
1773
|
+
const lower = candidate.toLowerCase();
|
|
1774
|
+
if (lowerFiles.has(lower) || lowerDirs.has(lower)) return segments.length - 1;
|
|
1775
|
+
let prefix = "";
|
|
1776
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
1777
|
+
prefix = prefix.length > 0 ? `${prefix}/${segments[i]}` : segments[i];
|
|
1778
|
+
if (lowerFiles.has(prefix.toLowerCase())) return i;
|
|
1779
|
+
}
|
|
1780
|
+
return -1;
|
|
1781
|
+
}
|
|
1782
|
+
var STAGED_ARCHIVE_EXT = ".zip";
|
|
1783
|
+
async function findStagedPoolFile(paths, fileId) {
|
|
1784
|
+
const poolDir = paths.local.stagingPoolRoot();
|
|
1785
|
+
const names = await readdir(poolDir).catch(() => []);
|
|
1786
|
+
for (const name of names) {
|
|
1787
|
+
const ext = extname(name);
|
|
1788
|
+
const stem = basename(name, ext);
|
|
1789
|
+
if (stem === fileId) return join(poolDir, name);
|
|
1790
|
+
}
|
|
1791
|
+
return void 0;
|
|
1792
|
+
}
|
|
1793
|
+
async function resolveStagedPoolFiles(paths, fileIds) {
|
|
1794
|
+
const poolDir = paths.local.stagingPoolRoot();
|
|
1795
|
+
const names = await readdir(poolDir).catch(() => []);
|
|
1796
|
+
const byStem = /* @__PURE__ */ new Map();
|
|
1797
|
+
for (const name of names) {
|
|
1798
|
+
const ext = extname(name);
|
|
1799
|
+
const stem = basename(name, ext);
|
|
1800
|
+
if (!byStem.has(stem)) byStem.set(stem, join(poolDir, name));
|
|
1801
|
+
}
|
|
1802
|
+
const out = /* @__PURE__ */ new Map();
|
|
1803
|
+
for (const fileId of fileIds) {
|
|
1804
|
+
const hit = byStem.get(fileId);
|
|
1805
|
+
if (hit !== void 0) out.set(fileId, hit);
|
|
1806
|
+
}
|
|
1807
|
+
return out;
|
|
1808
|
+
}
|
|
1809
|
+
async function writeStagedPoolFile(paths, fileId, filename, stream) {
|
|
1810
|
+
await mkdir(paths.local.stagingPoolRoot(), { recursive: true });
|
|
1811
|
+
const ext = extname(filename).toLowerCase();
|
|
1812
|
+
const dest = paths.local.stagingPoolFile(fileId, ext);
|
|
1813
|
+
const tmp = `${dest}.tmp-${Date.now()}`;
|
|
1814
|
+
try {
|
|
1815
|
+
await pipeline(stream, createWriteStream(tmp));
|
|
1816
|
+
await rename(tmp, dest);
|
|
1817
|
+
return dest;
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
await rm(tmp, { force: true }).catch(() => {
|
|
1820
|
+
});
|
|
1821
|
+
throw err;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
async function writeStagedArchiveFile(paths, fileId, stream) {
|
|
1825
|
+
return writeStagedPoolFile(paths, fileId, "archive.zip", stream);
|
|
1826
|
+
}
|
|
1827
|
+
async function findStagedArchiveFile(paths, fileId) {
|
|
1828
|
+
const existing = await findStagedPoolFile(paths, fileId);
|
|
1829
|
+
if (existing === void 0) return void 0;
|
|
1830
|
+
return extname(existing).toLowerCase() === STAGED_ARCHIVE_EXT ? existing : void 0;
|
|
1831
|
+
}
|
|
1832
|
+
async function removeStagedPoolFile(paths, fileId) {
|
|
1833
|
+
const existing = await findStagedPoolFile(paths, fileId);
|
|
1834
|
+
if (existing === void 0) return false;
|
|
1835
|
+
await rm(existing, { force: true }).catch(() => {
|
|
1836
|
+
});
|
|
1837
|
+
const stillThere = await stat(existing).catch(() => void 0);
|
|
1838
|
+
return stillThere === void 0;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// src/hoard/write-versioned.ts
|
|
1842
|
+
async function writeVersioned(paths, readOnly, cmd) {
|
|
1843
|
+
if (readOnly) {
|
|
1844
|
+
throw conflict(
|
|
1845
|
+
"server.read_only_archive",
|
|
1846
|
+
"server is viewing a read-only archive; versioned writes are blocked"
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
return await cmd(paths.latest);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// src/hoard/versioned-folder-ops.ts
|
|
1853
|
+
function buildVersionedFolderOps(paths, readOnly, kind) {
|
|
1854
|
+
const folderOf = (id) => (current) => kind === "resource" ? current.resource(id) : current.character(id);
|
|
1855
|
+
const trashPrefix = kind === "resource" ? "resources-" : "characters-";
|
|
1856
|
+
const deletedKind = kind === "resource" ? "resources" : "characters";
|
|
1857
|
+
async function ensureFolder(id) {
|
|
1858
|
+
await writeVersioned(
|
|
1859
|
+
paths,
|
|
1860
|
+
readOnly.current,
|
|
1861
|
+
(current) => mkdir(folderOf(id)(current), { recursive: true })
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
async function removeFolder(id) {
|
|
1865
|
+
await writeVersioned(
|
|
1866
|
+
paths,
|
|
1867
|
+
readOnly.current,
|
|
1868
|
+
(current) => rm(folderOf(id)(current), {
|
|
1869
|
+
recursive: true,
|
|
1870
|
+
force: true
|
|
1871
|
+
}).catch(() => {
|
|
1872
|
+
})
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1875
|
+
async function markDeleted(id) {
|
|
1876
|
+
return writeVersioned(paths, readOnly.current, async (current) => {
|
|
1877
|
+
const folder = folderOf(id)(current);
|
|
1878
|
+
await mkdir(folder, { recursive: true });
|
|
1879
|
+
const marker = current.deletedMarker(deletedKind, id);
|
|
1880
|
+
await writeFile(marker, "");
|
|
1881
|
+
return marker;
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
async function moveFolderToTrash(id) {
|
|
1885
|
+
await mkdir(paths.local.trash(), { recursive: true });
|
|
1886
|
+
return writeVersioned(paths, readOnly.current, async (current) => {
|
|
1887
|
+
const src = folderOf(id)(current);
|
|
1888
|
+
const dest = join(
|
|
1889
|
+
paths.local.trash(),
|
|
1890
|
+
`${trashPrefix}${id}-${Date.now()}`
|
|
1891
|
+
);
|
|
1892
|
+
try {
|
|
1893
|
+
await rename(src, dest);
|
|
1894
|
+
} catch (err) {
|
|
1895
|
+
const code = err.code;
|
|
1896
|
+
if (code === "ENOENT") {
|
|
1897
|
+
return dest;
|
|
1898
|
+
}
|
|
1899
|
+
if (code === "EPERM" || code === "EBUSY" || code === "UNKNOWN") {
|
|
1900
|
+
return src;
|
|
1901
|
+
}
|
|
1902
|
+
throw err;
|
|
1903
|
+
}
|
|
1904
|
+
return dest;
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
return {
|
|
1908
|
+
ensureFolder,
|
|
1909
|
+
removeFolder,
|
|
1910
|
+
markDeleted,
|
|
1911
|
+
moveFolderToTrash
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
async function archiveStaleFiles(args) {
|
|
1915
|
+
const { sourceFolder, destFolder, match, archivePrefix } = args;
|
|
1916
|
+
const entries = await readdir(sourceFolder).catch(() => []);
|
|
1917
|
+
const stale = entries.filter(match);
|
|
1918
|
+
if (stale.length === 0) return;
|
|
1919
|
+
await mkdir(destFolder, { recursive: true });
|
|
1920
|
+
const stamp = Date.now();
|
|
1921
|
+
await Promise.all(
|
|
1922
|
+
stale.map(async (name, i) => {
|
|
1923
|
+
const ext = extname(name);
|
|
1924
|
+
const archiveName = stale.length === 1 ? `${archivePrefix}${stamp}${ext}` : `${archivePrefix}${stamp}_${i}${ext}`;
|
|
1925
|
+
try {
|
|
1926
|
+
await rename(join(sourceFolder, name), join(destFolder, archiveName));
|
|
1927
|
+
} catch (err) {
|
|
1928
|
+
if (!isEnoentError(err)) throw err;
|
|
1929
|
+
}
|
|
1930
|
+
})
|
|
1931
|
+
);
|
|
1932
|
+
}
|
|
1933
|
+
function isEnoentError(err) {
|
|
1934
|
+
return err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
export { ORDER_MANIFEST_NAME, PluginVaultPathError, RESOURCE_DATA_DIR_NAME, archiveStaleFiles, assertInside, assertSafeSegment, buildVersionedFolderOps, commitVaultFile, createNextVersion, createOccupiedNames, createStoragePaths, currentVersion, discardVaultTempFile, ensureBootstrapVersion, extractArchiveInto, findStagedArchiveFile, findStagedPoolFile, imageVariantKey, listArchiveEntries, listVersions, naturalSort, occupyEntryName, orderEntries, orderManifestPath, parseOrderManifest, parsePluginVaultDest, readActiveVersion, readOrderManifest, removeStagedPoolFile, resolveStagedPoolFiles, sanitizeEntryName, streamStoredZip, sumDirSizes, uniqueEntryName, validateArchiveBudget, vaultFileSha256, vaultReadFile, vaultRemoveFile, vaultStatFile, vaultTempFile, vaultTotalSize, versionedDbFile, versionedPath, writeActiveVersion, writeOrderManifest, writeStagedArchiveFile, writeStagedPoolFile, writeVersioned };
|
|
1938
|
+
//# sourceMappingURL=index.js.map
|
|
1939
|
+
//# sourceMappingURL=index.js.map
|