zipcalm 0.0.0-stage → 1.0.2
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 +21 -0
- package/README.md +260 -2
- package/bin/zipcalm.js +191 -0
- package/package.json +67 -4
- package/server/app.js +309 -0
- package/server/boot.js +145 -0
- package/server/config/defaults.js +153 -0
- package/server/config/index.js +140 -0
- package/server/config/schema.js +244 -0
- package/server/core/auth/password.js +89 -0
- package/server/core/auth/store.js +261 -0
- package/server/core/compress/blacklist.js +236 -0
- package/server/core/compress/capRegistry.js +171 -0
- package/server/core/compress/deleter.js +285 -0
- package/server/core/compress/hardlinkScan.js +67 -0
- package/server/core/compress/orchestrator.js +783 -0
- package/server/core/compress/preflight.js +376 -0
- package/server/core/compress/registry.js +240 -0
- package/server/core/compress/sourceWalk.js +84 -0
- package/server/core/compress/verify.js +47 -0
- package/server/core/engineRunner.js +369 -0
- package/server/core/fs/list.js +164 -0
- package/server/core/fs/platformProfile.js +167 -0
- package/server/core/fs/repairNames.js +150 -0
- package/server/core/fs/roots.js +178 -0
- package/server/core/fs/safePath.js +173 -0
- package/server/core/fs/search.js +108 -0
- package/server/core/fs/stat.js +110 -0
- package/server/core/fs/systemTrash.js +212 -0
- package/server/core/fs/writeOps.js +257 -0
- package/server/core/hardlink/executor.js +106 -0
- package/server/core/hardlink/scanner.js +193 -0
- package/server/core/metrics/collector.js +279 -0
- package/server/core/metrics/procfs.js +107 -0
- package/server/core/task/events.js +82 -0
- package/server/core/task/manager.js +987 -0
- package/server/core/task/recovery.js +116 -0
- package/server/core/task/store.js +240 -0
- package/server/http/middleware/auth.js +130 -0
- package/server/http/middleware/error.js +54 -0
- package/server/http/middleware/logger.js +27 -0
- package/server/http/middleware/pathGuard.js +119 -0
- package/server/http/router.js +29 -0
- package/server/http/routes/auth.js +115 -0
- package/server/http/routes/blacklist.js +97 -0
- package/server/http/routes/capabilities.js +21 -0
- package/server/http/routes/compress.js +339 -0
- package/server/http/routes/fs.js +109 -0
- package/server/http/routes/hardlink.js +160 -0
- package/server/http/routes/health.js +52 -0
- package/server/http/routes/metrics.js +36 -0
- package/server/http/routes/tasks.js +304 -0
- package/server/lib/atomic-json.js +118 -0
- package/server/lib/errors.js +145 -0
- package/server/lib/fs-probe.js +25 -0
- package/server/lib/logger.js +68 -0
- package/server/lib/runtime-guard.js +49 -0
- package/server/lib/throttle.js +42 -0
- package/server/plugins/capabilities/password/index.js +300 -0
- package/server/plugins/capabilities/password/methods/aes256.js +31 -0
- package/server/plugins/capabilities/password/methods/zip20.js +29 -0
- package/server/plugins/formats/_shared/createGuards.js +26 -0
- package/server/plugins/formats/_shared/entrySafety.js +174 -0
- package/server/plugins/formats/_shared/nameRepair.js +123 -0
- package/server/plugins/formats/_shared/streams.js +18 -0
- package/server/plugins/formats/_shared/tar/compression.js +114 -0
- package/server/plugins/formats/_shared/tar/reader.js +74 -0
- package/server/plugins/formats/_shared/tar/writer.js +206 -0
- package/server/plugins/formats/_shared/tarFamily.js +207 -0
- package/server/plugins/formats/rar/index.js +354 -0
- package/server/plugins/formats/rar/js-engine.js +122 -0
- package/server/plugins/formats/rar/js-runner.mjs +129 -0
- package/server/plugins/formats/rar/unrar.js +266 -0
- package/server/plugins/formats/sevenzip/engine.js +355 -0
- package/server/plugins/formats/sevenzip/index.js +461 -0
- package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
- package/server/plugins/formats/tar/index.js +19 -0
- package/server/plugins/formats/targz/index.js +19 -0
- package/server/plugins/formats/tarzst/index.js +26 -0
- package/server/plugins/formats/zip/index.js +587 -0
- package/shared/app-version.js +7 -0
- package/shared/capabilities.js +21 -0
- package/shared/compress-modes.js +38 -0
- package/shared/error-codes.js +117 -0
- package/shared/format-list.js +101 -0
- package/shared/hardlink-hint.js +21 -0
- package/shared/task-phase.js +62 -0
- package/shared/task-status.js +84 -0
- package/web/dist/assets/index-D3zjq7N2.js +131 -0
- package/web/dist/assets/index-D9Vdmrb9.css +1 -0
- package/web/dist/index.html +18 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// RAR 外部解压工具封装(unrar 优先,7z 兜底)
|
|
2
|
+
//
|
|
3
|
+
// 为什么必须走外部二进制:RAR 的压缩算法是闭源商业算法,Node 生态没有合规实现。
|
|
4
|
+
// **绝不把 rar/unrar 打进项目或仓库**(docs/18 §附三法律边界);调用用户已安装的程序是合法的。
|
|
5
|
+
//
|
|
6
|
+
// 实测结论(UNRAR 5.70 / p7zip 16.02):
|
|
7
|
+
// - `unrar lt` 是机器可读的技术列表(Name / Type / Size / Packed size / Flags: encrypted)
|
|
8
|
+
// - 缺密码与错密码在 unrar 侧**同为 exit 11 + 同一句提示**,无法从输出区分 —— 只能由调用方
|
|
9
|
+
// 根据"我们有没有传密码"来判定 MISSING_PASSWORD / BAD_PASSWORD(三态口径见 P4)
|
|
10
|
+
// - 损坏包 exit 1("Unexpected end of archive" / "Total errors: N")
|
|
11
|
+
// - p7zip 16.02 对 rar5 返回 exit 2(实测),所以 **unrar 是唯一可靠后端**,7z 仅作兜底
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
|
|
16
|
+
import { ERROR_CODES, apiError } from '../../../lib/errors.js';
|
|
17
|
+
import { runEngineProcess } from '../../../core/engineRunner.js';
|
|
18
|
+
import { jsAvailable, jsVersion } from './js-engine.js';
|
|
19
|
+
|
|
20
|
+
/** 二进制探测结果缓存 60s(与格式注册表的可用性缓存同频,装了工具最多一分钟就该亮起来) */
|
|
21
|
+
const PROBE_TTL_MS = 60 * 1000;
|
|
22
|
+
let probeCache = null;
|
|
23
|
+
|
|
24
|
+
/** 单次命令超时:列表/校验给 60s,解压给 30min(大包慢盘) */
|
|
25
|
+
export const LIST_TIMEOUT_MS = 60 * 1000;
|
|
26
|
+
export const EXTRACT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
/** 候选路径:环境变量优先,其次常见安装位置(禁止写死单一绝对路径) */
|
|
29
|
+
/**
|
|
30
|
+
* 候选二进制。
|
|
31
|
+
*
|
|
32
|
+
* `ZC_UNRAR_BIN` / `ZC_7Z_BIN` **设了就独占**(不是"优先再回落"):语义是"我明确指定用哪个二进制",
|
|
33
|
+
* 指到不可执行的文件说明部署配错,应当如实报不可用并让人去修,而不是悄悄换另一个
|
|
34
|
+
* (那样运维看不出自己配错,回退链也测不出来)。与 7z 的 `ZC_7Z_BIN` 同规则。
|
|
35
|
+
*/
|
|
36
|
+
function candidatesOf(kind) {
|
|
37
|
+
const envValue = kind === 'unrar' ? process.env.ZC_UNRAR_BIN : process.env.ZC_7Z_BIN;
|
|
38
|
+
if (typeof envValue === 'string' && envValue.length > 0) return [envValue];
|
|
39
|
+
return kind === 'unrar'
|
|
40
|
+
? ['/bin/unrar', '/usr/bin/unrar', '/usr/local/bin/unrar']
|
|
41
|
+
: ['/bin/7z', '/usr/bin/7z', '/usr/local/bin/7z'];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 可执行文件判定:必须存在且真的能执行(只看存在会把坏链接/无执行位当可用) */
|
|
45
|
+
function isExecutable(candidate) {
|
|
46
|
+
try {
|
|
47
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 探测可用后端。
|
|
56
|
+
* @returns {{unrar: string|null, sevenZip: string|null}}
|
|
57
|
+
*/
|
|
58
|
+
export function resolveTools() {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
if (probeCache && now - probeCache.at < PROBE_TTL_MS) return probeCache.tools;
|
|
61
|
+
const tools = {
|
|
62
|
+
unrar: candidatesOf('unrar').find(isExecutable) || null,
|
|
63
|
+
sevenZip: candidatesOf('sevenZip').find(isExecutable) || null,
|
|
64
|
+
};
|
|
65
|
+
probeCache = { at: now, tools };
|
|
66
|
+
return tools;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 测试用:清掉探测缓存(换了环境变量后不必等 60s) */
|
|
70
|
+
export function resetProbeCache() {
|
|
71
|
+
probeCache = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 运行外部命令,按行回调(用于进度解析)。
|
|
76
|
+
* @param {string} bin
|
|
77
|
+
* @param {string[]} args
|
|
78
|
+
* @param {{timeoutMs?: number, onLine?: (line: string) => void, signal?: AbortSignal}} [options]
|
|
79
|
+
* @returns {Promise<{code: number, stdout: string, stderr: string, timedOut: boolean, aborted: boolean}>}
|
|
80
|
+
*/
|
|
81
|
+
export function runCommand(bin, args, options = {}) {
|
|
82
|
+
// 绑核 / nice / 全局串行 / 超时硬杀 / 取消:统一由 core/engineRunner.js 处理(与 7z 共用一把锁)
|
|
83
|
+
return runEngineProcess({
|
|
84
|
+
command: bin,
|
|
85
|
+
args,
|
|
86
|
+
// **必须**强制 UTF-8 locale:默认 locale 与 C.UTF-8 都会让 unrar 输出有损的 `?`(不可还原),
|
|
87
|
+
// 只有 en_US.UTF-8 / zh_CN.UTF-8 才输出可逆的 PUA 转义(详见 _shared/nameRepair.js 头注释)
|
|
88
|
+
env: { LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' },
|
|
89
|
+
timeoutMs: options.timeoutMs || LIST_TIMEOUT_MS,
|
|
90
|
+
onLine: options.onLine,
|
|
91
|
+
signal: options.signal,
|
|
92
|
+
label: 'unrar',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 是否加密:`lt` 的条目块里出现 `Flags: encrypted` */
|
|
97
|
+
export function hasEncryptionFlag(ltOutput) {
|
|
98
|
+
return /Flags:.*encrypted/i.test(String(ltOutput || ''));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 解析 `unrar lt` 的技术列表。
|
|
103
|
+
* 输出随版本变化 → 解析不出来就返回空数组,由调用方降级到 `lb`(裸名列表),**不报错**。
|
|
104
|
+
* @returns {Array<{name: string, type: 'dir'|'file', size: number, compressedSize: number}>}
|
|
105
|
+
*/
|
|
106
|
+
export function parseTechnicalList(ltOutput) {
|
|
107
|
+
const entries = [];
|
|
108
|
+
let current = null;
|
|
109
|
+
for (const rawLine of String(ltOutput || '').split(/\r?\n/)) {
|
|
110
|
+
const line = rawLine.trim();
|
|
111
|
+
const nameMatch = line.match(/^Name:\s*(.+)$/);
|
|
112
|
+
if (nameMatch) {
|
|
113
|
+
if (current && current.name) entries.push(current);
|
|
114
|
+
current = { name: nameMatch[1], type: 'file', size: 0, compressedSize: 0 };
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!current) continue;
|
|
118
|
+
const typeMatch = line.match(/^Type:\s*(\S+)/);
|
|
119
|
+
if (typeMatch) {
|
|
120
|
+
current.type = /dir/i.test(typeMatch[1]) ? 'dir' : 'file';
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const sizeMatch = line.match(/^Size:\s*(\d+)/);
|
|
124
|
+
if (sizeMatch) {
|
|
125
|
+
current.size = Number(sizeMatch[1]);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const packedMatch = line.match(/^Packed size:\s*(\d+)/);
|
|
129
|
+
if (packedMatch) current.compressedSize = Number(packedMatch[1]);
|
|
130
|
+
}
|
|
131
|
+
if (current && current.name) entries.push(current);
|
|
132
|
+
return entries.map((entry) => ({
|
|
133
|
+
// 归档内路径统一成 POSIX:RAR 可能用反斜杠(Windows 打的包)
|
|
134
|
+
name: String(entry.name).replace(/\\/g, '/'),
|
|
135
|
+
type: entry.type,
|
|
136
|
+
size: entry.type === 'dir' ? 0 : entry.size,
|
|
137
|
+
compressedSize: entry.compressedSize,
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `lb` 裸名列表(解析降级路径):只有名字,大小未知 */
|
|
142
|
+
export function parseBareList(lbOutput) {
|
|
143
|
+
return String(lbOutput || '')
|
|
144
|
+
.split(/\r?\n/)
|
|
145
|
+
.map((line) => line.trim())
|
|
146
|
+
.filter((line) => line.length > 0)
|
|
147
|
+
.map((line) => {
|
|
148
|
+
const name = line.replace(/\\/g, '/');
|
|
149
|
+
return { name, type: name.endsWith('/') ? 'dir' : 'file', size: 0, compressedSize: 0 };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 把 unrar/7z 的失败翻译成契约错误码(**四种情况不能混用**)。
|
|
155
|
+
* @param {{stderr: string, stdout: string, code: number, timedOut?: boolean, aborted?: boolean}} outcome
|
|
156
|
+
* @param {{hasPassword: boolean}} context
|
|
157
|
+
*/
|
|
158
|
+
export function mapToolFailure(outcome, context) {
|
|
159
|
+
if (outcome.aborted) throw apiError(ERROR_CODES.ABORTED, '任务已取消');
|
|
160
|
+
if (outcome.timedOut) {
|
|
161
|
+
throw apiError(ERROR_CODES.INTERNAL_ERROR, 'RAR 解压超时(外部程序未在限定时间内完成)');
|
|
162
|
+
}
|
|
163
|
+
const text = `${outcome.stdout}\n${outcome.stderr}`;
|
|
164
|
+
// 密码相关:unrar 对"没给密码"和"密码给错"都是同一句提示,只能靠我们是否传了密码来判定
|
|
165
|
+
if (/password/i.test(text)) {
|
|
166
|
+
if (!context.hasPassword) throw apiError(ERROR_CODES.MISSING_PASSWORD, '该压缩包已加密,请输入密码');
|
|
167
|
+
throw apiError(ERROR_CODES.BAD_PASSWORD, '密码错误');
|
|
168
|
+
}
|
|
169
|
+
if (/cannot open|No such file|is not RAR archive|Unexpected end|corrupt|CRC failed|checksum/i.test(text)) {
|
|
170
|
+
throw apiError(ERROR_CODES.ARCHIVE_CORRUPTED, '压缩包损坏');
|
|
171
|
+
}
|
|
172
|
+
throw apiError(ERROR_CODES.ARCHIVE_CORRUPTED, '压缩包损坏', {
|
|
173
|
+
exitCode: outcome.code,
|
|
174
|
+
detail: text.trim().split(/\r?\n/).slice(-2).join(' / ').slice(0, 200),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 是否存在可用的后端(unrar 优先) */
|
|
179
|
+
export function pickBinaries() {
|
|
180
|
+
const tools = resolveTools();
|
|
181
|
+
return { unrar: tools.unrar, sevenZip: tools.sevenZip };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 同步探测版本(供 /formats 的 unavailableReason 附带线索;失败返回 null) */
|
|
185
|
+
export function probeUnrarVersion(bin) {
|
|
186
|
+
try {
|
|
187
|
+
const output = execFileSync(bin, [], { encoding: 'utf8', timeout: 5000 });
|
|
188
|
+
const matched = String(output).match(/UNRAR\s+([\d.]+)/i);
|
|
189
|
+
return matched ? matched[1] : null;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 版本号比较(只比较数字段,缺位按 0 算):a<b 返回 -1,相等 0,a>b 返回 1 */
|
|
196
|
+
export function compareVersion(a, b) {
|
|
197
|
+
const left = String(a || '0').split('.').map((part) => Number.parseInt(part, 10) || 0);
|
|
198
|
+
const right = String(b || '0').split('.').map((part) => Number.parseInt(part, 10) || 0);
|
|
199
|
+
const length = Math.max(left.length, right.length);
|
|
200
|
+
for (let index = 0; index < length; index += 1) {
|
|
201
|
+
const diff = (left[index] || 0) - (right[index] || 0);
|
|
202
|
+
if (diff !== 0) return diff > 0 ? 1 : -1;
|
|
203
|
+
}
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 「文件名是否可靠」的版本阈值。
|
|
209
|
+
*
|
|
210
|
+
* 实测依据(同一份 RAR5 中文名样本 `scripts/fixtures/rar/s7-chinese.rar`):
|
|
211
|
+
* unrar 5.70(DSM 自带)→ `s7/????????????????.txt` / 落盘字节 `efbfbe ee83a5…`(UTF-8 被当 UTF-16)✗
|
|
212
|
+
* unrar 7.23(新版) → `s7/中文文件名.txt` / 落盘字节 `e4b8ad e69687…` ✓
|
|
213
|
+
* node-unrar-js 2.0.2(内置 unrar 6.1.7)→ 仍是乱码 ✗
|
|
214
|
+
* 所以阈值取 **7.0**(保守):低于它一律认为文件名不可靠,需要提示用户升级。
|
|
215
|
+
*/
|
|
216
|
+
export const FILENAME_SAFE_MIN_VERSION = '7.0';
|
|
217
|
+
|
|
218
|
+
export function isFilenameSafe(version) {
|
|
219
|
+
if (!version) return false;
|
|
220
|
+
return compareVersion(version, FILENAME_SAFE_MIN_VERSION) >= 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 解析 RAR 引擎(三级回退:原生 unrar → node-unrar-js → 7z → 无)。
|
|
225
|
+
*
|
|
226
|
+
* 默认策略(`ZC_UNRAR_ENGINE=auto`):**有原生 unrar 就用原生**,哪怕版本旧——
|
|
227
|
+
* 因为实测 js 引擎修不了中文名,且对损坏包不报错,换过去是能力退化;
|
|
228
|
+
* 原生缺失时才回落到 js(至少"能解"),再不行看 7z。
|
|
229
|
+
* `ZC_UNRAR_ENGINE=native|js` 可强制指定(验收与排障用)。
|
|
230
|
+
*
|
|
231
|
+
* @returns {{kind: 'unrar'|'js'|'7z'|null, bin: string|null, version: string|null, filenameSafe: boolean, reason: string|null}}
|
|
232
|
+
*/
|
|
233
|
+
export function resolveEngine() {
|
|
234
|
+
const mode = String(process.env.ZC_UNRAR_ENGINE || 'auto').toLowerCase();
|
|
235
|
+
const tools = resolveTools();
|
|
236
|
+
const native = tools.unrar
|
|
237
|
+
? { kind: 'unrar', bin: tools.unrar, version: probeUnrarVersion(tools.unrar) }
|
|
238
|
+
: null;
|
|
239
|
+
const nativeEngine = native
|
|
240
|
+
? { ...native, filenameSafe: isFilenameSafe(native.version), reason: null }
|
|
241
|
+
: null;
|
|
242
|
+
const jsEngine = jsAvailable()
|
|
243
|
+
? { kind: 'js', bin: null, version: jsVersion(), filenameSafe: false, reason: null }
|
|
244
|
+
: null;
|
|
245
|
+
const sevenEngine = tools.sevenZip
|
|
246
|
+
? { kind: '7z', bin: tools.sevenZip, version: probe7zVersion(tools.sevenZip), filenameSafe: false, reason: null }
|
|
247
|
+
: null;
|
|
248
|
+
|
|
249
|
+
if (mode === 'native') return nativeEngine || { kind: null, bin: null, version: null, filenameSafe: false, reason: 'ZC_UNRAR_ENGINE=native 但没找到可执行的 unrar' };
|
|
250
|
+
if (mode === 'js') return jsEngine || { kind: null, bin: null, version: null, filenameSafe: false, reason: 'ZC_UNRAR_ENGINE=js 但 node-unrar-js 不可用' };
|
|
251
|
+
|
|
252
|
+
const picked = nativeEngine || jsEngine || sevenEngine;
|
|
253
|
+
if (picked) return picked;
|
|
254
|
+
return { kind: null, bin: null, version: null, filenameSafe: false, reason: '未找到 unrar / node-unrar-js / 7z' };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 7z 的版本(只在没有 unrar/js 时用于兜底诊断) */
|
|
258
|
+
function probe7zVersion(bin) {
|
|
259
|
+
try {
|
|
260
|
+
const output = execFileSync(bin, [], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
261
|
+
const matched = String(output).match(/7-Zip[^\n]*?(\d+\.\d+)/i);
|
|
262
|
+
return matched ? matched[1] : null;
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// 7z 外部引擎封装(三级回退:系统 7z/7za → WASM 引擎 → 不可用)
|
|
2
|
+
//
|
|
3
|
+
// 为什么先探测系统二进制:系统 `/bin/7z`(p7zip 16.02)零新增依赖、零加载开销,
|
|
4
|
+
// 只有部署机上没有 7z 时才需要 WASM 兜底(WASM 引擎需额外依赖,见 docs/18 §附四)。
|
|
5
|
+
//
|
|
6
|
+
// 实测事实(p7zip 16.02,本项目验证环境):
|
|
7
|
+
// - `7z a -t7z -mx0..9` 的级别真实生效(同一份 20 KB 文本:-mx0=20210B / -mx9=282B)
|
|
8
|
+
// - 加密:`7z a -p<密码>` 可加密;**解压时"没给密码"与"给错密码"退出码相同(都是 2)**,
|
|
9
|
+
// 无法从退出码区分 —— 只能按"调用方有没有传密码"判定 MISSING_PASSWORD / BAD_PASSWORD
|
|
10
|
+
// - `7z l -slt` 是机器可读列表:`Path / Size / Packed Size / Attributes / Encrypted`
|
|
11
|
+
// - 符号链接条目在 `Attributes` 里体现为权限位以 `l` 开头(如 `A_ lrwxrwxrwx`)
|
|
12
|
+
// - 目录条目 `Attributes = D_ ...`(压缩包里不含空目录时可能没有目录条目,父目录由解压时自动创建)
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import fsp from 'node:fs/promises';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { pipeline } from 'node:stream/promises';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
import { ERROR_CODES, apiError } from '../../../lib/errors.js';
|
|
24
|
+
import { currentCpuCores, runEngineProcess } from '../../../core/engineRunner.js';
|
|
25
|
+
|
|
26
|
+
// 供 index.js 的 `-mmt` 对齐使用
|
|
27
|
+
export { currentCpuCores };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* WASM 引擎能不能直接写这个路径所在的文件系统?
|
|
31
|
+
*
|
|
32
|
+
* **不能写网络盘(CIFS/SMB/NFS)**:WASM 侧通过 Emscripten 的 NODEFS 落盘,它创建文件后会做
|
|
33
|
+
* chmod/chown/utime 这类元数据操作,而 CIFS 挂载会直接拒绝 → 7z 报 `errno=63 : Operation not permitted`
|
|
34
|
+
* (该构建里 errno 63 = EPERM,已在 js7z.js 的 ERRNO_CODES 表里核对过)。
|
|
35
|
+
*
|
|
36
|
+
* 对照实验(同一份 runner,只改挂载根):
|
|
37
|
+
* 挂 CIFS 目录 → exit=2,产物 0B,报 errno=63
|
|
38
|
+
* 挂本地 btrfs → exit=0,产物 166B
|
|
39
|
+
* 读 CIFS + 写本地 → exit=0(**读没问题,只有写不行**)
|
|
40
|
+
*
|
|
41
|
+
* 因此:WASM 模式下**可写路径一律先落本地盘**,再由 Node 拷到目标(Node 的 fs 在 CIFS 上正常)。
|
|
42
|
+
*/
|
|
43
|
+
export function needsLocalWorkPath(engine) {
|
|
44
|
+
return Boolean(engine) && engine.kind === 'wasm';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 在本地盘(os.tmpdir())建一个临时工作目录,供 WASM 引擎写产物 */
|
|
48
|
+
export async function makeLocalWorkDir(prefix) {
|
|
49
|
+
return fsp.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 把本地盘上的文件搬到目标路径(可能是 CIFS 网络盘)。
|
|
54
|
+
*
|
|
55
|
+
* **不能用 `fs.copyFile`**:CIFS 挂载上它必报 `EPERM: operation not permitted, copyfile`
|
|
56
|
+
* (实测 copyFile / COPYFILE_EXCL 都失败,而 `readFile+writeFile` 与流式 pipeline 都成功)。
|
|
57
|
+
* 这里用流式管道:内存占用与文件大小无关,适合大压缩包。
|
|
58
|
+
*/
|
|
59
|
+
export async function copyLocalToTarget(sourcePath, targetPath) {
|
|
60
|
+
await pipeline(fs.createReadStream(sourcePath), fs.createWriteStream(targetPath));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// CPU 纪律(绑核「总核数 − 1」+ nice + 全局串行)统一由 `server/core/engineRunner.js` 提供,与 7z 共用同一把锁。
|
|
64
|
+
// 这是部署环境约束(CPU 核数少的机器上跑满可能影响系统响应),不是 7z 私有实现,RAR 的 js 引擎同样要用。
|
|
65
|
+
|
|
66
|
+
/** 探测结果缓存(60s,与格式注册表的可用性缓存同频) */
|
|
67
|
+
const PROBE_TTL_MS = 60 * 1000;
|
|
68
|
+
let probeCache = null;
|
|
69
|
+
|
|
70
|
+
export const LIST_TIMEOUT_MS = 60 * 1000;
|
|
71
|
+
export const CREATE_TIMEOUT_MS = 60 * 60 * 1000;
|
|
72
|
+
export const EXTRACT_TIMEOUT_MS = 60 * 60 * 1000;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 候选二进制。
|
|
76
|
+
*
|
|
77
|
+
* `ZC_7Z_BIN` **设了就独占**(不是"优先再回落"):这个变量的语义是"我明确指定用哪个二进制",
|
|
78
|
+
* 若它指向不可执行的文件,说明部署配置有误——此时应当如实报"不可用"并让人去修,
|
|
79
|
+
* 而不是悄悄换用另一个 7z(那样"回退链②③"永远测不出来,运维也看不出自己配错了)。
|
|
80
|
+
*/
|
|
81
|
+
function nativeCandidates() {
|
|
82
|
+
const fromEnv = process.env.ZC_7Z_BIN;
|
|
83
|
+
if (typeof fromEnv === 'string' && fromEnv.length > 0) return [fromEnv];
|
|
84
|
+
return ['/bin/7z', '/usr/bin/7z', '/usr/local/bin/7z', '/bin/7za', '/usr/bin/7za'];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isExecutable(candidate) {
|
|
88
|
+
try {
|
|
89
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** WASM 引擎的包名(可选依赖):装了就用,没装就不算可用 */
|
|
97
|
+
const WASM_PACKAGES = ['js7z-tools'];
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 探测可用引擎。
|
|
101
|
+
* @returns {{kind: 'native'|'wasm'|null, bin: string|null, wasmPackage: string|null, nativePresent: boolean}}
|
|
102
|
+
*/
|
|
103
|
+
export function resolveEngine() {
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
if (probeCache && now - probeCache.at < PROBE_TTL_MS) return probeCache.engine;
|
|
106
|
+
|
|
107
|
+
const bin = nativeCandidates().find(isExecutable) || null;
|
|
108
|
+
let engine = { kind: bin ? 'native' : null, bin, wasmPackage: null, nativePresent: Boolean(bin) };
|
|
109
|
+
|
|
110
|
+
// WASM 兜底:系统没有二进制时才启用。判定标准是"依赖可解析"
|
|
111
|
+
// (真正的加载/执行放到子进程里,见 wasm-runner.mjs —— 缺依赖时子进程启动即失败,不会误报可用)。
|
|
112
|
+
let resolvableWasm = null;
|
|
113
|
+
// ZC_7Z_DISABLE_WASM=1:用于验收"回退③两者都没有",也供运维在 WASM 出问题时临时关掉
|
|
114
|
+
const wasmDisabled = process.env.ZC_7Z_DISABLE_WASM === '1';
|
|
115
|
+
if (!bin && !wasmDisabled) {
|
|
116
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
117
|
+
for (const name of WASM_PACKAGES) {
|
|
118
|
+
try {
|
|
119
|
+
requireFromHere.resolve(name);
|
|
120
|
+
resolvableWasm = name;
|
|
121
|
+
break;
|
|
122
|
+
} catch {
|
|
123
|
+
/* 未安装:继续看下一个候选 */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
engine = {
|
|
128
|
+
kind: bin ? 'native' : (resolvableWasm ? 'wasm' : null),
|
|
129
|
+
bin,
|
|
130
|
+
wasmPackage: resolvableWasm,
|
|
131
|
+
nativePresent: Boolean(bin),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
probeCache = { at: now, engine };
|
|
135
|
+
return engine;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 测试用:清掉探测缓存(换了环境变量后不必等 60 秒) */
|
|
139
|
+
export function resetProbeCache() {
|
|
140
|
+
probeCache = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 版本探测(供 probe()/诊断;失败返回 null) */
|
|
144
|
+
export function probeVersion(bin) {
|
|
145
|
+
try {
|
|
146
|
+
const output = execFileSync(bin, [], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
147
|
+
const matched = String(output).match(/7-Zip[^\n]*?(\d+\.\d+)/i);
|
|
148
|
+
return matched ? matched[1] : null;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 跑一次 7z 命令(薄封装:绑核/降优先级/串行/超时硬杀都在 `core/engineRunner.js` 里统一做)。
|
|
156
|
+
* @param {string} bin
|
|
157
|
+
* @param {string[]} args
|
|
158
|
+
* @param {{cwd?: string, timeoutMs?: number, onLine?: (line: string) => void, signal?: AbortSignal}} [options]
|
|
159
|
+
*/
|
|
160
|
+
export function run7z(bin, args, options = {}) {
|
|
161
|
+
return runEngineProcess({
|
|
162
|
+
command: bin,
|
|
163
|
+
args,
|
|
164
|
+
cwd: options.cwd,
|
|
165
|
+
timeoutMs: options.timeoutMs || LIST_TIMEOUT_MS,
|
|
166
|
+
onLine: options.onLine,
|
|
167
|
+
signal: options.signal,
|
|
168
|
+
label: '7z',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 解析 `7z l -slt` 输出。
|
|
174
|
+
* 结构:头部块(Path/Type/Physical Size…)→ `----------` 分隔线 → 每个条目一块。
|
|
175
|
+
* @returns {Array<{name: string, type: 'dir'|'file', size: number, compressedSize: number, encrypted: boolean, symlink: boolean}>}
|
|
176
|
+
*/
|
|
177
|
+
export function parseSlt(output) {
|
|
178
|
+
const lines = String(output || '').split(/\r?\n/);
|
|
179
|
+
const separatorAt = lines.findIndex((line) => /^-{5,}$/.test(line.trim()));
|
|
180
|
+
if (separatorAt < 0) return [];
|
|
181
|
+
|
|
182
|
+
const entries = [];
|
|
183
|
+
let current = null;
|
|
184
|
+
const flush = () => {
|
|
185
|
+
if (current && current.name) entries.push(current);
|
|
186
|
+
current = null;
|
|
187
|
+
};
|
|
188
|
+
for (const rawLine of lines.slice(separatorAt + 1)) {
|
|
189
|
+
const line = rawLine.trim();
|
|
190
|
+
if (line.startsWith('Path = ')) {
|
|
191
|
+
flush();
|
|
192
|
+
current = {
|
|
193
|
+
name: line.slice(7).replace(/\\/g, '/'),
|
|
194
|
+
type: 'file',
|
|
195
|
+
size: 0,
|
|
196
|
+
compressedSize: 0,
|
|
197
|
+
encrypted: false,
|
|
198
|
+
symlink: false,
|
|
199
|
+
};
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!current) continue;
|
|
203
|
+
const attributes = line.match(/^Attributes = (.*)$/);
|
|
204
|
+
if (attributes) {
|
|
205
|
+
const attributeText = attributes[1];
|
|
206
|
+
// 目录标记:p7zip 16.02 写 `D_ drwxr-xr-x`,7-Zip 25.01(WASM)写 `D drwxr-xr-x` —— 两者都要认
|
|
207
|
+
if (/^D[\s_]/.test(attributeText) || /^D$/.test(attributeText)) current.type = 'dir';
|
|
208
|
+
// 符号链接判定:**权限串首字符必须是 `l`**(`A_ lrwxrwxrwx`)。
|
|
209
|
+
// 不能宽匹配 `-rw-r--r--`——普通文件的权限串也以 `-` 开头,宽匹配会把所有普通文件误判成链接(实测踩过)。
|
|
210
|
+
const permission = attributeText.split(/\s+/).find((token) => /^[dl-][rwx-]{9}$/.test(token)) || '';
|
|
211
|
+
if (permission.startsWith('l')) current.symlink = true;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const size = line.match(/^Size = (\d+)/);
|
|
215
|
+
if (size) {
|
|
216
|
+
current.size = Number(size[1]);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const packed = line.match(/^Packed Size = (\d+)/);
|
|
220
|
+
if (packed) {
|
|
221
|
+
current.compressedSize = Number(packed[1]);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (/^Encrypted = \+/.test(line)) current.encrypted = true;
|
|
225
|
+
// 25.01 用 `Folder = +` 表达目录(与 Attributes 双保险)
|
|
226
|
+
if (/^Folder = \+/.test(line)) current.type = 'dir';
|
|
227
|
+
}
|
|
228
|
+
flush();
|
|
229
|
+
return entries;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 列表里是否含加密内容(任一文件条目 Encrypted=+ 即视为加密包) */
|
|
233
|
+
export function isEncryptedList(entries) {
|
|
234
|
+
return entries.some((entry) => entry.encrypted === true);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 失败 → 契约错误码(四种情况不能混用)。
|
|
239
|
+
* @param {{stdout: string, stderr: string, code: number, timedOut?: boolean, aborted?: boolean}} outcome
|
|
240
|
+
* @param {{hasPassword: boolean, operation: 'list'|'test'|'extract'|'create'}} context
|
|
241
|
+
*/
|
|
242
|
+
export function mapFailure(outcome, context) {
|
|
243
|
+
if (outcome.aborted) throw apiError(ERROR_CODES.ABORTED, '任务已取消');
|
|
244
|
+
if (outcome.timedOut) throw apiError(ERROR_CODES.INTERNAL_ERROR, '7z 操作超时(外部程序未在限定时间内完成)');
|
|
245
|
+
const text = `${outcome.stdout}\n${outcome.stderr}`;
|
|
246
|
+
const passwordState = () => {
|
|
247
|
+
if (!context.hasPassword) throw apiError(ERROR_CODES.MISSING_PASSWORD, '该压缩包已加密,请输入密码');
|
|
248
|
+
throw apiError(ERROR_CODES.BAD_PASSWORD, '密码错误');
|
|
249
|
+
};
|
|
250
|
+
// 明确的密码字样(p7zip 16.02 会打 "Wrong password"/"Enter password")
|
|
251
|
+
if (/wrong password|enter password|can not open encrypted|password/i.test(text)) passwordState();
|
|
252
|
+
// 明确的数据损坏信号
|
|
253
|
+
if (/data error|crc failed|unexpected end|headers error|is not archive/i.test(text)) {
|
|
254
|
+
// 落到下面的"损坏"分支
|
|
255
|
+
} else if (context.archiveEncrypted) {
|
|
256
|
+
// 关键:WASM 版 7-Zip(25.01)密码错时只说 "Archives with Errors",**不含 password 字样**。
|
|
257
|
+
// 已知包是加密的,又没命中损坏信号 → 就是密码问题(有没有传密码决定是哪一态)。
|
|
258
|
+
passwordState();
|
|
259
|
+
}
|
|
260
|
+
if (context.operation === 'create') {
|
|
261
|
+
throw apiError(ERROR_CODES.INTERNAL_ERROR, '7z 压缩失败', {
|
|
262
|
+
detail: sanitizeDetail(text),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
throw apiError(ERROR_CODES.ARCHIVE_CORRUPTED, '压缩包损坏', {
|
|
266
|
+
exitCode: outcome.code,
|
|
267
|
+
detail: sanitizeDetail(text),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 把 7z 输出里的**宿主绝对路径**收敛成文件名,避免把存储根路径带进任务 error(进而出现在 API 响应里)。
|
|
273
|
+
*
|
|
274
|
+
* 为什么必须做:原生引擎调 7z 时传的是宿主绝对路径,失败时 7z 会把路径原样打出来;
|
|
275
|
+
* 而 m2/m3/probes 都有"响应不含绝对路径"的断言 —— 带路径的错误文案会让这些断言变红,
|
|
276
|
+
* 也确实属于信息泄漏(用户看到的错误不需要知道服务器目录结构)。
|
|
277
|
+
* 完整原文仍写服务日志,便于排查。
|
|
278
|
+
*/
|
|
279
|
+
function sanitizeDetail(rawText) {
|
|
280
|
+
const tail = String(rawText || '').trim().split(/\r?\n/).slice(-2).join(' / ').slice(0, 200);
|
|
281
|
+
if (tail.length === 0) return '';
|
|
282
|
+
console.error(`[sevenzip] 7z 失败原文:${tail}`);
|
|
283
|
+
return tail.replace(/\/[^\s'":,]+/g, (matched) => matched.split('/').filter(Boolean).pop() || matched);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** WASM 执行器脚本路径(与被本文件同目录) */
|
|
287
|
+
const WASM_RUNNER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'wasm-runner.mjs');
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 把宿主绝对路径翻译成 WASM 虚拟路径,并给出需要挂载的目录清单。
|
|
291
|
+
*
|
|
292
|
+
* 规则:
|
|
293
|
+
* - `-o/abs/dir` → 挂载 `/abs/dir` 本身,参数变 `-o/mN`
|
|
294
|
+
* - `/abs/file` → 挂载其父目录,参数变 `/mN/file`
|
|
295
|
+
* - 相对名(create 的条目名)→ 原样保留,靠 `cwd` 决定基准
|
|
296
|
+
* @returns {{argv: string[], cwdWasm: string|null, mounts: Array<{host: string, wasm: string}>}}
|
|
297
|
+
*/
|
|
298
|
+
export function planWasmMounts(argv, cwd) {
|
|
299
|
+
const mounts = [];
|
|
300
|
+
const mountOf = (hostAbs) => {
|
|
301
|
+
const found = mounts.find((item) => item.host === hostAbs);
|
|
302
|
+
if (found) return found.wasm;
|
|
303
|
+
const wasmPath = `/m${mounts.length}`;
|
|
304
|
+
mounts.push({ host: hostAbs, wasm: wasmPath });
|
|
305
|
+
return wasmPath;
|
|
306
|
+
};
|
|
307
|
+
const translated = argv.map((arg) => {
|
|
308
|
+
const outputPrefix = arg.match(/^-o(\/.*)$/);
|
|
309
|
+
if (outputPrefix) return `-o${mountOf(outputPrefix[1])}`;
|
|
310
|
+
if (arg.startsWith('/')) return `${mountOf(path.dirname(arg))}/${path.basename(arg)}`;
|
|
311
|
+
return arg;
|
|
312
|
+
});
|
|
313
|
+
return { argv: translated, cwdWasm: cwd ? mountOf(cwd) : null, mounts };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 统一执行入口:引擎是原生二进制就走 spawn,是 WASM 就走子进程执行器。
|
|
318
|
+
*
|
|
319
|
+
* 串行闸门与绑核/降优先级都来自 `server/core/engineRunner.js`(跨插件共享一把锁),
|
|
320
|
+
* 避免"每个格式各写一份 CPU 纪律、下一个格式必然漏掉"。
|
|
321
|
+
*/
|
|
322
|
+
export function runEngine(engine, options = {}) {
|
|
323
|
+
// 串行/限核由 runEngineProcess 内部统一处理,这里不再自己排队(避免双重排队)
|
|
324
|
+
return runEngineUnsafe(engine, options);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function runEngineUnsafe(engine, options = {}) {
|
|
328
|
+
if (engine.kind === 'native') return run7z(engine.bin, options.argv, options);
|
|
329
|
+
if (engine.kind !== 'wasm') {
|
|
330
|
+
throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, '没有可用的 7z 引擎(系统二进制与 WASM 都不可用)');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const spec = planWasmMounts(options.argv, options.cwd);
|
|
334
|
+
// 绑核 / nice / 全局串行 / 超时硬杀 / 取消:统一由 core/engineRunner.js 处理
|
|
335
|
+
const outcome = await runEngineProcess({
|
|
336
|
+
command: process.execPath,
|
|
337
|
+
args: [WASM_RUNNER, JSON.stringify(spec)],
|
|
338
|
+
timeoutMs: options.timeoutMs || LIST_TIMEOUT_MS,
|
|
339
|
+
signal: options.signal,
|
|
340
|
+
onLine: options.onLine,
|
|
341
|
+
label: '7z-wasm',
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// 信封是 stderr 的**最后一行** JSON(前面的行是运行器诊断信息)
|
|
345
|
+
let code = -1;
|
|
346
|
+
try {
|
|
347
|
+
const lastLine = String(outcome.stderr || '').trim().split('\n').filter((line) => line.startsWith('{')).pop() || '{}';
|
|
348
|
+
const parsed = JSON.parse(lastLine);
|
|
349
|
+
code = typeof parsed.exit === 'number' ? parsed.exit : -1;
|
|
350
|
+
} catch {
|
|
351
|
+
code = -1;
|
|
352
|
+
}
|
|
353
|
+
// WASM 模式下把 7z 输出放进 stdout,让 parseSlt 等既有解析逻辑无需分支
|
|
354
|
+
return { code, stdout: outcome.stdout, stderr: '', timedOut: outcome.timedOut, aborted: outcome.aborted };
|
|
355
|
+
}
|