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,587 @@
|
|
|
1
|
+
// zip 格式包(M2:archiver 写 / unzipper 读)
|
|
2
|
+
//
|
|
3
|
+
// 契约:docs/M0/插件契约.md §3.4 create / §3.4.1 extract / §3.5 list / §3.6 test / §3.2 available / §3.3 estimate
|
|
4
|
+
//
|
|
5
|
+
// 两个库各司其职(读写分离):
|
|
6
|
+
// - **archiver**:只负责生成(流式写入 zip);
|
|
7
|
+
// - **unzipper**:负责读取(列条目 / CRC 校验 / 解压)。
|
|
8
|
+
// archiver 没有读取能力,硬用一个库去凑是行不通的。
|
|
9
|
+
//
|
|
10
|
+
// 安全(解压侧头号风险 = zip-slip):
|
|
11
|
+
// 写盘**之前**先枚举全部条目名 → 用与 pathGuard 同一套判定(core/fs/safePath.js 的 normalizeUserPath)
|
|
12
|
+
// 规范化并校验 → 拒绝 `..`、绝对路径、盘符、NUL、symlink 条目 → 全部通过后才开始写。
|
|
13
|
+
// 这样恶意归档不会产生任何"写到一半才发现"的副作用。
|
|
14
|
+
//
|
|
15
|
+
// 结构:create / extract 各自拆成「前置检查 → 收集计划 → 执行 → 汇总」四段小函数,
|
|
16
|
+
// 每个函数只做一件事,便于单独审读与测试。
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import fsp from 'node:fs/promises';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import zlib from 'node:zlib';
|
|
22
|
+
|
|
23
|
+
import archiver from 'archiver';
|
|
24
|
+
import zipEncrypted from 'archiver-zip-encrypted';
|
|
25
|
+
import unzipper from 'unzipper';
|
|
26
|
+
|
|
27
|
+
import { ERROR_CODES, apiError } from '../../../lib/errors.js';
|
|
28
|
+
import { createThrottledReporter } from '../../../lib/throttle.js';
|
|
29
|
+
// 创建前置守卫(库可用 + 绝不覆盖)**各格式共用一套**——本格式不再自带一份实现
|
|
30
|
+
import { assertCreatable as assertSharedCreatable } from '../_shared/createGuards.js';
|
|
31
|
+
// 条目安全判定与写出**各格式共用一套**(zip-slip 防护不各写一份,见 _shared/entrySafety.js)
|
|
32
|
+
import { planTargets, writePlan } from '../_shared/entrySafety.js';
|
|
33
|
+
import { extractWith7z } from '../../capabilities/password/index.js';
|
|
34
|
+
|
|
35
|
+
/** zip 加密标志位(general purpose bit 0)与 WinZip AES 伪压缩方法号 */
|
|
36
|
+
const ENCRYPTED_FLAG = 0x1;
|
|
37
|
+
const AES_COMPRESSION_METHOD = 99;
|
|
38
|
+
|
|
39
|
+
const FORMAT_ID = 'zip';
|
|
40
|
+
|
|
41
|
+
// ===== 元数据(静态,注册表启动时不等待任何异步探测)=====
|
|
42
|
+
|
|
43
|
+
const meta = {
|
|
44
|
+
id: FORMAT_ID,
|
|
45
|
+
name: 'ZIP',
|
|
46
|
+
ext: '.zip',
|
|
47
|
+
extAliases: [],
|
|
48
|
+
mime: 'application/zip',
|
|
49
|
+
levelRange: [0, 9],
|
|
50
|
+
defaultLevel: 6,
|
|
51
|
+
// 能力声明(docs/19 §4.1):ZIP 规范有加密 → 支持密码能力;TAR 系列声明空数组
|
|
52
|
+
capabilities: ['password'],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ===== available =====
|
|
56
|
+
|
|
57
|
+
async function available() {
|
|
58
|
+
try {
|
|
59
|
+
// 两个库都是纯 JS,只要能取到入口就可用(不需要外部二进制)。
|
|
60
|
+
// 注意 unzipper 的导出形状:`Open` 是**对象**(含 file/url/buffer 等方法),不是函数——
|
|
61
|
+
// 早期写成 `typeof unzipper.Open === 'function'` 会误判为不可用(真实踩过)。
|
|
62
|
+
return typeof archiver === 'function' && typeof unzipper?.Open?.file === 'function';
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function unavailableReason() {
|
|
69
|
+
const ok = await available();
|
|
70
|
+
return ok ? null : 'zip 依赖(archiver / unzipper)未正确安装';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ===== estimate =====
|
|
74
|
+
|
|
75
|
+
async function estimate(inputBytes, options = {}) {
|
|
76
|
+
const bytes = Number.isFinite(inputBytes) && inputBytes > 0 ? inputBytes : 0;
|
|
77
|
+
// 已压缩格式(jpg/mp4/zip…)比值接近 1;其余给常见经验值。
|
|
78
|
+
// M2 只做"够算预检分母"的粗估,精确化在 M6。
|
|
79
|
+
const names = Array.isArray(options.sample && options.sample.names) ? options.sample.names : [];
|
|
80
|
+
const compressedExt = /\.(jpe?g|png|gif|webp|heic|mp4|mkv|mov|mp3|flac|zip|7z|rar|gz|bz2|xz|docx|xlsx|pptx)$/i;
|
|
81
|
+
const compressedRatio = names.length > 0 && names.every((name) => compressedExt.test(name)) ? 0.98 : 0.6;
|
|
82
|
+
return {
|
|
83
|
+
estimatedBytes: Math.ceil(bytes * compressedRatio),
|
|
84
|
+
ratio: compressedRatio,
|
|
85
|
+
basis: 'heuristic',
|
|
86
|
+
confidence: 'low',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ===== 公共工具 =====
|
|
91
|
+
|
|
92
|
+
/** 仅返回 basename,绝不把绝对路径放进错误 details(避免泄漏本机目录结构) */
|
|
93
|
+
function safeName(target) {
|
|
94
|
+
return typeof target === 'string' ? path.basename(target) : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 打开归档;文件不存在 → PATH_NOT_FOUND,解析失败 → ARCHIVE_CORRUPTED(都不透传本机路径) */
|
|
98
|
+
async function openArchive(archivePath) {
|
|
99
|
+
try {
|
|
100
|
+
return await unzipper.Open.file(archivePath);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error && error.code === 'ENOENT') {
|
|
103
|
+
throw apiError(ERROR_CODES.PATH_NOT_FOUND, '压缩包不存在', { archive: safeName(archivePath) });
|
|
104
|
+
}
|
|
105
|
+
throw apiError(ERROR_CODES.ARCHIVE_CORRUPTED, '压缩包无法解析(可能已损坏)', {
|
|
106
|
+
archive: safeName(archivePath),
|
|
107
|
+
reason: (error && (error.code || error.message)) || 'parse-failed',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 条目是否目录 */
|
|
113
|
+
function isDirectoryEntry(entry) {
|
|
114
|
+
return entry.type === 'Directory';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 是否符号链接条目(zip 用 external attributes 高位存 unix mode)。
|
|
119
|
+
* M2 策略:**任何 symlink 条目一律拒绝**(比"仅拒绝指向目标外的链接"更保守,
|
|
120
|
+
* 因为 symlink 的可见性还依赖解压工具行为,拒绝最省心)。
|
|
121
|
+
*/
|
|
122
|
+
function isSymlinkEntry(entry) {
|
|
123
|
+
try {
|
|
124
|
+
const mode = (entry.externalFileAttributes >>> 16) & 0xffff;
|
|
125
|
+
return (mode & 0o170000) === 0o120000;
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function entryToItem(entry) {
|
|
132
|
+
const isDir = isDirectoryEntry(entry);
|
|
133
|
+
return {
|
|
134
|
+
name: entry.path,
|
|
135
|
+
type: isDir ? 'dir' : 'file',
|
|
136
|
+
size: isDir ? 0 : Number(entry.uncompressedSize || 0),
|
|
137
|
+
compressedSize: Number(entry.compressedSize || 0),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 读取侧「条目 → 通用条目」适配:open() 给出可读流,link 标记符号链接。
|
|
143
|
+
* 安全判定与写出统一交给 `_shared/entrySafety.js`(与 tar 系列共用同一套 zip-slip 判定)。
|
|
144
|
+
*/
|
|
145
|
+
function toSharedEntries(directory) {
|
|
146
|
+
return directory.files.map((entry) => ({
|
|
147
|
+
name: entry.path,
|
|
148
|
+
isDir: isDirectoryEntry(entry),
|
|
149
|
+
size: Number(entry.uncompressedSize || 0),
|
|
150
|
+
link: isSymlinkEntry(entry),
|
|
151
|
+
open: () => entry.stream(),
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 递归收集待归档条目(不跟随符号链接,避免把链接指向的外部内容打进去) */
|
|
156
|
+
async function collectEntries(ctx) {
|
|
157
|
+
const fileItems = [];
|
|
158
|
+
// 黑名单命中项(由编排层传入判定函数):**收集阶段就不放进包**,既省 CPU/磁盘,
|
|
159
|
+
// 也保证"没进包的不会被删除"从源头成立(M5 的删除集合 = 进包条目 − skipped)。
|
|
160
|
+
const shouldSkip = typeof (ctx.options && ctx.options.shouldSkip) === 'function' ? ctx.options.shouldSkip : null;
|
|
161
|
+
const skipped = [];
|
|
162
|
+
let skippedSymlinks = 0;
|
|
163
|
+
|
|
164
|
+
/** 命中就记账并返回 true(调用方据此 continue) */
|
|
165
|
+
const recordSkip = (absPath, name, segments) => {
|
|
166
|
+
if (!shouldSkip) return false;
|
|
167
|
+
const hit = shouldSkip({ name, segments });
|
|
168
|
+
if (!hit) return false;
|
|
169
|
+
skipped.push({ absPath, pattern: hit.pattern, layer: hit.layer });
|
|
170
|
+
return true;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
for (const source of ctx.sources) {
|
|
174
|
+
const sourceName = source.name || path.basename(source.absPath);
|
|
175
|
+
|
|
176
|
+
if (source.kind === 'file') {
|
|
177
|
+
const fileStat = await fsp.lstat(source.absPath).catch(() => null);
|
|
178
|
+
if (!fileStat || !fileStat.isFile()) {
|
|
179
|
+
throw apiError(ERROR_CODES.PATH_NOT_FOUND, '源文件在扫描后已不可用', { source: sourceName });
|
|
180
|
+
}
|
|
181
|
+
if (recordSkip(source.absPath, path.basename(source.absPath), [sourceName])) continue;
|
|
182
|
+
fileItems.push({ absPath: source.absPath, entryName: sourceName, size: fileStat.size });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 目录:保留目录名作为顶层,递归其子文件
|
|
187
|
+
const walk = async (dirAbs, prefix) => {
|
|
188
|
+
const dirents = await fsp.readdir(dirAbs, { withFileTypes: true });
|
|
189
|
+
for (const dirent of dirents) {
|
|
190
|
+
const childAbs = path.join(dirAbs, dirent.name);
|
|
191
|
+
const childEntry = `${prefix}/${dirent.name}`;
|
|
192
|
+
if (dirent.isSymbolicLink()) {
|
|
193
|
+
skippedSymlinks += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (dirent.isDirectory()) {
|
|
197
|
+
await walk(childAbs, childEntry);
|
|
198
|
+
} else if (dirent.isFile()) {
|
|
199
|
+
// 目录名规则(如 @eaDir)也参与匹配:所以 segments 带上完整相对路径
|
|
200
|
+
if (recordSkip(childAbs, dirent.name, childEntry.split('/'))) continue;
|
|
201
|
+
const fileStat = await fsp.lstat(childAbs);
|
|
202
|
+
fileItems.push({ absPath: childAbs, entryName: childEntry, size: fileStat.size });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
await walk(source.absPath, sourceName);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { fileItems, skippedSymlinks, skipped };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ===== create(压缩)=====
|
|
213
|
+
|
|
214
|
+
/** create 前置检查:直接复用共用守卫(库可用 + 绝不覆盖),本格式不再自带一份 */
|
|
215
|
+
async function assertCreatable(ctx) {
|
|
216
|
+
await assertSharedCreatable(ctx, available, 'zip');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 汇总待压总量,并建"条目名 → 大小"索引(进度需要按条目累加字节) */
|
|
220
|
+
function summarizeEntries(fileItems) {
|
|
221
|
+
const totalBytes = fileItems.reduce((sum, fileItem) => sum + fileItem.size, 0);
|
|
222
|
+
return { totalBytes, sizeByEntry: new Map(fileItems.map((fileItem) => [fileItem.entryName, fileItem.size])) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 决定压缩级别(未指定就用格式默认值) */
|
|
226
|
+
function resolveLevel(options) {
|
|
227
|
+
return Number.isInteger(options && options.level) ? options.level : meta.defaultLevel;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 能力注册(docs/19):archiver-zip-encrypted 提供 zip-encrypted 格式(AES-256/ZipCrypto)。
|
|
232
|
+
* registerFormat 每进程只能注册一次(重复抛错)——模块加载期执行一次,天然幂等。
|
|
233
|
+
*/
|
|
234
|
+
let zipEncryptedRegistered = false;
|
|
235
|
+
function ensureZipEncryptedFormat() {
|
|
236
|
+
if (zipEncryptedRegistered) return;
|
|
237
|
+
archiver.registerFormat('zip-encrypted', zipEncrypted);
|
|
238
|
+
zipEncryptedRegistered = true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 建立"归档 → 输出流"管道(archiver 写、file stream 落盘)。
|
|
242
|
+
* 能力装配点(docs/19 图 1 ★ 只改 2 行):archiverFormat/encryption 由编排层的能力
|
|
243
|
+
* 装配循环(capRegistry.prepareCreate)注入 options —— 本插件不认识任何具体能力。
|
|
244
|
+
*/
|
|
245
|
+
function openArchivePipeline(tempPath, level, options = {}) {
|
|
246
|
+
const archiverFormat = options.archiverFormat ?? 'zip';
|
|
247
|
+
if (archiverFormat !== 'zip') ensureZipEncryptedFormat();
|
|
248
|
+
const output = fs.createWriteStream(tempPath);
|
|
249
|
+
const archive = archiver(archiverFormat, { zlib: { level }, ...options.archiverExtra });
|
|
250
|
+
archive.pipe(output);
|
|
251
|
+
return { output, archive };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 删除半成品临时文件。
|
|
256
|
+
* `rmSync(..., { force: true })` 对不存在的路径不算错,因此不必先 `existsSync` 探一次;
|
|
257
|
+
* 这里刻意保持**同步**语义:调用点都在事件回调(`finish(() => …)`)里,无法 await,
|
|
258
|
+
* 而"清理必须在 reject 之前完成"是调用方可依赖的性质(测试会在任务结束后立刻检查没有 .part 残留)。
|
|
259
|
+
* 清理失败不掩盖原始错误。
|
|
260
|
+
*/
|
|
261
|
+
function removeTempFile(tempPath) {
|
|
262
|
+
try {
|
|
263
|
+
fs.rmSync(tempPath, { force: true });
|
|
264
|
+
} catch {
|
|
265
|
+
// 清理失败不掩盖原始错误
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 只允许结算一次(成功/失败/取消只会有一个生效) */
|
|
270
|
+
function createSettler(state) {
|
|
271
|
+
return (fn) => {
|
|
272
|
+
if (state.settled) return;
|
|
273
|
+
state.settled = true;
|
|
274
|
+
fn();
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 取消:中止归档 + 清理半成品 + 抛 ABORTED */
|
|
279
|
+
function abortCreate(archive, tempPath, finish, reject) {
|
|
280
|
+
finish(() => {
|
|
281
|
+
try {
|
|
282
|
+
archive.abort();
|
|
283
|
+
} catch {
|
|
284
|
+
/* abort 在归档已结束时可能抛错:取消路径上不影响后续清理,忽略即可 */
|
|
285
|
+
}
|
|
286
|
+
removeTempFile(tempPath);
|
|
287
|
+
reject(apiError(ERROR_CODES.ABORTED, '任务已取消'));
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 失败收尾:清理半成品后 reject(ENOSPC 等交给 errors.js 的映射) */
|
|
292
|
+
function attachCreateFailureHandlers({ archive, output, tempPath, finish, reject }) {
|
|
293
|
+
const fail = (error) => finish(() => {
|
|
294
|
+
removeTempFile(tempPath);
|
|
295
|
+
reject(error);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
archive.on('warning', (error) => {
|
|
299
|
+
if (error && error.code === 'ENOENT') return; // 条目消失:跳过而不是整个失败
|
|
300
|
+
fail(apiError(ERROR_CODES.INTERNAL_ERROR, `打包告警:${error.message}`, { cause: error.code || 'warning' }));
|
|
301
|
+
});
|
|
302
|
+
archive.on('error', fail);
|
|
303
|
+
output.on('error', fail);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 条目进度:archiver 每写完一个条目发 'entry'(事件驱动,不是定时采样) */
|
|
307
|
+
function attachEntryProgress(archive, { fileItems, report, counters, sizeByEntry, totalBytes }) {
|
|
308
|
+
archive.on('entry', (entry) => {
|
|
309
|
+
const name = entry && entry.name ? entry.name : '';
|
|
310
|
+
counters.processedBytes += sizeByEntry.get(name) || 0;
|
|
311
|
+
counters.processedFiles += 1;
|
|
312
|
+
report({
|
|
313
|
+
processedBytes: counters.processedBytes,
|
|
314
|
+
totalBytes,
|
|
315
|
+
processedFiles: counters.processedFiles,
|
|
316
|
+
totalFiles: fileItems.length,
|
|
317
|
+
currentFile: name,
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 把待压条目塞进归档;空集合也要上报一次(避免进度停在 0 让人以为卡住) */
|
|
323
|
+
function appendEntriesToArchive(archive, fileItems, report) {
|
|
324
|
+
for (const fileItem of fileItems) {
|
|
325
|
+
archive.file(fileItem.absPath, { name: fileItem.entryName });
|
|
326
|
+
}
|
|
327
|
+
if (fileItems.length === 0) {
|
|
328
|
+
report({ processedBytes: 0, totalBytes: 0, processedFiles: 0, totalFiles: 0, currentFile: null });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 流式写入归档:archiver 逐条目写、进度事件驱动、取消即清理 .part。
|
|
334
|
+
* 完成条件是**输出流 close**(finalize 返回 ≠ 文件已写完,契约第 5 条),
|
|
335
|
+
* 所以这里同时跟踪 outputClosed 与 archiveFinished 两个标志,二者齐备才算成功。
|
|
336
|
+
*/
|
|
337
|
+
function runCreateStream(ctx, fileItems, summary, stats) {
|
|
338
|
+
const { totalBytes, sizeByEntry } = summary;
|
|
339
|
+
const report = createThrottledReporter(ctx.onProgress || (() => {}), { intervalMs: 100 });
|
|
340
|
+
const counters = { processedBytes: 0, processedFiles: 0 };
|
|
341
|
+
|
|
342
|
+
return new Promise((resolve, reject) => {
|
|
343
|
+
const state = { settled: false, outputClosed: false, archiveFinished: false };
|
|
344
|
+
const { output, archive } = openArchivePipeline(ctx.tempPath, stats.level, ctx.options);
|
|
345
|
+
const finish = createSettler(state);
|
|
346
|
+
|
|
347
|
+
const resolveIfComplete = () => {
|
|
348
|
+
if (!state.outputClosed || !state.archiveFinished) return;
|
|
349
|
+
finish(() =>
|
|
350
|
+
resolve({
|
|
351
|
+
outputPath: ctx.tempPath,
|
|
352
|
+
entries: fileItems.length,
|
|
353
|
+
bytesIn: counters.processedBytes,
|
|
354
|
+
bytesOut: archive.pointer(),
|
|
355
|
+
durationMs: Date.now() - stats.startedAt,
|
|
356
|
+
skippedSymlinks: stats.skippedSymlinks,
|
|
357
|
+
skipped: stats.skipped || [],
|
|
358
|
+
// 契约 v2 §3.4 条款 10:实际进包条目的**源绝对路径**(进程内使用,不进 API)。
|
|
359
|
+
// INV-02 删除集合的第一手依据;禁止用包内路径(entryName)反推源路径。
|
|
360
|
+
entryPaths: fileItems.map((item) => item.absPath),
|
|
361
|
+
// INV-03 ⑤ 进包时快照(size 基准):collectEntries 已 stat 过,原样带出给删除前复核
|
|
362
|
+
entryStats: fileItems.map((item) => [item.absPath, { sizeBytes: item.size }]),
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
if (stats.signal) {
|
|
368
|
+
if (stats.signal.aborted) {
|
|
369
|
+
abortCreate(archive, ctx.tempPath, finish, reject);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
stats.signal.addEventListener('abort', () => abortCreate(archive, ctx.tempPath, finish, reject), { once: true });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
attachEntryProgress(archive, { fileItems, report, counters, sizeByEntry, totalBytes });
|
|
376
|
+
attachCreateFailureHandlers({ archive, output, tempPath: ctx.tempPath, finish, reject });
|
|
377
|
+
output.on('close', () => {
|
|
378
|
+
state.outputClosed = true;
|
|
379
|
+
resolveIfComplete();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
appendEntriesToArchive(archive, fileItems, report);
|
|
383
|
+
archive.finalize().then(
|
|
384
|
+
() => {
|
|
385
|
+
state.archiveFinished = true;
|
|
386
|
+
resolveIfComplete();
|
|
387
|
+
},
|
|
388
|
+
(error) => finish(() => {
|
|
389
|
+
removeTempFile(ctx.tempPath);
|
|
390
|
+
reject(error);
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function create(ctx) {
|
|
397
|
+
await assertCreatable(ctx);
|
|
398
|
+
|
|
399
|
+
const { fileItems, skippedSymlinks, skipped } = await collectEntries(ctx);
|
|
400
|
+
const summary = summarizeEntries(fileItems);
|
|
401
|
+
|
|
402
|
+
return runCreateStream(ctx, fileItems, summary, {
|
|
403
|
+
startedAt: Date.now(),
|
|
404
|
+
skippedSymlinks,
|
|
405
|
+
skipped,
|
|
406
|
+
level: resolveLevel(ctx.options),
|
|
407
|
+
signal: ctx.options && ctx.options.signal,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ===== list / test =====
|
|
412
|
+
|
|
413
|
+
async function list(archivePath) {
|
|
414
|
+
const directory = await openArchive(archivePath);
|
|
415
|
+
const entries = directory.files.map(entryToItem);
|
|
416
|
+
return { entries, count: entries.length, truncated: false };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function test(archivePath, options = {}) {
|
|
420
|
+
const directory = await openArchive(archivePath);
|
|
421
|
+
const failures = [];
|
|
422
|
+
let checked = 0;
|
|
423
|
+
// 优先 CRC(真实校验);Node 没有 zlib.crc32 时退化为"读通全部条目"(契约 §3.6 允许两种策略)
|
|
424
|
+
const canCrc = typeof zlib.crc32 === 'function';
|
|
425
|
+
const strategy = canCrc ? 'crc' : 'read-all';
|
|
426
|
+
|
|
427
|
+
for (const entry of directory.files) {
|
|
428
|
+
if (isDirectoryEntry(entry)) continue;
|
|
429
|
+
try {
|
|
430
|
+
const buffer = await entry.buffer(); // 完整 inflate:既是 CRC 的数据来源,也是"读得通"的证明
|
|
431
|
+
if (buffer.length !== Number(entry.uncompressedSize || 0)) {
|
|
432
|
+
failures.push({ entry: entry.path, reason: `解压后大小不符(期望 ${entry.uncompressedSize},实际 ${buffer.length})` });
|
|
433
|
+
} else if (canCrc && typeof entry.crc32 === 'number') {
|
|
434
|
+
const actual = zlib.crc32(buffer) >>> 0;
|
|
435
|
+
const expected = entry.crc32 >>> 0;
|
|
436
|
+
if (actual !== expected) {
|
|
437
|
+
failures.push({ entry: entry.path, reason: `CRC 不匹配(期望 ${expected},实际 ${actual})` });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
checked += 1;
|
|
441
|
+
} catch (error) {
|
|
442
|
+
failures.push({ entry: entry.path, reason: `读取失败:${(error && error.message) || 'unknown'}` });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { ok: failures.length === 0, checked, failures, strategy };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ===== extract(解压)=====
|
|
450
|
+
|
|
451
|
+
/** 判定归档加密形态(unzipper central directory:flags bit0 = 加密;compressionMethod=99 = AES) */
|
|
452
|
+
function detectEncryption(directory) {
|
|
453
|
+
for (const entry of directory.files) {
|
|
454
|
+
if (isDirectoryEntry(entry)) continue;
|
|
455
|
+
const flags = Number(entry.flags) || 0;
|
|
456
|
+
if ((flags & ENCRYPTED_FLAG) === 0) continue;
|
|
457
|
+
const isAes = Number(entry.compressionMethod) === AES_COMPRESSION_METHOD;
|
|
458
|
+
return { encrypted: true, isAes };
|
|
459
|
+
}
|
|
460
|
+
return { encrypted: false, isAes: false };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* 加密包解压(P4):7z 解到临时中转目录 → 复用 planTargets/writePlan 白名单链搬进目标目录。
|
|
465
|
+
*
|
|
466
|
+
* 为什么不直接 7z -o 目标目录:zip-slip 防护(INV-06)必须对最终交付的文件生效——
|
|
467
|
+
* 归档里的 `../` 条目在 7z 解包层可能被它自己的规范化剥离,但我们的防线不能依赖外部工具的
|
|
468
|
+
* 行为承诺。先解到中转目录、再经 planTargets 校验后搬移,加密包就没有绕过白名单的后门。
|
|
469
|
+
*
|
|
470
|
+
* @returns 同 extract 主路径的返回形状
|
|
471
|
+
*/
|
|
472
|
+
async function extractEncrypted(ctx, targetDir, overwrite, isAes) {
|
|
473
|
+
const password = ctx.options && ctx.options.password;
|
|
474
|
+
if (!password) {
|
|
475
|
+
throw apiError(ERROR_CODES.MISSING_PASSWORD, '该压缩包已加密,请输入密码');
|
|
476
|
+
}
|
|
477
|
+
// AES-256 与 ZipCrypto 统一走 7z(unzipper 只有 ZipCrypto 实现;AES 走它必假阴性)。
|
|
478
|
+
// 7z 缺失时 extractWith7z 返回明确错误,不静默降级为「文件损坏」。
|
|
479
|
+
const stagingDir = path.join(path.dirname(targetDir), `.${path.basename(targetDir)}.hl-7z-${Date.now()}`);
|
|
480
|
+
await fsp.mkdir(stagingDir, { recursive: true });
|
|
481
|
+
try {
|
|
482
|
+
const verdict = await extractWith7z(ctx.archivePath, password, stagingDir);
|
|
483
|
+
if (!verdict.ok) {
|
|
484
|
+
throw apiError(
|
|
485
|
+
ERROR_CODES[verdict.code] ? ERROR_CODES[verdict.code] : ERROR_CODES.INTERNAL_ERROR,
|
|
486
|
+
verdict.message,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
// 中转目录内容 → planTargets(zip-slip/覆盖检查)→ writePlan 搬移
|
|
490
|
+
const stagedEntries = [];
|
|
491
|
+
const walk = async (dirAbs, relativePrefix) => {
|
|
492
|
+
for (const dirent of await fsp.readdir(dirAbs, { withFileTypes: true })) {
|
|
493
|
+
const childAbs = path.join(dirAbs, dirent.name);
|
|
494
|
+
const relative = relativePrefix ? `${relativePrefix}/${dirent.name}` : dirent.name;
|
|
495
|
+
if (dirent.isDirectory()) {
|
|
496
|
+
await walk(childAbs, relative);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (!dirent.isFile()) continue; // 7z 产出的 symlink 等跳过(与主路径同口径)
|
|
500
|
+
const stat = await fsp.stat(childAbs).catch(() => null);
|
|
501
|
+
stagedEntries.push({
|
|
502
|
+
name: relative,
|
|
503
|
+
isDir: false,
|
|
504
|
+
size: stat ? stat.size : 0,
|
|
505
|
+
link: false,
|
|
506
|
+
open: () => fs.createReadStream(childAbs),
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
await walk(stagingDir, '');
|
|
511
|
+
const planned = await planTargets(stagedEntries, targetDir, overwrite);
|
|
512
|
+
const written = await writePlan(planned, targetDir, {
|
|
513
|
+
signal: ctx.options && ctx.options.signal,
|
|
514
|
+
onProgress: ctx.onProgress,
|
|
515
|
+
});
|
|
516
|
+
return {
|
|
517
|
+
targetDir,
|
|
518
|
+
entries: written.entries,
|
|
519
|
+
bytesOut: written.bytesOut,
|
|
520
|
+
durationMs: Date.now() - ctx.startedAt,
|
|
521
|
+
};
|
|
522
|
+
} finally {
|
|
523
|
+
// 中转目录无论成败都清掉(含密码的明文副本不留盘)
|
|
524
|
+
await fsp.rm(stagingDir, { recursive: true, force: true }).catch(() => {});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function extract(ctx) {
|
|
529
|
+
if (!(await available())) {
|
|
530
|
+
throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, 'zip 格式当前不可用');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const startedAt = Date.now();
|
|
534
|
+
const targetDir = path.resolve(ctx.targetDir);
|
|
535
|
+
const overwrite = Boolean(ctx.options && ctx.options.overwrite);
|
|
536
|
+
const directory = await openArchive(ctx.archivePath);
|
|
537
|
+
|
|
538
|
+
// ⓪ 加密检测(P4):加密包走 7z 通道(错误三态:MISSING_PASSWORD/BAD_PASSWORD/损坏)
|
|
539
|
+
const encryption = detectEncryption(directory);
|
|
540
|
+
if (encryption.encrypted) {
|
|
541
|
+
ctx.startedAt = startedAt;
|
|
542
|
+
return extractEncrypted(ctx, targetDir, overwrite, encryption.isAes);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ① 判定(写盘之前):zip-slip + 覆盖检查,全部通过才开始写 —— 与 tar 系列共用同一套代码
|
|
546
|
+
const planned = await planTargets(toSharedEntries(directory), targetDir, overwrite);
|
|
547
|
+
|
|
548
|
+
// ② 写出
|
|
549
|
+
const written = await writePlan(planned, targetDir, {
|
|
550
|
+
signal: ctx.options && ctx.options.signal,
|
|
551
|
+
onProgress: ctx.onProgress,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
targetDir,
|
|
556
|
+
entries: written.entries,
|
|
557
|
+
bytesOut: written.bytesOut,
|
|
558
|
+
durationMs: Date.now() - startedAt,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* 探测归档是否加密(供解压预检决定是否弹密码输入框)。
|
|
564
|
+
*
|
|
565
|
+
* 为什么单独导出:解压预检需要「不解压就能知道加没加密」,而这件事的实现方式
|
|
566
|
+
* 每个格式都不一样(ZIP 读 central directory 的标志位,RAR 看 unrar 的列表输出)。
|
|
567
|
+
* 统一成这个可选方法后,预检层不必认识具体格式。
|
|
568
|
+
*
|
|
569
|
+
* @param {string} archivePath
|
|
570
|
+
* @returns {Promise<{encrypted: boolean, isAes: boolean}>}
|
|
571
|
+
*/
|
|
572
|
+
async function probeEncryption(archivePath) {
|
|
573
|
+
const directory = await openArchive(archivePath);
|
|
574
|
+
return detectEncryption(directory);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export default {
|
|
578
|
+
...meta,
|
|
579
|
+
available,
|
|
580
|
+
unavailableReason,
|
|
581
|
+
estimate,
|
|
582
|
+
create,
|
|
583
|
+
extract,
|
|
584
|
+
list,
|
|
585
|
+
test,
|
|
586
|
+
probeEncryption,
|
|
587
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// dsh-skip-sensitive: 本文件只定义能力参数的字段名常量(PASSWORD: 'password' / HAS_PASSWORD: 'hasPassword'),不含任何凭据值;规则按关键字命中属误报
|
|
2
|
+
// 能力枚举与字段名(docs/19 §三:前后端共用的能力契约,单一来源)
|
|
3
|
+
//
|
|
4
|
+
// 「能力插件」是压缩之外的可插拔功能(首个:password 加密密码)。
|
|
5
|
+
// 本文件只定义**枚举与字段名**;能力的行为由 server/plugins/capabilities/<id>/ 实现,
|
|
6
|
+
// 前端不硬编码能力列表——运行时从 GET /api/v1/capabilities/:id 拉取。
|
|
7
|
+
|
|
8
|
+
/** 已知能力 id(白名单:capRegistry 只加载这里的 id 对应目录) */
|
|
9
|
+
export const CAPABILITY_IDS = Object.freeze(['password']);
|
|
10
|
+
|
|
11
|
+
/** 能力参数的字段名(前后端共用以避免字符串漂移) */
|
|
12
|
+
export const CAPABILITY_FIELDS = Object.freeze({
|
|
13
|
+
PASSWORD: 'password',
|
|
14
|
+
METHOD: 'method',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** 任务 options 上与能力相关的**脱敏后**字段(明文不落盘,docs/19 §六) */
|
|
18
|
+
export const OPTION_REDACTED_FIELDS = Object.freeze({
|
|
19
|
+
HAS_PASSWORD: 'hasPassword',
|
|
20
|
+
PASSWORD_METHOD: 'passwordMethod',
|
|
21
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// 压缩模式枚举(前后端共用的唯一来源)
|
|
2
|
+
//
|
|
3
|
+
// 依据:docs/M0/数据模型.md §一「shared/compress-modes.js」+ docs/04「两种压缩模式」。
|
|
4
|
+
// single = 每个源各自一个压缩包;bundle = 所有源合成一个压缩包。
|
|
5
|
+
// 注意:两种模式是互斥的任务模型(docs/04 §一),UI 用单选,不用两个按钮。
|
|
6
|
+
|
|
7
|
+
export const COMPRESS_MODE = Object.freeze({
|
|
8
|
+
SINGLE: 'single',
|
|
9
|
+
BUNDLE: 'bundle',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const COMPRESS_MODE_VALUES = Object.freeze(Object.values(COMPRESS_MODE));
|
|
13
|
+
|
|
14
|
+
// 默认模式:docs/08 第八节「默认值预设合理:模式单独压缩」
|
|
15
|
+
export const COMPRESS_MODE_DEFAULT = COMPRESS_MODE.SINGLE;
|
|
16
|
+
|
|
17
|
+
export const COMPRESS_MODE_LABELS = Object.freeze({
|
|
18
|
+
[COMPRESS_MODE.SINGLE]: '每个文件/文件夹单独压缩',
|
|
19
|
+
[COMPRESS_MODE.BUNDLE]: '合并为一个压缩包',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// 一句话说明:用于压缩抽屉里的辅助文案
|
|
23
|
+
export const COMPRESS_MODE_HINTS = Object.freeze({
|
|
24
|
+
[COMPRESS_MODE.SINGLE]: '选中的每一项各自生成一个压缩包,产物名与源同名',
|
|
25
|
+
[COMPRESS_MODE.BUNDLE]: '所有选中项打包进同一个压缩包,包内保留目录结构',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export function isCompressMode(value) {
|
|
29
|
+
return COMPRESS_MODE_VALUES.includes(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function compressModeLabel(mode) {
|
|
33
|
+
return COMPRESS_MODE_LABELS[mode] || '未知模式';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function compressModeHint(mode) {
|
|
37
|
+
return COMPRESS_MODE_HINTS[mode] || '';
|
|
38
|
+
}
|