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,68 @@
|
|
|
1
|
+
// 结构化日志(零依赖,JSON Lines)
|
|
2
|
+
//
|
|
3
|
+
// 设计要点:
|
|
4
|
+
// - 每行一个 JSON 对象,方便将来直接喂给日志分析;不需要引入 pino/winston。
|
|
5
|
+
// - **脱敏是硬要求**(INV-16:凭据不落明文):字段名命中敏感词的一律替换为 [redacted],
|
|
6
|
+
// 且请求日志只记 method/url/status/耗时,不记 Cookie/Authorization 内容。
|
|
7
|
+
// - 级别过滤用配置里的 log.level(debug|info|warn|error)。
|
|
8
|
+
|
|
9
|
+
const LEVEL_WEIGHT = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
10
|
+
|
|
11
|
+
// 命中即脱敏的字段名(大小写不敏感,包含匹配)
|
|
12
|
+
const SENSITIVE_KEY_PATTERN = /(password|passwd|token|authorization|cookie|secret|credential|apikey|api_key)/i;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 递归脱敏:对象里的敏感字段替换为 [redacted](数组按元素处理,深度上限 6 层防环)。
|
|
16
|
+
*/
|
|
17
|
+
export function redact(value, depth = 0) {
|
|
18
|
+
if (depth > 6) return '[deep]';
|
|
19
|
+
if (Array.isArray(value)) return value.map((item) => redact(item, depth + 1));
|
|
20
|
+
if (value && typeof value === 'object') {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const [key, val] of Object.entries(value)) {
|
|
23
|
+
out[key] = SENSITIVE_KEY_PATTERN.test(key) ? '[redacted]' : redact(val, depth + 1);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 创建 logger。
|
|
32
|
+
* @param {{level?: string, stream?: NodeJS.WritableStream, bindings?: object}} options
|
|
33
|
+
*/
|
|
34
|
+
export function createLogger(options = {}) {
|
|
35
|
+
const level = LEVEL_WEIGHT[options.level] ? options.level : 'info';
|
|
36
|
+
const stream = options.stream || process.stdout;
|
|
37
|
+
const bindings = options.bindings || {};
|
|
38
|
+
const threshold = LEVEL_WEIGHT[level];
|
|
39
|
+
|
|
40
|
+
function write(entryLevel, message, context) {
|
|
41
|
+
if (LEVEL_WEIGHT[entryLevel] < threshold) return;
|
|
42
|
+
const line = {
|
|
43
|
+
ts: new Date().toISOString(),
|
|
44
|
+
level: entryLevel,
|
|
45
|
+
msg: String(message),
|
|
46
|
+
...redact(bindings),
|
|
47
|
+
...(context ? redact(context) : {}),
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
stream.write(`${JSON.stringify(line)}\n`);
|
|
51
|
+
} catch {
|
|
52
|
+
// 日志写不出去也不能影响业务
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const logger = {
|
|
57
|
+
level,
|
|
58
|
+
debug: (msg, ctx) => write('debug', msg, ctx),
|
|
59
|
+
info: (msg, ctx) => write('info', msg, ctx),
|
|
60
|
+
warn: (msg, ctx) => write('warn', msg, ctx),
|
|
61
|
+
error: (msg, ctx) => write('error', msg, ctx),
|
|
62
|
+
/** 绑定固定字段(如 module 名) */
|
|
63
|
+
child(extra) {
|
|
64
|
+
return createLogger({ ...options, level, stream, bindings: { ...bindings, ...extra } });
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
return logger;
|
|
68
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// 运行期环境自检(M4 起的最低版本约束)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有:`.tar.zst` 依赖 Node ≥ 22.15 内置的 zstd(`node:zlib` 的 `createZstdCompress`)。
|
|
4
|
+
// 版本不够时**必须在启动时就明确拒绝**,而不是等用户点了压缩才在半个任务里炸 ——
|
|
5
|
+
// 也就是说:把"环境不满足"变成启动期的一次性、可读的失败,而不是运行期的随机失败。
|
|
6
|
+
//
|
|
7
|
+
// 设计成纯函数(可注入 version)便于单测模拟低版本。
|
|
8
|
+
|
|
9
|
+
/** 最低版本要求与理由(版本一升,这里同步改,单一事实源) */
|
|
10
|
+
export const MIN_NODE = { major: 22, minor: 15, reason: 'zstd 内置 API(node:zlib 的 createZstdCompress)自 v22.15.0 起提供,.tar.zst 依赖它' };
|
|
11
|
+
|
|
12
|
+
/** 解析 "22.23.3" / "v22.23.3" → {major, minor, patch} */
|
|
13
|
+
export function parseNodeVersion(version) {
|
|
14
|
+
const text = String(version || '').replace(/^v/, '');
|
|
15
|
+
const [major, minor, patch] = text.split('.').map((part) => Number.parseInt(part, 10));
|
|
16
|
+
return {
|
|
17
|
+
major: Number.isInteger(major) ? major : 0,
|
|
18
|
+
minor: Number.isInteger(minor) ? minor : 0,
|
|
19
|
+
patch: Number.isInteger(patch) ? patch : 0,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 版本检查(不抛错,返回结果供启动流程决定怎么报告)
|
|
25
|
+
* @param {{major: number, minor: number, reason: string}} [min]
|
|
26
|
+
* @param {string} [version]
|
|
27
|
+
*/
|
|
28
|
+
export function checkNodeVersion(min = MIN_NODE, version = process.versions.node) {
|
|
29
|
+
const current = parseNodeVersion(version);
|
|
30
|
+
const ok = current.major > min.major || (current.major === min.major && current.minor >= min.minor);
|
|
31
|
+
return {
|
|
32
|
+
ok,
|
|
33
|
+
required: `${min.major}.${min.minor}.0`,
|
|
34
|
+
current: String(version || '').replace(/^v/, ''),
|
|
35
|
+
reason: min.reason,
|
|
36
|
+
message: ok
|
|
37
|
+
? `Node 版本自检通过:v${String(version || '').replace(/^v/, '')} ≥ v${min.major}.${min.minor}.0`
|
|
38
|
+
: `Node 版本过低:本服务要求 **v${min.major}.${min.minor}.0 及以上**,当前是 v${String(version || '').replace(/^v/, '')}。\n`
|
|
39
|
+
+ `原因:${min.reason}\n`
|
|
40
|
+
+ '建议:升级 Node 到 22.15+(NAS 上可用套件管理器升级,或改用能提供 zstd 的运行环境)后重试。',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 版本不满足就抛错(启动流程捕获后打印并退出) */
|
|
45
|
+
export function assertNodeVersion(min = MIN_NODE, version = process.versions.node) {
|
|
46
|
+
const versionCheck = checkNodeVersion(min, version);
|
|
47
|
+
if (!versionCheck.ok) throw new Error(versionCheck.message);
|
|
48
|
+
return versionCheck;
|
|
49
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// 进度节流器(≥100ms 或跨过阈值才回调)
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要:压缩/解压都对每个条目、每个字节块产生事件;如果每次都广播给任务层,
|
|
4
|
+
// SSE 与前端会被刷爆(docs/05 §一:事件驱动 + 节流,不是定时采样)。
|
|
5
|
+
// 契约要求字节级进度按 ≥100ms 节流(插件契约 §3.4 第 3 条)。
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 包装一个进度回调,使其按最小间隔节流;但**文件级变化(currentFile 改变 / 计数变化)永远立即放行**,
|
|
9
|
+
* 这样"当前正在处理哪个文件"不会因为节流而滞后。
|
|
10
|
+
* @param {(payload: object) => void} sink
|
|
11
|
+
* @param {{intervalMs?: number}} [options]
|
|
12
|
+
*/
|
|
13
|
+
export function createThrottledReporter(sink, options = {}) {
|
|
14
|
+
const intervalMs = Number.isInteger(options.intervalMs) ? options.intervalMs : 100;
|
|
15
|
+
let lastEmitAt = 0;
|
|
16
|
+
let lastKey = '';
|
|
17
|
+
let pending = null;
|
|
18
|
+
|
|
19
|
+
function emit(payload, force) {
|
|
20
|
+
lastEmitAt = Date.now();
|
|
21
|
+
pending = null;
|
|
22
|
+
lastKey = `${payload.processedFiles}|${payload.currentFile}|${payload.stage || ''}`;
|
|
23
|
+
sink(payload);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return function report(payload) {
|
|
27
|
+
const key = `${payload.processedFiles}|${payload.currentFile}|${payload.stage || ''}`;
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
|
|
30
|
+
// 阶段/文件级变化 → 立即上报(保证"当前文件名"实时)
|
|
31
|
+
if (key !== lastKey) {
|
|
32
|
+
emit(payload, true);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
// 纯字节级变化 → 按间隔节流
|
|
36
|
+
if (now - lastEmitAt >= intervalMs) {
|
|
37
|
+
emit(payload, false);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
pending = payload;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// 加密密码能力插件(docs/19 §4.2 能力契约)
|
|
2
|
+
//
|
|
3
|
+
// 职责边界(docs/19 关键约束):**不碰文件系统、不碰 archiver**——
|
|
4
|
+
// 只做「参数校验 + 参数翻译(prepareCreate)+ 压后自测编排(verify)+ 候选密码序列 + 脱敏」。
|
|
5
|
+
// 真正写盘由 zip 格式插件完成;删除护栏由 orchestrator/deleter 按 verify 结果执行。
|
|
6
|
+
//
|
|
7
|
+
// 算法可追加:methods/ 下加一个模块文件即可,本文件零改动(docs/19 §4.3)。
|
|
8
|
+
//
|
|
9
|
+
// 解压侧能力边界(P4 实测结论):
|
|
10
|
+
// - unzipper 内置 Decrypt.js 只实现 **ZipCrypto**(纯 CRC 表流式解密,无 AES);
|
|
11
|
+
// - AES-256(WinZip AES,compressionMethod=99)unzipper 会按 inflate 误解 → 表现为
|
|
12
|
+
// 「inflate 失败/坏数据」,**不是密码错误**。所以解压侧必须先判加密方法:
|
|
13
|
+
// aes256 → 明确提示「当前不支持 AES-256 解压,请用 7-Zip」;zip20 → 带密码解,
|
|
14
|
+
// 错密码提示「密码错误」。
|
|
15
|
+
|
|
16
|
+
import { ERROR_CODES, apiError } from '../../../lib/errors.js';
|
|
17
|
+
|
|
18
|
+
import aes256 from './methods/aes256.js';
|
|
19
|
+
import zip20 from './methods/zip20.js';
|
|
20
|
+
|
|
21
|
+
/** 算法表(key = method id)。追加算法 = 加一个 methods/*.js + 这里一行注册 */
|
|
22
|
+
const METHODS = new Map([
|
|
23
|
+
[aes256.id, aes256],
|
|
24
|
+
[zip20.id, zip20],
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** zip 加密条目标志位(general purpose bit 0) */
|
|
28
|
+
const FLAG_ENCRYPTED = 0x1;
|
|
29
|
+
/** WinZip AES 的伪压缩方法号(central directory compressionMethod=99) */
|
|
30
|
+
const COMPRESSION_METHOD_AES = 99;
|
|
31
|
+
|
|
32
|
+
function methodById(id) {
|
|
33
|
+
return METHODS.get(id) || null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default {
|
|
37
|
+
id: 'password',
|
|
38
|
+
name: '加密密码',
|
|
39
|
+
|
|
40
|
+
// 双向声明(docs/19 §4.2):与格式插件的 capabilities 取交集才生效
|
|
41
|
+
appliesTo: ['zip'],
|
|
42
|
+
|
|
43
|
+
// 参数字段(算法枚举不在这里——由 methods/ 动态提供)
|
|
44
|
+
params: {
|
|
45
|
+
password: { type: 'string', required: true, secret: true, minLength: 1, maxLength: 1024 },
|
|
46
|
+
method: { type: 'methodRef', required: false, default: 'aes256' },
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 参数校验与规范化(PW-4:后端独立校验,不信任前端)。
|
|
51
|
+
* @param {{password?: string, method?: string}} raw
|
|
52
|
+
* @param {{formatId: string}} context
|
|
53
|
+
* @returns {{ok: true, value: {password: string, method: string}} | {ok: false, error: {code, message, details}}}
|
|
54
|
+
*/
|
|
55
|
+
validateParams(raw, { formatId } = {}) {
|
|
56
|
+
if (!raw || typeof raw.password !== 'string' || raw.password.length === 0) {
|
|
57
|
+
return { ok: false, error: { code: ERROR_CODES.VALIDATION_FAILED, message: '密码不能为空' } };
|
|
58
|
+
}
|
|
59
|
+
if (raw.password.length > this.params.password.maxLength) {
|
|
60
|
+
return { ok: false, error: { code: ERROR_CODES.VALIDATION_FAILED, message: `密码过长(上限 ${this.params.password.maxLength} 字符)` } };
|
|
61
|
+
}
|
|
62
|
+
const methodId = raw.method || this.params.method.default;
|
|
63
|
+
const method = methodById(methodId);
|
|
64
|
+
if (!method) {
|
|
65
|
+
return { ok: false, error: { code: ERROR_CODES.VALIDATION_FAILED, message: `未知加密算法:${String(methodId)}` } };
|
|
66
|
+
}
|
|
67
|
+
if (!method.available()) {
|
|
68
|
+
return { ok: false, error: { code: ERROR_CODES.VALIDATION_FAILED, message: `算法 ${method.name} 当前不可用` } };
|
|
69
|
+
}
|
|
70
|
+
if (formatId && !this.appliesTo.includes(formatId)) {
|
|
71
|
+
// PW-4 双保险:格式没声明能力 → 400(前端置灰只是体验)
|
|
72
|
+
return { ok: false, error: { code: ERROR_CODES.VALIDATION_FAILED, message: `格式 ${formatId} 不支持加密能力` } };
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, value: { password: raw.password, method: methodId } };
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 压缩前:把能力参数翻译成格式插件认识的东西(不做 IO)。
|
|
79
|
+
* @returns {{archiverFormat: string, extra: {encryptionMethod: string, password: string}}}
|
|
80
|
+
*/
|
|
81
|
+
prepareCreate({ params }) {
|
|
82
|
+
const method = methodById(params.method) || methodById(this.params.method.default);
|
|
83
|
+
return {
|
|
84
|
+
archiverFormat: method.engine.archiverFormat,
|
|
85
|
+
extra: {
|
|
86
|
+
encryptionMethod: method.engine.encryptionMethod,
|
|
87
|
+
password: params.password,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 压缩后自测(PW-2:删除原文件的前置条件)——用同一密码试解一次。
|
|
94
|
+
*
|
|
95
|
+
* 实现走 unzipper.Open.file(与格式插件 test() 同一读取面):
|
|
96
|
+
* - 全部条目带密码完整读通 + CRC 一致 → ok;
|
|
97
|
+
* - 任何条目读失败 → !ok(编排层把任务置 failed(VERIFY_FAILED),INV-01 拦住删除)。
|
|
98
|
+
*
|
|
99
|
+
* @param {{archivePath: string, params: {password: string, method: string}, formatApi: object}} args
|
|
100
|
+
*/
|
|
101
|
+
async verify({ archivePath, params }) {
|
|
102
|
+
const method = methodById(params.method);
|
|
103
|
+
if (!method) return { ok: false, error: `未知算法 ${params.method}` };
|
|
104
|
+
// 统一走 7z 试解:
|
|
105
|
+
// unzipper 只有 ZipCrypto 的解密实现,AES-256 包(compressionMethod=99)会被它
|
|
106
|
+
// 当成普通 inflate 解读 → 报「inflate 失败/坏数据」或 BAD_PASSWORD,属**假阴性**。
|
|
107
|
+
// 7-Zip 两种加密都能读,且本地环境与 NAS 上通常都有;探测不到时明确报错,不静默放行。
|
|
108
|
+
return verifyWith7z(archivePath, params.password);
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 解压时:给出候选密码序列(用户输入优先,其次常用密码本按 lastUsedAt 倒序)。
|
|
113
|
+
* P3 的密码本由调用方注入 candidates;本能力只定义顺序语义。
|
|
114
|
+
*/
|
|
115
|
+
prepareExtract({ userPassword, archiveCandidates = [] }) {
|
|
116
|
+
const candidates = [];
|
|
117
|
+
if (userPassword) candidates.push(userPassword);
|
|
118
|
+
for (const item of archiveCandidates) {
|
|
119
|
+
const value = typeof item === 'string' ? item : item && item.password;
|
|
120
|
+
if (value && !candidates.includes(value)) candidates.push(value);
|
|
121
|
+
}
|
|
122
|
+
return { candidates };
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 脱敏(PW-1):日志 / API 响应 / 落盘一律过这里——只留布尔与方法名,绝不回明文。
|
|
127
|
+
*/
|
|
128
|
+
redact(params) {
|
|
129
|
+
return {
|
|
130
|
+
hasPassword: Boolean(params && params.password),
|
|
131
|
+
method: params && params.method ? params.method : undefined,
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 加密元数据(GET /capabilities/password 用):给前端渲染算法下拉与 caveat。
|
|
137
|
+
* 绝不包含任何密码值。
|
|
138
|
+
*/
|
|
139
|
+
describe() {
|
|
140
|
+
return {
|
|
141
|
+
id: this.id,
|
|
142
|
+
name: this.name,
|
|
143
|
+
appliesTo: [...this.appliesTo],
|
|
144
|
+
methods: [...METHODS.values()].map((method) => ({
|
|
145
|
+
id: method.id,
|
|
146
|
+
name: method.name,
|
|
147
|
+
description: method.description,
|
|
148
|
+
compatibility: method.compatibility,
|
|
149
|
+
caveat: method.caveat,
|
|
150
|
+
available: method.available(),
|
|
151
|
+
})),
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 解压侧加密判定(P4):给格式插件 extract 用——判别条目加密方式。
|
|
157
|
+
* @param {{flags?: number, compressionMethod?: number}} entryVars unzipper 条目变量
|
|
158
|
+
* @returns {'none' | 'zip20' | 'aes-unsupported'}
|
|
159
|
+
*/
|
|
160
|
+
classifyEntry(entryVars) {
|
|
161
|
+
const flags = Number(entryVars && entryVars.flags) || 0;
|
|
162
|
+
if ((flags & FLAG_ENCRYPTED) === 0) return 'none';
|
|
163
|
+
if (Number(entryVars && entryVars.compressionMethod) === COMPRESSION_METHOD_AES) return 'aes-unsupported';
|
|
164
|
+
return 'zip20';
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/** 算法表暴露(解压侧/路由查询 caveat 用) */
|
|
168
|
+
methods: METHODS,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// —— 内部工具(不导出) ——
|
|
172
|
+
|
|
173
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
174
|
+
import { promisify } from 'node:util';
|
|
175
|
+
|
|
176
|
+
import unzipperPkg from 'unzipper';
|
|
177
|
+
const unzipper = unzipperPkg;
|
|
178
|
+
|
|
179
|
+
const execFileAsync = promisify(execFile);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 探测 7z 可执行文件(tool-self-detection:环境变量 → PATH → 常见绝对路径)。
|
|
183
|
+
* 找不到返回 null,由调用方给**明确错误**,绝不静默当作「验证通过」。
|
|
184
|
+
*/
|
|
185
|
+
let cached7z = null;
|
|
186
|
+
let cached7zProbed = false;
|
|
187
|
+
function resolve7zBinary() {
|
|
188
|
+
if (cached7zProbed) return cached7z;
|
|
189
|
+
cached7zProbed = true;
|
|
190
|
+
const candidates = [
|
|
191
|
+
process.env.ZC_7Z_BIN,
|
|
192
|
+
'7z',
|
|
193
|
+
'7za',
|
|
194
|
+
'7zz',
|
|
195
|
+
'/bin/7z',
|
|
196
|
+
'/usr/bin/7z',
|
|
197
|
+
'/usr/local/bin/7z',
|
|
198
|
+
'/volume1/@appstore/7-Zip/usr/local/bin/7z',
|
|
199
|
+
].filter(Boolean);
|
|
200
|
+
for (const candidate of candidates) {
|
|
201
|
+
try {
|
|
202
|
+
// 只做「能否调用」的轻量探测:--help 在 7z 各分支上都可用且不碰归档
|
|
203
|
+
execFileSync(candidate, ['--help'], { stdio: 'ignore', timeout: 5000 });
|
|
204
|
+
cached7z = candidate;
|
|
205
|
+
return cached7z;
|
|
206
|
+
} catch {
|
|
207
|
+
/* 试下一个 */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 用 7z 试解一次加密包(PW-2 的实现)。
|
|
215
|
+
* 7z t 会校验 CRC 并要求正确密码 —— 等价于「用这个密码完整读一遍」。
|
|
216
|
+
*/
|
|
217
|
+
export async function verifyWith7z(archivePath, password) {
|
|
218
|
+
const binary = resolve7zBinary();
|
|
219
|
+
if (!binary) {
|
|
220
|
+
return { ok: false, error: '未找到 7z,无法自测加密包(可设置 ZC_7Z_BIN 指定路径)' };
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
await execFileAsync(binary, ['t', `-p${password}`, '-y', archivePath], {
|
|
224
|
+
timeout: 120000,
|
|
225
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
226
|
+
});
|
|
227
|
+
return { ok: true };
|
|
228
|
+
} catch (error) {
|
|
229
|
+
const output = `${error && error.stdout ? error.stdout : ''}${error && error.stderr ? error.stderr : ''}`;
|
|
230
|
+
if (/wrong password|password is incorrect|加密|密码/i.test(output)) {
|
|
231
|
+
return { ok: false, error: '密码错误' };
|
|
232
|
+
}
|
|
233
|
+
// 绝不能把 execFile 的 error.message 直接透出去:它含**完整命令行**(`-p<明文密码>`)与本机绝对路径。
|
|
234
|
+
// 这些字串会被写进任务记录的 error 字段,等于把密码和目录结构泄漏到 API 响应里(实测踩过)。
|
|
235
|
+
return { ok: false, error: '加密包自测失败(7z 无法读取该包:可能格式不支持,或包已损坏)' };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function unzipperOpen(archivePath) {
|
|
240
|
+
return unzipper.Open.file(archivePath);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isDirectoryEntry(entry) {
|
|
244
|
+
return /\/$/.test(String(entry.path)) || entry.type === 'Directory';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 7z 实际解包(P4:AES-256 与 ZipCrypto 的统一解压通道——unzipper 只认 ZipCrypto,
|
|
249
|
+
* AES 走它必假阴性,所以加密包解压统一走 7z,一个实现两种算法)。
|
|
250
|
+
*
|
|
251
|
+
* 解到 **指定输出目录**(调用方传入临时目录),zip-slip 由两层防护:
|
|
252
|
+
* ① 7z 自身的路径规范化(p7zip 对 `../` 条目会拒绝或剥离);
|
|
253
|
+
* ② 调用方在解包后仍走 planTargets 白名单链把文件**搬**进最终目标(不在解包目录直接交付)。
|
|
254
|
+
*
|
|
255
|
+
* @returns {{ok: true} | {ok: false, code: 'BAD_PASSWORD'|'ARCHIVE_CORRUPTED'|'SEVENZIP_MISSING'|'INTERNAL_ERROR', message: string}}
|
|
256
|
+
*/
|
|
257
|
+
export async function extractWith7z(archivePath, password, outputDir) {
|
|
258
|
+
const binary = resolve7zBinary();
|
|
259
|
+
if (!binary) {
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
code: 'SEVENZIP_MISSING',
|
|
263
|
+
message: '当前环境缺少 7z,无法解压加密压缩包,请安装 7-Zip 或设置 ZC_7Z_BIN 环境变量',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
await execFileAsync(binary, ['x', `-p${password}`, '-y', `-o${outputDir}`, archivePath], {
|
|
268
|
+
timeout: 600000,
|
|
269
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
270
|
+
});
|
|
271
|
+
return { ok: true };
|
|
272
|
+
} catch (error) {
|
|
273
|
+
const output = `${error && error.stdout ? error.stdout : ''}${error && error.stderr ? error.stderr : ''}`;
|
|
274
|
+
if (/wrong password/i.test(output)) {
|
|
275
|
+
return { ok: false, code: 'BAD_PASSWORD', message: '密码错误' };
|
|
276
|
+
}
|
|
277
|
+
if (/Cannot open|Errors|ERROR/i.test(output) && /corrupt|损坏|unexpected end/i.test(output)) {
|
|
278
|
+
return { ok: false, code: 'ARCHIVE_CORRUPTED', message: '压缩包损坏' };
|
|
279
|
+
}
|
|
280
|
+
return { ok: false, code: 'INTERNAL_ERROR', message: (error && error.message) || '解压失败' };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 7z 可用性探测(解压路由判「缺 7z」用,不执行解包) */
|
|
285
|
+
export function sevenZipAvailable() {
|
|
286
|
+
return resolve7zBinary() !== null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** 加密条目检测(供 extract 路由/格式包判别):任一非目录条目加密 → true */
|
|
290
|
+
export async function archiveHasEncryption(archivePath) {
|
|
291
|
+
const directory = await unzipperOpen(archivePath);
|
|
292
|
+
for (const entry of directory.files) {
|
|
293
|
+
if (isDirectoryEntry(entry)) continue;
|
|
294
|
+
const flags = Number(entry.flags) || 0;
|
|
295
|
+
if ((flags & FLAG_ENCRYPTED) !== 0) {
|
|
296
|
+
return { encrypted: true, isAes: Number(entry.compressionMethod) === COMPRESSION_METHOD_AES };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { encrypted: false, isAes: false };
|
|
300
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// AES-256 加密算法模块(docs/19 §4.3 算法契约)
|
|
2
|
+
//
|
|
3
|
+
// 引擎:archiver-zip-encrypted(纯 JS,aes-js)——加密方法名 aes256。
|
|
4
|
+
// 兼容性事实来自实测与 docs/18 §附二:7-Zip / WinZip / macOS 归档工具可解;
|
|
5
|
+
// Linux Info-ZIP unzip 6.00 与 Windows 资源管理器**解不开**(资源管理器能看列表)。
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
id: 'aes256',
|
|
9
|
+
name: 'AES-256',
|
|
10
|
+
description: 'WinZip 2003 标准(AES-256),安全性最高',
|
|
11
|
+
|
|
12
|
+
compatibility: {
|
|
13
|
+
sevenZip: true,
|
|
14
|
+
winZip: true,
|
|
15
|
+
macArchiveUtility: true,
|
|
16
|
+
winExplorer: false, // 能看列表不能解压
|
|
17
|
+
linuxUnzip: false, // Info-ZIP unzip 6.00 不支持
|
|
18
|
+
},
|
|
19
|
+
caveat: 'Linux unzip 6.00 与 Windows 资源管理器无法解开;7-Zip / WinZip / macOS 可解',
|
|
20
|
+
|
|
21
|
+
// 纯 JS 实现,无外部依赖,恒可用
|
|
22
|
+
available() {
|
|
23
|
+
return true;
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
// 引擎适配:能力插件 prepareCreate 把它展开进格式插件的 create 选项
|
|
27
|
+
engine: {
|
|
28
|
+
archiverFormat: 'zip-encrypted',
|
|
29
|
+
encryptionMethod: 'aes256',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// ZipCrypto(传统加密)算法模块(docs/19 §4.3 算法契约)
|
|
2
|
+
//
|
|
3
|
+
// 引擎:archiver-zip-encrypted——加密方法名 zip20。
|
|
4
|
+
// 兼容性:几乎所有解压工具都支持(Windows 资源管理器 / Linux unzip / 7-Zip),
|
|
5
|
+
// 但加密强度弱(可被暴力破解),caveat 必须如实告知用户。
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
id: 'zip20',
|
|
9
|
+
name: 'ZipCrypto(传统)',
|
|
10
|
+
description: '传统 Zip 加密,兼容性最好但强度弱',
|
|
11
|
+
|
|
12
|
+
compatibility: {
|
|
13
|
+
sevenZip: true,
|
|
14
|
+
winZip: true,
|
|
15
|
+
macArchiveUtility: true,
|
|
16
|
+
winExplorer: true,
|
|
17
|
+
linuxUnzip: true,
|
|
18
|
+
},
|
|
19
|
+
caveat: '几乎所有工具可解,但加密强度弱(可被破解),敏感数据请选 AES-256',
|
|
20
|
+
|
|
21
|
+
available() {
|
|
22
|
+
return true;
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
engine: {
|
|
26
|
+
archiverFormat: 'zip-encrypted',
|
|
27
|
+
encryptionMethod: 'zip20',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// 格式包共用的「创建前置守卫」(各格式不许各写一份,避免漂移)
|
|
2
|
+
//
|
|
3
|
+
// 契约(docs/M0/插件契约.md §3.4 第 7 条):**绝不覆盖** ——
|
|
4
|
+
// `tempPath`(`outputPath + '.part'`)与 `outputPath` 任一已存在就必须直接抛 TARGET_EXISTS,
|
|
5
|
+
// 由总入口负责命名唯一化;格式包不许自作主张覆盖或改名。
|
|
6
|
+
|
|
7
|
+
import { ERROR_CODES, apiError } from '../../../lib/errors.js';
|
|
8
|
+
import { pathExists } from '../../../lib/fs-probe.js';
|
|
9
|
+
import { safeName } from './entrySafety.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {{tempPath: string, outputPath: string}} ctx 格式包上下文
|
|
13
|
+
* @param {() => Promise<boolean>} isAvailable 该格式的可用性探测
|
|
14
|
+
* @param {string} formatId 用于错误提示的格式 id
|
|
15
|
+
*/
|
|
16
|
+
export async function assertCreatable(ctx, isAvailable, formatId) {
|
|
17
|
+
if (!(await isAvailable())) {
|
|
18
|
+
throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, `${formatId} 格式当前不可用`);
|
|
19
|
+
}
|
|
20
|
+
if (await pathExists(ctx.tempPath)) {
|
|
21
|
+
throw apiError(ERROR_CODES.TARGET_EXISTS, '临时产物已存在', { file: safeName(ctx.tempPath) });
|
|
22
|
+
}
|
|
23
|
+
if (await pathExists(ctx.outputPath)) {
|
|
24
|
+
throw apiError(ERROR_CODES.TARGET_EXISTS, '目标压缩包已存在(绝不覆盖)', { file: safeName(ctx.outputPath) });
|
|
25
|
+
}
|
|
26
|
+
}
|