@xcanwin/manyoyo 8.7.3 → 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,197 @@
1
+ 'use strict';
2
+
3
+ // 容器导出 / 导入的档位与范围:服务端单一数据源,前端只渲染(GET /api/transfer/preview 下发)。
4
+
5
+ const path = require('path');
6
+
7
+ const FORMAT = 1;
8
+ const AGENT_HOME = '/root';
9
+
10
+ // 内容项。always:每档都带,不可取消的核心(取消 agentSessions 会让续接失效,自定义里允许取消,导入时会清掉续接标记)
11
+ const ITEMS = [
12
+ { id: 'history', label: '网页聊天记录', hint: '对话、备注、置顶、标题', tiers: ['light', 'standard', 'full'] },
13
+ { id: 'agentSessions', label: 'Agent 会话记录', hint: '换机器后能接着聊的关键', tiers: ['light', 'standard', 'full'] },
14
+ { id: 'settings', label: '容器设置', hint: '环境变量、自启动脚本、系统提示词、网络规则', tiers: ['light', 'standard', 'full'] },
15
+ { id: 'uploads', label: '聊天里的图片', hint: '', tiers: ['standard', 'full'] },
16
+ { id: 'agentExtras', label: 'Agent 其余改动', hint: '技能、MCP、设置、记忆', tiers: ['standard', 'full'] },
17
+ { id: 'workdir', label: '工作目录', hint: '宿主机上的项目文件', tiers: ['standard', 'full'] },
18
+ { id: 'systemLayer', label: '容器里其它改动', hint: '装的软件、全局包;只在镜像版本相同时还原', tiers: ['full'] },
19
+ { id: 'snapshots', label: '按轮回退快照', hint: '可能很大', tiers: ['full'] }
20
+ ];
21
+
22
+ const TIERS = [
23
+ { id: 'light', label: '轻量' },
24
+ { id: 'standard', label: '标准', recommended: true },
25
+ { id: 'full', label: '完整' }
26
+ ];
27
+
28
+ function itemsForTier(tier) {
29
+ return ITEMS.filter(item => item.tiers.includes(tier)).map(item => item.id);
30
+ }
31
+
32
+ // 勾选集合正好等于某档就返回那一档,否则 'custom'
33
+ function matchTier(itemIds) {
34
+ const set = new Set(itemIds);
35
+ for (const tier of TIERS) {
36
+ const expected = itemsForTier(tier.id);
37
+ if (expected.length === set.size && expected.every(id => set.has(id))) return tier.id;
38
+ }
39
+ return 'custom';
40
+ }
41
+
42
+ function normalizeItems(input, tier) {
43
+ const known = new Set(ITEMS.map(item => item.id));
44
+ if (Array.isArray(input)) {
45
+ const picked = [...new Set(input.map(String))];
46
+ const unknown = picked.find(id => !known.has(id));
47
+ if (unknown) throw new Error(`未知的导出内容: ${unknown}`);
48
+ return ITEMS.map(item => item.id).filter(id => picked.includes(id));
49
+ }
50
+ if (!TIERS.some(item => item.id === tier)) throw new Error(`未知的档位: ${tier}`);
51
+ return itemsForTier(tier);
52
+ }
53
+
54
+ // 容器里永远不导出的位置:缓存、临时目录、运行时生成的文件
55
+ const EXCLUDED_PREFIXES = [
56
+ '/tmp', '/var/tmp', '/run', '/var/run', '/var/cache', '/var/lib/apt/lists', '/var/log',
57
+ '/root/.cache', '/root/.npm/_cacache', '/root/.codex/.tmp', '/root/.codex/tmp',
58
+ '/proc', '/sys', '/dev',
59
+ '/etc/hosts', '/etc/hostname', '/etc/resolv.conf', '/etc/mtab'
60
+ ];
61
+
62
+ // 容器内路径统一成不含 .. 的绝对 POSIX 路径;不是绝对路径返回 null
63
+ function normalizeContainerPath(input) {
64
+ if (typeof input !== 'string' || !input.startsWith('/') || input.includes('\0')) return null;
65
+ const normalized = path.posix.normalize(input);
66
+ if (normalized.split('/').includes('..')) return null;
67
+ return normalized === '/' ? '/' : normalized.replace(/\/+$/, '');
68
+ }
69
+
70
+ function isUnder(target, base) {
71
+ return target === base || target.startsWith(`${base}/`);
72
+ }
73
+
74
+ function isExcludedPath(containerPath) {
75
+ const p = normalizeContainerPath(containerPath);
76
+ if (!p) return true;
77
+ if (EXCLUDED_PREFIXES.some(prefix => isUnder(p, prefix))) return true;
78
+ return p.split('/').some((segment, index, all) => segment === 'node_modules' && all[index + 1] === '.cache');
79
+ }
80
+
81
+ // Agent 会话记录:续接必需。清单由真实续接实测校准(见 FEEDBACK),少了补、多了无妨
82
+ const AGENT_SESSION_PATHS = [
83
+ `${AGENT_HOME}/.claude/projects`,
84
+ `${AGENT_HOME}/.claude.json`,
85
+ `${AGENT_HOME}/.codex/sessions`,
86
+ `${AGENT_HOME}/.codex/archived_sessions`,
87
+ `${AGENT_HOME}/.codex/session_index.jsonl`,
88
+ `${AGENT_HOME}/.gemini/tmp`,
89
+ `${AGENT_HOME}/.local/share/opencode`
90
+ ];
91
+ const AGENT_SESSION_PATTERNS = [/^\/root\/\.codex\/[^/]+\.sqlite[^/]*$/];
92
+ // Agent 其余目录:整个目录里相对镜像有变化的文件(会话记录部分已单独归类)
93
+ const AGENT_DIRS = [`${AGENT_HOME}/.claude`, `${AGENT_HOME}/.codex`, `${AGENT_HOME}/.gemini`, `${AGENT_HOME}/.config/opencode`, `${AGENT_HOME}/.local/share/opencode`];
94
+
95
+ function isAgentSessionPath(p) {
96
+ return AGENT_SESSION_PATHS.some(base => isUnder(p, base)) || AGENT_SESSION_PATTERNS.some(re => re.test(p));
97
+ }
98
+
99
+ /**
100
+ * 容器内一个路径属于哪一类:'agentSessions' | 'agentExtras' | 'systemLayer';永远不导出的返回 null。
101
+ * mounts:容器的挂载点目的地(含其下全部内容都不导出)。
102
+ */
103
+ function classifyLayerPath(containerPath, mounts = []) {
104
+ const p = normalizeContainerPath(containerPath);
105
+ if (!p || p === '/') return null;
106
+ if (isExcludedPath(p)) return null;
107
+ for (const mount of mounts) {
108
+ const m = normalizeContainerPath(mount);
109
+ if (m && isUnder(p, m)) return null;
110
+ }
111
+ if (isAgentSessionPath(p)) return 'agentSessions';
112
+ if (AGENT_DIRS.some(base => isUnder(p, base))) return 'agentExtras';
113
+ return 'systemLayer';
114
+ }
115
+
116
+ // 要 cp 的容器内顶层路径(cp 整棵子树,再按 diff 与分类过滤)。enabled:启用的内容项 id 集合
117
+ function layerScopes(enabled, diffPaths) {
118
+ const has = id => enabled.has(id);
119
+ const scopes = new Set();
120
+ if (has('agentExtras')) {
121
+ AGENT_DIRS.forEach(dir => scopes.add(dir));
122
+ scopes.add(`${AGENT_HOME}/.claude.json`);
123
+ } else if (has('agentSessions')) {
124
+ [`${AGENT_HOME}/.claude/projects`, `${AGENT_HOME}/.claude.json`, `${AGENT_HOME}/.codex`, `${AGENT_HOME}/.gemini/tmp`, `${AGENT_HOME}/.local/share/opencode`]
125
+ .forEach(dir => scopes.add(dir));
126
+ }
127
+ if (has('systemLayer')) {
128
+ for (const raw of diffPaths) {
129
+ const p = normalizeContainerPath(raw);
130
+ if (!p || classifyLayerPath(p) !== 'systemLayer') continue;
131
+ const parts = p.split('/').filter(Boolean);
132
+ scopes.add(`/${parts.slice(0, Math.min(parts.length, 2)).join('/')}`);
133
+ }
134
+ }
135
+ // 去掉被别的范围包含的
136
+ const list = [...scopes].sort();
137
+ return list.filter(scope => !list.some(other => other !== scope && isUnder(scope, other)));
138
+ }
139
+
140
+ // 解析 `diff` 输出:每行 `A|C|D <绝对路径>`
141
+ function parseDiff(output) {
142
+ const added = new Map();
143
+ const deleted = [];
144
+ for (const line of String(output || '').split('\n')) {
145
+ const match = /^([ACD])\s+(\/.*)$/.exec(line.trim());
146
+ if (!match) continue;
147
+ const p = normalizeContainerPath(match[2]);
148
+ if (!p) continue;
149
+ if (match[1] === 'D') deleted.push(p);
150
+ else added.set(p, match[1]);
151
+ }
152
+ return { added, deleted };
153
+ }
154
+
155
+ // 各 Agent 在容器里保存登录凭据的文件:不带密钥导出时必须跳过
156
+ const CREDENTIAL_FILES = [
157
+ `${AGENT_HOME}/.claude/.credentials.json`,
158
+ `${AGENT_HOME}/.codex/auth.json`,
159
+ `${AGENT_HOME}/.gemini/oauth_creds.json`,
160
+ `${AGENT_HOME}/.gemini/google_accounts.json`,
161
+ `${AGENT_HOME}/.local/share/opencode/auth.json`,
162
+ `${AGENT_HOME}/.config/opencode/auth.json`
163
+ ];
164
+ const isCredentialFile = p => CREDENTIAL_FILES.includes(p);
165
+
166
+ // 命令 / 脚本文本里写死的 `NAME=值`(名字含密钥词):值清空
167
+ function redactCommandText(text) {
168
+ return String(text || '').replace(/([A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|AUTH|CREDENTIAL)[A-Za-z0-9_]*=)(?:"[^"]*"|'[^']*'|[^\s"';&|]+)/gi, '$1');
169
+ }
170
+
171
+ // 密钥变量:名字含这些词的值
172
+ const SECRET_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|AUTH|CREDENTIAL)/i;
173
+ const isSecretName = name => SECRET_NAME_PATTERN.test(String(name || ''));
174
+
175
+ module.exports = {
176
+ FORMAT,
177
+ AGENT_HOME,
178
+ ITEMS,
179
+ TIERS,
180
+ EXCLUDED_PREFIXES,
181
+ AGENT_SESSION_PATHS,
182
+ AGENT_DIRS,
183
+ itemsForTier,
184
+ matchTier,
185
+ normalizeItems,
186
+ normalizeContainerPath,
187
+ isExcludedPath,
188
+ isUnder,
189
+ isAgentSessionPath,
190
+ classifyLayerPath,
191
+ layerScopes,
192
+ parseDiff,
193
+ isSecretName,
194
+ isCredentialFile,
195
+ redactCommandText,
196
+ CREDENTIAL_FILES
197
+ };
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ // 归档里的创建参数(runtimeSnapshot)来自别人时,哪些设置会让导入后的容器拥有更大的权限,
4
+ // 以及用户没有逐项确认时怎么处理。导出端把摘要写进清单,导入端据此让用户确认,并在建容器前过滤。
5
+
6
+ const path = require('path');
7
+ const { normalizePolicy, isUnrestricted } = require('../network-policy');
8
+
9
+ const RISKY_MODES = new Set(['sock', 'dind']);
10
+
11
+ function asArray(value) {
12
+ return Array.isArray(value) ? value.filter(item => typeof item === 'string') : [];
13
+ }
14
+
15
+ function volumeSource(volume) {
16
+ const first = String(volume).split(':')[0];
17
+ return first.startsWith('/') || first.startsWith('~') ? first : '';
18
+ }
19
+
20
+ // 需要确认的网络设置:任何“自定义”策略(里面可能放行内网 / 元数据地址,或干脆不限制),策略读不懂时也算(宁可多问一次)
21
+ function isNetworkOpen(network) {
22
+ if (!network || typeof network !== 'object') return false;
23
+ try {
24
+ const policy = normalizePolicy(network);
25
+ return policy.preset === 'custom' || isUnrestricted(policy);
26
+ } catch (error) {
27
+ return true;
28
+ }
29
+ }
30
+
31
+ // 默认只信任 manyoyo 自己的镜像名:归档里写别的镜像名会让导入后的容器拉取并运行别人指定的镜像(带着工作目录)
32
+ const TRUSTED_IMAGE_NAMES = ['ghcr.io/xcanwin/manyoyo', 'localhost/xcanwin/manyoyo'];
33
+
34
+ /**
35
+ * 摘要:{ containerMode, volumes, ports, autostart, networkOpen }
36
+ * autostartText:状态目录里的自启动脚本(快照里的 autostart 为空时用它)。
37
+ */
38
+ function summarizeRisks(snapshot, autostartText = '') {
39
+ const snap = snapshot && typeof snapshot === 'object' ? snapshot : {};
40
+ const autostart = String(snap.autostart || autostartText || '').trim();
41
+ return {
42
+ containerMode: RISKY_MODES.has(snap.containerMode) ? snap.containerMode : '',
43
+ volumes: asArray(snap.volumes),
44
+ ports: asArray(snap.ports),
45
+ autostart: autostart.length > 0,
46
+ envFiles: asArray(snap.envFile),
47
+ networkOpen: isNetworkOpen(snap.network)
48
+ };
49
+ }
50
+
51
+ /**
52
+ * 按用户确认过滤快照,得到可交给 buildCreateRuntime 的 createOptions。
53
+ * confirm:{ containerMode, volumes: [字符串], ports: bool, autostart: bool, envFiles: [路径], networkOpen: bool }
54
+ * options:{ hostPath, imageMode, pathExists(p), trustedImages? }
55
+ * 返回 { createOptions, dropped: [说明] }。first 永远不带;containerPath 保持原样(续接依赖它)。
56
+ */
57
+ function buildImportCreateOptions(snapshot, confirm, options) {
58
+ const snap = snapshot && typeof snapshot === 'object' ? snapshot : {};
59
+ const ok = confirm && typeof confirm === 'object' ? confirm : {};
60
+ const pathExists = options.pathExists || (() => false);
61
+ const dropped = [];
62
+ const createOptions = {
63
+ hostPath: options.hostPath,
64
+ containerPath: typeof snap.containerPath === 'string' ? snap.containerPath : '',
65
+ shellPrefix: snap.shellPrefix || '',
66
+ shell: snap.shell || '',
67
+ shellSuffix: snap.shellSuffix || '',
68
+ agentPromptCommand: snap.agentPromptCommand || '',
69
+ yolo: snap.yolo || '',
70
+ env: snap.env && typeof snap.env === 'object' && !Array.isArray(snap.env) ? snap.env : {},
71
+ systemPrompt: snap.systemPrompt || undefined
72
+ };
73
+ if (options.imageMode !== 'default') {
74
+ const trusted = [...TRUSTED_IMAGE_NAMES, ...asArray(options.trustedImages)];
75
+ if (snap.imageName && !trusted.includes(String(snap.imageName))) {
76
+ dropped.push(`镜像 ${String(snap.imageName).slice(0, 80)} 不是 manyoyo 的镜像,改用本机默认镜像`);
77
+ } else {
78
+ createOptions.imageName = snap.imageName || '';
79
+ createOptions.imageVersion = snap.imageVersion || '';
80
+ }
81
+ }
82
+ if (RISKY_MODES.has(snap.containerMode)) {
83
+ if (ok.containerMode === snap.containerMode) createOptions.containerMode = snap.containerMode;
84
+ else dropped.push(`容器模式 ${snap.containerMode} 未确认,改用普通模式`);
85
+ } else {
86
+ createOptions.containerMode = snap.containerMode || '';
87
+ }
88
+ const confirmedVolumes = new Set(asArray(ok.volumes));
89
+ createOptions.volumes = asArray(snap.volumes).filter(volume => {
90
+ if (!confirmedVolumes.has(volume)) { dropped.push(`挂载 ${volume} 未确认`); return false; }
91
+ const source = volumeSource(volume);
92
+ if (source && !pathExists(source.startsWith('~') ? path.join(process.env.HOME || '', source.slice(1)) : source)) {
93
+ dropped.push(`挂载 ${volume} 的宿主机路径在本机不存在`);
94
+ return false;
95
+ }
96
+ return true;
97
+ });
98
+ createOptions.ports = ok.ports === true ? asArray(snap.ports) : [];
99
+ if (!createOptions.ports.length && asArray(snap.ports).length) dropped.push('端口映射未确认');
100
+ createOptions.autostart = ok.autostart === true ? String(snap.autostart || '') : '';
101
+ if (snap.autostart && ok.autostart !== true) dropped.push('自启动脚本未确认');
102
+ // 环境变量文件里的内容会进容器环境:宿主机上的任意绝对路径,必须逐个确认
103
+ const confirmedEnvFiles = new Set(asArray(ok.envFiles));
104
+ createOptions.envFile = asArray(snap.envFile).filter(file => {
105
+ if (!path.isAbsolute(file) || !pathExists(file)) return false;
106
+ if (!confirmedEnvFiles.has(file)) { dropped.push(`环境变量文件 ${file} 未确认`); return false; }
107
+ return true;
108
+ });
109
+ if (snap.network && typeof snap.network === 'object') {
110
+ if (isNetworkOpen(snap.network) && ok.networkOpen !== true) {
111
+ dropped.push('自定义网络规则未确认,改用默认的受限网络');
112
+ } else {
113
+ createOptions.network = snap.network;
114
+ // 用户确认过“自定义”才跳过后面的风险检查;其它策略仍要过 buildCreateRuntime 里的宽规则检查
115
+ if (ok.networkOpen === true) createOptions.confirmRisk = true;
116
+ }
117
+ }
118
+ return { createOptions, dropped };
119
+ }
120
+
121
+ module.exports = { summarizeRisks, buildImportCreateOptions, isNetworkOpen, RISKY_MODES, TRUSTED_IMAGE_NAMES };
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ // 导出 / 导入用的容器运行时封装(docker / podman 命令行)。
4
+ // 导出导入的核心逻辑只依赖这里的接口,测试里用假实现替换。
5
+
6
+ const { spawn, spawnSync } = require('child_process');
7
+
8
+ function createCliRuntime({ command, env }) {
9
+ const sync = (args, options = {}) => spawnSync(command, args, { env: env || process.env, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, ...options });
10
+ const must = (args, what) => {
11
+ const result = sync(args);
12
+ if (result.status !== 0) throw new Error(`${what}失败: ${String(result.stderr || '').trim().slice(0, 300)}`);
13
+ return result.stdout;
14
+ };
15
+
16
+ return {
17
+ // 容器信息;容器不存在返回 null
18
+ inspect(name) {
19
+ const result = sync(['inspect', '--format', '{{json .}}', name]);
20
+ if (result.status !== 0) return null;
21
+ let data;
22
+ try {
23
+ data = JSON.parse(result.stdout.split('\n')[0]);
24
+ } catch (error) {
25
+ return null;
26
+ }
27
+ const labels = (data.Config && data.Config.Labels) || {};
28
+ return {
29
+ id: labels['manyoyo.id'] || '',
30
+ image: (data.Config && data.Config.Image) || '',
31
+ status: data.State && data.State.Paused ? 'paused' : (data.State && data.State.Running ? 'running' : 'stopped'),
32
+ mounts: (data.Mounts || []).map(item => item.Destination).filter(Boolean),
33
+ defaultCmd: labels['manyoyo.default_cmd'] || ''
34
+ };
35
+ },
36
+ pause(name) {
37
+ must(['pause', name], '暂停容器');
38
+ },
39
+ unpause(name) {
40
+ must(['unpause', name], '恢复容器');
41
+ },
42
+ // 暂停不可用时的退路:停止 / 启动(启动会重新跑自启动脚本)
43
+ stop(name) {
44
+ must(['stop', '-t', '10', name], '停止容器');
45
+ },
46
+ start(name) {
47
+ must(['start', name], '启动容器');
48
+ },
49
+ diff(name) {
50
+ return must(['diff', name], '读取容器改动');
51
+ },
52
+ // 返回 { stream, done }:stream 是 tar 输出;done 在进程结束后给出 { code, stderr }
53
+ cp(name, containerPath) {
54
+ const child = spawn(command, ['cp', `${name}:${containerPath}`, '-'], { env: env || process.env, stdio: ['ignore', 'pipe', 'pipe'] });
55
+ let stderr = '';
56
+ child.stderr.on('data', chunk => { if (stderr.length < 4096) stderr += chunk; });
57
+ const done = new Promise(resolve => child.on('close', code => resolve({ code, stderr })));
58
+ child.on('error', error => child.stdout.destroy(error));
59
+ return { stream: child.stdout, done, kill: () => child.kill('SIGKILL') };
60
+ },
61
+ // 把 tar 流写进容器:返回 { stdin, done }
62
+ cpIn(name, containerPath) {
63
+ const child = spawn(command, ['cp', '-', `${name}:${containerPath}`], { env: env || process.env, stdio: ['pipe', 'ignore', 'pipe'] });
64
+ let stderr = '';
65
+ child.stderr.on('data', chunk => { if (stderr.length < 4096) stderr += chunk; });
66
+ const done = new Promise(resolve => child.on('close', code => resolve({ code, stderr })));
67
+ return { stdin: child.stdin, done, kill: () => child.kill('SIGKILL') };
68
+ }
69
+ };
70
+ }
71
+
72
+ module.exports = { createCliRuntime };
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ // 宿主机侧解包的安全规则(导入的归档可能来自别人):
4
+ // - 拒绝绝对路径、`..`、空段、NUL、硬链接、设备等特殊文件;
5
+ // - 写文件前逐级 lstat:父目录必须是本次新建的真实目录(不是符号链接),目标不能是已存在的符号链接;
6
+ // - 去掉 setuid / setgid / sticky;
7
+ // - 符号链接不在解包时创建:先记下来,最后只在“相对路径且解析后仍在根目录内”时创建,其余跳过并计数。
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { pipeline } = require('stream/promises');
12
+ const { Readable } = require('stream');
13
+
14
+ class UnsafeEntryError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'UnsafeEntryError';
18
+ }
19
+ }
20
+
21
+ // 归档里的相对路径 → 段数组;不合规直接抛错
22
+ function splitEntryPath(name) {
23
+ if (typeof name !== 'string' || name.length === 0) throw new UnsafeEntryError('条目名为空');
24
+ if (name.includes('\0')) throw new UnsafeEntryError('条目名含 NUL');
25
+ if (name.includes('\\')) throw new UnsafeEntryError(`条目名含反斜杠: ${name}`);
26
+ if (name.startsWith('/')) throw new UnsafeEntryError(`条目名是绝对路径: ${name}`);
27
+ const segments = name.split('/');
28
+ if (segments[segments.length - 1] === '') segments.pop();
29
+ if (!segments.length) throw new UnsafeEntryError('条目名为空');
30
+ for (const segment of segments) {
31
+ if (segment === '' ) throw new UnsafeEntryError(`条目名含空段: ${name}`);
32
+ if (segment === '.' || segment === '..') throw new UnsafeEntryError(`条目名含 ${segment}: ${name}`);
33
+ }
34
+ return segments;
35
+ }
36
+
37
+ function safeMode(mode, isDir) {
38
+ // 只留 rwx 九位,去掉 setuid / setgid / sticky;目录至少本人可进入可写(解包过程需要)
39
+ const base = (Number(mode) || 0) & 0o777;
40
+ return isDir ? (base | 0o700) : (base || 0o600);
41
+ }
42
+
43
+ class SafeExtractor {
44
+ /**
45
+ * root:本次导入的临时目录(调用方新建,已存在)。
46
+ * limits:{ maxFiles, maxBytes },防止无限膨胀。
47
+ */
48
+ constructor(root, limits = {}) {
49
+ this.root = fs.realpathSync(root);
50
+ this.created = new Set([this.root]);
51
+ this.deferredLinks = [];
52
+ this.skippedLinks = 0;
53
+ this.skippedSpecial = 0;
54
+ this.files = 0;
55
+ this.bytes = 0;
56
+ this.maxFiles = limits.maxFiles || 5_000_000;
57
+ this.maxBytes = limits.maxBytes || Infinity;
58
+ }
59
+
60
+ // 逐级确认父目录是“本次新建的真实目录”;缺的就建
61
+ ensureDir(segments) {
62
+ let current = this.root;
63
+ for (const segment of segments) {
64
+ const next = path.join(current, segment);
65
+ let stat = null;
66
+ try {
67
+ stat = fs.lstatSync(next);
68
+ } catch (error) {
69
+ if (error.code !== 'ENOENT') throw error;
70
+ }
71
+ if (stat === null) {
72
+ fs.mkdirSync(next, { mode: 0o700 });
73
+ this.created.add(next);
74
+ } else if (!stat.isDirectory() || stat.isSymbolicLink()) {
75
+ throw new UnsafeEntryError(`父路径不是目录: ${next}`);
76
+ } else if (!this.created.has(next)) {
77
+ throw new UnsafeEntryError(`父目录不是本次导入新建的: ${next}`);
78
+ }
79
+ current = next;
80
+ }
81
+ return current;
82
+ }
83
+
84
+ addDir(name, mode) {
85
+ const segments = splitEntryPath(name);
86
+ const dir = this.ensureDir(segments);
87
+ try { fs.chmodSync(dir, safeMode(mode, true)); } catch (error) { /* 尽力而为 */ }
88
+ }
89
+
90
+ // body:异步可迭代的字节流
91
+ async addFile(name, mode, body, expectedSize) {
92
+ const segments = splitEntryPath(name);
93
+ this.files += 1;
94
+ if (this.files > this.maxFiles) throw new UnsafeEntryError('条目数量超过上限');
95
+ if (this.bytes + expectedSize > this.maxBytes) throw new UnsafeEntryError('解包大小超过上限');
96
+ const parent = this.ensureDir(segments.slice(0, -1));
97
+ const target = path.join(parent, segments[segments.length - 1]);
98
+ let flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0);
99
+ const fd = fs.openSync(target, flags, safeMode(mode, false));
100
+ try {
101
+ const stream = fs.createWriteStream(null, { fd, autoClose: false });
102
+ const counted = Readable.from((async function* count(self) {
103
+ for await (const chunk of body) {
104
+ self.bytes += chunk.length;
105
+ if (self.bytes > self.maxBytes) throw new UnsafeEntryError('解包大小超过上限');
106
+ yield chunk;
107
+ }
108
+ })(this));
109
+ await pipeline(counted, stream);
110
+ } finally {
111
+ fs.closeSync(fd);
112
+ }
113
+ return target;
114
+ }
115
+
116
+ // 符号链接:先记下,最后统一处理
117
+ addSymlink(name, linkname) {
118
+ const segments = splitEntryPath(name);
119
+ this.deferredLinks.push({ segments, linkname: String(linkname || '') });
120
+ }
121
+
122
+ skipSpecial() {
123
+ this.skippedSpecial += 1;
124
+ }
125
+
126
+ // 所有文件落地后调用:只创建“相对路径且解析后仍在 root 内”的链接
127
+ finalizeSymlinks() {
128
+ let made = 0;
129
+ for (const { segments, linkname } of this.deferredLinks) {
130
+ const verdict = this.checkLink(segments, linkname);
131
+ if (!verdict) {
132
+ this.skippedLinks += 1;
133
+ continue;
134
+ }
135
+ try {
136
+ const parent = this.ensureDir(segments.slice(0, -1));
137
+ const target = path.join(parent, segments[segments.length - 1]);
138
+ try {
139
+ fs.lstatSync(target);
140
+ this.skippedLinks += 1;
141
+ continue;
142
+ } catch (error) {
143
+ if (error.code !== 'ENOENT') throw error;
144
+ }
145
+ fs.symlinkSync(linkname, target);
146
+ made += 1;
147
+ } catch (error) {
148
+ this.skippedLinks += 1;
149
+ }
150
+ }
151
+ this.deferredLinks = [];
152
+ return made;
153
+ }
154
+
155
+ checkLink(segments, linkname) {
156
+ if (!linkname || linkname.includes('\0') || path.isAbsolute(linkname)) return false;
157
+ const dirOfLink = path.join(this.root, ...segments.slice(0, -1));
158
+ const resolved = path.resolve(dirOfLink, linkname);
159
+ return resolved === this.root || resolved.startsWith(`${this.root}${path.sep}`);
160
+ }
161
+ }
162
+
163
+ module.exports = { SafeExtractor, UnsafeEntryError, splitEntryPath, safeMode };