zipcalm 0.0.0-stage → 1.0.1

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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -2
  3. package/bin/zipcalm.js +191 -0
  4. package/package.json +64 -4
  5. package/server/app.js +309 -0
  6. package/server/boot.js +145 -0
  7. package/server/config/defaults.js +153 -0
  8. package/server/config/index.js +140 -0
  9. package/server/config/schema.js +244 -0
  10. package/server/core/auth/password.js +89 -0
  11. package/server/core/auth/store.js +261 -0
  12. package/server/core/compress/blacklist.js +236 -0
  13. package/server/core/compress/capRegistry.js +171 -0
  14. package/server/core/compress/deleter.js +285 -0
  15. package/server/core/compress/hardlinkScan.js +67 -0
  16. package/server/core/compress/orchestrator.js +783 -0
  17. package/server/core/compress/preflight.js +376 -0
  18. package/server/core/compress/registry.js +240 -0
  19. package/server/core/compress/sourceWalk.js +84 -0
  20. package/server/core/compress/verify.js +47 -0
  21. package/server/core/engineRunner.js +369 -0
  22. package/server/core/fs/list.js +164 -0
  23. package/server/core/fs/repairNames.js +150 -0
  24. package/server/core/fs/roots.js +185 -0
  25. package/server/core/fs/safePath.js +173 -0
  26. package/server/core/fs/search.js +108 -0
  27. package/server/core/fs/stat.js +110 -0
  28. package/server/core/fs/systemTrash.js +212 -0
  29. package/server/core/fs/writeOps.js +257 -0
  30. package/server/core/hardlink/executor.js +106 -0
  31. package/server/core/hardlink/scanner.js +193 -0
  32. package/server/core/metrics/collector.js +279 -0
  33. package/server/core/metrics/procfs.js +107 -0
  34. package/server/core/task/events.js +82 -0
  35. package/server/core/task/manager.js +987 -0
  36. package/server/core/task/recovery.js +116 -0
  37. package/server/core/task/store.js +240 -0
  38. package/server/http/middleware/auth.js +130 -0
  39. package/server/http/middleware/error.js +54 -0
  40. package/server/http/middleware/logger.js +27 -0
  41. package/server/http/middleware/pathGuard.js +119 -0
  42. package/server/http/router.js +29 -0
  43. package/server/http/routes/auth.js +115 -0
  44. package/server/http/routes/blacklist.js +97 -0
  45. package/server/http/routes/capabilities.js +21 -0
  46. package/server/http/routes/compress.js +339 -0
  47. package/server/http/routes/fs.js +109 -0
  48. package/server/http/routes/hardlink.js +160 -0
  49. package/server/http/routes/health.js +52 -0
  50. package/server/http/routes/metrics.js +36 -0
  51. package/server/http/routes/tasks.js +304 -0
  52. package/server/lib/atomic-json.js +118 -0
  53. package/server/lib/errors.js +145 -0
  54. package/server/lib/fs-probe.js +25 -0
  55. package/server/lib/logger.js +68 -0
  56. package/server/lib/runtime-guard.js +49 -0
  57. package/server/lib/throttle.js +42 -0
  58. package/server/plugins/capabilities/password/index.js +300 -0
  59. package/server/plugins/capabilities/password/methods/aes256.js +31 -0
  60. package/server/plugins/capabilities/password/methods/zip20.js +29 -0
  61. package/server/plugins/formats/_shared/createGuards.js +26 -0
  62. package/server/plugins/formats/_shared/entrySafety.js +174 -0
  63. package/server/plugins/formats/_shared/nameRepair.js +123 -0
  64. package/server/plugins/formats/_shared/streams.js +18 -0
  65. package/server/plugins/formats/_shared/tar/compression.js +114 -0
  66. package/server/plugins/formats/_shared/tar/reader.js +74 -0
  67. package/server/plugins/formats/_shared/tar/writer.js +206 -0
  68. package/server/plugins/formats/_shared/tarFamily.js +207 -0
  69. package/server/plugins/formats/rar/index.js +354 -0
  70. package/server/plugins/formats/rar/js-engine.js +122 -0
  71. package/server/plugins/formats/rar/js-runner.mjs +129 -0
  72. package/server/plugins/formats/rar/unrar.js +266 -0
  73. package/server/plugins/formats/sevenzip/engine.js +355 -0
  74. package/server/plugins/formats/sevenzip/index.js +461 -0
  75. package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
  76. package/server/plugins/formats/tar/index.js +19 -0
  77. package/server/plugins/formats/targz/index.js +19 -0
  78. package/server/plugins/formats/tarzst/index.js +26 -0
  79. package/server/plugins/formats/zip/index.js +587 -0
  80. package/shared/app-version.js +7 -0
  81. package/shared/capabilities.js +21 -0
  82. package/shared/compress-modes.js +38 -0
  83. package/shared/error-codes.js +117 -0
  84. package/shared/format-list.js +101 -0
  85. package/shared/hardlink-hint.js +21 -0
  86. package/shared/task-phase.js +62 -0
  87. package/shared/task-status.js +84 -0
  88. package/web/dist/assets/index-D9Vdmrb9.css +1 -0
  89. package/web/dist/assets/index-DiDB4ClR.js +131 -0
  90. package/web/dist/index.html +18 -0
