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,206 @@
|
|
|
1
|
+
// tar 系列的**写入侧**:收集条目(与 zip 同规则)+ 流式写出(可选压缩层)
|
|
2
|
+
//
|
|
3
|
+
// 一个实现服务 tar / tar.gz / tar.zst —— 三种格式的归档层完全相同,差别只在压缩层。
|
|
4
|
+
// 边界(INV-15):只读源文件、只写 ctx.tempPath,不删源、不预检、不排队。
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import fsp from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { PassThrough } from 'node:stream';
|
|
10
|
+
|
|
11
|
+
import * as tar from 'tar';
|
|
12
|
+
|
|
13
|
+
import { ERROR_CODES, apiError } from '../../../../lib/errors.js';
|
|
14
|
+
import { createThrottledReporter } from '../../../../lib/throttle.js';
|
|
15
|
+
import { pipelineWith } from '../streams.js';
|
|
16
|
+
import { createCompressStream } from './compression.js';
|
|
17
|
+
|
|
18
|
+
/** 求多个目录的公共祖先(用于一次 Pack 覆盖所有源;名字取相对它的路径) */
|
|
19
|
+
export function commonAncestor(dirs) {
|
|
20
|
+
if (dirs.length === 0) return path.sep;
|
|
21
|
+
let parts = path.resolve(dirs[0]).split(path.sep);
|
|
22
|
+
for (const dir of dirs.slice(1)) {
|
|
23
|
+
const other = path.resolve(dir).split(path.sep);
|
|
24
|
+
let index = 0;
|
|
25
|
+
while (index < parts.length && index < other.length && parts[index] === other[index]) index += 1;
|
|
26
|
+
parts = parts.slice(0, index);
|
|
27
|
+
}
|
|
28
|
+
const base = parts.join(path.sep);
|
|
29
|
+
return base === '' ? path.sep : base;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 收集条目(与 zip 同规则:目录保留顶层名、不跟随符号链接),并算出一个公共祖先作为 `cwd`。
|
|
34
|
+
*
|
|
35
|
+
* 关于包内名字:node-tar v7 的流式写入是 `Pack({cwd}) + add(相对路径)`,没有 v6 那种"任意指定包内名"的入口。
|
|
36
|
+
* 所以做法是 `cwd = 各源父目录的公共祖先`、名字取相对路径 —— 常规用法(同一目录下的多个源)下名字与
|
|
37
|
+
* zip 侧完全一致(`源名/子路径/文件`);若调用方把**不同父目录**的源混在一个任务里,
|
|
38
|
+
* 包内名字会带上必要的中间目录(避免冲突),这是本格式已知的命名边界。
|
|
39
|
+
*/
|
|
40
|
+
export async function collectEntries(ctx) {
|
|
41
|
+
const fileEntries = [];
|
|
42
|
+
const sourceParents = [];
|
|
43
|
+
const sourceStats = new Map();
|
|
44
|
+
const skipped = [];
|
|
45
|
+
// 黑名单命中项(由编排层传入):收集阶段就不放进包
|
|
46
|
+
const shouldSkip = typeof (ctx.options && ctx.options.shouldSkip) === 'function' ? ctx.options.shouldSkip : null;
|
|
47
|
+
let skippedSymlinks = 0;
|
|
48
|
+
|
|
49
|
+
for (const source of ctx.sources) {
|
|
50
|
+
const sourceName = source.name || path.basename(source.absPath);
|
|
51
|
+
sourceParents.push(path.dirname(source.absPath));
|
|
52
|
+
const collected = await collectSource(source, sourceName, skippedSymlinks, shouldSkip, skipped);
|
|
53
|
+
skippedSymlinks = collected.skippedSymlinks;
|
|
54
|
+
fileEntries.push(...collected.entries);
|
|
55
|
+
sourceStats.set(sourceName, { count: collected.entries.length, bytes: collected.bytes });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const base = commonAncestor(sourceParents);
|
|
59
|
+
for (const fileEntry of fileEntries) {
|
|
60
|
+
fileEntry.name = path.relative(base, fileEntry.absPath).split(path.sep).join('/');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
items: fileEntries,
|
|
65
|
+
base,
|
|
66
|
+
sourceStats,
|
|
67
|
+
totalBytes: fileEntries.reduce((sum, fileEntry) => sum + fileEntry.size, 0),
|
|
68
|
+
totalFiles: fileEntries.length,
|
|
69
|
+
skippedSymlinks,
|
|
70
|
+
skipped,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 单个源 → 条目列表(文件直接用;目录递归;符号链接跳过并计数) */
|
|
75
|
+
async function collectSource(source, sourceName, skipped, shouldSkip, skippedEntries) {
|
|
76
|
+
let skippedSymlinks = skipped;
|
|
77
|
+
const entries = [];
|
|
78
|
+
|
|
79
|
+
/** 命中就记账并返回 true(调用方据此 continue) */
|
|
80
|
+
const recordSkip = (absPath, name, segments) => {
|
|
81
|
+
if (!shouldSkip) return false;
|
|
82
|
+
const hit = shouldSkip({ name, segments });
|
|
83
|
+
if (!hit) return false;
|
|
84
|
+
skippedEntries.push({ absPath, pattern: hit.pattern, layer: hit.layer });
|
|
85
|
+
return true;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (source.kind === 'file') {
|
|
89
|
+
const fileStat = await fsp.lstat(source.absPath).catch(() => null);
|
|
90
|
+
if (!fileStat || !fileStat.isFile()) {
|
|
91
|
+
throw apiError(ERROR_CODES.PATH_NOT_FOUND, '源文件在扫描后已不可用', { source: sourceName });
|
|
92
|
+
}
|
|
93
|
+
if (recordSkip(source.absPath, path.basename(source.absPath), [sourceName])) {
|
|
94
|
+
return { entries, bytes: 0, skippedSymlinks };
|
|
95
|
+
}
|
|
96
|
+
entries.push({ absPath: source.absPath, size: fileStat.size, sourceName });
|
|
97
|
+
return { entries, bytes: fileStat.size, skippedSymlinks };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let bytes = 0;
|
|
101
|
+
const walk = async (dirAbs, prefix) => {
|
|
102
|
+
const dirents = await fsp.readdir(dirAbs, { withFileTypes: true });
|
|
103
|
+
for (const dirent of dirents) {
|
|
104
|
+
const childAbs = path.join(dirAbs, dirent.name);
|
|
105
|
+
if (dirent.isSymbolicLink()) {
|
|
106
|
+
skippedSymlinks += 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (dirent.isDirectory()) {
|
|
110
|
+
await walk(childAbs, `${prefix}/${dirent.name}`);
|
|
111
|
+
} else if (dirent.isFile()) {
|
|
112
|
+
// 目录名规则(如 @eaDir)也参与匹配:segments 用完整相对路径
|
|
113
|
+
if (recordSkip(childAbs, dirent.name, `${prefix}/${dirent.name}`.split('/'))) continue;
|
|
114
|
+
const fileStat = await fsp.lstat(childAbs);
|
|
115
|
+
entries.push({ absPath: childAbs, size: fileStat.size, sourceName });
|
|
116
|
+
bytes += fileStat.size;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
await walk(source.absPath, sourceName);
|
|
121
|
+
return { entries, bytes, skippedSymlinks };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 建好写入链路:Pack(纯 JS 打包)→ 压缩层(可省)→ 输出流 */
|
|
125
|
+
function openTarPipeline(ctx, collected, stats) {
|
|
126
|
+
const output = fs.createWriteStream(ctx.tempPath);
|
|
127
|
+
const pack = new tar.Pack({ cwd: collected.base, portable: true, gzip: false });
|
|
128
|
+
const compressor = createCompressStream(stats.compression, stats.level);
|
|
129
|
+
const done = pipelineWith([pack, compressor || new PassThrough(), output], stats.signal);
|
|
130
|
+
return { pack, done };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 把条目逐个加进 Pack(每个条目检查一次取消信号:大目录要能及时中断) */
|
|
134
|
+
function addEntriesToPack(pack, fileItems, signal) {
|
|
135
|
+
for (const fileEntry of fileItems) {
|
|
136
|
+
if (signal && signal.aborted) throw apiError(ERROR_CODES.ABORTED, '任务已取消');
|
|
137
|
+
pack.add(fileEntry.name);
|
|
138
|
+
}
|
|
139
|
+
pack.end();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 把执行器抛出的错误翻译成契约错误(取消 → ABORTED,其余原样上抛) */
|
|
143
|
+
function mapTarFailure(error, signal) {
|
|
144
|
+
if (error && (error.name === 'AbortError' || (signal && signal.aborted))) {
|
|
145
|
+
return apiError(ERROR_CODES.ABORTED, '任务已取消');
|
|
146
|
+
}
|
|
147
|
+
return error;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 写入归档:单个 `Pack({ cwd })` 一次性写全部条目 → 套压缩层 → 输出到 tempPath。
|
|
152
|
+
*
|
|
153
|
+
* 进度粒度说明(诚实交代):node-tar v7 的流式写入不暴露"逐条写完"的回调,所以这里**按源**上报
|
|
154
|
+
* (`currentFile` 为源名,`processedBytes/processedFiles` 是精确累计值)。zip 侧是逐条事件,粒度更细。
|
|
155
|
+
*/
|
|
156
|
+
export async function writeTarStream(ctx, collected, stats) {
|
|
157
|
+
const report = createThrottledReporter(ctx.onProgress || (() => {}), { intervalMs: 100 });
|
|
158
|
+
const startedAt = Date.now();
|
|
159
|
+
const { items, totalBytes, totalFiles } = collected;
|
|
160
|
+
const { pack, done } = openTarPipeline(ctx, collected, stats);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
addEntriesToPack(pack, items, stats.signal);
|
|
164
|
+
reportBySource(collected.sourceStats, { report, totalBytes, totalFiles });
|
|
165
|
+
await done;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
await discardTemp(ctx.tempPath);
|
|
168
|
+
throw mapTarFailure(error, stats.signal);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 产物大小:stat 失败(理论上不会)就当 0,不用 existsSync 先探一次(同步 I/O)
|
|
172
|
+
const bytesOut = await fsp.stat(ctx.tempPath).then((stat) => stat.size).catch(() => 0);
|
|
173
|
+
return {
|
|
174
|
+
outputPath: ctx.tempPath,
|
|
175
|
+
entries: totalFiles,
|
|
176
|
+
bytesIn: totalBytes,
|
|
177
|
+
bytesOut,
|
|
178
|
+
durationMs: Date.now() - startedAt,
|
|
179
|
+
skippedSymlinks: collected.skippedSymlinks,
|
|
180
|
+
// 契约 v2 §3.4 条款 10:实际进包条目的**源绝对路径**(进程内使用,不进 API)。
|
|
181
|
+
// INV-02 删除集合的第一手依据;禁止用包内路径(name = 相对 cwd 的包内名)反推源路径。
|
|
182
|
+
entryPaths: items.map((item) => item.absPath),
|
|
183
|
+
// INV-03 ⑤ 进包时快照(size 基准):collectEntries 已 stat 过,原样带出给删除前复核
|
|
184
|
+
entryStats: items.map((item) => [item.absPath, { sizeBytes: item.size }]),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 按源汇总进度(每个源记一次,避免"进度瞬间 100%"的假象) */
|
|
189
|
+
function reportBySource(sourceStats, { report, totalBytes, totalFiles }) {
|
|
190
|
+
let processedBytes = 0;
|
|
191
|
+
let processedFiles = 0;
|
|
192
|
+
for (const [sourceName, sourceStat] of sourceStats) {
|
|
193
|
+
processedBytes += sourceStat.bytes;
|
|
194
|
+
processedFiles += sourceStat.count;
|
|
195
|
+
report({ processedBytes, totalBytes, processedFiles, totalFiles, currentFile: sourceName });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 失败/取消时清掉半成品(rm --force:不存在的路径不算错;清理失败不掩盖原始错误) */
|
|
200
|
+
async function discardTemp(tempPath) {
|
|
201
|
+
try {
|
|
202
|
+
await fsp.rm(tempPath, { force: true });
|
|
203
|
+
} catch {
|
|
204
|
+
/* 见上 */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// tar 系列格式包工厂(tar / tar.gz / tar.zst 共用一个实现,只是压缩层不同)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要工厂:三种格式的**归档层完全相同**(node-tar 写/读 tar),差别只在外面套不套 gzip/zstd。
|
|
4
|
+
// 各写一份 = 三份 zip-slip 判定与三份进度逻辑,迟早漂移。这里压缩层是策略参数:
|
|
5
|
+
// none → 直通(.tar)
|
|
6
|
+
// gzip → node:zlib gzip(.tar.gz)
|
|
7
|
+
// zstd → **node:zlib 内置 zstd**(Node ≥ 22.15);不可用时回退系统 `zstd` 命令(ZC_ZSTD_BIN 可覆盖)
|
|
8
|
+
//
|
|
9
|
+
// 安全:条目判定与写出复用 `_shared/entrySafety.js`(与 zip **同一套** zip-slip 防护)。
|
|
10
|
+
// 边界(INV-15):格式包不删源文件、不做预检、不排队、不落盘任务状态。
|
|
11
|
+
//
|
|
12
|
+
// 文件分工(本文件只放契约面与装配,实现拆到 _shared/tar/ 下,单文件不超过约定行数):
|
|
13
|
+
// _shared/tar/compression.js —— 压缩层策略与 zstd 回退链
|
|
14
|
+
// _shared/tar/writer.js —— 收集条目 + 流式写出
|
|
15
|
+
// _shared/tar/reader.js —— 流式解析 + 错误映射
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import fsp from 'node:fs/promises';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { pipeline } from 'node:stream/promises';
|
|
21
|
+
import zlib from 'node:zlib';
|
|
22
|
+
|
|
23
|
+
import * as tar from 'tar';
|
|
24
|
+
|
|
25
|
+
import { ERROR_CODES, apiError } from '../../../lib/errors.js';
|
|
26
|
+
import { assertCreatable } from './createGuards.js';
|
|
27
|
+
import { planTargets } from './entrySafety.js';
|
|
28
|
+
import { builtinZstdUsable, findZstdBinary } from './tar/compression.js';
|
|
29
|
+
import { collectEntries, writeTarStream } from './tar/writer.js';
|
|
30
|
+
import { mapArchiveError, parseArchive, readEntryBuffer, toApiEntry } from './tar/reader.js';
|
|
31
|
+
|
|
32
|
+
/** 压得动的文本/结构化数据 vs 已压缩格式:用于 estimate 的粗估(M4 只求"够算预检分母") */
|
|
33
|
+
const ALREADY_COMPRESSED = /\.(jpe?g|png|gif|webp|heic|mp4|mkv|mov|mp3|flac|zip|7z|rar|gz|bz2|xz|zst|docx|xlsx|pptx)$/i;
|
|
34
|
+
|
|
35
|
+
// ===== 契约面实现 =====
|
|
36
|
+
// 全部写成「模块级函数 + spec 参数」:一是审计要求单函数 ≤50 行代码,二是每个动作可独立单测。
|
|
37
|
+
// 工厂(文件末尾)只做装配,不含逻辑。
|
|
38
|
+
|
|
39
|
+
async function pluginAvailable(spec) {
|
|
40
|
+
// node-tar 可用性就看真实用到的入口:Pack(写)/ list(读)
|
|
41
|
+
if (typeof tar.Pack !== 'function' || typeof tar.list !== 'function') return false;
|
|
42
|
+
if (spec.compression === 'gzip') return typeof zlib.createGzip === 'function';
|
|
43
|
+
if (spec.compression === 'zstd') return builtinZstdUsable() || Boolean(findZstdBinary());
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function pluginUnavailableReason(spec) {
|
|
48
|
+
if (await pluginAvailable(spec)) return null;
|
|
49
|
+
if (spec.compression === 'zstd') {
|
|
50
|
+
return `zstd 不可用:Node 内置 zstd 需要 Node ≥ 22.15(当前 ${process.version}),且系统未找到 zstd 命令(可用 ZC_ZSTD_BIN 指定路径)`;
|
|
51
|
+
}
|
|
52
|
+
return `${spec.id} 依赖(node-tar / node:zlib)未正确安装`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 粗估:tar 不压(≈1.0 + 头部开销);gzip/zstd 按"是否已压缩格式"给经验比值 */
|
|
56
|
+
async function pluginEstimate(spec, inputBytes, options = {}) {
|
|
57
|
+
const bytes = Number.isFinite(inputBytes) && inputBytes > 0 ? inputBytes : 0;
|
|
58
|
+
const names = Array.isArray(options.sample && options.sample.names) ? options.sample.names : [];
|
|
59
|
+
const everyEntryAlreadyCompressed = names.length > 0 && names.every((entryName) => ALREADY_COMPRESSED.test(entryName));
|
|
60
|
+
const ratioTable = ESTIMATED_RATIO[spec.compression];
|
|
61
|
+
const ratioOf = typeof ratioTable === 'number'
|
|
62
|
+
? ratioTable
|
|
63
|
+
: (everyEntryAlreadyCompressed ? ratioTable.alreadyCompressed : ratioTable.mixed);
|
|
64
|
+
const headerOverhead = TAR_BLOCK_BYTES * Math.max(1, names.length || 1);
|
|
65
|
+
return {
|
|
66
|
+
estimatedBytes: Math.ceil(bytes * ratioOf) + (spec.compression === 'none' ? headerOverhead : 0),
|
|
67
|
+
ratio: ratioOf,
|
|
68
|
+
basis: 'heuristic',
|
|
69
|
+
confidence: 'low',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function pluginCreate(spec, ctx) {
|
|
74
|
+
await assertCreatable(ctx, () => pluginAvailable(spec), spec.id);
|
|
75
|
+
const collected = await collectEntries(ctx);
|
|
76
|
+
const level = Number.isInteger(ctx.options && ctx.options.level) ? ctx.options.level : (spec.defaultLevel || 6);
|
|
77
|
+
const written = await writeTarStream(ctx, collected, {
|
|
78
|
+
compression: spec.compression,
|
|
79
|
+
level,
|
|
80
|
+
signal: ctx.options && ctx.options.signal,
|
|
81
|
+
});
|
|
82
|
+
// 契约 v2:把黑名单跳过项回报给编排层(它负责换成 userPath 并排除出删除集合)
|
|
83
|
+
return { ...written, skipped: collected.skipped || [] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function pluginList(spec, archivePath) {
|
|
87
|
+
const entries = [];
|
|
88
|
+
try {
|
|
89
|
+
await parseArchive(archivePath, spec.compression, (entry) => {
|
|
90
|
+
entries.push(toApiEntry(entry));
|
|
91
|
+
entry.resume(); // 目录/不需要内容的场景要显式放流,否则解析器会卡住
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw mapArchiveError(error, archivePath);
|
|
95
|
+
}
|
|
96
|
+
return { entries, count: entries.length, truncated: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 完整性校验:tar 没有 CRC,走契约允许的 `read-all`(逐条目读通 + 大小一致) */
|
|
100
|
+
async function pluginTest(spec, archivePath) {
|
|
101
|
+
const failures = [];
|
|
102
|
+
let checked = 0;
|
|
103
|
+
try {
|
|
104
|
+
await parseArchive(archivePath, spec.compression, async (entry) => {
|
|
105
|
+
const info = toApiEntry(entry);
|
|
106
|
+
if (info.type === 'dir') {
|
|
107
|
+
entry.resume();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const buffer = await readEntryBuffer(entry);
|
|
111
|
+
if (buffer.length !== info.size) {
|
|
112
|
+
failures.push({ entry: info.name, reason: `解压后大小不符(期望 ${info.size},实际 ${buffer.length})` });
|
|
113
|
+
}
|
|
114
|
+
checked += 1;
|
|
115
|
+
});
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// 解析/解压失败(截断、损坏的 gzip/zstd 流)→ 归档损坏
|
|
118
|
+
throw mapArchiveError(error, archivePath);
|
|
119
|
+
}
|
|
120
|
+
return { ok: failures.length === 0, checked, failures, strategy: 'read-all' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 第一遍:只收集条目名与类型(**不落盘**),交给共用判定做 zip-slip + 覆盖检查 */
|
|
124
|
+
async function collectEntryMeta(spec, archivePath) {
|
|
125
|
+
const meta = [];
|
|
126
|
+
try {
|
|
127
|
+
await parseArchive(archivePath, spec.compression, (entry) => {
|
|
128
|
+
meta.push({
|
|
129
|
+
name: entry.path || entry.name || '',
|
|
130
|
+
isDir: entry.type === 'Directory',
|
|
131
|
+
size: Number(entry.size || 0),
|
|
132
|
+
link: entry.type === 'SymbolicLink' || entry.type === 'Link',
|
|
133
|
+
});
|
|
134
|
+
entry.resume();
|
|
135
|
+
});
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw mapArchiveError(error, archivePath);
|
|
138
|
+
}
|
|
139
|
+
return meta;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 预先建好所有目标目录:第二遍必须**同步**接管条目流,不能在里面 await mkdir */
|
|
143
|
+
async function prepareTargetDirs(plan) {
|
|
144
|
+
for (const plannedEntry of plan) {
|
|
145
|
+
if (plannedEntry.isDir) await fsp.mkdir(plannedEntry.targetPath, { recursive: true });
|
|
146
|
+
else await fsp.mkdir(path.dirname(plannedEntry.targetPath), { recursive: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function pluginExtract(spec, ctx) {
|
|
151
|
+
if (!(await pluginAvailable(spec))) {
|
|
152
|
+
throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, `${spec.id} 格式当前不可用`);
|
|
153
|
+
}
|
|
154
|
+
const startedAt = Date.now();
|
|
155
|
+
const targetDir = path.resolve(ctx.targetDir);
|
|
156
|
+
const overwrite = Boolean(ctx.options && ctx.options.overwrite);
|
|
157
|
+
|
|
158
|
+
const meta = await collectEntryMeta(spec, ctx.archivePath);
|
|
159
|
+
const planned = await planTargets(meta.map((entryMeta, index) => ({ ...entryMeta, index })), targetDir, overwrite);
|
|
160
|
+
await prepareTargetDirs(planned.plan);
|
|
161
|
+
|
|
162
|
+
// 第二遍:按顺序把每个条目的数据写到判定好的目标路径
|
|
163
|
+
let cursor = 0;
|
|
164
|
+
await parseArchive(ctx.archivePath, spec.compression, (entry) => {
|
|
165
|
+
const plannedEntry = planned.plan[cursor];
|
|
166
|
+
cursor += 1;
|
|
167
|
+
if (!plannedEntry || plannedEntry.isDir) {
|
|
168
|
+
entry.resume();
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
// 同步 attach:pipeline 立即开始消费该条目
|
|
172
|
+
return pipeline(entry, fs.createWriteStream(plannedEntry.targetPath));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
targetDir,
|
|
177
|
+
entries: cursor,
|
|
178
|
+
bytesOut: planned.totalBytes,
|
|
179
|
+
durationMs: Date.now() - startedAt,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* tar 系列格式包工厂:只做契约面装配,不含任何逻辑。
|
|
185
|
+
* @param {{id: string, name: string, ext: string, extAliases?: string[], mime: string,
|
|
186
|
+
* compression: 'none'|'gzip'|'zstd', levelRange?: [number, number], defaultLevel?: number}} spec
|
|
187
|
+
*/
|
|
188
|
+
export function createTarPlugin(spec) {
|
|
189
|
+
return {
|
|
190
|
+
id: spec.id,
|
|
191
|
+
name: spec.name,
|
|
192
|
+
ext: spec.ext,
|
|
193
|
+
extAliases: spec.extAliases || [],
|
|
194
|
+
mime: spec.mime,
|
|
195
|
+
levelRange: spec.levelRange,
|
|
196
|
+
defaultLevel: spec.defaultLevel,
|
|
197
|
+
// 能力声明(docs/19 §4.1):TAR/gzip/zstd 规范无加密 → 空数组(前端据此置灰密码开关)
|
|
198
|
+
capabilities: spec.capabilities || [],
|
|
199
|
+
available: () => pluginAvailable(spec),
|
|
200
|
+
unavailableReason: () => pluginUnavailableReason(spec),
|
|
201
|
+
estimate: (inputBytes, options) => pluginEstimate(spec, inputBytes, options),
|
|
202
|
+
create: (ctx) => pluginCreate(spec, ctx),
|
|
203
|
+
extract: (ctx) => pluginExtract(spec, ctx),
|
|
204
|
+
list: (archivePath) => pluginList(spec, archivePath),
|
|
205
|
+
test: (archivePath) => pluginTest(spec, archivePath),
|
|
206
|
+
};
|
|
207
|
+
}
|