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,376 @@
|
|
|
1
|
+
// 压缩/解压预检(M2 最小版:权限 + 2 倍空间 + 目标可写)
|
|
2
|
+
//
|
|
3
|
+
// 契约:INV-07「剩余空间 ≥ 待压文件 × preflightFactor(默认 2)」、INV-08「绝不覆盖」。
|
|
4
|
+
// M6 才做黑名单与滚动预检的完整逻辑;M2 只做「够不够空间 + 权限够不够」这两件最关键的事,
|
|
5
|
+
// 并且**先拦后做**:预检失败不建任务(docs/06 §三)。
|
|
6
|
+
//
|
|
7
|
+
// 结构:每个预检拆成"收集事实 → 判定拦截原因 → 组装响应"三步,避免一个函数里混着 I/O、
|
|
8
|
+
// 判定与响应拼装(那种函数既难测也改不动)。
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import fsp from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { ERROR_CODES, apiError } from '../../lib/errors.js';
|
|
15
|
+
import { matchEntry } from './blacklist.js';
|
|
16
|
+
import { collectFilesUnder } from './sourceWalk.js';
|
|
17
|
+
import { accumulateHardlink, createHardlinkAccumulator, discardHardlink, finalizeHardlinks } from './hardlinkScan.js';
|
|
18
|
+
import { formatPreservesHardlinks, shouldHintHardlink } from '../../../shared/hardlink-hint.js';
|
|
19
|
+
import { pathExists } from '../../lib/fs-probe.js';
|
|
20
|
+
|
|
21
|
+
/** 压缩估算的粗比值(M2 只用于给前端一个量级;真实压缩比由格式包 estimate 决定) */
|
|
22
|
+
const ESTIMATED_OUTPUT_RATIO = 0.6;
|
|
23
|
+
|
|
24
|
+
/** 读取目录所在文件系统的可用字节(statfs 在 Node 18.15+ / 22 可用) */
|
|
25
|
+
async function freeBytesOf(dir) {
|
|
26
|
+
try {
|
|
27
|
+
const stats = await fsp.statfs(dir);
|
|
28
|
+
return Number(stats.bavail) * Number(stats.bsize);
|
|
29
|
+
} catch {
|
|
30
|
+
return null; // 拿不到就不拦(宁可放过也不误杀),但会在响应里体现 freeBytes=null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function isWritable(dir) {
|
|
35
|
+
try {
|
|
36
|
+
await fsp.access(dir, fs.constants.W_OK);
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 单个源 → 预检条目(不存在 / 不可读都在这里定下来,后续判定只看条目) */
|
|
44
|
+
async function sourceItemOf(source, hardlinkAccumulator) {
|
|
45
|
+
let fileStat;
|
|
46
|
+
try {
|
|
47
|
+
fileStat = await fsp.lstat(source.absPath);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return {
|
|
50
|
+
path: source.path,
|
|
51
|
+
name: source.name,
|
|
52
|
+
kind: source.kind,
|
|
53
|
+
size: 0,
|
|
54
|
+
exists: false,
|
|
55
|
+
readable: false,
|
|
56
|
+
error: error.code === 'ENOENT' ? ERROR_CODES.PATH_NOT_FOUND : ERROR_CODES.PERMISSION_DENIED,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let readable = true;
|
|
61
|
+
try {
|
|
62
|
+
await fsp.access(source.absPath, fs.constants.R_OK);
|
|
63
|
+
} catch {
|
|
64
|
+
readable = false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isDir = fileStat.isDirectory();
|
|
68
|
+
let size = isDir ? 0 : fileStat.size;
|
|
69
|
+
let fileCount = isDir ? 0 : 1;
|
|
70
|
+
if (isDir) {
|
|
71
|
+
const nested = await directoryStats(source.absPath, hardlinkAccumulator);
|
|
72
|
+
size = nested.bytes;
|
|
73
|
+
fileCount = nested.files;
|
|
74
|
+
} else {
|
|
75
|
+
accumulateHardlink(hardlinkAccumulator, {
|
|
76
|
+
absPath: source.absPath,
|
|
77
|
+
dev: fileStat.dev,
|
|
78
|
+
ino: fileStat.ino,
|
|
79
|
+
size: fileStat.size,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
path: source.path,
|
|
85
|
+
name: source.name,
|
|
86
|
+
kind: isDir ? 'dir' : 'file',
|
|
87
|
+
size,
|
|
88
|
+
fileCount,
|
|
89
|
+
exists: true,
|
|
90
|
+
readable,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function collectSourceItems(sources, hardlinkAccumulator) {
|
|
95
|
+
const items = [];
|
|
96
|
+
for (const source of sources) items.push(await sourceItemOf(source, hardlinkAccumulator));
|
|
97
|
+
return items;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 给每个源标注"是否会被整体跳过",并汇总被跳过的文件清单。
|
|
102
|
+
*
|
|
103
|
+
* 为什么跳过项要从"待压字节"里扣掉:它们根本不会进包(不耗压缩 CPU、不占产物空间),
|
|
104
|
+
* 若仍按 totalBytes 做 2 倍空间判定,会出现"明明只剩几个文本文件,却因为目录里有 100GB 视频被拒"。
|
|
105
|
+
*
|
|
106
|
+
* @param {Array} items collectSourceItems 的结果
|
|
107
|
+
* @param {Array} sources 与 items 同序的源(要 absPath)
|
|
108
|
+
* @param {Array} layers resolveLayers 的结果(空数组 = 黑名单关闭)
|
|
109
|
+
* @returns {Promise<{items: Array, skipped: Array<{path: string, pattern: string, layer: string}>, skippedBytes: number}>}
|
|
110
|
+
*/
|
|
111
|
+
async function annotateWithBlacklist(items, sources, layers, hardlinkAccumulator) {
|
|
112
|
+
const skipped = [];
|
|
113
|
+
let skippedBytes = 0;
|
|
114
|
+
const annotated = [];
|
|
115
|
+
|
|
116
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
117
|
+
const one = await annotateOneSource(items[index], sources[index], layers, hardlinkAccumulator);
|
|
118
|
+
annotated.push(one.item);
|
|
119
|
+
skipped.push(...one.skipped);
|
|
120
|
+
skippedBytes += one.skippedBytes;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { items: annotated, skipped, skippedBytes };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 单个源的标注:返回标注后的条目、命中清单与命中字节数。
|
|
128
|
+
* 抽出来是为了让 annotateWithBlacklist 只做"遍历 + 汇总"(圈复杂度压在阈值内)。
|
|
129
|
+
*/
|
|
130
|
+
async function annotateOneSource(sourceItem, sourceRef, layers, hardlinkAccumulator) {
|
|
131
|
+
const annotatedItem = { ...sourceItem, willSkip: false, skipReason: null, keptFiles: Number(sourceItem.fileCount) || 0 };
|
|
132
|
+
if (!sourceItem.exists || !sourceRef || layers.length === 0) return { item: annotatedItem, skipped: [], skippedBytes: 0 };
|
|
133
|
+
|
|
134
|
+
const files = await collectFilesUnder(sourceRef.absPath);
|
|
135
|
+
const skipped = [];
|
|
136
|
+
let keptFiles = 0;
|
|
137
|
+
let skippedBytes = 0;
|
|
138
|
+
for (const file of files) {
|
|
139
|
+
const hit = matchEntry(layers, { name: file.name, segments: file.segments });
|
|
140
|
+
if (!hit) {
|
|
141
|
+
keptFiles += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
skipped.push({ path: joinUserPath(sourceItem.path, file.relative), pattern: hit.pattern, layer: hit.layer });
|
|
145
|
+
skippedBytes += Number(file.size) || 0;
|
|
146
|
+
// 被跳过的文件不会进包 → 从硬链接统计里扣掉,避免"两个成员都被跳过却提示会存两份"
|
|
147
|
+
discardHardlink(hardlinkAccumulator, file);
|
|
148
|
+
}
|
|
149
|
+
annotatedItem.keptFiles = keptFiles;
|
|
150
|
+
// 整个源都被跳过(且确实有文件):前端据此提示"该目录没有可压缩文件",single 模式据此不建空包
|
|
151
|
+
if (files.length > 0 && keptFiles === 0) {
|
|
152
|
+
annotatedItem.willSkip = true;
|
|
153
|
+
annotatedItem.skipReason = 'blacklisted';
|
|
154
|
+
}
|
|
155
|
+
return { item: annotatedItem, skipped, skippedBytes };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 用户路径拼接(POSIX 风格;relative 为空表示源本身就是文件) */
|
|
159
|
+
function joinUserPath(base, relative) {
|
|
160
|
+
if (!relative) return base;
|
|
161
|
+
const prefix = base === '/' ? '' : String(base).replace(/\/$/, '');
|
|
162
|
+
return `${prefix}/${relative}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 压缩预检的拦截判定(顺序即优先级:不存在 > 不可读 > 目录不存在 > 不可写 > 空间不足) */
|
|
166
|
+
function compressBlockedReason({ items, outputExists, outputWritable, freeBytes, requiredBytes, factor, totalBytes, keptBytes }) {
|
|
167
|
+
const missing = items.filter((item) => !item.exists);
|
|
168
|
+
const unreadable = items.filter((item) => item.exists && !item.readable);
|
|
169
|
+
|
|
170
|
+
if (missing.length > 0) return { code: ERROR_CODES.PATH_NOT_FOUND, message: '有源条目不存在' };
|
|
171
|
+
if (unreadable.length > 0) return { code: ERROR_CODES.PERMISSION_DENIED, message: '有源条目不可读' };
|
|
172
|
+
// 输出目录必须已存在:我们不会替用户"静默创建目录"(那是副作用);不存在 → PATH_NOT_FOUND(诚实错误码)
|
|
173
|
+
if (!outputExists) return { code: ERROR_CODES.PATH_NOT_FOUND, message: '输出目录不存在' };
|
|
174
|
+
if (!outputWritable) return { code: ERROR_CODES.PERMISSION_DENIED, message: '输出目录不可写' };
|
|
175
|
+
// 全部命中黑名单:没有可压的东西(契约错误码 ALL_SOURCES_SKIPPED,422)
|
|
176
|
+
if (totalBytes > 0 && keptBytes === 0) {
|
|
177
|
+
return { code: ERROR_CODES.ALL_SOURCES_SKIPPED, message: '没有可压缩的文件:全部命中黑名单(可在设置里调整或关闭黑名单)' };
|
|
178
|
+
}
|
|
179
|
+
if (freeBytes !== null && freeBytes < requiredBytes) {
|
|
180
|
+
return {
|
|
181
|
+
code: ERROR_CODES.DISK_FULL,
|
|
182
|
+
message: `空间不足:需要 ${requiredBytes} 字节(待压 × ${factor}),剩余 ${freeBytes} 字节`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 组装硬链接响应块。
|
|
190
|
+
*
|
|
191
|
+
* 只做"提示所需的信息",**不参与任何空间判定**(INV-07 保持原样):
|
|
192
|
+
* - `duplicateBytes` 与格式无关(同一份数据被多存多少),便于前端在 tar 下做正向说明
|
|
193
|
+
* - `estimatedBytes` 只体现"存几份",**不含压缩比**(压缩比因内容而异,不能承诺最终大小)
|
|
194
|
+
*/
|
|
195
|
+
function buildHardlinksBlock(format, keptBytes, hardlinkAccumulator) {
|
|
196
|
+
const stats = finalizeHardlinks(hardlinkAccumulator);
|
|
197
|
+
const preservedByFormat = formatPreservesHardlinks(format);
|
|
198
|
+
return {
|
|
199
|
+
groups: stats.groups,
|
|
200
|
+
files: stats.files,
|
|
201
|
+
// 逻辑大小 = 真正会进包的文件字节(已扣掉黑名单跳过项)
|
|
202
|
+
logicalBytes: keptBytes,
|
|
203
|
+
duplicateBytes: stats.duplicatedBytes,
|
|
204
|
+
preservedByFormat,
|
|
205
|
+
estimatedBytes: preservedByFormat ? Math.max(0, keptBytes - stats.duplicatedBytes) : keptBytes,
|
|
206
|
+
hint: !preservedByFormat && shouldHintHardlink({ logicalBytes: keptBytes, duplicatedBytes: stats.duplicatedBytes }),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 压缩预检。
|
|
212
|
+
* @param {{config: object, rootAbs: string}} deps
|
|
213
|
+
* @param {{sources: Array<{path: string, absPath: string, name: string, kind: string}>, format: string, mode: string, outputDir: string}} input
|
|
214
|
+
*/
|
|
215
|
+
export async function compressPreflight(deps, input) {
|
|
216
|
+
const factor = deps.config.compress.preflightFactor;
|
|
217
|
+
// 硬链接统计与"目录字节数"共用同一次遍历(dev/ino 来自已有的 lstat,不额外多 syscall)
|
|
218
|
+
const hardlinkAccumulator = createHardlinkAccumulator();
|
|
219
|
+
const rawItems = await collectSourceItems(input.sources, hardlinkAccumulator);
|
|
220
|
+
// 黑名单层由调用方解析(路由里一次性解析,预检与打包共用同一份判定语义)
|
|
221
|
+
const layers = Array.isArray(deps.blacklistLayers) ? deps.blacklistLayers : [];
|
|
222
|
+
const { items, skipped, skippedBytes } = await annotateWithBlacklist(rawItems, input.sources, layers, hardlinkAccumulator);
|
|
223
|
+
|
|
224
|
+
const totalBytes = items.reduce((sum, item) => sum + item.size, 0);
|
|
225
|
+
// 「待压字节」= 全部源 − 黑名单跳过:跳过项不进包,不该占用 2 倍空间额度
|
|
226
|
+
const keptBytes = Math.max(0, totalBytes - skippedBytes);
|
|
227
|
+
|
|
228
|
+
const outputDir = input.outputDir;
|
|
229
|
+
const outputExists = await pathExists(outputDir);
|
|
230
|
+
const outputWritable = outputExists ? await isWritable(outputDir) : false;
|
|
231
|
+
const freeBytes = await freeBytesOf(outputExists ? outputDir : path.dirname(outputDir));
|
|
232
|
+
const requiredBytes = keptBytes * factor;
|
|
233
|
+
|
|
234
|
+
const blockedReason = compressBlockedReason({
|
|
235
|
+
items,
|
|
236
|
+
outputExists,
|
|
237
|
+
outputWritable,
|
|
238
|
+
freeBytes,
|
|
239
|
+
requiredBytes,
|
|
240
|
+
factor,
|
|
241
|
+
totalBytes,
|
|
242
|
+
keptBytes,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const hardlinks = buildHardlinksBlock(input.format, keptBytes, hardlinkAccumulator);
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
canProceed: blockedReason === null,
|
|
249
|
+
blockedReason,
|
|
250
|
+
mode: input.mode,
|
|
251
|
+
format: input.format,
|
|
252
|
+
totalBytes,
|
|
253
|
+
estimatedOutputBytes: Math.ceil(keptBytes * ESTIMATED_OUTPUT_RATIO),
|
|
254
|
+
requiredBytes,
|
|
255
|
+
freeBytes,
|
|
256
|
+
preflightFactor: factor,
|
|
257
|
+
items,
|
|
258
|
+
// 硬链接提示(只读信息;hint=false 时前端不应展示任何提示条)
|
|
259
|
+
hardlinks,
|
|
260
|
+
// 契约 §compress/preflight:skipped[] = { path, pattern, layer }(对外只有用户路径)
|
|
261
|
+
skipped,
|
|
262
|
+
packages: [],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 递归统计目录(字节数 + 文件数;异步、不跟随符号链接)—— 进度分母用 */
|
|
267
|
+
async function directoryStats(dirAbs, hardlinkAccumulator) {
|
|
268
|
+
let bytes = 0;
|
|
269
|
+
let files = 0;
|
|
270
|
+
let dirents;
|
|
271
|
+
try {
|
|
272
|
+
dirents = await fsp.readdir(dirAbs, { withFileTypes: true });
|
|
273
|
+
} catch {
|
|
274
|
+
return { bytes, files };
|
|
275
|
+
}
|
|
276
|
+
for (const dirent of dirents) {
|
|
277
|
+
const child = path.join(dirAbs, dirent.name);
|
|
278
|
+
if (dirent.isSymbolicLink()) continue;
|
|
279
|
+
if (dirent.isDirectory()) {
|
|
280
|
+
const nested = await directoryStats(child, hardlinkAccumulator);
|
|
281
|
+
bytes += nested.bytes;
|
|
282
|
+
files += nested.files;
|
|
283
|
+
} else if (dirent.isFile()) {
|
|
284
|
+
try {
|
|
285
|
+
const childStat = await fsp.lstat(child);
|
|
286
|
+
bytes += childStat.size;
|
|
287
|
+
files += 1;
|
|
288
|
+
accumulateHardlink(hardlinkAccumulator, {
|
|
289
|
+
absPath: child,
|
|
290
|
+
dev: childStat.dev,
|
|
291
|
+
ino: childStat.ino,
|
|
292
|
+
size: childStat.size,
|
|
293
|
+
});
|
|
294
|
+
} catch {
|
|
295
|
+
/* 竞态删除:忽略 */
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { bytes, files };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 解压预检:归档可读 + 目标目录可写 + 空间够(解压后总量 × factor)。
|
|
304
|
+
* @param {{config: object, plugin: object}} deps
|
|
305
|
+
*/
|
|
306
|
+
export async function extractPreflight(deps, input) {
|
|
307
|
+
assertExtractInput(input);
|
|
308
|
+
await assertArchiveReadable(input);
|
|
309
|
+
|
|
310
|
+
// 用 list() 校验归档可解析,并取解压后总量(不解析 → ARCHIVE_CORRUPTED)
|
|
311
|
+
const listing = await deps.plugin.list(input.archiveAbsPath);
|
|
312
|
+
const totalBytes = listing.entries
|
|
313
|
+
.filter((entry) => entry.type === 'file')
|
|
314
|
+
.reduce((sum, entry) => sum + entry.size, 0);
|
|
315
|
+
|
|
316
|
+
const factor = deps.config.compress.preflightFactor;
|
|
317
|
+
const targetExists = await pathExists(input.targetAbsDir);
|
|
318
|
+
// 目标目录**可能尚不存在**(解压时才创建):存在则检查它本身,否则检查其父目录。
|
|
319
|
+
// 注意必须用**绝对路径**(input.targetAbsDir)做文件系统检查——早期版本误用了 API 用户路径,
|
|
320
|
+
// 结果把 "/m2-test/out" 当成了文件系统根下的 /m2-test/out,误报 PERMISSION_DENIED(真实踩过)。
|
|
321
|
+
const checkDir = targetExists ? input.targetAbsDir : path.dirname(input.targetAbsDir);
|
|
322
|
+
const targetWritable = await isWritable(checkDir);
|
|
323
|
+
const freeBytes = await freeBytesOf(checkDir);
|
|
324
|
+
const requiredBytes = totalBytes * factor;
|
|
325
|
+
|
|
326
|
+
let blockedReason = null;
|
|
327
|
+
if (!targetWritable) blockedReason = { code: ERROR_CODES.PERMISSION_DENIED, message: '目标目录不可写' };
|
|
328
|
+
else if (freeBytes !== null && freeBytes < requiredBytes) {
|
|
329
|
+
blockedReason = {
|
|
330
|
+
code: ERROR_CODES.DISK_FULL,
|
|
331
|
+
message: `空间不足:需要 ${requiredBytes} 字节(解压后 × ${factor}),剩余 ${freeBytes} 字节`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
canProceed: blockedReason === null,
|
|
337
|
+
blockedReason,
|
|
338
|
+
archive: input.archivePath,
|
|
339
|
+
targetDir: input.targetDir,
|
|
340
|
+
targetExists,
|
|
341
|
+
entries: listing.entries.length,
|
|
342
|
+
totalBytes,
|
|
343
|
+
requiredBytes,
|
|
344
|
+
freeBytes,
|
|
345
|
+
preflightFactor: factor,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 入参完整性:内部绝对路径由编排器从 record.internal 传进来,缺了就说明调用方写错了
|
|
350
|
+
* (必须显式报错,不能靠"stat 失败"兜底,否则会伪装成权限问题) */
|
|
351
|
+
function assertExtractInput(input) {
|
|
352
|
+
if (!input || typeof input.archiveAbsPath !== 'string' || !input.archiveAbsPath) {
|
|
353
|
+
throw apiError(ERROR_CODES.INTERNAL_ERROR, '解压预检缺少归档绝对路径(内部错误)');
|
|
354
|
+
}
|
|
355
|
+
if (typeof input.targetAbsDir !== 'string' || !input.targetAbsDir) {
|
|
356
|
+
throw apiError(ERROR_CODES.INTERNAL_ERROR, '解压预检缺少目标目录绝对路径(内部错误)');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 归档必须存在且是普通文件;文件系统错误翻译成契约错误码 */
|
|
361
|
+
async function assertArchiveReadable(input) {
|
|
362
|
+
let archiveStat;
|
|
363
|
+
try {
|
|
364
|
+
archiveStat = await fsp.stat(input.archiveAbsPath);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (!error || !error.code) throw error; // 非文件系统错误:交给上层按 INTERNAL_ERROR 处理
|
|
367
|
+
throw apiError(
|
|
368
|
+
error.code === 'ENOENT' ? ERROR_CODES.PATH_NOT_FOUND : ERROR_CODES.PERMISSION_DENIED,
|
|
369
|
+
'压缩包不可读',
|
|
370
|
+
{ archive: path.basename(input.archivePath || input.archiveAbsPath) },
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
if (!archiveStat.isFile()) {
|
|
374
|
+
throw apiError(ERROR_CODES.NOT_A_DIRECTORY, '目标不是文件', { archive: path.basename(input.archivePath) });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// 格式注册表(M2:先只 zip;M4 扩展四格式时**只需加目录**,不改本文件)
|
|
2
|
+
//
|
|
3
|
+
// 契约(docs/M0/插件契约.md §五):
|
|
4
|
+
// - 自动发现:扫描 server/plugins/formats/<id>/index.js;
|
|
5
|
+
// - 双索引:按 id 与按**每个扩展名**(含 extAliases)建索引;
|
|
6
|
+
// - 匹配优先级:最长复合扩展名优先(.tar.gz 不能被 .gz 抢走);
|
|
7
|
+
// - 启动校验:id/ext 唯一 + 必需方法齐全;**不合规只禁用该包并告警,不影响其他包**;
|
|
8
|
+
// - 可用性结果缓存 ≤ 60s(用户现场装了 zstd 之类,最多一分钟 UI 就该亮起来)。
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
13
|
+
|
|
14
|
+
import { ERROR_CODES, apiError } from '../../lib/errors.js';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PLUGINS_DIR = path.resolve(
|
|
17
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
18
|
+
'../../plugins/formats',
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const AVAILABILITY_TTL_MS = 60 * 1000;
|
|
22
|
+
/** 被拒格式包的统一日志文案(三处拒绝原因共用,避免文案漂移) */
|
|
23
|
+
const REJECTED_LOG = 'format plugin rejected';
|
|
24
|
+
|
|
25
|
+
/** 契约要求的必需成员 */
|
|
26
|
+
const REQUIRED_MEMBERS = ['id', 'name', 'ext', 'mime'];
|
|
27
|
+
const REQUIRED_METHODS = ['available', 'estimate', 'create', 'extract', 'list', 'test'];
|
|
28
|
+
|
|
29
|
+
function validatePlugin(plugin, source) {
|
|
30
|
+
const problems = [];
|
|
31
|
+
for (const member of REQUIRED_MEMBERS) {
|
|
32
|
+
if (typeof plugin[member] !== 'string' || plugin[member].length === 0) problems.push(`缺少字符串元数据 ${member}`);
|
|
33
|
+
}
|
|
34
|
+
for (const method of REQUIRED_METHODS) {
|
|
35
|
+
if (typeof plugin[method] !== 'function') problems.push(`缺少方法 ${method}()`);
|
|
36
|
+
}
|
|
37
|
+
if (typeof plugin.ext === 'string' && !plugin.ext.startsWith('.')) problems.push('ext 必须以 . 开头');
|
|
38
|
+
if (problems.length > 0) {
|
|
39
|
+
return { ok: false, reason: `${source}: ${problems.join(';')}` };
|
|
40
|
+
}
|
|
41
|
+
return { ok: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createRegistry(options = {}) {
|
|
45
|
+
const logger = options.logger || { info() {}, warn() {} };
|
|
46
|
+
const pluginsDir = options.pluginsDir || DEFAULT_PLUGINS_DIR;
|
|
47
|
+
|
|
48
|
+
/** Map<id, plugin> */
|
|
49
|
+
const byId = new Map();
|
|
50
|
+
/** Map<ext(lowercase), plugin> */
|
|
51
|
+
const byExt = new Map();
|
|
52
|
+
/** 扩展名按长度降序(最长优先匹配) */
|
|
53
|
+
let extKeysDesc = [];
|
|
54
|
+
/** Map<id, {available:boolean, at:number}> —— 可用性探测结果(60 秒内复用,避免每次列格式都探测) */
|
|
55
|
+
const availabilityCache = new Map();
|
|
56
|
+
const loadFailures = [];
|
|
57
|
+
/** 插件目录上次扫描时的 mtime:用于「加/删一个目录即注册」的运行时重扫 */
|
|
58
|
+
let lastScanMtimeMs = 0;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 目录内容变化时重扫("新增一个格式 = 新增一个目录"的运行时支撑)。
|
|
62
|
+
* 目录 mtime 在增删子项时会变;没有变化就直接跳过,代价只有一次 stat。
|
|
63
|
+
* @returns {Promise<boolean>} 是否真的重扫了
|
|
64
|
+
*/
|
|
65
|
+
async function refreshIfChanged() {
|
|
66
|
+
try {
|
|
67
|
+
const stat = await fs.stat(pluginsDir);
|
|
68
|
+
if (stat.mtimeMs === lastScanMtimeMs) return false;
|
|
69
|
+
lastScanMtimeMs = stat.mtimeMs;
|
|
70
|
+
await discover();
|
|
71
|
+
return true;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
logger.warn('format plugins dir stat failed', { dir: pluginsDir, error: error.message });
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 单个目录 → 注册所需信息。
|
|
80
|
+
* 加载失败 / 校验不过 / id 或扩展名冲突都只记原因并返回 null(**不影响其他格式包**)。
|
|
81
|
+
* @returns {Promise<{plugin: object, exts: string[]}|null>}
|
|
82
|
+
*/
|
|
83
|
+
async function registerPluginFrom(dirent) {
|
|
84
|
+
const source = `plugins/formats/${dirent.name}`;
|
|
85
|
+
const entryFile = path.join(pluginsDir, dirent.name, 'index.js');
|
|
86
|
+
let plugin;
|
|
87
|
+
try {
|
|
88
|
+
const module = await import(pathToFileURL(entryFile).href);
|
|
89
|
+
plugin = module.default || module.plugin || module;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
loadFailures.push(`${source}: 加载失败 ${error.message}`);
|
|
92
|
+
logger.warn('format plugin import failed', { source, error: error.message });
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const valid = validatePlugin(plugin, source);
|
|
97
|
+
if (!valid.ok) {
|
|
98
|
+
loadFailures.push(valid.reason);
|
|
99
|
+
logger.warn(REJECTED_LOG, { source, reason: valid.reason });
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// id / ext 全局唯一:冲突时禁用后来者(不影响已注册的包)
|
|
104
|
+
if (byId.has(plugin.id)) {
|
|
105
|
+
loadFailures.push(`${source}: id 与已注册格式冲突(${plugin.id})`);
|
|
106
|
+
logger.warn(REJECTED_LOG, { source, reason: `id 冲突 ${plugin.id}` });
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const exts = [plugin.ext, ...(Array.isArray(plugin.extAliases) ? plugin.extAliases : [])].map((ext) => ext.toLowerCase());
|
|
110
|
+
const extConflict = exts.find((ext) => byExt.has(ext));
|
|
111
|
+
if (extConflict) {
|
|
112
|
+
loadFailures.push(`${source}: 扩展名与已注册格式冲突(${extConflict})`);
|
|
113
|
+
logger.warn(REJECTED_LOG, { source, reason: `扩展名冲突 ${extConflict}` });
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return { plugin, exts };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 扫描插件目录并注册 */
|
|
120
|
+
async function discover() {
|
|
121
|
+
byId.clear();
|
|
122
|
+
byExt.clear();
|
|
123
|
+
extKeysDesc = [];
|
|
124
|
+
loadFailures.length = 0;
|
|
125
|
+
|
|
126
|
+
let dirents;
|
|
127
|
+
try {
|
|
128
|
+
dirents = await fs.readdir(pluginsDir, { withFileTypes: true });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
logger.warn('format plugins dir unreadable', { dir: pluginsDir, error: error.message });
|
|
131
|
+
return { loaded: 0, failed: 0 };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const dirent of dirents) {
|
|
135
|
+
if (!dirent.isDirectory()) continue;
|
|
136
|
+
// `_shared` 之类的下划线目录是内部共享代码目录,不是格式包(约定:格式包 id 不用下划线打头)
|
|
137
|
+
if (dirent.name.startsWith('_')) continue;
|
|
138
|
+
const registered = await registerPluginFrom(dirent);
|
|
139
|
+
if (!registered) continue;
|
|
140
|
+
byId.set(registered.plugin.id, registered.plugin);
|
|
141
|
+
for (const ext of registered.exts) byExt.set(ext, registered.plugin);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
extKeysDesc = [...byExt.keys()].sort((a, b) => b.length - a.length);
|
|
145
|
+
// 记下本次扫描时的目录 mtime,供 refreshIfChanged 判断"有没有增删过"
|
|
146
|
+
try {
|
|
147
|
+
lastScanMtimeMs = (await fs.stat(pluginsDir)).mtimeMs;
|
|
148
|
+
} catch {
|
|
149
|
+
lastScanMtimeMs = 0;
|
|
150
|
+
}
|
|
151
|
+
const loadedFormatIds = [...byId.keys()];
|
|
152
|
+
logger.info('format registry loaded', { formats: loadedFormatIds, failures: loadFailures.length });
|
|
153
|
+
return { loaded: byId.size, failed: loadFailures.length };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 可用性(带 60s 缓存) */
|
|
157
|
+
async function isAvailable(id) {
|
|
158
|
+
const plugin = byId.get(id);
|
|
159
|
+
if (!plugin) return false;
|
|
160
|
+
const cached = availabilityCache.get(id);
|
|
161
|
+
if (cached && Date.now() - cached.at < AVAILABILITY_TTL_MS) return cached.available;
|
|
162
|
+
|
|
163
|
+
let probeResult = false;
|
|
164
|
+
try {
|
|
165
|
+
probeResult = Boolean(await plugin.available());
|
|
166
|
+
} catch (error) {
|
|
167
|
+
logger.warn('format availability probe failed', { id, error: error.message });
|
|
168
|
+
probeResult = false;
|
|
169
|
+
}
|
|
170
|
+
availabilityCache.set(id, { available: probeResult, at: Date.now() });
|
|
171
|
+
return probeResult;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 供 API /formats 使用 */
|
|
175
|
+
async function list() {
|
|
176
|
+
// 每次列格式前先看看插件目录有没有增删(放一个目录进去就能出现,不需要改注册代码、也不需要重启)
|
|
177
|
+
await refreshIfChanged();
|
|
178
|
+
const formatItems = [];
|
|
179
|
+
for (const plugin of byId.values()) {
|
|
180
|
+
const available = await isAvailable(plugin.id);
|
|
181
|
+
let unavailableReason = null;
|
|
182
|
+
if (!available && typeof plugin.unavailableReason === 'function') {
|
|
183
|
+
try {
|
|
184
|
+
unavailableReason = await plugin.unavailableReason();
|
|
185
|
+
} catch {
|
|
186
|
+
unavailableReason = null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
formatItems.push({
|
|
190
|
+
id: plugin.id,
|
|
191
|
+
name: plugin.name,
|
|
192
|
+
ext: plugin.ext,
|
|
193
|
+
extAliases: plugin.extAliases || [],
|
|
194
|
+
mime: plugin.mime,
|
|
195
|
+
available,
|
|
196
|
+
unavailableReason,
|
|
197
|
+
levelRange: plugin.levelRange || null,
|
|
198
|
+
defaultLevel: plugin.defaultLevel ?? null,
|
|
199
|
+
// 只读格式(如 RAR):可解压但不能压缩。前端压缩抽屉要靠这个字段把它过滤掉,
|
|
200
|
+
// 否则它会出现在压缩格式列表里、甚至被默认选中,用户点「开始压缩」才失败。
|
|
201
|
+
extractOnly: plugin.extractOnly === true,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return formatItems;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function get(id) {
|
|
208
|
+
return byId.get(id) || null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 取插件;取不到抛契约错误码(调用方不用自己拼错) */
|
|
212
|
+
function require(id) {
|
|
213
|
+
const plugin = byId.get(id);
|
|
214
|
+
if (!plugin) {
|
|
215
|
+
throw apiError(ERROR_CODES.UNSUPPORTED_FORMAT, `不支持的压缩格式:${id}`, { supported: [...byId.keys()] });
|
|
216
|
+
}
|
|
217
|
+
return plugin;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 按扩展名取插件(最长优先);找不到返回 null */
|
|
221
|
+
function byExtension(ext) {
|
|
222
|
+
if (typeof ext !== 'string' || ext === '') return null;
|
|
223
|
+
const lower = ext.toLowerCase();
|
|
224
|
+
// extKeysDesc 已按长度降序排好:find 取到的就是最长匹配(与"逐个 endsWith 提前返回"等价)
|
|
225
|
+
const matched = extKeysDesc.find((candidate) => lower === candidate || lower.endsWith(candidate));
|
|
226
|
+
return matched ? byExt.get(matched) : null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
discover,
|
|
231
|
+
refreshIfChanged,
|
|
232
|
+
list,
|
|
233
|
+
get,
|
|
234
|
+
require,
|
|
235
|
+
byExtension,
|
|
236
|
+
isAvailable,
|
|
237
|
+
ids: () => [...byId.keys()],
|
|
238
|
+
failures: () => [...loadFailures],
|
|
239
|
+
};
|
|
240
|
+
}
|