@@ -0,0 +1,354 @@
1
+ // RAR 格式包(**只读:仅解压,不支持压缩**)
2
+ //
3
+ // 法律边界(docs/18 §附三):RAR 的压缩算法是闭源商业算法,未经授权不能生成 RAR 文件;
4
+ // 因此 `create()` / `estimate()` 一律拒绝,并在 `unavailableReason()` 里写明原因。
5
+ // 解压走**用户已安装**的 unrar(优先)或 7z —— 调用外部程序合法,且**绝不把二进制打进仓库**。
6
+ //
7
+ // 复用的既有设施(不造第二套):
8
+ // - 条目安全:`_shared/entrySafety.js` 的 planTargets / writePlan(与 zip、tar 同一套 zip-slip 防护
9
+ // 与「不覆盖同名」检查),外部程序先解到中转目录,再由这套代码做白名单判定后搬进目标目录
10
+ // - 错误三态(P4):MISSING_PASSWORD / BAD_PASSWORD / ARCHIVE_CORRUPTED,判定见 unrar.js 的 mapToolFailure
11
+ //
12
+ // 为什么先解到中转目录再搬:unrar 会按归档内的路径直接写盘,若归档里有 `../` 或绝对路径条目,
13
+ // 外部程序可能写到目标目录之外。中转 + planTargets 才能保证"写之前先判定"。
14
+
15
+ import fs from 'node:fs';
16
+ import fsp from 'node:fs/promises';
17
+ import path from 'node:path';
18
+
19
+ import { ERROR_CODES, apiError } from '../../../lib/errors.js';
20
+ import { planTargets, writePlan } from '../_shared/entrySafety.js';
21
+ import { jsExtractTo, jsInspect, jsTest } from './js-engine.js';
22
+ import { repairName } from '../_shared/nameRepair.js';
23
+ import {
24
+ EXTRACT_TIMEOUT_MS,
25
+ LIST_TIMEOUT_MS,
26
+ hasEncryptionFlag,
27
+ mapToolFailure,
28
+ parseBareList,
29
+ parseTechnicalList,
30
+ resolveEngine,
31
+ resetProbeCache,
32
+ runCommand,
33
+ } from './unrar.js';
34
+
35
+ const FORMAT_ID = 'rar';
36
+
37
+ /** 压缩侧的统一拒绝文案(法律边界,措辞不要弱化) */
38
+ const CREATE_REFUSED = 'RAR 压缩算法为闭源商业算法,未授权不能生成 RAR 文件;本插件仅支持解压';
39
+
40
+ /** 进度上报的最小间隔(解析外部程序输出时自己节流,避免每行都推一次) */
41
+ const PROGRESS_INTERVAL_MS = 120;
42
+
43
+ /** 当前引擎(三级回退:原生 unrar → node-unrar-js → 7z;具体见 unrar.js 的 resolveEngine) */
44
+ function currentEngine() {
45
+ const engine = resolveEngine();
46
+ return engine.kind ? engine : null;
47
+ }
48
+
49
+ async function available() {
50
+ return currentEngine() !== null;
51
+ }
52
+
53
+ async function unavailableReason() {
54
+ const engine = resolveEngine();
55
+ if (engine.reason) {
56
+ return `RAR 解压不可用:${engine.reason}(请安装 unrar 7.x 以上,或用 ZC_UNRAR_BIN 指定路径)`;
57
+ }
58
+ if (engine.kind && engine.filenameSafe === false) {
59
+ // 能用但文件名不可靠:把话说清楚,别让用户以为是编码识别问题
60
+ return `当前 unrar 版本 ${engine.version || '未知'} 对 RAR5 中文文件名有已知 bug(解出会乱码),建议升级到 7.x 以上`;
61
+ }
62
+ return '未找到 unrar / node-unrar-js / 7z,无法解压 RAR(请安装 unrar,或用 ZC_UNRAR_BIN 指定路径)';
63
+ }
64
+
65
+ async function estimate() {
66
+ // 压缩侧不支持:抛错而不是返回 0,避免"预检通过然后建任务失败"
67
+ throw apiError(ERROR_CODES.UNSUPPORTED_FORMAT, CREATE_REFUSED);
68
+ }
69
+
70
+ async function create() {
71
+ throw apiError(ERROR_CODES.UNSUPPORTED_FORMAT, CREATE_REFUSED);
72
+ }
73
+
74
+ /** 7z 的技术列表(`-slt` 的 `Key = Value` 形式)——只在没有 unrar 时用到 */
75
+ function parseSevenZipList(output) {
76
+ const entries = [];
77
+ let current = null;
78
+ for (const rawLine of String(output || '').split(/\r?\n/)) {
79
+ const line = rawLine.trim();
80
+ if (line.startsWith('Path = ')) {
81
+ if (current && current.name) entries.push(current);
82
+ current = { name: line.slice(7), type: 'file', size: 0, compressedSize: 0 };
83
+ continue;
84
+ }
85
+ if (!current) continue;
86
+ if (line === 'Folder = +') current.type = 'dir';
87
+ const sizeMatch = line.match(/^Size = (\d+)/);
88
+ if (sizeMatch) current.size = Number(sizeMatch[1]);
89
+ const packedMatch = line.match(/^Packed Size = (\d+)/);
90
+ if (packedMatch) current.compressedSize = Number(packedMatch[1]);
91
+ }
92
+ if (current && current.name) entries.push(current);
93
+ // 7z 会把归档自身作为第一条列出来,去掉它
94
+ return entries.slice(1).map((entry) => ({
95
+ name: String(entry.name).replace(/\\/g, '/'),
96
+ type: entry.type,
97
+ size: entry.type === 'dir' ? 0 : entry.size,
98
+ compressedSize: entry.compressedSize,
99
+ }));
100
+ }
101
+
102
+ /**
103
+ * 读条目清单;同时判定是否加密。
104
+ * 解析失败(输出格式随版本变)→ 降级成 `lb` 裸名列表,**不报错**。
105
+ * @returns {Promise<{entries: Array, encrypted: boolean, degraded: boolean}>}
106
+ */
107
+ async function inspectEntries(archivePath, engine) {
108
+ if (engine.kind === 'js') {
109
+ // js 引擎自己解析文件头(不走外部进程),错误已在适配层映射成契约错误码
110
+ return { ...(await jsInspect(archivePath)), degraded: false };
111
+ }
112
+ if (engine.kind === '7z') {
113
+ const outcome = await runCommand(engine.bin, ['l', '-slt', '-p-', archivePath], { timeoutMs: LIST_TIMEOUT_MS });
114
+ if (outcome.code !== 0) mapToolFailure(outcome, { hasPassword: false });
115
+ const entries = parseSevenZipList(outcome.stdout);
116
+ const encrypted = /Encrypted = \+/i.test(outcome.stdout);
117
+ return { entries, encrypted, degraded: false };
118
+ }
119
+
120
+ const technical = await runCommand(engine.bin, ['lt', '-p-', archivePath], { timeoutMs: LIST_TIMEOUT_MS });
121
+ if (technical.code !== 0) mapToolFailure(technical, { hasPassword: false });
122
+ const entries = parseTechnicalList(technical.stdout);
123
+ const encrypted = hasEncryptionFlag(technical.stdout);
124
+ if (entries.length > 0) return { entries, encrypted, degraded: false };
125
+
126
+ // 降级:只拿名字(大小未知 → 0)。宁可"信息少一点",也不要因为解析不了就报错。
127
+ const bare = await runCommand(engine.bin, ['lb', '-p-', archivePath], { timeoutMs: LIST_TIMEOUT_MS });
128
+ if (bare.code !== 0) mapToolFailure(bare, { hasPassword: false });
129
+ return { entries: parseBareList(bare.stdout), encrypted, degraded: true };
130
+ }
131
+
132
+ async function list(archivePath) {
133
+ const engine = currentEngine();
134
+ if (!engine) throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, await unavailableReason());
135
+ const info = await inspectEntries(archivePath, engine);
136
+ const items = info.entries.map((entry) => ({
137
+ // 列表也要还原名字:前端"内含 N 个条目"与解压后的实际文件名必须一致
138
+ name: repairName(entry.name).name,
139
+ type: entry.type,
140
+ size: entry.size,
141
+ compressedSize: entry.compressedSize,
142
+ }));
143
+ // encrypted 必须透出:解压预检据此让前端弹密码框(P4 三态的第一道)
144
+ return { entries: items, count: items.length, truncated: false, encrypted: info.encrypted };
145
+ }
146
+
147
+ async function test(archivePath, options = {}) {
148
+ const engine = currentEngine();
149
+ if (!engine) throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, await unavailableReason());
150
+ const password = options && typeof options.password === 'string' ? options.password : '';
151
+ if (engine.kind === 'js') {
152
+ // js 没有独立 test API:完整解一遍到临时目录并核对条数/字节数(见 js-engine.js 的说明)
153
+ return jsTest(archivePath, { password });
154
+ }
155
+ const { entries } = await inspectEntries(archivePath, engine);
156
+ const args = ['t', password ? `-p${password}` : '-p-', archivePath];
157
+ const outcome = await runCommand(engine.bin, args, { timeoutMs: LIST_TIMEOUT_MS });
158
+ if (outcome.code !== 0) mapToolFailure(outcome, { hasPassword: Boolean(password) });
159
+ return {
160
+ ok: true,
161
+ checked: entries.filter((entry) => entry.type !== 'dir').length,
162
+ failures: [],
163
+ strategy: `${engine.kind}-test`,
164
+ };
165
+ }
166
+
167
+ /** 递归收集中转目录里的条目(符号链接一律不收:解压产物里不该有链接) */
168
+ async function collectStaged(stagingDir) {
169
+ const collected = [];
170
+ const walk = async (dirAbs, relativePrefix) => {
171
+ const dirents = await fsp.readdir(dirAbs, { withFileTypes: true });
172
+ for (const dirent of dirents) {
173
+ const abs = path.join(dirAbs, dirent.name);
174
+ const relative = relativePrefix ? `${relativePrefix}/${dirent.name}` : dirent.name;
175
+ if (dirent.isSymbolicLink()) continue;
176
+ if (dirent.isDirectory()) {
177
+ collected.push({ name: `${relative}/`, isDir: true, size: 0, link: false, open: null });
178
+ await walk(abs, relative);
179
+ continue;
180
+ }
181
+ if (!dirent.isFile()) continue;
182
+ const stat = await fsp.lstat(abs).catch(() => null);
183
+ collected.push({
184
+ name: relative,
185
+ isDir: false,
186
+ size: stat ? stat.size : 0,
187
+ link: false,
188
+ open: () => fs.createReadStream(abs),
189
+ });
190
+ }
191
+ };
192
+ await walk(stagingDir, '');
193
+ return collected;
194
+ }
195
+
196
+ /**
197
+ * 阶段一(外部程序解到中转目录)的进度:解析 `Extracting <name> OK` 行 → **文件级**。
198
+ *
199
+ * 为什么 processedBytes 固定为 0:unrar 不报字节数(只给文件名与百分比),若这里按文件数
200
+ * 比例估算字节,阶段二搬移时 writePlan 会从 0 重新计数 → 进度会**倒退**。宁可这一阶段只给
201
+ * 文件名("正在解第 N 个"),阶段二再给精确字节,保证整条进度流单调不减。
202
+ */
203
+ function makeExtractReporter(onProgress, totalFiles) {
204
+ let processedFiles = 0;
205
+ let lastAt = 0;
206
+ const report = typeof onProgress === 'function' ? onProgress : () => {};
207
+ return (line) => {
208
+ // 真实输出形如 `Extracting s2/a.txt OK`;头部还有一行 `Extracting from <archive>` 要排除
209
+ if (/^Extracting\s+from\s+/i.test(line)) return;
210
+ const matched = line.match(/^Extracting\s+(.+)$/i);
211
+ if (!matched) return;
212
+ const name = matched[1].replace(/\s+\d+%\s*$/, '').replace(/\s+OK\s*$/i, '').trim();
213
+ if (!name) return;
214
+ processedFiles += 1;
215
+ const now = Date.now();
216
+ if (now - lastAt < PROGRESS_INTERVAL_MS) return;
217
+ lastAt = now;
218
+ report({ processedFiles, totalFiles, processedBytes: 0, currentFile: name });
219
+ };
220
+ }
221
+
222
+ async function extract(ctx) {
223
+ const engine = currentEngine();
224
+ if (!engine) throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, await unavailableReason());
225
+
226
+ const startedAt = Date.now();
227
+ const targetDir = path.resolve(ctx.targetDir);
228
+ const overwrite = Boolean(ctx.options && ctx.options.overwrite);
229
+ const password = ctx.options && typeof ctx.options.password === 'string' ? ctx.options.password : '';
230
+ const signal = ctx.options && ctx.options.signal;
231
+
232
+ // ⓪ 先读清单:既拿到文件数(进度分母/校验),也判定是否加密
233
+ const info = await inspectEntries(ctx.archivePath, engine);
234
+ if (info.encrypted && !password) {
235
+ throw apiError(ERROR_CODES.MISSING_PASSWORD, '该压缩包已加密,请输入密码');
236
+ }
237
+
238
+ // ① 解到目标目录**旁边**的中转目录(外部程序不直接写用户目录)
239
+ const stagingDir = path.join(path.dirname(targetDir), `.${path.basename(targetDir)}.hl-rar-${Date.now()}`);
240
+ await fsp.mkdir(stagingDir, { recursive: true });
241
+ try {
242
+ const fileCount = info.entries.filter((entry) => entry.type !== 'dir').length;
243
+ if (engine.kind === 'js') {
244
+ // js 引擎在进程内解析并落盘(Node fs,CIFS 上没问题),自带"少文件/少字节"的损坏检测
245
+ await jsExtractTo(ctx.archivePath, stagingDir, { password });
246
+ } else {
247
+ const args = ['x', '-o-', '-y', password ? `-p${password}` : '-p-', ctx.archivePath, `${stagingDir}/`];
248
+ const outcome = await runCommand(engine.bin, args, {
249
+ timeoutMs: EXTRACT_TIMEOUT_MS,
250
+ signal,
251
+ onLine: makeExtractReporter(ctx.onProgress, fileCount),
252
+ });
253
+ if (outcome.code !== 0) mapToolFailure(outcome, { hasPassword: Boolean(password) });
254
+ }
255
+
256
+ // ② 安全判定 + 搬进目标目录(zip-slip / 不覆盖同名 / 进度 / 半成品清理 都由这套共享代码负责)
257
+ const staged = await collectStaged(stagingDir);
258
+ const planned = await planTargets(staged, targetDir, overwrite);
259
+ const written = await writePlan(planned, targetDir, { signal, onProgress: ctx.onProgress });
260
+ return {
261
+ targetDir,
262
+ entries: written.entries,
263
+ namesDecoded: planned.repairedNames || 0,
264
+ bytesOut: written.bytesOut,
265
+ durationMs: Date.now() - startedAt,
266
+ encrypted: info.encrypted,
267
+ };
268
+ } finally {
269
+ // 中转目录无论成败都清掉(加密包的明文副本不留盘)
270
+ await fsp.rm(stagingDir, { recursive: true, force: true }).catch(() => {});
271
+ }
272
+ }
273
+
274
+ export { resetProbeCache };
275
+
276
+ export default {
277
+ id: FORMAT_ID,
278
+ name: 'RAR(仅解压)',
279
+ ext: '.rar',
280
+ // RAR5 也走同一扩展名,没有别的别名
281
+ extAliases: [],
282
+ mime: 'application/vnd.rar',
283
+ // 压缩侧不可用:levelRange/defaultLevel 置空,前端压缩设置不该出现它
284
+ levelRange: null,
285
+ defaultLevel: null,
286
+ capabilities: [],
287
+ /** 只解压标记:压缩列表据此排除本格式(消费方见 README 的格式说明) */
288
+ extractOnly: true,
289
+ available,
290
+ unavailableReason,
291
+ estimate,
292
+ create,
293
+ extract,
294
+ list,
295
+ test,
296
+ /**
297
+ * 探测归档是否加密(供解压预检决定是否弹密码输入框)。
298
+ *
299
+ * 实现方式是复用列表阶段已经拿到的信息:`unrar lt` 的输出里每一条都带
300
+ * `* Encrypted = +` 标志,`7z -slt` 同理,所以不需要额外开一次进程去猜测。
301
+ * RAR 没有 ZIP 那种 AES/ZipCrypto 之分,`isAes` 恒为 false(保持与 ZIP 同构的返回形状)。
302
+ *
303
+ * @param {string} archivePath
304
+ * @returns {Promise<{encrypted: boolean, isAes: boolean}>}
305
+ */
306
+ async probeEncryption(archivePath) {
307
+ const engine = currentEngine();
308
+ if (!engine) throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, await unavailableReason());
309
+ const info = await inspectEntries(archivePath, engine);
310
+ return { encrypted: info.encrypted === true, isAes: false };
311
+ },
312
+ /**
313
+ * 用给定密码完整试解一次(供解压侧「先校验」阶段调用)。
314
+ *
315
+ * 为什么必须由本插件提供、而不是让编排层统一用 7z:
316
+ * p7zip 16.02 读不了加密 RAR5(实测报 `Can not open the file as archive`),
317
+ * 而本机 `unrar 5.70` 可以。工具能力按格式不同,这个知识属于插件自己。
318
+ *
319
+ * @param {string} archivePath
320
+ * @param {string} password
321
+ * @returns {Promise<{ok: boolean, checked?: number, strategy?: string, error?: string}>}
322
+ */
323
+ async verifyWithPassword(archivePath, password) {
324
+ try {
325
+ const result = await test(archivePath, { password });
326
+ return { ok: true, checked: result.checked, strategy: result.strategy };
327
+ } catch (error) {
328
+ // 保留原始错误码(BAD_PASSWORD / ARCHIVE_CORRUPTED / ABORTED),交给上层原样抛给用户。
329
+ // 如果在这里一律收敛成 VERIFY_FAILED,用户看到的就只是"校验未通过",
330
+ // 分不清到底是密码打错了还是包坏了 —— 三态区分就白做了。
331
+ return {
332
+ ok: false,
333
+ code: (error && error.code) || null,
334
+ error: (error && error.message) || '加密包校验失败',
335
+ };
336
+ }
337
+ },
338
+ /**
339
+ * 诊断:当前用哪个引擎、版本多少、文件名是否可靠。
340
+ * 形如 `{kind:'unrar', bin:'/bin/unrar', version:'5.70', filenameSafe:false}`
341
+ * 或 `{kind:'js', bin:null, version:'2.0.2', filenameSafe:false}`。
342
+ */
343
+ probe: () => {
344
+ const engine = resolveEngine();
345
+ return {
346
+ kind: engine.kind,
347
+ bin: engine.bin,
348
+ version: engine.version,
349
+ filenameSafe: engine.filenameSafe,
350
+ jsPackage: engine.kind === 'js' ? 'node-unrar-js' : null,
351
+ reason: engine.reason,
352
+ };
353
+ },
354
+ };
@@ -0,0 +1,122 @@
1
+ // RAR 的纯 JS/WASM 引擎适配(node-unrar-js)——**客户端侧**:只负责起子进程、解析信封、映射错误码
2
+ //
3
+ // 真正的解压在 `js-runner.mjs` 子进程里跑。原因(务必保留这段说明,别再改回同进程):
4
+ // 1) `node-unrar-js` 的 `extract()` 是**同步**的(返回 ArcFiles,不是 Promise)。
5
+ // 在服务进程里直接调 → 解压期间事件循环停摆:SSE 不推进度、HTTP 全站无响应、取消按钮点不动。
6
+ // 2) WASM 内存会占在服务进程里(7z 那边峰值 460MB 在子进程),WASM 崩了会带走整个服务。
7
+ // 3) 同步执行无法取消 —— 子进程 + SIGKILL 是唯一可靠的超时/取消手段。
8
+ //
9
+ // CPU 纪律(绑核「总核数 − 1」+ nice + 全局串行)统一由 `server/core/engineRunner.js` 提供,与 7z 共用同一把锁。
10
+ //
11
+ // **实测能力边界(node-unrar-js@2.0.2,内置 unrar 6.1.7)**:
12
+ // ✅ rar4 / rar5、嵌套目录、纯 ASCII 文件名
13
+ // ✅ 多卷(给 part1 自动续卷)
14
+ // ✅ 加密包:正确密码可解;缺密码 → MISSING_PASSWORD;错密码 → BAD_PASSWORD
15
+ // ❌ **RAR5 中文名仍然乱码**(与 unrar 5.70 一致)→ 这一层**修不了文件名**,所以有原生 unrar 时仍优先原生
16
+ // ⚠️ 损坏包**不报错**:静默只解出部分文件(原生 unrar 会明确报 ARCHIVE_CORRUPTED);
17
+ // 这里用「解出条数/字节数 vs 清单」兜底,清单本身被截断时抓不到(已知限制)
18
+
19
+ import { createRequire } from 'node:module';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ import { ERROR_CODES, apiError } from '../../../lib/errors.js';
24
+ import { runEngineProcess } from '../../../core/engineRunner.js';
25
+
26
+ const JS_PACKAGE = 'node-unrar-js';
27
+ const RUNNER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'js-runner.mjs');
28
+
29
+ const LIST_TIMEOUT_MS = 60 * 1000;
30
+ const EXTRACT_TIMEOUT_MS = 30 * 60 * 1000;
31
+
32
+ let loaded = null;
33
+
34
+ /** 载入 node-unrar-js(只用于"能不能用"的探测与版本号;真正干活在子进程) */
35
+ export function loadUnrarJs() {
36
+ if (loaded !== null) return loaded.module;
37
+ try {
38
+ loaded = { module: createRequire(import.meta.url)(JS_PACKAGE) };
39
+ } catch {
40
+ loaded = { module: null };
41
+ }
42
+ return loaded.module;
43
+ }
44
+
45
+ export function jsAvailable() {
46
+ return Boolean(loadUnrarJs());
47
+ }
48
+
49
+ export function jsVersion() {
50
+ if (!jsAvailable()) return null;
51
+ try {
52
+ return createRequire(import.meta.url)(`${JS_PACKAGE}/package.json`).version;
53
+ } catch {
54
+ return 'unknown';
55
+ }
56
+ }
57
+
58
+ /** 错误码 → 契约错误(三态与原生 unrar 完全一致) */
59
+ function throwMapped(errorCode, message, details) {
60
+ if (errorCode === 'MISSING_PASSWORD') throw apiError(ERROR_CODES.MISSING_PASSWORD, message || '该压缩包已加密,请输入密码');
61
+ if (errorCode === 'BAD_PASSWORD') throw apiError(ERROR_CODES.BAD_PASSWORD, message || '密码错误');
62
+ throw apiError(ERROR_CODES.ARCHIVE_CORRUPTED, message || '压缩包损坏', details);
63
+ }
64
+
65
+ /**
66
+ * 跑一次 js 引擎子进程。
67
+ * @param {{op: 'list'|'extract'|'test', archivePath: string, targetPath?: string, password?: string}} spec
68
+ * @param {{timeoutMs?: number, signal?: AbortSignal}} [options]
69
+ * @returns {Promise<object>} 信封内容
70
+ */
71
+ export async function runJsEngine(spec, options = {}) {
72
+ if (!jsAvailable()) throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, 'node-unrar-js 不可用');
73
+ const timeoutMs = options.timeoutMs || (spec.op === 'list' ? LIST_TIMEOUT_MS : EXTRACT_TIMEOUT_MS);
74
+ // 测试用:允许把超时压到很小,验证"超时能被 SIGKILL 掉"
75
+ const overrideTimeout = Number(process.env.ZC_RAR_JS_TIMEOUT_MS);
76
+ const effectiveTimeout = Number.isInteger(overrideTimeout) && overrideTimeout > 0 ? overrideTimeout : timeoutMs;
77
+
78
+ const outcome = await runEngineProcess(
79
+ { command: process.execPath, args: [RUNNER, JSON.stringify(spec)], label: 'unrar-js' },
80
+ { timeoutMs: effectiveTimeout, signal: options.signal },
81
+ );
82
+
83
+ if (outcome.aborted) throw apiError(ERROR_CODES.ABORTED, '任务已取消');
84
+ if (outcome.timedOut) {
85
+ throw apiError(ERROR_CODES.INTERNAL_ERROR, 'RAR 解压超时(js 引擎未在限定时间内完成,已强制终止)');
86
+ }
87
+
88
+ const lastLine = String(outcome.stdout || '').trim().split(/\r?\n/).filter((line) => line.startsWith('{')).pop();
89
+ if (!lastLine) {
90
+ // 子进程崩了/没输出:任务失败即可,**服务不受影响**(这正是子进程化的收益)
91
+ throw apiError(ERROR_CODES.INTERNAL_ERROR, 'js 引擎异常退出(详见服务日志)', {
92
+ exitCode: outcome.code,
93
+ reason: String(outcome.stderr || '').slice(-120),
94
+ });
95
+ }
96
+ const envelope = JSON.parse(lastLine);
97
+ if (envelope.ok !== true) throwMapped(envelope.errorCode, envelope.message, { reason: envelope.reason });
98
+ return envelope;
99
+ }
100
+
101
+ /** 读清单(含是否加密) */
102
+ export async function jsInspect(archivePath) {
103
+ const envelope = await runJsEngine({ op: 'list', archivePath });
104
+ return { entries: envelope.entries || [], encrypted: envelope.encrypted === true };
105
+ }
106
+
107
+ /** 校验:完整解一遍到临时目录(子进程自己清理),并核对条数/字节数 */
108
+ export async function jsTest(archivePath, options = {}) {
109
+ const password = options && typeof options.password === 'string' ? options.password : '';
110
+ const envelope = await runJsEngine({ op: 'test', archivePath, ...(password ? { password } : {}) });
111
+ return { ok: true, checked: envelope.fileCount || 0, failures: [], strategy: 'unrar-js-extract' };
112
+ }
113
+
114
+ /** 解压到指定目录 */
115
+ export async function jsExtractTo(archivePath, targetDir, options = {}) {
116
+ const password = options && typeof options.password === 'string' ? options.password : '';
117
+ const envelope = await runJsEngine(
118
+ { op: 'extract', archivePath, targetPath: targetDir, ...(password ? { password } : {}) },
119
+ { signal: options.signal },
120
+ );
121
+ return { fileCount: envelope.fileCount || 0, bytesOut: envelope.bytesOut || 0 };
122
+ }
@@ -0,0 +1,129 @@
1
+ // RAR 的 js 引擎执行器(**子进程**,每次操作一个独立进程)
2
+ //
3
+ // 为什么必须子进程(不能像最初那样在服务进程里直接调):
4
+ // 1) `node-unrar-js` 的 `extract()` 是**同步**的(返回 ArcFiles,不是 Promise)。在服务进程里直接调
5
+ // = 解压期间 Node 事件循环停摆 → SSE 不推进度、全站 HTTP 无响应、任务取消按钮点不动。
6
+ // 2) WASM 内存占在服务进程里(7z 那边峰值 460MB 在子进程),且 WASM 崩溃会**带走整个服务**。
7
+ // 3) 同步执行无法取消;子进程 + SIGKILL 是唯一可靠的超时/取消手段。
8
+ //
9
+ // 协议(父进程 ↔ 本脚本):
10
+ // 入参:argv[2] = JSON spec
11
+ // { op: 'list'|'extract'|'test', archivePath, targetPath?, password? }
12
+ // 出参:stdout **只打印一行 JSON** 结果信封;诊断/异常文本走 stderr。
13
+ // 成功:{"ok":true, ...}
14
+ // 失败:{"ok":false,"errorCode":"MISSING_PASSWORD|BAD_PASSWORD|ARCHIVE_CORRUPTED","message":"…"}
15
+
16
+ import { createRequire } from 'node:module';
17
+ import fsp from 'node:fs/promises';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+
21
+ const spec = JSON.parse(process.argv[2] || '{}');
22
+
23
+ /** 结果信封只走 stdout 一行,父进程按最后一行 JSON 解析 */
24
+ function emitEnvelope(payload) {
25
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
26
+ }
27
+
28
+ /** js 侧异常 → 契约错误码(与原生 unrar 的三态口径保持一致) */
29
+ function classify(error) {
30
+ const reason = String((error && (error.reason || error.message)) || '');
31
+ if (/MISSING_PASSWORD/i.test(reason)) return { errorCode: 'MISSING_PASSWORD', message: '该压缩包已加密,请输入密码' };
32
+ if (/BAD_PASSWORD|WRONG_PASSWORD/i.test(reason)) {
33
+ return spec.password
34
+ ? { errorCode: 'BAD_PASSWORD', message: '密码错误' }
35
+ : { errorCode: 'MISSING_PASSWORD', message: '该压缩包已加密,请输入密码' };
36
+ }
37
+ return { errorCode: 'ARCHIVE_CORRUPTED', message: '压缩包损坏', reason: reason.slice(0, 80) };
38
+ }
39
+
40
+ function headerList(extractor) {
41
+ const listed = extractor.getFileList();
42
+ const container = Array.isArray(listed) ? listed[0] : listed;
43
+ return Array.from((container && container.fileHeaders) || []);
44
+ }
45
+
46
+ function toEntry(header) {
47
+ const name = String(header.name || '').replace(/\\/g, '/');
48
+ const isDir = Boolean(header.flags && header.flags.directory) || name.endsWith('/');
49
+ return {
50
+ name,
51
+ type: isDir ? 'dir' : 'file',
52
+ size: isDir ? 0 : Number(header.unpSize || 0),
53
+ compressedSize: Number(header.packSize || 0),
54
+ encrypted: Boolean(header.flags && header.flags.encrypted),
55
+ };
56
+ }
57
+
58
+ async function walkFiles(dirAbs, prefix = '') {
59
+ const out = [];
60
+ for (const dirent of await fsp.readdir(dirAbs, { withFileTypes: true }).catch(() => [])) {
61
+ const relative = prefix ? `${prefix}/${dirent.name}` : dirent.name;
62
+ if (dirent.isDirectory()) {
63
+ out.push(...await walkFiles(path.join(dirAbs, dirent.name), relative));
64
+ continue;
65
+ }
66
+ const stat = await fsp.lstat(path.join(dirAbs, dirent.name)).catch(() => null);
67
+ out.push({ name: relative, size: stat ? stat.size : 0 });
68
+ }
69
+ return out;
70
+ }
71
+
72
+ async function main() {
73
+ const lib = createRequire(import.meta.url)('node-unrar-js');
74
+
75
+ if (spec.op === 'list') {
76
+ const extractor = await lib.createExtractorFromFile({ filepath: spec.archivePath });
77
+ const entries = headerList(extractor).map(toEntry);
78
+ emitEnvelope({ ok: true, entries, encrypted: entries.some((entry) => entry.encrypted) });
79
+ return;
80
+ }
81
+
82
+ // extract / test 都要真正解一遍(js 没有独立的 test API)
83
+ const targetDir = spec.targetPath || (await fsp.mkdtemp(path.join(os.tmpdir(), 'zc-rarjs-')));
84
+ const ownTemp = !spec.targetPath;
85
+ try {
86
+ await fsp.mkdir(targetDir, { recursive: true });
87
+ const extractor = await lib.createExtractorFromFile({
88
+ filepath: spec.archivePath,
89
+ targetPath: targetDir,
90
+ ...(spec.password ? { password: spec.password } : {}),
91
+ });
92
+ const expected = headerList(extractor).map(toEntry).filter((entry) => entry.type === 'file');
93
+
94
+ // 关键:`files` 是**惰性迭代**,必须消费才会真正写盘(只调 extract() 不迭代 = 目标目录为空)
95
+ const result = extractor.extract();
96
+ let consumed = 0;
97
+ for (const item of result.files || []) {
98
+ if (item && item.fileHeader) consumed += 1;
99
+ }
100
+
101
+ const actual = await walkFiles(targetDir);
102
+ const actualBytes = actual.reduce((sum, item) => sum + item.size, 0);
103
+ const expectedBytes = expected.reduce((sum, item) => sum + item.size, 0);
104
+
105
+ // 损坏包在 js 侧不报错(静默少文件):用「条数/字节数 vs 清单」兜底。
106
+ // 注意:清单本身被截断时这条也抓不到(已知限制,已记 docs/17 D-11)。
107
+ if (expected.length > 0 && (actual.length < expected.length || actualBytes < expectedBytes)) {
108
+ emitEnvelope({
109
+ ok: false,
110
+ errorCode: 'ARCHIVE_CORRUPTED',
111
+ message: '压缩包损坏',
112
+ expectedFiles: expected.length,
113
+ actualFiles: actual.length,
114
+ });
115
+ return;
116
+ }
117
+
118
+ emitEnvelope({ ok: true, fileCount: actual.length, bytesOut: actualBytes, consumed, targetDir });
119
+ } finally {
120
+ if (ownTemp) await fsp.rm(targetDir, { recursive: true, force: true }).catch(() => {});
121
+ }
122
+ }
123
+
124
+ main().catch((error) => {
125
+ const classified = classify(error);
126
+ process.stderr.write(`[rar-js-runner] ${(error && (error.reason || error.message)) || 'failed'}\n`);
127
+ emitEnvelope({ ok: false, ...classified });
128
+ process.exitCode = 0;
129
+ });