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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -2
  3. package/bin/zipcalm.js +191 -0
  4. package/package.json +67 -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/platformProfile.js +167 -0
  24. package/server/core/fs/repairNames.js +150 -0
  25. package/server/core/fs/roots.js +178 -0
  26. package/server/core/fs/safePath.js +173 -0
  27. package/server/core/fs/search.js +108 -0
  28. package/server/core/fs/stat.js +110 -0
  29. package/server/core/fs/systemTrash.js +212 -0
  30. package/server/core/fs/writeOps.js +257 -0
  31. package/server/core/hardlink/executor.js +106 -0
  32. package/server/core/hardlink/scanner.js +193 -0
  33. package/server/core/metrics/collector.js +279 -0
  34. package/server/core/metrics/procfs.js +107 -0
  35. package/server/core/task/events.js +82 -0
  36. package/server/core/task/manager.js +987 -0
  37. package/server/core/task/recovery.js +116 -0
  38. package/server/core/task/store.js +240 -0
  39. package/server/http/middleware/auth.js +130 -0
  40. package/server/http/middleware/error.js +54 -0
  41. package/server/http/middleware/logger.js +27 -0
  42. package/server/http/middleware/pathGuard.js +119 -0
  43. package/server/http/router.js +29 -0
  44. package/server/http/routes/auth.js +115 -0
  45. package/server/http/routes/blacklist.js +97 -0
  46. package/server/http/routes/capabilities.js +21 -0
  47. package/server/http/routes/compress.js +339 -0
  48. package/server/http/routes/fs.js +109 -0
  49. package/server/http/routes/hardlink.js +160 -0
  50. package/server/http/routes/health.js +52 -0
  51. package/server/http/routes/metrics.js +36 -0
  52. package/server/http/routes/tasks.js +304 -0
  53. package/server/lib/atomic-json.js +118 -0
  54. package/server/lib/errors.js +145 -0
  55. package/server/lib/fs-probe.js +25 -0
  56. package/server/lib/logger.js +68 -0
  57. package/server/lib/runtime-guard.js +49 -0
  58. package/server/lib/throttle.js +42 -0
  59. package/server/plugins/capabilities/password/index.js +300 -0
  60. package/server/plugins/capabilities/password/methods/aes256.js +31 -0
  61. package/server/plugins/capabilities/password/methods/zip20.js +29 -0
  62. package/server/plugins/formats/_shared/createGuards.js +26 -0
  63. package/server/plugins/formats/_shared/entrySafety.js +174 -0
  64. package/server/plugins/formats/_shared/nameRepair.js +123 -0
  65. package/server/plugins/formats/_shared/streams.js +18 -0
  66. package/server/plugins/formats/_shared/tar/compression.js +114 -0
  67. package/server/plugins/formats/_shared/tar/reader.js +74 -0
  68. package/server/plugins/formats/_shared/tar/writer.js +206 -0
  69. package/server/plugins/formats/_shared/tarFamily.js +207 -0
  70. package/server/plugins/formats/rar/index.js +354 -0
  71. package/server/plugins/formats/rar/js-engine.js +122 -0
  72. package/server/plugins/formats/rar/js-runner.mjs +129 -0
  73. package/server/plugins/formats/rar/unrar.js +266 -0
  74. package/server/plugins/formats/sevenzip/engine.js +355 -0
  75. package/server/plugins/formats/sevenzip/index.js +461 -0
  76. package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
  77. package/server/plugins/formats/tar/index.js +19 -0
  78. package/server/plugins/formats/targz/index.js +19 -0
  79. package/server/plugins/formats/tarzst/index.js +26 -0
  80. package/server/plugins/formats/zip/index.js +587 -0
  81. package/shared/app-version.js +7 -0
  82. package/shared/capabilities.js +21 -0
  83. package/shared/compress-modes.js +38 -0
  84. package/shared/error-codes.js +117 -0
  85. package/shared/format-list.js +101 -0
  86. package/shared/hardlink-hint.js +21 -0
  87. package/shared/task-phase.js +62 -0
  88. package/shared/task-status.js +84 -0
  89. package/web/dist/assets/index-D3zjq7N2.js +131 -0
  90. package/web/dist/assets/index-D9Vdmrb9.css +1 -0
  91. package/web/dist/index.html +18 -0
