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,783 @@
|
|
|
1
|
+
// 压缩/解压编排(M2)
|
|
2
|
+
//
|
|
3
|
+
// 一条管道同时服务两种任务(docs/11 M2「范围说明」):
|
|
4
|
+
//
|
|
5
|
+
// precheck → scanning → compressing|extracting → verifying → [waiting_confirm] → deleting → done
|
|
6
|
+
//
|
|
7
|
+
// 关键不变量:
|
|
8
|
+
// - **INV-01:删除前必须校验通过**。verify 不通过 → VERIFY_FAILED,且绝不进入 deleting;
|
|
9
|
+
// - INV-08:产物命名绝不覆盖(同名加 " (1)"),临时产物 `.part` 由本层原子 rename 成正式产物;
|
|
10
|
+
// - 删除动作统一走 deleteService(受 `delete.mode` 约束:log-only 默认只记日志)。
|
|
11
|
+
//
|
|
12
|
+
// 结构:**每个阶段一个模块级函数**(预检 / 打包 / 校验 / 解压 / 删除),
|
|
13
|
+
// 任务主体 `runCompressTask` / `runExtractTask` 只做阶段编排与结果汇总;
|
|
14
|
+
// 阶段函数通过 `deps` 显式取依赖(config/logger/registry/manager/deleteService/rootAbs),不使用闭包捕获。
|
|
15
|
+
//
|
|
16
|
+
// 边界(M2 简化):任务与历史只在内存(M3 落盘)、SSE 未实现(前端轮询)、黑名单未接入(M6)。
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import fsp from 'node:fs/promises';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { ERROR_CODES, apiError } from '../../lib/errors.js';
|
|
23
|
+
import { pathExists } from '../../lib/fs-probe.js';
|
|
24
|
+
import { matchEntry } from './blacklist.js';
|
|
25
|
+
import { toUserPath } from '../fs/safePath.js';
|
|
26
|
+
import { compressPreflight, extractPreflight } from './preflight.js';
|
|
27
|
+
import { assertVerified, verifyArchive } from './verify.js';
|
|
28
|
+
|
|
29
|
+
/** 产物命名:不覆盖已有文件(docs/M0 数据模型 §9.2;上限 1000 次尝试防病态目录死循环) */
|
|
30
|
+
async function uniqueTargetPath(dirAbs, baseName, ext) {
|
|
31
|
+
for (let attempt = 0; attempt < 1000; attempt += 1) {
|
|
32
|
+
const suffix = attempt === 0 ? '' : ` (${attempt})`;
|
|
33
|
+
const candidate = path.join(dirAbs, `${baseName}${suffix}${ext}`);
|
|
34
|
+
if (!(await pathExists(candidate))) return candidate;
|
|
35
|
+
}
|
|
36
|
+
throw apiError(ERROR_CODES.TARGET_EXISTS, '同名产物过多,无法生成不冲突的文件名', { file: `${baseName}${ext}` });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 默认解压目标目录:与压缩包同级的同名目录(已存在则加 " (n)",避免与既有目录混在一起) */
|
|
40
|
+
export async function defaultExtractDir(archiveAbsPath) {
|
|
41
|
+
const dir = path.dirname(archiveAbsPath);
|
|
42
|
+
// 复合扩展名要整体去掉(`x.tar.gz` → `x`,而不是 `x.tar`)
|
|
43
|
+
const base = path.basename(archiveAbsPath).replace(/\.(tar\.(gz|zst)|tar|zip|tgz|tzst)$/i, '');
|
|
44
|
+
for (let attempt = 0; attempt < 1000; attempt += 1) {
|
|
45
|
+
const suffix = attempt === 0 ? '' : ` (${attempt})`;
|
|
46
|
+
const candidate = path.join(dir, `${base}${suffix}`);
|
|
47
|
+
if (!(await pathExists(candidate))) return candidate;
|
|
48
|
+
}
|
|
49
|
+
throw apiError(ERROR_CODES.TARGET_EXISTS, '无法生成不冲突的解压目录', { base });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 归档绝对路径 → 校验用路径(产物用 userPath 记录,这里换回本地路径) */
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 本次任务使用的存储根:**任务自己的根优先**(多根下在哪个卷/共享下建的任务,就用哪个根)。
|
|
56
|
+
* 为什么不能用 deps.rootAbs:那样在 /volume1 下建的任务,产物路径与删除复核会按 fs-root 算(INV-06 会误判)。
|
|
57
|
+
*/
|
|
58
|
+
function rootAbsOf(record, deps) {
|
|
59
|
+
return (record && record.internal && record.internal.rootAbs) || deps.rootAbs;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function archiveAbsOf(rootAbs, userPath) {
|
|
63
|
+
return path.join(rootAbs, ...userPath.split('/').filter(Boolean));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ===== 阶段一:删除(压缩删原文件 / 解压删压缩包共用)=====
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 从格式包 create() 返回的 entryPaths(契约 v2 §3.4 条款 10:实际进包条目的**源绝对路径**,
|
|
70
|
+
* 进程内使用不进 API)构造删除集合。
|
|
71
|
+
*
|
|
72
|
+
* INV-02 语义:targets = 进包条目 − skipped。**禁止**用「全部源条目」当删除集合(P-01 定时炸弹),
|
|
73
|
+
* **禁止**用包内路径反推源路径(lead 确认的第一手事实原则:collectEntries 收集时 absPath 就在手上)。
|
|
74
|
+
*
|
|
75
|
+
* @param {string[]} entryPaths 格式包返回的实际进包 absPath 清单
|
|
76
|
+
* @param {string[]} skippedAbsPaths 被跳过条目的 absPath(M6 起由黑名单命中项填充;打包阶段就不进包,本参数是删除集合的第二道防线)
|
|
77
|
+
* @param {Map<string,{sizeBytes:number,mtimeMs:number,ino:number}>} [entryStats] 批次④ stat 复核快照(同一次 walk 顺手记录;缺省按 null sizeBytes)
|
|
78
|
+
* @param {(absPath: string) => string} toUserPathOf absPath → userPath 单向转换(orchestrator 持有 root)
|
|
79
|
+
* @returns {{targets: Array<{userPath,absPath,kind,size}>, excluded: number}} 命中集合与被差集排除数
|
|
80
|
+
*/
|
|
81
|
+
export function buildDeleteTargetsFromEntries(entryPaths, skippedAbsPaths, entryStats, toUserPathOf) {
|
|
82
|
+
const skipped = new Set(skippedAbsPaths || []);
|
|
83
|
+
const targets = [];
|
|
84
|
+
let excluded = 0;
|
|
85
|
+
for (const absPath of entryPaths || []) {
|
|
86
|
+
if (skipped.has(absPath)) {
|
|
87
|
+
excluded += 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const stat = entryStats ? entryStats.get(absPath) : null;
|
|
91
|
+
targets.push({
|
|
92
|
+
userPath: toUserPathOf(absPath),
|
|
93
|
+
absPath,
|
|
94
|
+
kind: 'file',
|
|
95
|
+
size: stat ? stat.sizeBytes : 0,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return { targets, excluded };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 删除执行前的 stat 复核(契约 INV-03 ⑤):确认后逐条 stat,与进包时记录的 size 比对;
|
|
103
|
+
* 变化条目跳过(不删),**全部变化 → 整批拒删**(目录被动过,保守处理)。
|
|
104
|
+
*
|
|
105
|
+
* 快照来源:进包清单建立时的 entryStats(sizeBytes),由 collectEntries 阶段记录;
|
|
106
|
+
* 快照缺失(size=null)的条目视为「无基准可查」→ 放行(当前 zip/tar 的 size 都有记录)。
|
|
107
|
+
*
|
|
108
|
+
* @param {Array<{userPath,absPath,kind,size}>} targets 内部形状删除集合
|
|
109
|
+
* @param {Map<string,{sizeBytes:number}>} [entryStats] 进包时快照
|
|
110
|
+
* @param {() => Promise<{sizeBytes: number|null, mtimeMs: number, ino: number}>} statOne 单条 stat(注入便于测试)
|
|
111
|
+
* @returns {Promise<{kept: Array, changed: Array<{userPath,absPath,reason}>}>}
|
|
112
|
+
*/
|
|
113
|
+
export async function filterStaleDeleteTargets(targets, entryStats, statOne) {
|
|
114
|
+
const kept = [];
|
|
115
|
+
const changed = [];
|
|
116
|
+
for (const target of targets || []) {
|
|
117
|
+
let current = null;
|
|
118
|
+
try {
|
|
119
|
+
current = await statOne(target.absPath);
|
|
120
|
+
} catch {
|
|
121
|
+
current = null; // ENOENT / 权限等:源已不在或不可读 → 视为已变化
|
|
122
|
+
}
|
|
123
|
+
const baseline = entryStats ? entryStats.get(target.absPath) : null;
|
|
124
|
+
// 无基准(快照缺失)→ 无法判定变化,放行(不虚构拦截)
|
|
125
|
+
if (!baseline || baseline.sizeBytes === null || baseline.sizeBytes === undefined) {
|
|
126
|
+
kept.push(target);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!current || Number(current.sizeBytes) !== Number(baseline.sizeBytes)) {
|
|
130
|
+
changed.push({ userPath: target.userPath, absPath: target.absPath, reason: current ? 'source_changed' : 'source_gone' });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
kept.push(target);
|
|
134
|
+
}
|
|
135
|
+
return { kept, changed };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 把 API 用户路径的删除目标转成删除服务入参形状 */
|
|
139
|
+
function buildDeleteTargets(input) {
|
|
140
|
+
return input.targets.map((target) => ({
|
|
141
|
+
path: target.userPath,
|
|
142
|
+
kind: target.kind || 'file',
|
|
143
|
+
sizeBytes: Number.isFinite(target.size) ? target.size : null,
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 字节比对容差(与路由层 parseExpectedCounts 同口径):目录 size 估算与磁盘块对齐允许 ±1 */
|
|
148
|
+
const EXPECTED_BYTES_TOLERANCE = 1;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 确认数字复核(INV-03:expectedFiles 与 expectedBytes 双维度)。
|
|
152
|
+
*
|
|
153
|
+
* 双层防线设计(M5-fix-plan F-02):路由层 parseExpectedCounts 已做同步 fast-fail(给 4xx 体验);
|
|
154
|
+
* 这里是**权威防线**——在删除执行前的最后一刻再比一次,覆盖"路由校验通过后、执行前"窗口内
|
|
155
|
+
* 待删集合发生变化(如 stat 复核剔除条目)的情况。两层口径一致,不冗余。
|
|
156
|
+
*/
|
|
157
|
+
export function assertExpectedCountsMatch(gate, targets) {
|
|
158
|
+
const actualFiles = targets.length;
|
|
159
|
+
if (Number.isInteger(gate.expectedFiles) && gate.expectedFiles !== actualFiles) {
|
|
160
|
+
throw apiError(ERROR_CODES.VALIDATION_FAILED, '确认弹窗与实际待删数量不一致,已中止删除', {
|
|
161
|
+
expectedFiles: gate.expectedFiles,
|
|
162
|
+
actualFiles,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (Number.isFinite(gate.expectedBytes)) {
|
|
166
|
+
const actualBytes = targets.reduce((sum, target) => sum + (Number(target.sizeBytes) || 0), 0);
|
|
167
|
+
if (Math.abs(gate.expectedBytes - actualBytes) > EXPECTED_BYTES_TOLERANCE) {
|
|
168
|
+
throw apiError(ERROR_CODES.VALIDATION_FAILED, '确认弹窗与实际待删字节数不一致,已中止删除', {
|
|
169
|
+
expectedBytes: gate.expectedBytes,
|
|
170
|
+
actualBytes,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 等二次确认(创建时已确认则直接放行)。
|
|
178
|
+
* @returns {Promise<boolean>} true = 可以删;false = 用户放弃(调用方据此跳过删除,原文件不动)
|
|
179
|
+
*/
|
|
180
|
+
async function awaitDeleteConfirm(manager, logger, info, skipConfirm) {
|
|
181
|
+
if (skipConfirm) {
|
|
182
|
+
info.delete.confirmed = true;
|
|
183
|
+
info.delete.confirmedAt = Date.now();
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
manager.markWaitingConfirm(info.id);
|
|
188
|
+
const gate = await manager.waitConfirm(info.id);
|
|
189
|
+
if (gate.confirmed !== true) {
|
|
190
|
+
info.delete.status = 'skipped';
|
|
191
|
+
info.delete.confirmed = false;
|
|
192
|
+
info.delete.deletedAt = Date.now();
|
|
193
|
+
// 契约 §3.2/§3.3:放弃原因区分 user_cancelled(用户取消)/ confirm_timeout(超时);
|
|
194
|
+
// 两者任务终态都是 done(不回滚压缩结果)——runner 正常返回,由调用方 finalize('done')。
|
|
195
|
+
const reason = gate.reason === 'confirm_timeout' ? 'confirm_timeout' : 'user_cancelled';
|
|
196
|
+
info.delete.skipReason = reason;
|
|
197
|
+
logger.info('删除确认未达成,原文件/压缩包保持不动', { taskId: info.id, reason });
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
assertExpectedCountsMatch(gate, info.delete.targets);
|
|
201
|
+
info.delete.confirmed = true;
|
|
202
|
+
info.delete.confirmedAt = gate.at || Date.now();
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 把删除结果回填到任务 info.delete(字段名与契约一致) */
|
|
207
|
+
function applyDeleteOutcome(info, deleteOutcome) {
|
|
208
|
+
info.delete.mode = deleteOutcome.mode;
|
|
209
|
+
info.delete.batchId = deleteOutcome.batchId;
|
|
210
|
+
info.delete.logFile = deleteOutcome.logFile;
|
|
211
|
+
info.delete.deleted = deleteOutcome.deleted;
|
|
212
|
+
info.delete.skipped = deleteOutcome.skipped;
|
|
213
|
+
info.delete.failed = deleteOutcome.failed;
|
|
214
|
+
info.delete.deletedAt = Date.now();
|
|
215
|
+
info.delete.status = deleteOutcome.failed.length > 0
|
|
216
|
+
? 'failed'
|
|
217
|
+
// F-08(契约 §2.2 枚举收敛):log-only 模式映射回 skipped(reason 标 log-only),
|
|
218
|
+
// 'logged' 是契约外枚举值,不再产出;log-only 的记账细节在删除日志(action=skipped-log-only)
|
|
219
|
+
: 'skipped';
|
|
220
|
+
info.delete.error = deleteOutcome.failed.length > 0
|
|
221
|
+
? { code: deleteOutcome.failed[0].code, message: '删除失败' }
|
|
222
|
+
: null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 契约 §9.5 集合差异复核:targets ⊆ 进包条目 − skipped,差异非空 → 抛 INVALID_STATE(整批拒删)。
|
|
227
|
+
*
|
|
228
|
+
* 与构造侧(buildDeleteTargetsFromEntries)**独立实现**,互为对方漂移的检测线:
|
|
229
|
+
* 构造侧改坏(如被改回"全部 sources")时,这里会拦住并拒绝删除——本函数导出供单元测试
|
|
230
|
+
* 做反例自证(禁用复核 → 污染集合不再被拦 → 测试红)。
|
|
231
|
+
*
|
|
232
|
+
* @param {Array<{userPath,absPath,kind,size}>} targets 待删集合
|
|
233
|
+
* @param {string[]} entryAbsPaths 调用方声明的进包清单(absPath 坐标系)
|
|
234
|
+
* @param {string[]} [skippedAbsPaths] 被跳过条目(同样不允许进删除集合)
|
|
235
|
+
* @returns {{violations: Array<{userPath,absPath}>}} 差异明细(空数组 = 通过)
|
|
236
|
+
*/
|
|
237
|
+
export function checkDeleteSetAgainstEntries(targets, entryAbsPaths, skippedAbsPaths = []) {
|
|
238
|
+
const allowed = new Set(entryAbsPaths);
|
|
239
|
+
const skippedAbs = new Set(skippedAbsPaths);
|
|
240
|
+
return {
|
|
241
|
+
violations: (targets || []).filter((t) => !allowed.has(t.absPath) || skippedAbs.has(t.absPath))
|
|
242
|
+
.map((t) => ({ userPath: t.userPath, absPath: t.absPath })),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 执行删除阶段:集合差异复核(契约 §9.5)→ 等二次确认 → 调删除服务 → 回填删除状态。
|
|
248
|
+
* 前置条件:调用方**必须**已经通过校验(INV-01)。
|
|
249
|
+
*/
|
|
250
|
+
async function stageDelete(record, input, deps) {
|
|
251
|
+
const { manager, logger, deleteService } = deps;
|
|
252
|
+
const rootAbs = rootAbsOf(record, deps);
|
|
253
|
+
const info = record.info;
|
|
254
|
+
if (!info.delete) return null;
|
|
255
|
+
|
|
256
|
+
info.delete.targets = buildDeleteTargets(input);
|
|
257
|
+
|
|
258
|
+
// 契约 §9.5:删除前复核 targets ⊆ 进包条目 − skipped,差异非空 → 拒绝删除整批并记日志。
|
|
259
|
+
// 注意:复核对象是 **input.targets(内部形状,含 absPath)**——info.delete.targets 已被
|
|
260
|
+
// buildDeleteTargets 映射成契约形状(丢了 absPath),坐标系必须与 entryAbsPaths 一致。
|
|
261
|
+
const { violations } = checkDeleteSetAgainstEntries(input.targets, input.entryAbsPaths, input.skippedAbsPaths || []);
|
|
262
|
+
if (input.entryAbsPaths && violations.length > 0) {
|
|
263
|
+
logger.error('删除集合与进包清单存在差异,整批拒绝删除(契约 §9.5)', {
|
|
264
|
+
taskId: info.id,
|
|
265
|
+
violations: violations.map((v) => v.userPath),
|
|
266
|
+
});
|
|
267
|
+
throw apiError(ERROR_CODES.INVALID_STATE, '删除集合与实际进包条目不一致,已拒绝删除', {
|
|
268
|
+
violationCount: violations.length,
|
|
269
|
+
violations: violations.slice(0, 5).map((v) => v.userPath),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const confirmed = await awaitDeleteConfirm(manager, logger, info, input.confirmedAtCreate === true);
|
|
274
|
+
if (!confirmed) return null;
|
|
275
|
+
|
|
276
|
+
// INV-03 ⑤:确认后、删除前的最后一道复核——逐条 stat 与进包时快照比对,
|
|
277
|
+
// 变化条目跳过(reason=source_changed/source_gone),全部变化 → 整批拒删(保守)。
|
|
278
|
+
if (input.entryStats) {
|
|
279
|
+
const statOne = async (absPath) => {
|
|
280
|
+
const stat = await fsp.stat(absPath);
|
|
281
|
+
return { sizeBytes: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino };
|
|
282
|
+
};
|
|
283
|
+
const { kept, changed } = await filterStaleDeleteTargets(input.targets, input.entryStats, statOne);
|
|
284
|
+
if (changed.length > 0) {
|
|
285
|
+
logger.warn('删除前 stat 复核发现源变动', { taskId: info.id, changed: changed.map((c) => ({ path: c.userPath, reason: c.reason })) });
|
|
286
|
+
if (kept.length === 0) {
|
|
287
|
+
throw apiError(ERROR_CODES.INVALID_STATE, '全部待删条目在确认后发生变动,已整批拒绝删除', {
|
|
288
|
+
changed: changed.slice(0, 5).map((c) => c.userPath),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
info.delete.skipped = [...(info.delete.skipped || []), ...changed.map((c) => ({ path: c.userPath, reason: c.reason }))];
|
|
292
|
+
input.targets = kept;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// deleting 是真正的执行相位(rename/移入回收站),必须与后续任务的压缩互斥:
|
|
297
|
+
// 让位后 activeId 可能属于 B;source 重叠时 B 正在读的文件不能被 A 移走。
|
|
298
|
+
// 等待发生在 runner 内部(确认 HTTP 请求不挂起);拿到槽位后才进入 deleting。
|
|
299
|
+
await manager.waitForExecutionSlot(info.id);
|
|
300
|
+
manager.setPhase(info.id, 'deleting');
|
|
301
|
+
info.delete.status = 'deleting';
|
|
302
|
+
const deleteOutcome = await deleteService.deleteTargets({
|
|
303
|
+
// INV-06 复核基准:任务自己的根(多根下不能用服务默认根)
|
|
304
|
+
rootAbs,
|
|
305
|
+
taskId: info.id,
|
|
306
|
+
kind: info.kind,
|
|
307
|
+
reason: input.reason,
|
|
308
|
+
targets: input.targets,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
applyDeleteOutcome(info, deleteOutcome);
|
|
312
|
+
return deleteOutcome;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ===== 阶段二:压缩 =====
|
|
316
|
+
|
|
317
|
+
/** 压缩预检:权限 + 2 倍空间 + 输出目录;不通过直接抛契约错误码(任务随后 failed) */
|
|
318
|
+
async function stageCompressPrecheck(record, deps) {
|
|
319
|
+
const { config, manager } = deps;
|
|
320
|
+
const rootAbs = rootAbsOf(record, deps);
|
|
321
|
+
const info = record.info;
|
|
322
|
+
|
|
323
|
+
manager.setPhase(info.id, 'precheck');
|
|
324
|
+
const outputDirAbs = record.internal.outputDirAbs || path.dirname(info.sources[0].absPath);
|
|
325
|
+
// 黑名单层:与创建任务时的预检用同一套解析(enabled=false 表示整条黑名单关掉)
|
|
326
|
+
const blacklistLayers = await resolveBlacklistLayers(deps, info);
|
|
327
|
+
const preflight = await compressPreflight(
|
|
328
|
+
{ config, rootAbs, blacklistLayers },
|
|
329
|
+
{ sources: info.sources, format: info.format, mode: info.mode, outputDir: outputDirAbs },
|
|
330
|
+
);
|
|
331
|
+
if (!preflight.canProceed) {
|
|
332
|
+
throw apiError(preflight.blockedReason.code, preflight.blockedReason.message);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
info.result = {
|
|
336
|
+
preflight: {
|
|
337
|
+
totalBytes: preflight.totalBytes,
|
|
338
|
+
requiredBytes: preflight.requiredBytes,
|
|
339
|
+
freeBytes: preflight.freeBytes,
|
|
340
|
+
preflightFactor: preflight.preflightFactor,
|
|
341
|
+
},
|
|
342
|
+
packages: [],
|
|
343
|
+
// 对外视图(只有用户路径)。**运行期以打包器回报为准**:预检只是预测,
|
|
344
|
+
// 若这里也预填一次、打包时再 push 一次,就会得到翻倍重复的 skipped(实测踩过)。
|
|
345
|
+
skipped: [],
|
|
346
|
+
};
|
|
347
|
+
// 内部视图:absPath 只放 internal(删除集合要用它,API 响应不出现)
|
|
348
|
+
record.internal.skippedEntries = [];
|
|
349
|
+
return outputDirAbs;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** 解析黑名单层(deps.blacklist 缺失时按"不启用"处理:不自造隐式保护) */
|
|
353
|
+
async function resolveBlacklistLayers(deps, info) {
|
|
354
|
+
if (!deps.blacklist) return [];
|
|
355
|
+
const options = (info.options && info.options.blacklist) || {};
|
|
356
|
+
return deps.blacklist.layersFor({
|
|
357
|
+
enabled: options.enabled !== false,
|
|
358
|
+
taskPatterns: Array.isArray(options.extraPatterns) ? options.extraPatterns : [],
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** 命中判定函数(供格式包在收集条目时调用):返回 {pattern, layer} 或 null */
|
|
363
|
+
function shouldSkipMatcher(layers) {
|
|
364
|
+
if (!layers || layers.length === 0) return null;
|
|
365
|
+
return (entry) => matchEntry(layers, entry);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 按模式分组:single=每个源一个包;bundle=全部源一个包 */
|
|
369
|
+
function groupSourcesForCompress(info) {
|
|
370
|
+
return info.mode === 'single' ? info.sources.map((source) => [source]) : [info.sources];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 通用能力装配循环(docs/19 图 1 ★):编排层不认识任何具体能力——
|
|
375
|
+
* 「格式声明 ∩ 能力 appliesTo」的交集决定哪些能力参与本次压缩;每个能力用
|
|
376
|
+
* prepareCreate() 把自己的参数翻译成格式插件的 create 选项(archiverFormat / extra)。
|
|
377
|
+
*
|
|
378
|
+
* 用户启用入口也走通用形态:options.capabilities = { <capId>: {…参数} }(由 HTTP 层
|
|
379
|
+
* 校验后写入)。交集为空 → 返回空数组,装配循环零动作。
|
|
380
|
+
*
|
|
381
|
+
* @returns {Array<{id, cap, params}>} 本次压缩实际启用的能力(verify 阶段复用)
|
|
382
|
+
*/
|
|
383
|
+
function assembleCapabilities(record, deps, plugin) {
|
|
384
|
+
const { capRegistry } = deps;
|
|
385
|
+
const info = record.info;
|
|
386
|
+
const declared = Array.isArray(plugin.capabilities) ? plugin.capabilities : [];
|
|
387
|
+
const userEnabled = info.options && typeof info.options.capabilities === 'object' ? info.options.capabilities : {};
|
|
388
|
+
const assembled = [];
|
|
389
|
+
for (const capId of declared) {
|
|
390
|
+
const cap = capRegistry && capRegistry.get(capId);
|
|
391
|
+
if (!cap) continue; // 声明了但能力未加载:按「无此能力」处理(前端不展示,用户不会传参)
|
|
392
|
+
if (!cap.appliesTo.includes(plugin.id)) continue;
|
|
393
|
+
const rawParams = userEnabled[capId];
|
|
394
|
+
if (rawParams === undefined) continue; // 用户未启用该能力
|
|
395
|
+
const verdict = cap.validateParams(rawParams, { formatId: plugin.id });
|
|
396
|
+
if (!verdict.ok) {
|
|
397
|
+
throw apiError(verdict.error.code, verdict.error.message, verdict.error.details);
|
|
398
|
+
}
|
|
399
|
+
assembled.push({ id: capId, cap, params: verdict.value });
|
|
400
|
+
}
|
|
401
|
+
return assembled;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** 单个包的名字:单源用源名,多源用「首个源名 等 N 项」 */
|
|
405
|
+
function packageBaseName(group) {
|
|
406
|
+
return group.length === 1 ? group[0].name : `${group[0].name} 等 ${group.length} 项`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** 压缩一个包:写 .part → 原子 rename → 记账到 result.packages */
|
|
410
|
+
async function compressOnePackage(record, deps, plugin, group, groupIndex, outputDirAbs) {
|
|
411
|
+
const { manager, logger } = deps;
|
|
412
|
+
const rootAbs = rootAbsOf(record, deps);
|
|
413
|
+
const info = record.info;
|
|
414
|
+
|
|
415
|
+
const outputAbsPath = await uniqueTargetPath(outputDirAbs, packageBaseName(group), plugin.ext);
|
|
416
|
+
const tempPath = `${outputAbsPath}.part`;
|
|
417
|
+
|
|
418
|
+
// 上次中断遗留的 .part 必须让路:改名保留(INV-10 留证据)而不是沿用或直接删,
|
|
419
|
+
// 否则本次 create() 会因 TARGET_EXISTS 失败 —— "保留 .part" 与 "可重试" 才能同时成立。
|
|
420
|
+
if (await pathExists(tempPath)) {
|
|
421
|
+
const archived = `${tempPath}.interrupted-${Date.now()}`;
|
|
422
|
+
await fsp.rename(tempPath, archived);
|
|
423
|
+
logger.warn('发现遗留 .part,已改名保留以便重试', { taskId: info.id, archived: path.basename(archived) });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 进度分母:本组待压字节数/文件数(来自预检统计)
|
|
427
|
+
const groupBytes = group.reduce((sum, source) => sum + (Number(source.sizeBytes) || 0), 0);
|
|
428
|
+
const groupFiles = group.reduce((sum, source) => sum + (Number(source.fileCount) || 0), 0);
|
|
429
|
+
manager.updateProgress(info.id, { totalBytes: groupBytes, totalFiles: groupFiles, processedBytes: 0, processedFiles: 0 });
|
|
430
|
+
|
|
431
|
+
const layers = await resolveBlacklistLayers(deps, info);
|
|
432
|
+
// 通用能力装配循环(docs/19 图 1):prepareCreate 的产物并进 create 选项——
|
|
433
|
+
// archiverFormat 决定 archiver 工厂形态,archiverExtra 是能力专属参数(透传给引擎)。
|
|
434
|
+
const assembledCapabilities = assembleCapabilities(record, deps, plugin);
|
|
435
|
+
const capabilityOptions = {};
|
|
436
|
+
for (const { cap, params } of assembledCapabilities) {
|
|
437
|
+
const translated = cap.prepareCreate({ params, formatId: info.format });
|
|
438
|
+
capabilityOptions.archiverFormat = translated.archiverFormat;
|
|
439
|
+
capabilityOptions.archiverExtra = { ...(capabilityOptions.archiverExtra || {}), ...translated.extra };
|
|
440
|
+
}
|
|
441
|
+
// 能力参数存 internal(含明文,仅进程内存;PW-1:store.js 落盘时剔除,verify 阶段复用)
|
|
442
|
+
record.internal.capabilityParams = assembledCapabilities.map((item) => ({ id: item.id, params: item.params }));
|
|
443
|
+
const created = await plugin.create({
|
|
444
|
+
taskId: info.id,
|
|
445
|
+
format: info.format,
|
|
446
|
+
mode: info.mode,
|
|
447
|
+
sources: group,
|
|
448
|
+
outputPath: outputAbsPath,
|
|
449
|
+
tempPath,
|
|
450
|
+
options: {
|
|
451
|
+
level: info.options.level,
|
|
452
|
+
preserveRoot: true,
|
|
453
|
+
signal: manager.getAbortSignal(info.id),
|
|
454
|
+
// 格式包在"收集条目"阶段就跳过命中项(省 CPU/磁盘,且它们不会进 entryPaths → 不会被删)
|
|
455
|
+
shouldSkip: shouldSkipMatcher(layers),
|
|
456
|
+
...capabilityOptions,
|
|
457
|
+
},
|
|
458
|
+
onProgress: (payload) => manager.updateProgress(info.id, payload),
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// 汇总本次包的黑名单跳过项:对外 userPath、对内 absPath(删除集合要用)
|
|
462
|
+
for (const skippedEntry of created.skipped || []) {
|
|
463
|
+
record.internal.skippedEntries.push(skippedEntry);
|
|
464
|
+
info.result.skipped.push({
|
|
465
|
+
path: toUserPath(rootAbs, skippedEntry.absPath),
|
|
466
|
+
pattern: skippedEntry.pattern,
|
|
467
|
+
layer: skippedEntry.layer,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 原子落盘:.part → 正式产物(崩溃/取消不会留下"看起来正常"的半成品)
|
|
472
|
+
await fsp.rename(tempPath, outputAbsPath);
|
|
473
|
+
|
|
474
|
+
info.result.packages.push({
|
|
475
|
+
sourceIndex: groupIndex,
|
|
476
|
+
sourcePath: group[0].path,
|
|
477
|
+
outputPath: toUserPath(rootAbs, outputAbsPath),
|
|
478
|
+
bytesOut: Number(created.bytesOut) || 0,
|
|
479
|
+
entries: created.entries,
|
|
480
|
+
durationMs: created.durationMs,
|
|
481
|
+
verified: false,
|
|
482
|
+
verifyStrategy: null,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
bytesIn: Number(created.bytesIn) || 0,
|
|
487
|
+
bytesOut: Number(created.bytesOut) || 0,
|
|
488
|
+
// 契约 v2 §3.4 条款 10:格式包回报的实际进包条目(源绝对路径)——INV-02 删除集合依据
|
|
489
|
+
entryPaths: Array.isArray(created.entryPaths) ? created.entryPaths : [],
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** 打包阶段:逐个包执行,返回累计字节数 */
|
|
494
|
+
async function stageCompressPackages(record, deps, plugin, outputDirAbs) {
|
|
495
|
+
const { manager } = deps;
|
|
496
|
+
manager.setPhase(record.info.id, 'compressing');
|
|
497
|
+
|
|
498
|
+
let bytesIn = 0;
|
|
499
|
+
let bytesOut = 0;
|
|
500
|
+
const createdPackages = [];
|
|
501
|
+
for (const [groupIndex, group] of groupSourcesForCompress(record.info).entries()) {
|
|
502
|
+
const created = await compressOnePackage(record, deps, plugin, group, groupIndex, outputDirAbs);
|
|
503
|
+
createdPackages.push(created);
|
|
504
|
+
bytesIn += created.bytesIn;
|
|
505
|
+
bytesOut += created.bytesOut;
|
|
506
|
+
}
|
|
507
|
+
return { bytesIn, bytesOut, createdPackages };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** 校验阶段(INV-01):逐个产物做完整性校验,任一不通过即抛 VERIFY_FAILED */
|
|
511
|
+
async function stageVerifyPackages(record, deps, plugin) {
|
|
512
|
+
const { manager } = deps;
|
|
513
|
+
const rootAbs = rootAbsOf(record, deps);
|
|
514
|
+
manager.setPhase(record.info.id, 'verifying');
|
|
515
|
+
|
|
516
|
+
// 加密包不走通用 CRC 校验:那一步没有密码,读条目会直接报 MISSING_PASSWORD,
|
|
517
|
+
// 结果必然是「校验未通过」(假阴性)。改由能力自测覆盖 —— cap.verify() 的定义
|
|
518
|
+
// 就是「用同一密码试解一次」,强度等价(真要校验 CRC 也必须先解密)。
|
|
519
|
+
// 注意:这里只要求「有启用的能力」,能力自测失败会在 stageCapabilityVerify 抛 VERIFY_FAILED。
|
|
520
|
+
const capabilityCovered = Array.isArray(record.internal.capabilityParams)
|
|
521
|
+
&& record.internal.capabilityParams.length > 0;
|
|
522
|
+
|
|
523
|
+
for (const pkg of record.info.result.packages) {
|
|
524
|
+
if (capabilityCovered) {
|
|
525
|
+
pkg.verified = true;
|
|
526
|
+
pkg.verifyStrategy = 'capability';
|
|
527
|
+
pkg.checkedEntries = 0;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const verification = await verifyArchive(plugin, archiveAbsOf(rootAbs, pkg.outputPath));
|
|
531
|
+
assertVerified(verification);
|
|
532
|
+
pkg.verified = true;
|
|
533
|
+
pkg.verifyStrategy = verification.strategy;
|
|
534
|
+
pkg.checkedEntries = verification.checked;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 能力自测阶段(PW-2):装配循环里启用的能力逐个 verify——
|
|
540
|
+
* 任一失败抛 VERIFY_FAILED(任务 failed,INV-01 拦住删除,原文件一个不删)。
|
|
541
|
+
* 通用形态:编排层不知道能力具体自测什么(加密包是"同密码试解",未来能力自测各自定义)。
|
|
542
|
+
*/
|
|
543
|
+
async function stageCapabilityVerify(record, deps) {
|
|
544
|
+
const capabilityParams = record.internal.capabilityParams || [];
|
|
545
|
+
if (capabilityParams.length === 0) return;
|
|
546
|
+
const { manager } = deps;
|
|
547
|
+
const rootAbs = rootAbsOf(record, deps);
|
|
548
|
+
const info = record.info;
|
|
549
|
+
|
|
550
|
+
for (const { id, params } of capabilityParams) {
|
|
551
|
+
const cap = deps.capRegistry.get(id);
|
|
552
|
+
if (!cap || typeof cap.verify !== 'function') continue;
|
|
553
|
+
for (const pkg of info.result.packages) {
|
|
554
|
+
const verdict = await cap.verify({
|
|
555
|
+
archivePath: archiveAbsOf(rootAbs, pkg.outputPath),
|
|
556
|
+
params,
|
|
557
|
+
formatApi: null,
|
|
558
|
+
});
|
|
559
|
+
if (!verdict.ok) {
|
|
560
|
+
throw apiError(ERROR_CODES.VERIFY_FAILED, `能力 ${id} 自测未通过:${verdict.error || '未知原因'}`, {
|
|
561
|
+
capability: id,
|
|
562
|
+
package: pkg.outputPath,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// 脱敏后写入 options(PW-1:明文只在内存 internal;对外只有布尔+方法名)
|
|
568
|
+
for (const { id, params } of capabilityParams) {
|
|
569
|
+
const cap = deps.capRegistry.get(id);
|
|
570
|
+
if (!cap) continue;
|
|
571
|
+
const redacted = cap.redact(params);
|
|
572
|
+
info.options.capabilities = info.options.capabilities || {};
|
|
573
|
+
info.options.capabilities[id] = { ...redacted };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** 汇总压缩结果(字节数、节省量、比率、完成时间) */
|
|
578
|
+
function summarizeCompressResult(record, bytesIn, bytesOut) {
|
|
579
|
+
const info = record.info;
|
|
580
|
+
info.result.bytesIn = bytesIn;
|
|
581
|
+
info.result.bytesOut = bytesOut;
|
|
582
|
+
info.result.savedBytes = Math.max(0, bytesIn - bytesOut);
|
|
583
|
+
info.result.ratio = bytesIn > 0 ? Number((bytesOut / bytesIn).toFixed(4)) : 0;
|
|
584
|
+
info.result.completedAt = Date.now();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** 压缩任务主体:只做阶段编排 */
|
|
588
|
+
async function runCompressTask(record, deps) {
|
|
589
|
+
const { registry, manager } = deps;
|
|
590
|
+
const rootAbs = rootAbsOf(record, deps);
|
|
591
|
+
const info = record.info;
|
|
592
|
+
const plugin = registry.require(info.format);
|
|
593
|
+
|
|
594
|
+
if (!(await registry.isAvailable(info.format))) {
|
|
595
|
+
throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, `格式 ${info.format} 当前不可用`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const outputDirAbs = await stageCompressPrecheck(record, deps);
|
|
599
|
+
const { bytesIn, bytesOut, createdPackages } = await stageCompressPackages(record, deps, plugin, outputDirAbs);
|
|
600
|
+
await stageVerifyPackages(record, deps, plugin);
|
|
601
|
+
await stageCapabilityVerify(record, deps); // PW-2:能力自测(加密包同密码试解),失败 → failed 且不删原文件
|
|
602
|
+
summarizeCompressResult(record, bytesIn, bytesOut);
|
|
603
|
+
|
|
604
|
+
// 删除原文件:仅当 keepOriginal=false(管理器已据此建好 delete 状态)。
|
|
605
|
+
// 删除集合 = 实际进包条目(created.entryPaths,第一手事实)− skipped(INV-02),
|
|
606
|
+
// 绝不用 info.sources 全量(未进包条目删了就是静默数据丢失)。
|
|
607
|
+
if (info.delete) {
|
|
608
|
+
const allEntryPaths = createdPackages.flatMap((pkg) => pkg.entryPaths || []);
|
|
609
|
+
// skipped 的 absPath 只在 internal(对外 result.skipped 只有 userPath,契约 §2.2 红线)
|
|
610
|
+
const skippedAbsPaths = (record.internal.skippedEntries || []).map((item) => item.absPath).filter(Boolean);
|
|
611
|
+
// 进包时快照(INV-03 ⑤ 的基准):collectEntries 已对每个文件 stat 过(size 记录在条目上),
|
|
612
|
+
// 这里原样带出作为删除前复核的比对基准,不做第二次 walk。
|
|
613
|
+
const entryStats = new Map(createdPackages.flatMap((pkg) => pkg.entryStats || []));
|
|
614
|
+
const { targets } = buildDeleteTargetsFromEntries(allEntryPaths, skippedAbsPaths, entryStats, (absPath) => toUserPath(rootAbs, absPath));
|
|
615
|
+
await stageDelete(record, {
|
|
616
|
+
targets,
|
|
617
|
+
reason: 'compress_success_verified',
|
|
618
|
+
confirmedAtCreate: info.options.deleteConfirmed === true,
|
|
619
|
+
// §9.5 复核依据:调用方声明的进包清单与 skipped(absPath 坐标系,与 targets 同源)
|
|
620
|
+
entryAbsPaths: allEntryPaths,
|
|
621
|
+
skippedAbsPaths,
|
|
622
|
+
entryStats,
|
|
623
|
+
}, deps);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return manager.finalize(info.id, 'done');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ===== 阶段三:解压 =====
|
|
630
|
+
|
|
631
|
+
/** 解压预检:归档可读可解析 + 目标目录可写 + 空间(解压后 × factor) */
|
|
632
|
+
async function stageExtractPrecheck(record, deps, plugin) {
|
|
633
|
+
const { config, manager } = deps;
|
|
634
|
+
const info = record.info;
|
|
635
|
+
|
|
636
|
+
manager.setPhase(info.id, 'precheck');
|
|
637
|
+
const preflight = await extractPreflight(
|
|
638
|
+
{ config, plugin },
|
|
639
|
+
{
|
|
640
|
+
archivePath: info.archivePath,
|
|
641
|
+
archiveAbsPath: record.internal.archiveAbsPath,
|
|
642
|
+
targetDir: info.targetDir,
|
|
643
|
+
targetAbsDir: record.internal.targetAbsDir,
|
|
644
|
+
},
|
|
645
|
+
);
|
|
646
|
+
if (!preflight.canProceed) {
|
|
647
|
+
throw apiError(preflight.blockedReason.code, preflight.blockedReason.message);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
info.result = {
|
|
651
|
+
preflight: {
|
|
652
|
+
totalBytes: preflight.totalBytes,
|
|
653
|
+
requiredBytes: preflight.requiredBytes,
|
|
654
|
+
freeBytes: preflight.freeBytes,
|
|
655
|
+
preflightFactor: preflight.preflightFactor,
|
|
656
|
+
entries: preflight.entries,
|
|
657
|
+
},
|
|
658
|
+
packages: [],
|
|
659
|
+
skipped: [],
|
|
660
|
+
};
|
|
661
|
+
return preflight;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** 写出阶段:调格式包 extract(zip-slip 预扫描在格式包内、写盘之前完成) */
|
|
665
|
+
async function stageExtractWrite(record, deps, plugin) {
|
|
666
|
+
const { manager } = deps;
|
|
667
|
+
const info = record.info;
|
|
668
|
+
|
|
669
|
+
manager.setPhase(info.id, 'extracting');
|
|
670
|
+
return plugin.extract({
|
|
671
|
+
taskId: info.id,
|
|
672
|
+
format: info.format,
|
|
673
|
+
archivePath: record.internal.archiveAbsPath,
|
|
674
|
+
targetDir: record.internal.targetAbsDir,
|
|
675
|
+
options: {
|
|
676
|
+
overwrite: false,
|
|
677
|
+
signal: manager.getAbortSignal(info.id),
|
|
678
|
+
// P4:加密包解压密码——来自任务创建时的 options.password(明文仅内存存活,
|
|
679
|
+
// 落盘脱敏走 manager 的 PW-1 通道;这里透传给格式插件的加密分支)
|
|
680
|
+
...(info.options && info.options.password ? { password: info.options.password } : {}),
|
|
681
|
+
},
|
|
682
|
+
onProgress: (payload) => manager.updateProgress(info.id, payload),
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** 校验阶段(解压侧"先校验"):归档自身必须完整,否则绝不进入删除 */
|
|
687
|
+
async function stageVerifyArchive(record, deps, plugin) {
|
|
688
|
+
const { manager } = deps;
|
|
689
|
+
manager.setPhase(record.info.id, 'verifying');
|
|
690
|
+
// 加密包的无密码 CRC 校验必然失败(ZIP 侧 unzipper 读不了 AES,RAR 侧 unrar 没密码也不干),
|
|
691
|
+
// 所以改走「同一密码完整试解一次」——试解成功本身就含全量 CRC,是独立证据。
|
|
692
|
+
//
|
|
693
|
+
// 用哪个工具试解由**格式插件自己决定**:这里优先问插件要 `verifyWithPassword`。
|
|
694
|
+
// 为什么不能一律用 7z:p7zip 16.02 读不了加密 RAR5(报 Can not open the file as archive),
|
|
695
|
+
// 而 ZIP 的 AES 又只有 7z 读得了(unzipper 只认 ZipCrypto)。工具能力是按格式分的,
|
|
696
|
+
// 编排层不该知道这些细节,否则每加一个加密格式都要回来改这里。
|
|
697
|
+
//
|
|
698
|
+
// 未加密包保持原路径(行为与改动前一致,回归由既有测试锁)。
|
|
699
|
+
if (typeof record.info.options?.password === 'string' && record.info.options.password.length > 0) {
|
|
700
|
+
const archivePath = record.internal.archiveAbsPath;
|
|
701
|
+
const password = record.info.options.password;
|
|
702
|
+
|
|
703
|
+
if (typeof plugin.verifyWithPassword === 'function') {
|
|
704
|
+
const verdict = await plugin.verifyWithPassword(archivePath, password);
|
|
705
|
+
if (!verdict || verdict.ok !== true) {
|
|
706
|
+
// 透传插件给的错误码:密码错就是 BAD_PASSWORD、包坏就是 ARCHIVE_CORRUPTED,
|
|
707
|
+
// 不要一律压成 VERIFY_FAILED,否则前端只能提示"校验未通过",用户无从判断该改什么。
|
|
708
|
+
const code = (verdict && verdict.code) || ERROR_CODES.VERIFY_FAILED;
|
|
709
|
+
throw apiError(code, (verdict && verdict.error) || '加密压缩包校验未通过');
|
|
710
|
+
}
|
|
711
|
+
return { ok: true, strategy: verdict.strategy || 'plugin-test', checked: verdict.checked ?? null, failures: [] };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// 插件未提供专用校验(如 ZIP)→ 回落到 7z 同密码试解
|
|
715
|
+
const { verifyWith7z } = await import('../../plugins/capabilities/password/index.js');
|
|
716
|
+
const verdict = await verifyWith7z(archivePath, password);
|
|
717
|
+
if (!verdict.ok) {
|
|
718
|
+
throw apiError(ERROR_CODES.VERIFY_FAILED, `加密压缩包校验未通过:${verdict.error || '未知原因'}`);
|
|
719
|
+
}
|
|
720
|
+
return { ok: true, strategy: '7z-crc', checked: null, failures: [] };
|
|
721
|
+
}
|
|
722
|
+
const verification = await verifyArchive(plugin, record.internal.archiveAbsPath);
|
|
723
|
+
assertVerified(verification);
|
|
724
|
+
return verification;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** 解压任务主体:只做阶段编排 */
|
|
728
|
+
async function runExtractTask(record, deps) {
|
|
729
|
+
const { registry, manager } = deps;
|
|
730
|
+
const info = record.info;
|
|
731
|
+
const plugin = registry.require(info.format);
|
|
732
|
+
|
|
733
|
+
if (!(await registry.isAvailable(info.format))) {
|
|
734
|
+
throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, `格式 ${info.format} 当前不可用`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const preflight = await stageExtractPrecheck(record, deps, plugin);
|
|
738
|
+
manager.updateProgress(info.id, {
|
|
739
|
+
totalBytes: preflight.totalBytes,
|
|
740
|
+
totalFiles: preflight.entries,
|
|
741
|
+
processedBytes: 0,
|
|
742
|
+
processedFiles: 0,
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
const extracted = await stageExtractWrite(record, deps, plugin);
|
|
746
|
+
const verification = await stageVerifyArchive(record, deps, plugin);
|
|
747
|
+
|
|
748
|
+
info.result.bytesOut = extracted.bytesOut;
|
|
749
|
+
info.result.entries = extracted.entries;
|
|
750
|
+
info.result.durationMs = extracted.durationMs;
|
|
751
|
+
info.result.verified = true;
|
|
752
|
+
info.result.verifyStrategy = verification.strategy;
|
|
753
|
+
info.result.completedAt = Date.now();
|
|
754
|
+
|
|
755
|
+
// 删除压缩包:仅当 deleteArchiveAfterExtract=true
|
|
756
|
+
if (info.delete) {
|
|
757
|
+
let archiveSize = 0;
|
|
758
|
+
try {
|
|
759
|
+
archiveSize = (await fsp.stat(record.internal.archiveAbsPath)).size;
|
|
760
|
+
} catch {
|
|
761
|
+
archiveSize = 0;
|
|
762
|
+
}
|
|
763
|
+
await stageDelete(record, {
|
|
764
|
+
targets: [{ userPath: info.archivePath, absPath: record.internal.archiveAbsPath, kind: 'file', size: archiveSize }],
|
|
765
|
+
reason: 'extract_success_verified',
|
|
766
|
+
confirmedAtCreate: info.options.deleteConfirmed === true,
|
|
767
|
+
}, deps);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return manager.finalize(info.id, 'done');
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* 创建编排器:把 deps 绑定到阶段函数上,对外只暴露两个任务入口。
|
|
775
|
+
* @param {{config: object, logger: object, registry: object, manager: object, deleteService: object, rootAbs: string}} deps
|
|
776
|
+
*/
|
|
777
|
+
export function createOrchestrator(deps) {
|
|
778
|
+
return {
|
|
779
|
+
runCompress: (record) => runCompressTask(record, deps),
|
|
780
|
+
runExtract: (record) => runExtractTask(record, deps),
|
|
781
|
+
defaultExtractDir,
|
|
782
|
+
};
|
|
783
|
+
}
|