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,174 @@
1
+ // 归档条目安全判定与写出(**所有格式包共用一套**,M4)
2
+ //
3
+ // 为什么必须共用:zip-slip 防护是 M2 验收过的关键安全代码(拒绝 `..` / 绝对路径 / 盘符 / NUL / symlink 逃逸)。
4
+ // 每个格式各写一份 = 迟早漂移出漏洞;tar 与 zip 的差异只在"条目从哪来",判定与写出完全一致。
5
+ //
6
+ // 判定复用 core/fs/safePath.js 的 normalizeUserPath(与 pathGuard 同一套语义),
7
+ // 再叠加归档特有的盘符检查与"目标目录 + 分隔符"前缀校验。
8
+ //
9
+ // 写盘顺序铁律:**先把所有条目判定完,再开始写** —— 恶意归档不会产生"写到一半才发现"的副作用。
10
+
11
+ import fs from 'node:fs';
12
+ import fsp from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ import { pipelineWith } from './streams.js';
15
+
16
+ import { ERROR_CODES, apiError } from '../../../lib/errors.js';
17
+ import { pathExists } from '../../../lib/fs-probe.js';
18
+ import { createThrottledReporter } from '../../../lib/throttle.js';
19
+ import { normalizeUserPath } from '../../../core/fs/safePath.js';
20
+ import { repairEntries } from './nameRepair.js';
21
+
22
+ const MAX_PATH_LENGTH = 4096;
23
+ const MAX_SEGMENT_LENGTH = 255;
24
+
25
+ /** 仅返回 basename:错误 details 里绝不出现本机绝对路径 */
26
+ export function safeName(target) {
27
+ return typeof target === 'string' ? path.basename(target) : null;
28
+ }
29
+
30
+ /**
31
+ * 归档条目名 → 目标目录内的绝对路径;任何逃逸企图直接抛错(zip-slip 核心)。
32
+ * @returns {{targetPath: string, segments: string[], isRoot: boolean}}
33
+ */
34
+ export function resolveEntryTarget(targetDir, entryName) {
35
+ if (typeof entryName !== 'string' || entryName.length === 0) {
36
+ throw apiError(ERROR_CODES.INVALID_PATH, '归档条目名为空', { entry: entryName });
37
+ }
38
+ const unified = entryName.replace(/\\/g, '/');
39
+ // 盘符(Windows 绝对路径形态,如 C:/evil):API 语义里不可能合法
40
+ if (/^[A-Za-z]:/.test(unified)) {
41
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, '归档条目含盘符(zip-slip)', { entry: entryName });
42
+ }
43
+ // 绝对路径:一律拒绝(总是相对目标目录展开)
44
+ if (unified.startsWith('/')) {
45
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, '归档条目为绝对路径(zip-slip)', { entry: entryName });
46
+ }
47
+
48
+ let normalized;
49
+ try {
50
+ normalized = normalizeUserPath(unified, {
51
+ maxPathLength: MAX_PATH_LENGTH,
52
+ maxPathSegmentLength: MAX_SEGMENT_LENGTH,
53
+ });
54
+ } catch (error) {
55
+ // normalizeUserPath 抛的是契约错误(.. / 编码 .. / NUL / 超长):原样抛,只补条目名便于定位
56
+ if (error && error.details) error.details.entry = entryName;
57
+ throw error;
58
+ }
59
+
60
+ const root = path.resolve(targetDir);
61
+ const target = path.resolve(root, ...normalized.segments);
62
+ if (target !== root && !target.startsWith(root + path.sep)) {
63
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, '归档条目逃逸目标目录(zip-slip)', { entry: entryName });
64
+ }
65
+ return { targetPath: target, segments: normalized.segments, isRoot: target === root };
66
+ }
67
+
68
+ /** 符号链接条目:M4 策略同样是**一律拒绝**(比"仅拒绝指向目标外的链接"更保守) */
69
+ export function rejectSymlink(entryName) {
70
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, '归档包含符号链接条目,已拒绝解压', { entry: entryName });
71
+ }
72
+
73
+ /**
74
+ * 写盘前的条目预检:zip-slip 判定 + 覆盖判定(INV-08)。
75
+ * @param {Array<{name: string, isDir: boolean, size: number, link?: boolean, open?: Function}>} entries
76
+ * @param {string} targetDir 目标目录(绝对路径)
77
+ * @param {boolean} overwrite 是否允许覆盖已有同名文件
78
+ */
79
+ export async function planTargets(entries, targetDir, overwrite) {
80
+ const plan = [];
81
+ let totalBytes = 0;
82
+ let totalFiles = 0;
83
+
84
+ // 共享阶段:先在**还原后的名字**上做路径判定(否则 `../` 可能藏在 PUA 转义里逃过检查)。
85
+ // 还原是增强不是必需路径:解不开/撞名就保留原名,绝不阻断解压。
86
+ const repaired = repairEntries(entries);
87
+
88
+ for (const entry of repaired.entries) {
89
+ if (entry.link) rejectSymlink(entry.name);
90
+ const unified = String(entry.name || '').replace(/\\/g, '/');
91
+ const nameForCheck = unified.replace(/\/+$/, '');
92
+ const resolved = resolveEntryTarget(targetDir, nameForCheck);
93
+ const isDir = Boolean(entry.isDir) || unified.endsWith('/');
94
+ const size = isDir ? 0 : Number(entry.size || 0);
95
+
96
+ plan.push({
97
+ entry,
98
+ isDir,
99
+ targetPath: resolved.targetPath,
100
+ displayName: entry.name,
101
+ size,
102
+ open: entry.open,
103
+ });
104
+ if (!isDir) {
105
+ totalBytes += size;
106
+ totalFiles += 1;
107
+ }
108
+ }
109
+
110
+ if (!overwrite) {
111
+ for (const plannedEntry of plan) {
112
+ if (plannedEntry.isDir) continue;
113
+ if (await pathExists(plannedEntry.targetPath)) {
114
+ throw apiError(ERROR_CODES.TARGET_EXISTS, '目标目录已存在同名文件(overwrite=false)', { entry: plannedEntry.displayName });
115
+ }
116
+ }
117
+ }
118
+
119
+ return { plan, totalBytes, totalFiles, repairedNames: repaired.repaired, repairConflicts: repaired.conflicts };
120
+ }
121
+
122
+ /**
123
+ * 按计划写盘:目录先建、文件走流式管道;失败/取消清理半成品(契约 §3.4.1 第 4 条)。
124
+ * @param {{plan: Array, totalBytes: number, totalFiles: number}} planned planTargets 的返回
125
+ */
126
+ export async function writePlan(planned, targetDir, options = {}) {
127
+ const { signal } = options;
128
+ const report = createThrottledReporter(options.onProgress || (() => {}), { intervalMs: 100 });
129
+ let processedBytes = 0;
130
+ let processedFiles = 0;
131
+ let entries = 0;
132
+
133
+ await fsp.mkdir(targetDir, { recursive: true });
134
+
135
+ for (const plannedEntry of planned.plan) {
136
+ if (signal && signal.aborted) throw apiError(ERROR_CODES.ABORTED, '任务已取消');
137
+
138
+ if (plannedEntry.isDir) {
139
+ await fsp.mkdir(plannedEntry.targetPath, { recursive: true });
140
+ entries += 1;
141
+ continue;
142
+ }
143
+
144
+ await fsp.mkdir(path.dirname(plannedEntry.targetPath), { recursive: true });
145
+ try {
146
+ // 注意:signal 为空时绝不能传 undefined(pipeline 会把它当 stage 校验并抛错)
147
+ await pipelineWith([plannedEntry.open(), fs.createWriteStream(plannedEntry.targetPath)], signal);
148
+ } catch (error) {
149
+ try {
150
+ // force: true = 不存在也不算错(因此不需要先探一次是否存在)
151
+ await fsp.rm(plannedEntry.targetPath, { force: true });
152
+ } catch {
153
+ /* 清理失败不掩盖原始错误 */
154
+ }
155
+ if (error && (error.name === 'AbortError' || (signal && signal.aborted))) {
156
+ throw apiError(ERROR_CODES.ABORTED, '任务已取消');
157
+ }
158
+ throw error;
159
+ }
160
+
161
+ entries += 1;
162
+ processedFiles += 1;
163
+ processedBytes += plannedEntry.size;
164
+ report({
165
+ processedBytes,
166
+ totalBytes: planned.totalBytes,
167
+ processedFiles,
168
+ totalFiles: planned.totalFiles,
169
+ currentFile: plannedEntry.displayName,
170
+ });
171
+ }
172
+
173
+ return { entries, bytesOut: processedBytes };
174
+ }
@@ -0,0 +1,123 @@
1
+ // 乱码文件名的识别与还原(**跨格式共享**:zip / tar / 7z / rar 都受益)
2
+ //
3
+ // 原理(实测,见 docs/17 D-13):老版 unrar(5.70)与 node-unrar-js(内置 6.1.7)等引擎,
4
+ // 在处理 RAR5 的 UTF-8 文件名时会把字节**逐个**转成 Unicode 私有区字符,并在开头插一个 U+FFFE 标记:
5
+ //
6
+ // 原始字节 e4 b8 ad e6 96 87 …("中文…")
7
+ // 落盘名 73 37 2f efbfbe ee83a4 ee82b8 ee82ad …
8
+ // └s7/┘└U+FFFE┘└── U+E0XX(= 原字节 0xXX)──┘
9
+ //
10
+ // 这是**无损字节转义**:去掉 U+FFFE,把每个 U+E0xx 还原成字节 0xXX,拼回 UTF-8 即得原名。
11
+ //
12
+ // ⚠️ 前置条件:引擎必须在 `en_US.UTF-8`(或 zh_CN.UTF-8)下运行。默认 locale 与 **C.UTF-8** 都会
13
+ // 输出有损的 `?`(字节已丢,不可还原)—— 这是实测踩到的陷阱。
14
+ //
15
+ // 判定**宁严勿松**:宁可漏还原(用户还能手动改),也不能误改正常文件名。
16
+ // 三重校验全过才还原:① 含 U+FFFE 标记 ② 解码结果是合法 UTF-8 ③ 往返一致(重新编码 == 原始字节)。
17
+
18
+ /** 私有区字节替身的码点范围:U+E000..U+E0FF 对应原始字节 0x00..0xFF */
19
+ const PUA_BYTE_BASE = 0xe000;
20
+ const PUA_BYTE_MAX = 0xe0ff;
21
+ /** 转义标记:U+FFFE(非字符,正常文件名不可能包含) */
22
+ const ESCAPE_MARKER = '\uFFFE';
23
+
24
+ /** 名字里是否带转义标记(**硬门槛**:没有标记一律不动) */
25
+ export function hasEscapeMarker(name) {
26
+ return String(name || '').includes(ESCAPE_MARKER);
27
+ }
28
+
29
+ /**
30
+ * 按 PUA 转义规则还原成字节,再解成 UTF-8 字符串。
31
+ * @param {string} name
32
+ * @returns {string} 还原结果(调用方必须自己再做校验,本函数不做判断)
33
+ */
34
+ export function decodePuaEscaped(name) {
35
+ const cleaned = String(name || '').replaceAll(ESCAPE_MARKER, '');
36
+ const bytes = [];
37
+ for (const ch of cleaned) {
38
+ const cp = ch.codePointAt(0);
39
+ if (cp >= PUA_BYTE_BASE && cp <= PUA_BYTE_MAX) bytes.push(cp - PUA_BYTE_BASE);
40
+ else if (cp < 0x80) bytes.push(cp);
41
+ else bytes.push(...Buffer.from(ch, 'utf8'));
42
+ }
43
+ return Buffer.from(bytes).toString('utf8');
44
+ }
45
+
46
+ /** 把转义名还原出的原始字节序列(供报告里贴 hex) */
47
+ export function escapedBytesHex(name) {
48
+ const cleaned = String(name || '').replaceAll(ESCAPE_MARKER, '');
49
+ const bytes = [];
50
+ for (const ch of cleaned) {
51
+ const cp = ch.codePointAt(0);
52
+ if (cp >= PUA_BYTE_BASE && cp <= PUA_BYTE_MAX) bytes.push(cp - PUA_BYTE_BASE);
53
+ else if (cp < 0x80) bytes.push(cp);
54
+ else bytes.push(...Buffer.from(ch, 'utf8'));
55
+ }
56
+ return Buffer.from(bytes).toString('hex');
57
+ }
58
+
59
+ /**
60
+ * 判定单条名字是否应还原(三重校验)。
61
+ * @param {string} name
62
+ * @returns {{name: string, repaired: boolean, reason: string|null, hex?: string}}
63
+ */
64
+ export function repairName(name) {
65
+ const original = String(name || '');
66
+ // ① 硬门槛:必须含 U+FFFE 标记(正常名即使含合法私有区字符也不会被误改)
67
+ if (!hasEscapeMarker(original)) return { name: original, repaired: false, reason: null };
68
+ const hex = escapedBytesHex(original);
69
+ const candidate = decodePuaEscaped(original);
70
+ // ② 必须是合法 UTF-8(解出替换符说明字节非法)
71
+ if (!candidate || candidate.includes('\uFFFD')) {
72
+ return { name: original, repaired: false, reason: 'invalid-utf8', hex };
73
+ }
74
+ // ③ 往返一致:把结果按 UTF-8 重新编码,必须等于原始字节序列
75
+ if (Buffer.from(candidate, 'utf8').toString('hex') !== hex) {
76
+ return { name: original, repaired: false, reason: 'roundtrip-mismatch', hex };
77
+ }
78
+ // 路径结构必须仍然正常(不允许还原出空段 / . / ..,防止藏在转义里的越界)
79
+ //
80
+ // 注意:**目录条目名带尾部 '/'**(收集阶段就是用尾斜杠标记目录的),
81
+ // 不能把那个尾斜杠当成「空段」判成非法 —— 否则凡是被转义的目录名都永远还原不了,
82
+ // 而它的子文件却能还原,结果是目录与子项名字对不上、结构错位(实测踩到)。
83
+ const hadTrailingSlash = candidate.endsWith('/');
84
+ const body = hadTrailingSlash ? candidate.slice(0, -1) : candidate;
85
+ if (body.length === 0) return { name: original, repaired: false, reason: 'unsafe-path', hex };
86
+ const segments = body.split('/');
87
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
88
+ return { name: original, repaired: false, reason: 'unsafe-path', hex };
89
+ }
90
+ const decoded = hadTrailingSlash ? `${body}/` : body;
91
+ if (decoded === original) return { name: original, repaired: false, reason: null };
92
+ return { name: decoded, repaired: true, reason: null, hex };
93
+ }
94
+
95
+ /**
96
+ * 批量还原一组条目名,并处理"还原后撞名"。
97
+ *
98
+ * 撞名策略:**保留原名**(不静默覆盖、不静默改名),把冲突记进 conflicts 交给调用方记日志/报告。
99
+ *
100
+ * @param {Array<{name: string}>} entries
101
+ * @returns {{entries: Array, repaired: number, conflicts: Array<{from: string, to: string, reason: string}>}}
102
+ */
103
+ export function repairEntries(entries) {
104
+ const conflicts = [];
105
+ const taken = new Set(entries.map((entry) => String(entry.name || '')));
106
+ let repaired = 0;
107
+
108
+ const next = entries.map((entry) => {
109
+ const verdict = repairName(entry.name);
110
+ if (!verdict.repaired) return entry;
111
+ // 同一批里还原后与他人(原始名或已还原名)撞名 → 放弃这次还原
112
+ if (taken.has(verdict.name) && verdict.name !== entry.name) {
113
+ conflicts.push({ from: entry.name, to: verdict.name, reason: 'target-exists' });
114
+ return entry;
115
+ }
116
+ taken.delete(String(entry.name || ''));
117
+ taken.add(verdict.name);
118
+ repaired += 1;
119
+ return { ...entry, name: verdict.name, nameRepairedFrom: entry.name };
120
+ });
121
+
122
+ return { entries: next, repaired, conflicts };
123
+ }
@@ -0,0 +1,18 @@
1
+ // 流工具(各格式包共用的小工具)
2
+ //
3
+ // 为什么要单独抽一个 `pipelineWith`:`stream.pipeline(a, b, undefined)` **不是"没有选项"**,
4
+ // 而是把 `undefined` 当成一个 stage 去校验并抛
5
+ // `The "body" argument must be of type function or ... Received undefined`。
6
+ // 而"有取消信号时传 { signal }、没有时不传"是很自然的写法 —— 一不留神就写成了三元表达式带 undefined。
7
+ // 实测踩过:M4 的 tar 系列在没有信号时崩在这里(zip 侧因为运行期总有信号,一直没暴露)。
8
+
9
+ import { pipeline } from 'node:stream/promises';
10
+
11
+ /**
12
+ * 按需附加 `{ signal }` 的 pipeline(signal 为空时不传第三个参数)。
13
+ * @param {Array} stages 至少两个流(最后一个是目标)
14
+ * @param {AbortSignal} [signal]
15
+ */
16
+ export function pipelineWith(stages, signal) {
17
+ return signal ? pipeline(...stages, { signal }) : pipeline(...stages);
18
+ }
@@ -0,0 +1,114 @@
1
+ // tar 系列的**压缩层策略**(tar / tar.gz / tar.zst 只差这一层)
2
+ //
3
+ // zstd 走「能力自探测 + 回退链」(docs/02 §二):
4
+ // ① Node ≥ 22.15 内置 `node:zlib` 的 zstd(零依赖、流式)—— 首选;
5
+ // ② 系统 `zstd` 命令(`ZC_ZSTD_BIN` 可覆盖路径);
6
+ // ③ 都不可用 → `available()=false` 并给出原因(由上层 tarFamily 组装)。
7
+ //
8
+ // 排障开关:`ZIPCALM_DISABLE_BUILTIN_ZSTD=1` 强制禁用内置实现,用于验证回退链是否真的生效。
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { spawn } from 'node:child_process';
13
+ import { Duplex } from 'node:stream';
14
+ import zlib from 'node:zlib';
15
+
16
+ import { ERROR_CODES, apiError } from '../../../../lib/errors.js';
17
+
18
+ /** Node 内置 zstd 是否可用(可用环境变量强制禁用,便于验证回退链) */
19
+ export function builtinZstdUsable() {
20
+ if (process.env.ZIPCALM_DISABLE_BUILTIN_ZSTD === '1') return false;
21
+ return typeof zlib.createZstdCompress === 'function' && typeof zlib.createZstdDecompress === 'function';
22
+ }
23
+
24
+ /** 系统 zstd 命令探测:ZC_ZSTD_BIN 覆盖 → PATH 查找(找不到返回 null) */
25
+ export function findZstdBinary() {
26
+ const override = process.env.ZC_ZSTD_BIN;
27
+ if (override) {
28
+ try {
29
+ fs.accessSync(override, fs.constants.X_OK);
30
+ return override;
31
+ } catch {
32
+ // 指定了但不可执行:视为不可用,不静默落到 PATH(避免"以为换了实现其实没换")
33
+ return null;
34
+ }
35
+ }
36
+ for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
37
+ if (!dir) continue;
38
+ const candidate = path.join(dir, 'zstd');
39
+ try {
40
+ fs.accessSync(candidate, fs.constants.X_OK);
41
+ return candidate;
42
+ } catch {
43
+ /* 继续找 */
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /** 把一个外部命令包装成 Duplex(stdin 进 / stdout 出),失败即 destroy —— 回退链的流式实现 */
50
+ export function spawnDuplex(command, args, label) {
51
+ const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'inherit'] });
52
+ const duplex = new Duplex({
53
+ write(chunk, encoding, callback) {
54
+ if (child.stdin.write(chunk, encoding)) callback();
55
+ else child.stdin.once('drain', callback);
56
+ },
57
+ final(callback) {
58
+ child.stdin.end();
59
+ callback();
60
+ },
61
+ read() {
62
+ child.stdout.resume();
63
+ },
64
+ });
65
+ let exited = false;
66
+ child.stdout.on('data', (chunk) => {
67
+ if (!duplex.push(chunk)) child.stdout.pause();
68
+ });
69
+ child.stdout.on('end', () => {
70
+ if (!exited) duplex.push(null);
71
+ });
72
+ child.on('error', (error) => duplex.destroy(apiError(ERROR_CODES.INTERNAL_ERROR, `${label} 启动失败:${error.message}`)));
73
+ child.on('close', (code) => {
74
+ exited = true;
75
+ if (code !== 0) duplex.destroy(apiError(ERROR_CODES.INTERNAL_ERROR, `${label} 退出码 ${code}`));
76
+ else duplex.push(null);
77
+ });
78
+ return duplex;
79
+ }
80
+
81
+ /** 压缩流(写归档时用;compression='none' 返回 undefined 表示不套压缩层) */
82
+ export function createCompressStream(compression, level) {
83
+ if (compression === 'gzip') return zlib.createGzip({ level });
84
+ if (compression === 'zstd') {
85
+ if (builtinZstdUsable()) {
86
+ return zlib.createZstdCompress({ params: { [zlib.constants.ZSTD_c_compressionLevel]: level } });
87
+ }
88
+ const binary = findZstdBinary();
89
+ if (!binary) throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, 'zstd 不可用:Node 内置不可用且未找到系统 zstd');
90
+ return spawnDuplex(binary, [`-${level}`, '-q', '-c'], 'zstd');
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ /** 解压流(读归档时用) */
96
+ export function createDecompressStream(compression) {
97
+ if (compression === 'gzip') return zlib.createGunzip();
98
+ if (compression === 'zstd') {
99
+ if (builtinZstdUsable()) return zlib.createZstdDecompress();
100
+ const binary = findZstdBinary();
101
+ if (!binary) throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, 'zstd 不可用:Node 内置不可用且未找到系统 zstd');
102
+ return spawnDuplex(binary, ['-d', '-q', '-c'], 'zstd');
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ /** 打开归档并接上解压层(返回可读流;解析错误由调用方映射成 ARCHIVE_CORRUPTED) */
108
+ export function openArchiveStream(archivePath, compression) {
109
+ const source = fs.createReadStream(archivePath);
110
+ const decompressor = createDecompressStream(compression);
111
+ if (!decompressor) return source;
112
+ source.on('error', (error) => decompressor.destroy(error));
113
+ return source.pipe(decompressor);
114
+ }
@@ -0,0 +1,74 @@
1
+ // tar 系列的**读取侧**:流式解析 + 条目读取辅助 + 错误映射
2
+ //
3
+ // 三种 tar 格式共用同一套解析(压缩层由 compression.js 负责解掉)。
4
+
5
+ import path from 'node:path';
6
+ import { pipeline } from 'node:stream/promises';
7
+
8
+ import * as tar from 'tar';
9
+
10
+ import { ERROR_CODES, apiError } from '../../../../lib/errors.js';
11
+ import { openArchiveStream } from './compression.js';
12
+
13
+ /**
14
+ * 流式解析归档:逐条目回调。
15
+ *
16
+ * **关键约束(实测踩过)**:`onentry` 里必须**同步**接管条目流(attach 消费者 / resume),
17
+ * 只要延迟一个微任务,node-tar 就把该条目的数据丢掉(实测同一归档:同步读到 5/7 字节,延迟读到 0 字节)。
18
+ * 因此回调本身不做 await,而是"同步启动消费并返回 Promise",由这里统一收集等待。
19
+ */
20
+ export async function parseArchive(archivePath, compression, onEntry) {
21
+ const pending = [];
22
+ const errors = [];
23
+ const parser = tar.list({
24
+ onentry: (entry) => {
25
+ try {
26
+ const handlerResult = onEntry(entry);
27
+ if (handlerResult && typeof handlerResult.then === 'function') {
28
+ pending.push(handlerResult.catch((error) => errors.push(error)));
29
+ }
30
+ } catch (error) {
31
+ errors.push(error);
32
+ entry.resume();
33
+ }
34
+ },
35
+ });
36
+ await pipeline(openArchiveStream(archivePath, compression), parser);
37
+ await Promise.all(pending);
38
+ if (errors.length > 0) throw errors[0];
39
+ }
40
+
41
+ /** 读条目内容(用于 test 的 read-all 校验) */
42
+ export function readEntryBuffer(entry) {
43
+ return new Promise((resolve, reject) => {
44
+ const chunks = [];
45
+ entry.on('data', (chunk) => chunks.push(chunk));
46
+ entry.on('end', () => resolve(Buffer.concat(chunks)));
47
+ entry.on('error', reject);
48
+ });
49
+ }
50
+
51
+ /** node-tar 条目 → 契约里的条目形态(name/type/size/compressedSize) */
52
+ export function toApiEntry(entry) {
53
+ const name = entry.path || entry.name || '';
54
+ const isDir = entry.type === 'Directory';
55
+ return {
56
+ name,
57
+ type: isDir ? 'dir' : 'file',
58
+ size: Number(entry.size || 0),
59
+ compressedSize: 0,
60
+ };
61
+ }
62
+
63
+ /** 归档解析失败统一映射成契约错误码(不透传本机路径) */
64
+ export function mapArchiveError(error, archivePath) {
65
+ if (error && error.code && String(error.code).startsWith('A')) return error; // 已是契约错误码
66
+ const reason = (error && (error.code || error.message)) || 'parse-failed';
67
+ if (reason === 'ENOENT') {
68
+ return apiError(ERROR_CODES.PATH_NOT_FOUND, '压缩包不存在', { archive: path.basename(archivePath) });
69
+ }
70
+ return apiError(ERROR_CODES.ARCHIVE_CORRUPTED, '压缩包无法解析(可能已损坏)', {
71
+ archive: path.basename(archivePath),
72
+ reason,
73
+ });
74
+ }