@@ -0,0 +1,461 @@
1
+ // 7z 格式包(**双向**:可压缩、可解压)
2
+ //
3
+ // 引擎:三级回退链(见 engine.js 与 docs/18 §附四)——
4
+ // ① 系统 7z / 7za(如 p7zip 16.02)→ 默认走这条,零新增依赖
5
+ // ② WASM 引擎(js7z-tools,MT)→ **尚未接入调用适配**(依赖需额外安装,见 docs/17 D-7)
6
+ // ③ 都没有 → available=false + unavailableReason 说明
7
+ //
8
+ // 与 RAR 的关系:RAR 只读(extractOnly),7z 双向 —— 所以本插件**不要** extractOnly 标记,
9
+ // 它会正常出现在压缩格式列表里。
10
+ //
11
+ // 条目安全:解压先落到目标目录旁边的中转目录,再经 `_shared/entrySafety.js` 的
12
+ // planTargets + writePlan 搬入(与 zip / tar / rar 同一套 zip-slip / 符号链接 / 不覆盖判定)。
13
+ //
14
+ // 内存上限提示(docs/18 §附四坑 2):WASM 引擎是 32 位地址空间(≤4 GB),超大输入会 OOM;
15
+ // 系统二进制没有这个限制。当前只走系统二进制,故预检不额外拦;接入 WASM 后需在预检按 2 GB 上限提示。
16
+
17
+ import fs from 'node:fs';
18
+ import fsp from 'node:fs/promises';
19
+ import path from 'node:path';
20
+
21
+ import { ERROR_CODES, apiError } from '../../../lib/errors.js';
22
+ import { planTargets, writePlan } from '../_shared/entrySafety.js';
23
+ import {
24
+ CREATE_TIMEOUT_MS,
25
+ copyLocalToTarget,
26
+ currentCpuCores,
27
+ EXTRACT_TIMEOUT_MS,
28
+ LIST_TIMEOUT_MS,
29
+ isEncryptedList,
30
+ makeLocalWorkDir,
31
+ mapFailure,
32
+ needsLocalWorkPath,
33
+ parseSlt,
34
+ probeVersion,
35
+ resetProbeCache,
36
+ resolveEngine,
37
+ runEngine,
38
+ } from './engine.js';
39
+
40
+ const FORMAT_ID = '7z';
41
+ /** WASM 引擎的上游 7-Zip 版本(跑一次 `7z i` 取,缓存住;取不到就 null) */
42
+ let wasmVersionCache = null;
43
+ async function wasmVersion() {
44
+ if (wasmVersionCache !== null) return wasmVersionCache;
45
+ try {
46
+ const outcome = await runEngine(resolveEngine(), { argv: ['i'], timeoutMs: 30000 });
47
+ const matched = String(outcome.stdout || '').match(/7-Zip[^\n]*?(\d+\.\d+)/i);
48
+ wasmVersionCache = matched ? matched[1] : null;
49
+ } catch {
50
+ wasmVersionCache = null;
51
+ }
52
+ return wasmVersionCache;
53
+ }
54
+ const LEVEL_RANGE = [0, 9];
55
+ const DEFAULT_LEVEL = 5;
56
+ /** WASM 引擎单任务输入上限:32 位 ILP32 地址空间(4 GB),实测峰值约 420–460 MB / 89 MB 输入,留足余量取 2 GB */
57
+ export const WASM_MAX_INPUT_BYTES = 2 * 1024 * 1024 * 1024;
58
+
59
+ /** 进度上报节流(解析外部程序输出,避免每行都推一次) */
60
+ const PROGRESS_INTERVAL_MS = 120;
61
+
62
+ /** 取引擎;不可用时抛契约错误码(调用方不用自己拼错) */
63
+ function requireEngine() {
64
+ const engine = resolveEngine();
65
+ if (engine.kind !== 'native' && engine.kind !== 'wasm') {
66
+ throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, '未找到 7z/7za,也无法加载 WASM 引擎(可设置 ZC_7Z_BIN 指定路径)');
67
+ }
68
+ return engine;
69
+ }
70
+
71
+ async function available() {
72
+ const engine = resolveEngine();
73
+ return engine.kind === 'native' || engine.kind === 'wasm';
74
+ }
75
+
76
+ async function unavailableReason() {
77
+ const engine = resolveEngine();
78
+ if (process.env.ZC_7Z_DISABLE_WASM === '1') {
79
+ return '未找到 7z/7za,且 WASM 引擎已被 ZC_7Z_DISABLE_WASM=1 关闭(请安装 p7zip,或去掉该变量)';
80
+ }
81
+ if (engine.wasmPackage) {
82
+ // 依赖在但加载不了(例如包被裁掉/缺 wasm 文件):说清楚,别让用户猜
83
+ return `仅检测到 WASM 引擎依赖(${engine.wasmPackage})但无法使用;请安装 7z/7za,或用 ZC_7Z_BIN 指定路径`;
84
+ }
85
+ return '未找到 7z/7za,也无法加载 WASM 引擎(请安装 p7zip,或用 ZC_7Z_BIN 指定路径)';
86
+ }
87
+
88
+ /** 压缩级别 → -mxN(非法值回落默认,不抛错) */
89
+ function resolveLevel(options) {
90
+ const raw = options && options.level;
91
+ const level = Number(raw);
92
+ if (!Number.isInteger(level)) return DEFAULT_LEVEL;
93
+ return Math.min(LEVEL_RANGE[1], Math.max(LEVEL_RANGE[0], level));
94
+ }
95
+
96
+ /** 7z 的压缩率粗估:实测与 tar.zst 接近(只优 ~1%),这里按 0.98 给量级,仅用于预检分母 */
97
+ async function estimate(inputBytes) {
98
+ const bytes = Number(inputBytes) || 0;
99
+ return Math.max(0, Math.ceil(bytes * 0.98));
100
+ }
101
+
102
+ /**
103
+ * 大输入保护:WASM 引擎是 32 位地址空间,超大输入会 OOM 且可能让宿主内存吃紧。
104
+ * **在写盘之前**就拒绝并给替代方案,而不是让用户跑到一半才失败。
105
+ * @param {{kind: string}} engine
106
+ * @param {number} totalBytes
107
+ */
108
+ export function assertInputWithinEngineLimit(engine, totalBytes) {
109
+ if (engine && engine.kind === 'wasm' && Number(totalBytes) > WASM_MAX_INPUT_BYTES) {
110
+ const gb = (Number(totalBytes) / (1024 * 1024 * 1024)).toFixed(1);
111
+ throw apiError(
112
+ ERROR_CODES.VALIDATION_FAILED,
113
+ `输入约 ${gb} GB,超过 WASM 引擎的 2 GB 上限(32 位地址空间,容易内存不足):建议改用 tar.zst,或安装系统 p7zip 后重试`,
114
+ );
115
+ }
116
+ }
117
+
118
+ /** 读条目清单(同时给出是否加密) */
119
+ async function inspect(archivePath) {
120
+ const engine = requireEngine();
121
+ const outcome = await runEngine(engine, { argv: ['l', '-slt', '-p-', archivePath], timeoutMs: LIST_TIMEOUT_MS });
122
+ if (outcome.code !== 0) mapFailure(outcome, { hasPassword: false, operation: 'list' });
123
+ const entries = parseSlt(outcome.stdout);
124
+ return { entries, encrypted: isEncryptedList(entries) };
125
+ }
126
+
127
+ async function list(archivePath) {
128
+ const { entries, encrypted } = await inspect(archivePath);
129
+ const items = entries.map((entry) => ({
130
+ name: entry.name,
131
+ type: entry.type,
132
+ size: entry.size,
133
+ compressedSize: entry.compressedSize,
134
+ }));
135
+ // encrypted 必须透出:解压预检据此弹密码框
136
+ return { entries: items, count: items.length, truncated: false, encrypted };
137
+ }
138
+
139
+ async function test(archivePath, options = {}) {
140
+ const engine = requireEngine();
141
+ const password = options && typeof options.password === 'string' ? options.password : '';
142
+ const info = await inspect(archivePath);
143
+ const entries = info.entries;
144
+ const args = ['t', '-y', password ? `-p${password}` : '-p-', archivePath];
145
+ const outcome = await runEngine(engine, { argv: args, timeoutMs: LIST_TIMEOUT_MS });
146
+ if (outcome.code !== 0) {
147
+ mapFailure(outcome, { hasPassword: Boolean(password), archiveEncrypted: info.encrypted, operation: 'test' });
148
+ }
149
+ return {
150
+ ok: true,
151
+ checked: entries.filter((entry) => entry.type !== 'dir').length,
152
+ failures: [],
153
+ strategy: '7z-test',
154
+ };
155
+ }
156
+
157
+ /** 供解压预检调用(与 zip/rar 同构) */
158
+ async function probeEncryption(archivePath) {
159
+ const { encrypted } = await inspect(archivePath);
160
+ return { encrypted, isAes: false };
161
+ }
162
+
163
+ /** 供加密包"同一密码再试解一次"使用(编排层优先问插件要这个方法) */
164
+ async function verifyWithPassword(archivePath, password) {
165
+ try {
166
+ const result = await test(archivePath, { password });
167
+ return { ok: true, checked: result.checked, strategy: result.strategy };
168
+ } catch (error) {
169
+ // 保留原始错误码:密码错 / 包损坏不能被压成"校验未通过"
170
+ return { ok: false, code: (error && error.code) || null, error: (error && error.message) || '加密包校验失败' };
171
+ }
172
+ }
173
+
174
+ // ===== 压缩 =====
175
+
176
+ /**
177
+ * 收集要进包的条目(与 zip 同口径):目录保留源名作顶层,文件用源名作条目名;
178
+ * 符号链接不进包;命中黑名单(shouldSkip)的条目记账后跳过。
179
+ * @returns {Promise<{sources: Array<{cwd: string, names: string[], entryPaths: string[], sizes: Map<string, number>}>, entryPaths: string[], skipped: Array, skippedSymlinks: number, totalBytes: number}>}
180
+ */
181
+ async function collectPlan(ctx) {
182
+ const shouldSkip = typeof (ctx.options && ctx.options.shouldSkip) === 'function' ? ctx.options.shouldSkip : null;
183
+ const skipped = [];
184
+ let skippedSymlinks = 0;
185
+ let totalBytes = 0;
186
+ const perSource = [];
187
+ const entryPaths = [];
188
+
189
+ const recordSkip = (absPath, name, segments) => {
190
+ if (!shouldSkip) return false;
191
+ const hit = shouldSkip({ name, segments });
192
+ if (!hit) return false;
193
+ skipped.push({ absPath, pattern: hit.pattern, layer: hit.layer });
194
+ return true;
195
+ };
196
+
197
+ for (const source of ctx.sources) {
198
+ const sourceName = source.name || path.basename(source.absPath);
199
+ const cwd = path.dirname(source.absPath);
200
+ const names = [];
201
+ const sizes = new Map();
202
+
203
+ if (source.kind === 'file') {
204
+ const fileStat = await fsp.lstat(source.absPath).catch(() => null);
205
+ if (!fileStat || !fileStat.isFile()) {
206
+ throw apiError(ERROR_CODES.PATH_NOT_FOUND, '源文件在扫描后已不可用', { source: sourceName });
207
+ }
208
+ if (recordSkip(source.absPath, path.basename(source.absPath), [sourceName])) continue;
209
+ names.push(sourceName);
210
+ sizes.set(sourceName, fileStat.size);
211
+ totalBytes += fileStat.size;
212
+ entryPaths.push(source.absPath);
213
+ perSource.push({ cwd, names, sizes });
214
+ continue;
215
+ }
216
+
217
+ const walk = async (dirAbs, prefix) => {
218
+ const dirents = await fsp.readdir(dirAbs, { withFileTypes: true });
219
+ for (const dirent of dirents) {
220
+ const childAbs = path.join(dirAbs, dirent.name);
221
+ const relative = `${prefix}/${dirent.name}`;
222
+ if (dirent.isSymbolicLink()) {
223
+ skippedSymlinks += 1;
224
+ continue;
225
+ }
226
+ if (dirent.isDirectory()) {
227
+ await walk(childAbs, relative);
228
+ continue;
229
+ }
230
+ if (!dirent.isFile()) continue;
231
+ if (recordSkip(childAbs, dirent.name, relative.split('/'))) continue;
232
+ const fileStat = await fsp.lstat(childAbs).catch(() => null);
233
+ if (!fileStat || !fileStat.isFile()) continue;
234
+ names.push(relative);
235
+ sizes.set(relative, fileStat.size);
236
+ totalBytes += fileStat.size;
237
+ entryPaths.push(childAbs);
238
+ }
239
+ };
240
+ await walk(source.absPath, sourceName);
241
+ if (names.length > 0) perSource.push({ cwd, names, sizes });
242
+ }
243
+
244
+ return { perSource, entryPaths, skipped, skippedSymlinks, totalBytes };
245
+ }
246
+
247
+ /** 进度:解析 `+ <path>` 行(-bb1 输出),按文件累计字节 */
248
+ function makeCreateReporter(onProgress, totalBytes, sizeByPath) {
249
+ let processedBytes = 0;
250
+ let processedFiles = 0;
251
+ let totalFiles = sizeByPath.size;
252
+ let lastAt = 0;
253
+ const report = typeof onProgress === 'function' ? onProgress : () => {};
254
+ return (line) => {
255
+ const matched = line.match(/^\+\s+(.+?)\s*$/);
256
+ if (!matched) return;
257
+ const name = matched[1].replace(/\\/g, '/');
258
+ const size = sizeByPath.get(name) || 0;
259
+ processedBytes += size;
260
+ processedFiles += 1;
261
+ const now = Date.now();
262
+ if (now - lastAt < PROGRESS_INTERVAL_MS) return;
263
+ lastAt = now;
264
+ report({ processedBytes, totalBytes, processedFiles, totalFiles, currentFile: name });
265
+ };
266
+ }
267
+
268
+ async function create(ctx) {
269
+ const engine = requireEngine();
270
+ const plan = await collectPlan(ctx);
271
+ if (plan.entryPaths.length === 0) {
272
+ throw apiError(ERROR_CODES.ALL_SOURCES_SKIPPED, '没有可压缩的文件:全部命中黑名单');
273
+ }
274
+ assertInputWithinEngineLimit(engine, plan.totalBytes);
275
+
276
+ const level = resolveLevel(ctx.options);
277
+ const password = ctx.options && typeof ctx.options.password === 'string' ? ctx.options.password : '';
278
+ const sizeByPath = new Map();
279
+ for (const group of plan.perSource) {
280
+ for (const [name, size] of group.sizes) sizeByPath.set(name, size);
281
+ }
282
+ const onLine = makeCreateReporter(ctx.onProgress, plan.totalBytes, sizeByPath);
283
+
284
+ // WASM 引擎不能直接写网络盘(NODEFS 的 chmod 在 CIFS 上被拒 → EPERM,实测 errno=63):
285
+ // 先写本地盘,再由 Node 拷到目标路径。原生引擎没有这个限制,保持直写零拷贝。
286
+ const localWorkDir = needsLocalWorkPath(engine) ? await makeLocalWorkDir('zc7z-create-') : null;
287
+ const outputPath = localWorkDir ? path.join(localWorkDir, path.basename(ctx.tempPath)) : ctx.tempPath;
288
+
289
+ // 逐个源、分批把条目名当**命令行参数**传给 7z(cwd = 源的父目录,条目名即"源名/子路径")。
290
+ // 为什么不用 `-i@listfile`:p7zip 16.02 是 `Utf16=off` 构建,读 UTF-8 listfile 里的中文名会报
291
+ // `????: No more files`(实测),而中文名走 argv 是正常的。分批是为了避免参数过长撞 ARG_MAX。
292
+ const MAX_ARGS_CHARS = 60000;
293
+ for (const group of plan.perSource) {
294
+ const batches = [];
295
+ let current = [];
296
+ let currentChars = 0;
297
+ for (const name of group.names) {
298
+ if (currentChars + name.length > MAX_ARGS_CHARS && current.length > 0) {
299
+ batches.push(current);
300
+ current = [];
301
+ currentChars = 0;
302
+ }
303
+ current.push(name);
304
+ currentChars += name.length + 1;
305
+ }
306
+ if (current.length > 0) batches.push(current);
307
+
308
+ for (const batch of batches) {
309
+ // -mmt 与本进程被绑定的核数对齐:线程数 > 可用核只会互相等锁
310
+ const args = ['a', '-t7z', `-mx${level}`, '-y', '-bb1', `-mmt=${currentCpuCores()}`, ...(password ? [`-p${password}`] : []), outputPath, ...batch];
311
+ const outcome = await runEngine(engine, {
312
+ argv: args,
313
+ cwd: group.cwd,
314
+ timeoutMs: CREATE_TIMEOUT_MS,
315
+ onLine,
316
+ signal: ctx.options && ctx.options.signal,
317
+ });
318
+ if (outcome.code !== 0) {
319
+ await fsp.rm(ctx.tempPath, { force: true }).catch(() => {});
320
+ if (localWorkDir) await fsp.rm(localWorkDir, { recursive: true, force: true }).catch(() => {});
321
+ mapFailure(outcome, { hasPassword: Boolean(password), operation: 'create' });
322
+ }
323
+ }
324
+ }
325
+
326
+ // WASM:把本地盘的产物搬到目标(可能是 CIFS)路径;用流式拷贝(CIFS 上 copyFile 会 EPERM)
327
+ if (localWorkDir) {
328
+ try {
329
+ await copyLocalToTarget(outputPath, ctx.tempPath);
330
+ } catch (error) {
331
+ // 不能把底层错误的原文写进任务 error:Node 的 EPERM 文案里带着**两端的绝对路径**
332
+ // (`copyfile '/var/packages/...' -> '/volume1/.../storage/fs-root/...'`),
333
+ // 会把存储根路径泄漏到 API 响应里(m2/m3/probes 的"响应不含绝对路径"断言就是这么红的)。
334
+ throw apiError(ERROR_CODES.INTERNAL_ERROR, '压缩包写入目标位置失败(磁盘/权限问题,详见服务日志)', {
335
+ reason: (error && error.code) || 'COPY_FAILED',
336
+ });
337
+ } finally {
338
+ await fsp.rm(localWorkDir, { recursive: true, force: true }).catch(() => {});
339
+ }
340
+ }
341
+
342
+ return {
343
+ bytesIn: plan.totalBytes,
344
+ skippedSymlinks: plan.skippedSymlinks,
345
+ entryPaths: plan.entryPaths,
346
+ skipped: plan.skipped,
347
+ };
348
+ }
349
+
350
+ // ===== 解压 =====
351
+
352
+ /** 递归收集中转目录条目(符号链接一律不收) */
353
+ async function collectStaged(stagingDir) {
354
+ const collected = [];
355
+ const walk = async (dirAbs, relativePrefix) => {
356
+ const dirents = await fsp.readdir(dirAbs, { withFileTypes: true });
357
+ for (const dirent of dirents) {
358
+ const abs = path.join(dirAbs, dirent.name);
359
+ const relative = relativePrefix ? `${relativePrefix}/${dirent.name}` : dirent.name;
360
+ if (dirent.isSymbolicLink()) continue;
361
+ if (dirent.isDirectory()) {
362
+ collected.push({ name: `${relative}/`, isDir: true, size: 0, link: false, open: null });
363
+ await walk(abs, relative);
364
+ continue;
365
+ }
366
+ if (!dirent.isFile()) continue;
367
+ const stat = await fsp.lstat(abs).catch(() => null);
368
+ collected.push({
369
+ name: relative,
370
+ isDir: false,
371
+ size: stat ? stat.size : 0,
372
+ link: false,
373
+ open: () => fs.createReadStream(abs),
374
+ });
375
+ }
376
+ };
377
+ await walk(stagingDir, '');
378
+ return collected;
379
+ }
380
+
381
+ async function extract(ctx) {
382
+ const engine = requireEngine();
383
+ const startedAt = Date.now();
384
+ const targetDir = path.resolve(ctx.targetDir);
385
+ const overwrite = Boolean(ctx.options && ctx.options.overwrite);
386
+ const password = ctx.options && typeof ctx.options.password === 'string' ? ctx.options.password : '';
387
+ const signal = ctx.options && ctx.options.signal;
388
+
389
+ // ⓪ 先读清单:判断加密 + 符号链接条目(写盘之前就能拒绝)
390
+ const info = await inspect(ctx.archivePath);
391
+ const symlinkEntries = info.entries.filter((entry) => entry.symlink).map((entry) => entry.name);
392
+ if (symlinkEntries.length > 0) {
393
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, '压缩包含符号链接条目,已拒绝解压', { entry: symlinkEntries[0] });
394
+ }
395
+ if (info.encrypted && !password) {
396
+ throw apiError(ERROR_CODES.MISSING_PASSWORD, '该压缩包已加密,请输入密码');
397
+ }
398
+
399
+ // ① 解到中转目录(外部程序不直接写用户目录)
400
+ // WASM 引擎必须解到**本地盘**:它经 NODEFS 落盘时会做 chmod/chown,CIFS 直接拒绝(实测 EPERM/errno=63)。
401
+ // 原生引擎仍用"目标目录旁边"的中转目录(同卷移动,省一次拷贝)。
402
+ const localStaging = needsLocalWorkPath(engine) ? await makeLocalWorkDir('zc7z-extract-') : null;
403
+ const stagingDir = localStaging || path.join(path.dirname(targetDir), `.${path.basename(targetDir)}.zc7z-${Date.now()}`);
404
+ if (!localStaging) await fsp.mkdir(stagingDir, { recursive: true });
405
+
406
+ try {
407
+ const args = ['x', '-y', ...(password ? [`-p${password}`] : []), `-o${stagingDir}`, ctx.archivePath];
408
+ const outcome = await runEngine(engine, { argv: args, timeoutMs: EXTRACT_TIMEOUT_MS, signal });
409
+ if (outcome.code !== 0) {
410
+ mapFailure(outcome, { hasPassword: Boolean(password), archiveEncrypted: info.encrypted, operation: 'extract' });
411
+ }
412
+
413
+ // ② 安全判定 + 搬入目标目录(zip-slip / 不覆盖同名 / 进度 / 半成品清理)
414
+ const staged = await collectStaged(stagingDir);
415
+ const planned = await planTargets(staged, targetDir, overwrite);
416
+ const written = await writePlan(planned, targetDir, { signal, onProgress: ctx.onProgress });
417
+ return {
418
+ targetDir,
419
+ entries: written.entries,
420
+ bytesOut: written.bytesOut,
421
+ durationMs: Date.now() - startedAt,
422
+ encrypted: info.encrypted,
423
+ };
424
+ } finally {
425
+ await fsp.rm(stagingDir, { recursive: true, force: true }).catch(() => {});
426
+ }
427
+ }
428
+
429
+ export { resetProbeCache };
430
+
431
+ export default {
432
+ id: FORMAT_ID,
433
+ name: '7Z',
434
+ ext: '.7z',
435
+ extAliases: [],
436
+ mime: 'application/x-7z-compressed',
437
+ levelRange: LEVEL_RANGE,
438
+ defaultLevel: DEFAULT_LEVEL,
439
+ // 7z 原生支持密码:能力声明交给密码能力插件(appliesTo 由能力插件决定)
440
+ capabilities: ['password'],
441
+ available,
442
+ unavailableReason,
443
+ estimate,
444
+ create,
445
+ extract,
446
+ list,
447
+ test,
448
+ probeEncryption,
449
+ verifyWithPassword,
450
+ /** 诊断:当前用哪个引擎(验收要看这条) */
451
+ probe: async () => {
452
+ const engine = resolveEngine();
453
+ if (engine.kind === 'native') {
454
+ return { kind: engine.kind, bin: engine.bin, version: probeVersion(engine.bin), wasmPackage: null };
455
+ }
456
+ if (engine.kind === 'wasm') {
457
+ return { kind: engine.kind, bin: null, version: await wasmVersion(), wasmPackage: engine.wasmPackage };
458
+ }
459
+ return { kind: null, bin: null, version: null, wasmPackage: engine.wasmPackage || null };
460
+ },
461
+ };
@@ -0,0 +1,72 @@
1
+ // WASM 引擎执行器(**子进程**,每次操作一个实例)
2
+ //
3
+ // 为什么必须独立进程:
4
+ // 1) js7z-tools 的 MT 模式每次运行会拉起 worker 线程,运行时在完成后自行退出;
5
+ // 同一个进程里连续创建多个实例(或复用一个实例跑第二条命令)实测会**挂住不返回**。
6
+ // 一次操作 = 一个新进程 = 一个新实例,彻底规避这个状态残留。
7
+ // 2) 超时/取消可以硬杀进程,不会把宿主服务拖住。
8
+ //
9
+ // 通信协议(父进程 ↔ 本脚本):
10
+ // 入参:argv[2] = JSON spec
11
+ // { argv: string[], // 7z 参数(路径已是虚拟路径 /m0、/m1… 或相对名)
12
+ // cwdWasm: string|null, // 在 WASM 里 chdir 到哪(create 需相对名解析)
13
+ // mounts: [{host, wasm}] } // 把宿主目录挂进虚拟文件系统
14
+ // 出参:**stderr 打印一行 JSON 信封** {"exit": <code>}
15
+ // 7z 自身的输出留在 stdout(实测 js7z 直接写真实 stdout,覆盖 instance.print 不生效,
16
+ // 所以不能靠"把 7z 输出改道 stderr"来分流——只能反过来:信封走 stderr)
17
+ //
18
+ // 关键异步语义(docs/18 §附四坑 1):MT 版 `callMain` 是**异步**的,完成信号只在
19
+ // `onExit(exitCode)` 里。**绝不能在 callMain 返回后同步判定成功**——实测那样会误报"缺产物",
20
+ // 文件稍后才落盘。本脚本严格等 onExit 之后再打印信封。
21
+
22
+ import { createRequire } from 'node:module';
23
+
24
+ const spec = JSON.parse(process.argv[2] || '{}');
25
+
26
+ /** 诊断信息(启动失败等)写 stderr,与信封同一通道,父进程按 JSON 行识别 */
27
+ const emit = (text) => process.stderr.write(text);
28
+
29
+ async function main() {
30
+ // 依赖解析:包可能装在项目依赖目录(node_modules 软链或 NODE_PATH),交给 Node 自己找
31
+ const requireFrom = createRequire(import.meta.url);
32
+ const JS7z = requireFrom('js7z-tools');
33
+
34
+ const instance = await JS7z();
35
+ // 说明:js7z 的 7z 输出并不经过 instance.print(实测),会直接写到真实 stdout。
36
+ // 这里仍设置一次,兼容某些版本;真正的分流见文件头注释。
37
+
38
+ // 挂载宿主目录:NODEFS 让 WASM 里的路径直接读写真实文件
39
+ for (const mount of spec.mounts || []) {
40
+ try {
41
+ instance.FS.mkdir(mount.wasm);
42
+ } catch {
43
+ /* 已存在(同一路径重复挂载)不算错 */
44
+ }
45
+ instance.FS.mount(instance.NODEFS, { root: mount.host }, mount.wasm);
46
+ }
47
+ if (spec.cwdWasm) instance.FS.chdir(spec.cwdWasm);
48
+
49
+ // 严格等 onExit:这是唯一可靠的完成信号
50
+ const exitCode = await new Promise((resolve) => {
51
+ let settled = false;
52
+ instance.onExit = (code) => {
53
+ settled = true;
54
+ resolve(typeof code === 'number' ? code : 0);
55
+ };
56
+ instance.callMain(spec.argv || []);
57
+ // 兜底:若某版本不触发 onExit,超时也不能永远挂着(父进程另有硬超时)
58
+ setTimeout(() => {
59
+ if (!settled) resolve(-1);
60
+ }, 10 * 60 * 1000).unref();
61
+ });
62
+
63
+ process.stderr.write(`${JSON.stringify({ exit: exitCode })}\n`);
64
+ // MT 运行时会在完成后自行退出线程;这里显式结束进程,避免 worker 让父进程等
65
+ process.exit(0);
66
+ }
67
+
68
+ main().catch((error) => {
69
+ emit(`\n[wasm-runner] 启动失败:${error && error.message}\n`);
70
+ process.stderr.write(`${JSON.stringify({ exit: -1, error: String(error && error.message) })}\n`);
71
+ process.exit(0);
72
+ });
@@ -0,0 +1,19 @@
1
+ /*
2
+ * tar 格式包(不压缩的裸 tar,node-tar)
3
+ *
4
+ * 归档层与 .tar.gz / .tar.zst 共用同一个工厂(`_shared/tarFamily.js`):
5
+ * zip-slip 判定、进度、流式写入只有一份实现,三种格式的差别只在压缩层。
6
+ * 契约见 docs/M0/插件契约.md §3.4 create / §3.4.1 extract / §3.5 list / §3.6 test。
7
+ */
8
+
9
+ import { createTarPlugin } from '../_shared/tarFamily.js';
10
+
11
+ export default createTarPlugin({
12
+ id: 'tar',
13
+ name: 'TAR',
14
+ ext: '.tar',
15
+ extAliases: [],
16
+ mime: 'application/x-tar',
17
+ compression: 'none',
18
+ // 不压缩:不支持 level(契约里 levelRange 省略即表示不支持)
19
+ });
@@ -0,0 +1,19 @@
1
+ /*
2
+ * tar.gz 格式包(node-tar + Node 内置 node:zlib 的 gzip)
3
+ *
4
+ * 复合扩展名 `.tar.gz` 是**一个整体**(另有别名 `.tgz`):注册表按"最长复合扩展名优先"匹配,
5
+ * 保证 `x.tar.gz` 命中本格式而不是被 `.gz` 抢走(契约 §3.1)。
6
+ */
7
+
8
+ import { createTarPlugin } from '../_shared/tarFamily.js';
9
+
10
+ export default createTarPlugin({
11
+ id: 'targz',
12
+ name: 'TAR.GZ',
13
+ ext: '.tar.gz',
14
+ extAliases: ['.tgz'],
15
+ mime: 'application/gzip',
16
+ compression: 'gzip',
17
+ levelRange: [0, 9],
18
+ defaultLevel: 6,
19
+ });
@@ -0,0 +1,26 @@
1
+ /*
2
+ * tar.zst 格式包(node-tar + zstd)
3
+ *
4
+ * zstd 能力**自探测 + 回退链**(docs/02 §二):
5
+ * ① Node ≥ 22.15 内置 `node:zlib` 的 `createZstdCompress`(零依赖、流式、跨平台)—— 首选;
6
+ * ② 系统 `zstd` 命令(`ZC_ZSTD_BIN` 可覆盖路径);
7
+ * ③ 都不可用 → `available() = false` 并给出明确原因(前端据此置灰,不静默降级、不假装可用)。
8
+ *
9
+ * 排障用开关:`ZIPCALM_DISABLE_BUILTIN_ZSTD=1` 强制禁用内置实现,用于验证回退链是否真的生效。
10
+ */
11
+
12
+ import { createTarPlugin } from '../_shared/tarFamily.js';
13
+
14
+ /** zstd 压缩级别:1(最快)~ 19(最高压缩率),默认 3(速度/体积折中) */
15
+ const ZSTD_LEVEL_RANGE = [1, 19];
16
+
17
+ export default createTarPlugin({
18
+ id: 'tarzst',
19
+ name: 'TAR.ZST',
20
+ ext: '.tar.zst',
21
+ extAliases: ['.tzst'],
22
+ mime: 'application/zstd',
23
+ compression: 'zstd',
24
+ levelRange: ZSTD_LEVEL_RANGE,
25
+ defaultLevel: 3,
26
+ });