@xcanwin/manyoyo 8.7.2 → 8.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,521 @@
1
+ 'use strict';
2
+
3
+ // 容器导入:边收边解包,不落临时包。归档可能来自别人,所有条目按不可信输入处理(见 safe-extract.js)。
4
+ // 顺序约定(导出端固定):manifest.json 第一条 → 每个容器的 c/<idx>/… → w/<n>/… → END.json 最后一条。
5
+ // 容器在遇到它的第一个 layer/ 条目(或它的段落结束)时建好,layer/ 条目流式写进新容器;失败或取消时撤销本次导入建的一切。
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const zlib = require('zlib');
11
+ const readline = require('readline');
12
+ const { once } = require('events');
13
+
14
+ const { readTarEntries, encodeEntryHeader, END_BLOCKS, pad } = require('./tar');
15
+ const { SafeExtractor, UnsafeEntryError, splitEntryPath } = require('./safe-extract');
16
+ const plan = require('./plan');
17
+
18
+ const MAX_JSON_BYTES = 64 * 1024 * 1024;
19
+ const MAX_MANIFEST_BYTES = 8 * 1024 * 1024;
20
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
21
+
22
+ class ImportError extends Error {
23
+ constructor(message, extra = {}) {
24
+ super(message);
25
+ this.name = 'ImportError';
26
+ Object.assign(this, extra);
27
+ }
28
+ }
29
+
30
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
31
+ const GZIP_MAGIC = Buffer.from([0x1f, 0x8b]);
32
+
33
+ // 按文件头判断压缩格式并解压,返回可异步迭代的字节流
34
+ async function decompress(source) {
35
+ const iterator = source[Symbol.asyncIterator]();
36
+ let head = Buffer.alloc(0);
37
+ const chunks = [];
38
+ while (head.length < 4) {
39
+ const { value, done } = await iterator.next();
40
+ if (done) break;
41
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
42
+ chunks.push(chunk);
43
+ head = Buffer.concat([head, chunk]);
44
+ }
45
+ let transform;
46
+ if (head.length >= 4 && head.subarray(0, 4).equals(ZSTD_MAGIC)) {
47
+ if (typeof zlib.createZstdDecompress !== 'function') throw new ImportError('这个文件是 zstd 压缩的,当前 Node.js 版本不支持(需要 22.15 以上)');
48
+ transform = zlib.createZstdDecompress();
49
+ } else if (head.length >= 2 && head.subarray(0, 2).equals(GZIP_MAGIC)) {
50
+ transform = zlib.createGunzip();
51
+ } else {
52
+ throw new ImportError('不是 manyoyo 导出文件(不认识的压缩格式)');
53
+ }
54
+ transform.on('error', () => {});
55
+ // 下游关了(导入失败 / 取消)就停止搬运,并让上游迭代器收尾,不留悬挂的等待
56
+ const drained = () => new Promise((resolve, reject) => {
57
+ const onDrain = () => { transform.removeListener('close', onClose); resolve(); };
58
+ const onClose = () => { transform.removeListener('drain', onDrain); reject(new Error('解压流已关闭')); };
59
+ transform.once('drain', onDrain);
60
+ transform.once('close', onClose);
61
+ });
62
+ (async () => {
63
+ try {
64
+ for (const chunk of chunks) if (!transform.write(chunk)) await drained();
65
+ for (;;) {
66
+ const { value, done } = await iterator.next();
67
+ if (done) break;
68
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
69
+ if (!transform.write(chunk)) await drained();
70
+ }
71
+ transform.end();
72
+ } catch (error) {
73
+ if (iterator.return) iterator.return().catch(() => {});
74
+ if (!transform.destroyed) transform.destroy(error);
75
+ }
76
+ })();
77
+ return transform;
78
+ }
79
+
80
+ async function readAll(body, limit) {
81
+ const parts = [];
82
+ let size = 0;
83
+ for await (const chunk of body) {
84
+ size += chunk.length;
85
+ if (size > limit) throw new ImportError('文件内容过大');
86
+ parts.push(chunk);
87
+ }
88
+ return Buffer.concat(parts);
89
+ }
90
+
91
+ function validateManifest(manifest) {
92
+ if (!manifest || typeof manifest !== 'object') throw new ImportError('清单格式不对');
93
+ if (manifest.format !== plan.FORMAT) throw new ImportError(`不支持的导出格式: ${manifest.format}`);
94
+ if (!Array.isArray(manifest.containers) || manifest.containers.length === 0 || manifest.containers.length > 200) throw new ImportError('清单里没有容器');
95
+ const seen = new Set();
96
+ for (const item of manifest.containers) {
97
+ if (!item || !Number.isInteger(item.idx) || item.idx < 0 || seen.has(item.idx)) throw new ImportError('清单里的容器序号不对');
98
+ seen.add(item.idx);
99
+ if (typeof item.name !== 'string' || !NAME_RE.test(item.name)) throw new ImportError(`清单里的容器名不合法: ${String(item.name).slice(0, 60)}`);
100
+ if (item.work !== null && item.work !== undefined && !Number.isInteger(item.work)) throw new ImportError('清单里的工作目录编号不对');
101
+ }
102
+ const known = new Set(plan.ITEMS.map(item => item.id));
103
+ if (!Array.isArray(manifest.items) || manifest.items.some(id => !known.has(id))) throw new ImportError('清单里的内容项不对');
104
+ return manifest;
105
+ }
106
+
107
+ /**
108
+ * 只读文件开头:返回清单。head 是文件开头(最多 4MB)。
109
+ */
110
+ async function inspectArchiveHead(head) {
111
+ const { Readable } = require('stream');
112
+ const stream = await decompress(Readable.from([head]));
113
+ try {
114
+ for await (const { meta, body } of readTarEntries(stream, { maxEntrySize: MAX_MANIFEST_BYTES })) {
115
+ if (meta.name !== 'manifest.json' || meta.type !== 'file') throw new ImportError('不是 manyoyo 导出文件(第一条不是清单)');
116
+ const text = (await readAll(body, MAX_MANIFEST_BYTES)).toString('utf8');
117
+ let manifest;
118
+ try { manifest = JSON.parse(text); } catch (error) { throw new ImportError('清单不是合法的 JSON'); }
119
+ return validateManifest(manifest);
120
+ }
121
+ } catch (error) {
122
+ if (error instanceof ImportError) throw error;
123
+ throw new ImportError(`读不出清单,文件可能不是 manyoyo 导出文件或已损坏(${error.message})`);
124
+ } finally {
125
+ if (stream.destroy) stream.destroy();
126
+ }
127
+ throw new ImportError('文件里没有内容');
128
+ }
129
+
130
+ // 把容器名从 oldKey 换成 newKey:会话键是“容器名”或“容器名~agentId”
131
+ function renameSessionKey(key, oldName, newName) {
132
+ if (key === oldName) return newName;
133
+ if (typeof key === 'string' && key.startsWith(`${oldName}~`)) return `${newName}${key.slice(oldName.length)}`;
134
+ return key;
135
+ }
136
+
137
+ async function rewriteEventFile(from, to, oldName, newName) {
138
+ const input = fs.createReadStream(from, { encoding: 'utf8' });
139
+ const output = fs.createWriteStream(to, { flags: 'wx', mode: 0o600 });
140
+ const lines = readline.createInterface({ input, crlfDelay: Infinity });
141
+ try {
142
+ for await (const line of lines) {
143
+ if (!line.trim()) continue;
144
+ let event;
145
+ try { event = JSON.parse(line); } catch (error) { continue; }
146
+ if (event && typeof event === 'object') {
147
+ if (typeof event.aggregateId === 'string') event.aggregateId = renameSessionKey(event.aggregateId, oldName, newName);
148
+ if (typeof event.sessionId === 'string') event.sessionId = renameSessionKey(event.sessionId, oldName, newName);
149
+ }
150
+ if (!output.write(`${JSON.stringify(event)}\n`)) await once(output, 'drain');
151
+ }
152
+ } finally {
153
+ output.end();
154
+ await once(output, 'close').catch(() => {});
155
+ }
156
+ }
157
+
158
+ function rewriteProjection(text, oldName, newName) {
159
+ const data = JSON.parse(text);
160
+ if (data && typeof data === 'object' && typeof data.aggregateId === 'string') data.aggregateId = renameSessionKey(data.aggregateId, oldName, newName);
161
+ return `${JSON.stringify(data)}\n`;
162
+ }
163
+
164
+ function isRealDir(p) {
165
+ try { return fs.lstatSync(p).isDirectory(); } catch (error) { return false; }
166
+ }
167
+
168
+ function moveEntry(from, to) {
169
+ try {
170
+ fs.renameSync(from, to);
171
+ } catch (error) {
172
+ if (error.code !== 'EXDEV') throw error;
173
+ fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true, errorOnExist: true });
174
+ fs.rmSync(from, { recursive: true, force: true });
175
+ }
176
+ }
177
+
178
+ /**
179
+ * 导入主流程。
180
+ * options:
181
+ * source 原始上传流(压缩后的字节)
182
+ * plan { containers: { [idx]: { name, hostPath, imageMode, confirm } } }(调用方已按清单校验过名字与路径)
183
+ * deps { homeDir, webHistoryDir, runtime, createContainer, removeContainer, saveHistory, statfs?, containerState }
184
+ * job container-jobs 的任务对象(check / addBytes / addCreated / update)
185
+ */
186
+ async function importArchive(options) {
187
+ const { source, deps, job } = options;
188
+ const importPlan = options.plan || { containers: {} };
189
+ const tmpRoot = path.join(deps.homeDir, '.manyoyo', 'tmp', `import-${job.id}`);
190
+ fs.mkdirSync(tmpRoot, { recursive: true, mode: 0o700 });
191
+ job.addCreated('dirs', tmpRoot);
192
+ const cTree = path.join(tmpRoot, 'c');
193
+ fs.mkdirSync(cTree, { mode: 0o700 });
194
+
195
+ let free = Infinity;
196
+ try {
197
+ const stat = (deps.statfs || fs.statfsSync)(tmpRoot);
198
+ free = Number(stat.bavail) * Number(stat.bsize);
199
+ } catch (error) { /* 取不到就只靠过程中的监控 */ }
200
+ const maxBytes = options.maxBytes || (Number.isFinite(free) ? Math.max(0, free - 1024 * 1024 * 1024) : Infinity);
201
+
202
+ const cExtractor = new SafeExtractor(cTree, { maxBytes });
203
+ const workExtractors = new Map();
204
+ const state = {
205
+ manifest: null,
206
+ entries: 0,
207
+ sawEnd: false,
208
+ containers: new Map(), // idx -> { created, info, layer, mounts, swallowed }
209
+ destDirs: new Map(), // work n -> { dest, createdByUs, moved: [] }
210
+ layerLinks: new Set(),
211
+ stats: { layerFiles: 0, layerDropped: 0, layerSkippedSystem: 0, skippedLinks: 0 }
212
+ };
213
+
214
+ let manifestIndex = null;
215
+ const manifestByIdx = () => {
216
+ if (!manifestIndex) manifestIndex = new Map(state.manifest.containers.map(item => [item.idx, item]));
217
+ return manifestIndex;
218
+ };
219
+
220
+ // 工作目录的目的地:容器创建前先建好空目录(容器要挂载它);已存在且非空直接拒绝
221
+ function prepareDest(n, dest) {
222
+ if (state.destDirs.has(n)) {
223
+ if (state.destDirs.get(n).dest !== dest) throw new ImportError('共用同一个工作目录的容器必须导入到同一个位置');
224
+ return;
225
+ }
226
+ // 不同的工作目录不能落到同一个位置(会互相覆盖)
227
+ for (const [otherN, info] of state.destDirs) {
228
+ if (otherN !== n && info.dest === dest) throw new ImportError(`不同的工作目录不能导入到同一个位置: ${dest}`);
229
+ }
230
+ let createdByUs = false;
231
+ if (fs.existsSync(dest)) {
232
+ if (!isRealDir(dest) || fs.readdirSync(dest).length > 0) throw new ImportError(`工作目录已存在且不为空: ${dest}`);
233
+ } else {
234
+ fs.mkdirSync(dest, { recursive: true, mode: 0o755 });
235
+ createdByUs = true;
236
+ job.addCreated('dirs', dest);
237
+ }
238
+ state.destDirs.set(n, { dest, createdByUs, moved: [] });
239
+ }
240
+
241
+ async function ensureContainer(idx) {
242
+ let entry = state.containers.get(idx);
243
+ if (entry && entry.created) return entry;
244
+ const manifestItem = manifestByIdx().get(idx);
245
+ const chosen = importPlan.containers[idx];
246
+ entry = entry || { created: false };
247
+ state.containers.set(idx, entry);
248
+ const base = path.join(cTree, String(idx));
249
+ let history = {};
250
+ const historyFile = path.join(base, 'history.json');
251
+ if (fs.existsSync(historyFile)) {
252
+ const text = fs.readFileSync(historyFile, 'utf8');
253
+ if (text.length > MAX_JSON_BYTES) throw new ImportError('历史记录文件过大');
254
+ try { history = JSON.parse(text); } catch (error) { throw new ImportError(`历史记录不是合法的 JSON(${manifestItem.name})`); }
255
+ }
256
+ const stateFiles = {};
257
+ for (const file of ['env', 'autostart.sh', 'system-prompt.json', 'network.json', 'env-files.json']) {
258
+ const full = path.join(base, 'state', file);
259
+ if (fs.existsSync(full)) {
260
+ const stat = fs.lstatSync(full);
261
+ if (stat.isFile() && stat.size <= 1024 * 1024) stateFiles[file] = fs.readFileSync(full, 'utf8');
262
+ }
263
+ }
264
+ if (manifestItem.work !== null && manifestItem.work !== undefined) prepareDest(manifestItem.work, chosen.hostPath);
265
+ if (job.container !== chosen.name || job.stage !== '创建容器') job.update({ stage: '创建容器', container: chosen.name });
266
+ const hasSessions = state.manifest.items.includes('agentSessions');
267
+ // 先登记历史文件:建容器过程中一旦失败,撤销时也能把它删掉
268
+ job.addCreated('historyFiles', path.join(deps.webHistoryDir, `${chosen.name}.json`));
269
+ const created = await deps.createContainer({
270
+ onCreated: (name, id) => job.addCreated('containers', { name, id }),
271
+ idx,
272
+ oldName: manifestItem.name,
273
+ newName: chosen.name,
274
+ history,
275
+ stateFiles,
276
+ hostPath: chosen.hostPath,
277
+ imageMode: chosen.imageMode === 'default' ? 'default' : 'same',
278
+ confirm: chosen.confirm || {},
279
+ hasSessions,
280
+ manifestItem
281
+ });
282
+ if (!job.created.containers.some(item => item.name === created.containerName)) job.addCreated('containers', { name: created.containerName, id: created.id });
283
+ entry.created = created;
284
+ entry.mounts = [];
285
+ try {
286
+ const info = deps.runtime.inspect(created.containerName);
287
+ entry.mounts = info ? info.mounts : [];
288
+ entry.sameImage = Boolean(info) && info.image === manifestItem.image;
289
+ } catch (error) { entry.sameImage = false; }
290
+ // 事件日志、投影:改名后写进历史目录
291
+ await restoreHistoryFiles(idx, manifestItem.name, created.containerName, history, hasSessions);
292
+ // 聊天图片、回退快照
293
+ for (const dir of ['uploads', 'snap.git']) {
294
+ const from = path.join(base, 'box', dir);
295
+ if (isRealDir(from) && created.paths && created.paths.box) {
296
+ const to = path.join(created.paths.box, dir);
297
+ fs.rmSync(to, { recursive: true, force: true });
298
+ moveEntry(from, to);
299
+ }
300
+ }
301
+ return entry;
302
+ }
303
+
304
+ async function restoreHistoryFiles(idx, oldName, newName, history, hasSessions) {
305
+ const base = path.join(cTree, String(idx));
306
+ for (const sub of ['events', 'projections']) {
307
+ const dir = path.join(base, sub);
308
+ if (!isRealDir(dir)) continue;
309
+ const targetDir = path.join(deps.webHistoryDir, sub);
310
+ fs.mkdirSync(targetDir, { recursive: true });
311
+ for (const file of fs.readdirSync(dir)) {
312
+ const decoded = (() => { try { return decodeURIComponent(file.replace(/\.jsonl?$/, '')); } catch (error) { return null; } })();
313
+ if (decoded === null || (decoded !== oldName && !decoded.startsWith(`${oldName}~`))) continue;
314
+ const newFile = `${encodeURIComponent(renameSessionKey(decoded, oldName, newName))}${path.extname(file)}`;
315
+ const to = path.join(targetDir, newFile);
316
+ if (fs.existsSync(to)) throw new ImportError(`目标已有同名事件文件: ${newFile}`);
317
+ job.addCreated('historyFiles', to);
318
+ if (sub === 'events') await rewriteEventFile(path.join(dir, file), to, oldName, newName);
319
+ else fs.writeFileSync(to, rewriteProjection(fs.readFileSync(path.join(dir, file), 'utf8'), oldName, newName), { flag: 'wx', mode: 0o600 });
320
+ }
321
+ }
322
+ await deps.saveHistory(newName, history, { oldName, hasSessions, remark: (importPlan.containers[idx] || {}).remark });
323
+ }
324
+
325
+ async function finishContainer(idx) {
326
+ const entry = state.containers.get(idx);
327
+ if (!entry) return;
328
+ await ensureContainer(idx);
329
+ if (entry.layer) {
330
+ entry.layer.stdin.write(END_BLOCKS);
331
+ entry.layer.stdin.end();
332
+ const result = await entry.layer.done;
333
+ entry.layer = null;
334
+ if (result.code !== 0) throw new ImportError(`写入容器失败: ${String(result.stderr || '').trim().slice(0, 200)}`);
335
+ }
336
+ }
337
+
338
+ async function writeLayerEntry(idx, meta, body, rest) {
339
+ const entry = await ensureContainer(idx);
340
+ const abs = plan.normalizeContainerPath(`/${rest}`);
341
+ const mounts = entry.mounts || [];
342
+ const drop = async () => { state.stats.layerDropped += 1; for await (const piece of body) void piece; };
343
+ let segments;
344
+ try { segments = splitEntryPath(rest); } catch (error) { return drop(); }
345
+ if (!abs) return drop();
346
+ const cls = plan.classifyLayerPath(abs, mounts);
347
+ if (cls === null) return drop();
348
+ if (cls === 'systemLayer' && !entry.sameImage) {
349
+ state.stats.layerSkippedSystem += 1;
350
+ return drop();
351
+ }
352
+ // 经过本归档里出现过的符号链接的路径不写(否则能借链接绕到挂载点)
353
+ for (let i = 1; i < segments.length; i++) {
354
+ if (state.layerLinks.has(`${entry.created.containerName}:/${segments.slice(0, i).join('/')}`)) return drop();
355
+ }
356
+ if (meta.type === 'symlink') {
357
+ const target = meta.linkname.startsWith('/') ? meta.linkname : path.posix.join(path.posix.dirname(abs), meta.linkname);
358
+ const resolved = plan.normalizeContainerPath(target.startsWith('/') ? target : `/${target}`);
359
+ if (!resolved || plan.classifyLayerPath(resolved, mounts) === null) return drop();
360
+ state.layerLinks.add(`${entry.created.containerName}:${abs}`);
361
+ } else if (meta.type !== 'file' && meta.type !== 'dir') {
362
+ return drop();
363
+ }
364
+ if (!entry.layer) {
365
+ entry.layer = deps.runtime.cpIn(entry.created.containerName, '/');
366
+ entry.layer.stdin.on('error', () => {});
367
+ }
368
+ const stdin = entry.layer.stdin;
369
+ const write = async buffer => {
370
+ job.addBytes(buffer.length);
371
+ if (!stdin.write(buffer)) await once(stdin, 'drain');
372
+ };
373
+ await write(encodeEntryHeader({ name: segments.join('/'), type: meta.type, size: meta.size, mode: meta.mode, mtime: meta.mtime, linkname: meta.linkname }));
374
+ if (meta.type === 'file') {
375
+ for await (const chunk of body) await write(chunk);
376
+ const padding = pad(meta.size);
377
+ if (padding) await write(Buffer.alloc(padding));
378
+ state.stats.layerFiles += 1;
379
+ }
380
+ }
381
+
382
+ async function routeEntry(meta, body) {
383
+ const name = meta.name;
384
+ if (meta.type === 'hardlink' || meta.type === 'other') throw new UnsafeEntryError(`归档里有不允许的条目类型(${meta.type}): ${name}`);
385
+ // 不管最后是不是要这个条目,路径先过一遍:绝对路径、.. 等一律拒绝,而不是悄悄略过
386
+ splitEntryPath(name);
387
+ let m;
388
+ if ((m = /^c\/(\d+)\/layer\/(.+)$/.exec(name))) {
389
+ const idx = Number(m[1]);
390
+ if (!manifestByIdx().has(idx)) throw new ImportError(`归档里有清单没列出的容器: ${idx}`);
391
+ if (!importPlan.containers[idx]) { for await (const piece of body) void piece; return idx; }
392
+ await writeLayerEntry(idx, meta, body, m[2]);
393
+ return idx;
394
+ }
395
+ if ((m = /^c\/(\d+)(?:\/(.*))?$/.exec(name))) {
396
+ const idx = Number(m[1]);
397
+ if (!manifestByIdx().has(idx)) throw new ImportError(`归档里有清单没列出的容器: ${idx}`);
398
+ if (!importPlan.containers[idx]) { for await (const piece of body) void piece; return idx; }
399
+ if (!state.containers.has(idx)) state.containers.set(idx, { created: false });
400
+ await extractInto(cExtractor, { ...meta, name: name.slice(2) }, body);
401
+ return idx;
402
+ }
403
+ if ((m = /^w\/(\d+)(?:\/(.*))?$/.exec(name))) {
404
+ const n = Number(m[1]);
405
+ if (!state.destDirs.has(n)) { for await (const piece of body) void piece; return null; }
406
+ let extractor = workExtractors.get(n);
407
+ if (!extractor) {
408
+ const dir = path.join(tmpRoot, 'w', String(n));
409
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
410
+ extractor = new SafeExtractor(dir, { maxBytes });
411
+ workExtractors.set(n, extractor);
412
+ }
413
+ await extractInto(extractor, { ...meta, name: m[2] || '' }, body, m[2] === undefined);
414
+ return null;
415
+ }
416
+ throw new UnsafeEntryError(`归档里有不认识的路径: ${name}`);
417
+ }
418
+
419
+ async function extractInto(extractor, meta, body, isRoot = false) {
420
+ if (isRoot) { for await (const piece of body) void piece; return; }
421
+ if (meta.type === 'dir') {
422
+ extractor.addDir(meta.name, meta.mode);
423
+ } else if (meta.type === 'symlink') {
424
+ extractor.addSymlink(meta.name, meta.linkname);
425
+ // c/ 里的链接永远不创建(下面只对工作目录 finalize)
426
+ } else {
427
+ await extractor.addFile(meta.name, meta.mode, body, meta.size);
428
+ job.addBytes(meta.size);
429
+ }
430
+ }
431
+
432
+ async function undo() {
433
+ for (const created of [...(job.created.containers || [])].reverse()) {
434
+ try { await deps.removeContainer(created.name, created.id); } catch (error) { /* 宁可删不掉 */ }
435
+ }
436
+ for (const file of job.created.historyFiles || []) fs.rmSync(file, { force: true });
437
+ for (const info of state.destDirs.values()) {
438
+ if (info.createdByUs) fs.rmSync(info.dest, { recursive: true, force: true });
439
+ else for (const name of info.moved) fs.rmSync(path.join(info.dest, name), { recursive: true, force: true });
440
+ }
441
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
442
+ }
443
+
444
+ let stream = null;
445
+ try {
446
+ job.update({ stage: '读取清单' });
447
+ stream = await decompress(source);
448
+ let currentIdx = null;
449
+ for await (const { meta, body } of readTarEntries(stream)) {
450
+ job.check();
451
+ state.entries += 1;
452
+ if (state.entries === 1) {
453
+ if (meta.name !== 'manifest.json' || meta.type !== 'file') throw new ImportError('不是 manyoyo 导出文件(第一条不是清单)');
454
+ const text = (await readAll(body, MAX_MANIFEST_BYTES)).toString('utf8');
455
+ try { state.manifest = validateManifest(JSON.parse(text)); } catch (error) {
456
+ throw error instanceof ImportError ? error : new ImportError('清单不是合法的 JSON');
457
+ }
458
+ for (const item of state.manifest.containers) {
459
+ if (importPlan.containers[item.idx] && !NAME_RE.test(String(importPlan.containers[item.idx].name))) throw new ImportError(`导入后的容器名不合法: ${importPlan.containers[item.idx].name}`);
460
+ }
461
+ continue;
462
+ }
463
+ if (state.sawEnd) throw new ImportError('归档在结束标记之后还有内容');
464
+ if (meta.name === 'END.json') {
465
+ const end = JSON.parse((await readAll(body, 1024 * 1024)).toString('utf8'));
466
+ if (!end || end.entries !== state.entries) throw new ImportError('归档条目数与结束标记不符,文件不完整');
467
+ state.sawEnd = true;
468
+ continue;
469
+ }
470
+ const idx = await routeEntry(meta, body);
471
+ if (currentIdx !== null && idx !== currentIdx) await finishContainer(currentIdx);
472
+ currentIdx = idx === undefined ? currentIdx : idx;
473
+ const shown = idx !== null && idx !== undefined && importPlan.containers[idx] ? importPlan.containers[idx].name : job.container;
474
+ if (shown !== job.container) job.update({ container: shown });
475
+ }
476
+ if (!state.sawEnd) throw new ImportError('文件不完整(缺少结束标记),可能上传被中断');
477
+ if (currentIdx !== null) await finishContainer(currentIdx);
478
+ for (const idx of Object.keys(importPlan.containers).map(Number)) {
479
+ if (manifestByIdx().has(idx)) await finishContainer(idx);
480
+ }
481
+ // 工作目录:符号链接最后创建,然后把内容搬到目的地
482
+ job.update({ stage: '放置工作目录' });
483
+ for (const [n, extractor] of workExtractors) {
484
+ state.stats.skippedLinks += extractor.skippedLinks;
485
+ extractor.finalizeSymlinks();
486
+ state.stats.skippedLinks += extractor.skippedLinks;
487
+ const info = state.destDirs.get(n);
488
+ for (const name of fs.readdirSync(extractor.root)) {
489
+ moveEntry(path.join(extractor.root, name), path.join(info.dest, name));
490
+ info.moved.push(name);
491
+ }
492
+ }
493
+ const result = {
494
+ containers: [...state.containers.values()].filter(item => item.created).map(item => item.created.containerName),
495
+ stats: state.stats
496
+ };
497
+ // 成功:不再需要撤销记录
498
+ job.update({ created: { containers: [], dirs: [], historyFiles: [] } });
499
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
500
+ return result;
501
+ } catch (error) {
502
+ for (const entry of state.containers.values()) {
503
+ if (entry.layer) { try { entry.layer.kill && entry.layer.kill(); } catch (e) { /* ignore */ } }
504
+ }
505
+ // 释放解压流;上传流(req)不能销毁,否则收不到错误响应,由调用方丢弃剩余内容
506
+ if (stream && !stream.destroyed) stream.destroy();
507
+ if (source && source.path !== undefined && typeof source.destroy === 'function') source.destroy();
508
+ await undo();
509
+ throw error;
510
+ }
511
+ }
512
+
513
+ module.exports = {
514
+ ImportError,
515
+ importArchive,
516
+ inspectArchiveHead,
517
+ decompress,
518
+ renameSessionKey,
519
+ validateManifest,
520
+ NAME_RE
521
+ };