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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -2
  3. package/bin/zipcalm.js +191 -0
  4. package/package.json +67 -4
  5. package/server/app.js +309 -0
  6. package/server/boot.js +145 -0
  7. package/server/config/defaults.js +153 -0
  8. package/server/config/index.js +140 -0
  9. package/server/config/schema.js +244 -0
  10. package/server/core/auth/password.js +89 -0
  11. package/server/core/auth/store.js +261 -0
  12. package/server/core/compress/blacklist.js +236 -0
  13. package/server/core/compress/capRegistry.js +171 -0
  14. package/server/core/compress/deleter.js +285 -0
  15. package/server/core/compress/hardlinkScan.js +67 -0
  16. package/server/core/compress/orchestrator.js +783 -0
  17. package/server/core/compress/preflight.js +376 -0
  18. package/server/core/compress/registry.js +240 -0
  19. package/server/core/compress/sourceWalk.js +84 -0
  20. package/server/core/compress/verify.js +47 -0
  21. package/server/core/engineRunner.js +369 -0
  22. package/server/core/fs/list.js +164 -0
  23. package/server/core/fs/platformProfile.js +167 -0
  24. package/server/core/fs/repairNames.js +150 -0
  25. package/server/core/fs/roots.js +178 -0
  26. package/server/core/fs/safePath.js +173 -0
  27. package/server/core/fs/search.js +108 -0
  28. package/server/core/fs/stat.js +110 -0
  29. package/server/core/fs/systemTrash.js +212 -0
  30. package/server/core/fs/writeOps.js +257 -0
  31. package/server/core/hardlink/executor.js +106 -0
  32. package/server/core/hardlink/scanner.js +193 -0
  33. package/server/core/metrics/collector.js +279 -0
  34. package/server/core/metrics/procfs.js +107 -0
  35. package/server/core/task/events.js +82 -0
  36. package/server/core/task/manager.js +987 -0
  37. package/server/core/task/recovery.js +116 -0
  38. package/server/core/task/store.js +240 -0
  39. package/server/http/middleware/auth.js +130 -0
  40. package/server/http/middleware/error.js +54 -0
  41. package/server/http/middleware/logger.js +27 -0
  42. package/server/http/middleware/pathGuard.js +119 -0
  43. package/server/http/router.js +29 -0
  44. package/server/http/routes/auth.js +115 -0
  45. package/server/http/routes/blacklist.js +97 -0
  46. package/server/http/routes/capabilities.js +21 -0
  47. package/server/http/routes/compress.js +339 -0
  48. package/server/http/routes/fs.js +109 -0
  49. package/server/http/routes/hardlink.js +160 -0
  50. package/server/http/routes/health.js +52 -0
  51. package/server/http/routes/metrics.js +36 -0
  52. package/server/http/routes/tasks.js +304 -0
  53. package/server/lib/atomic-json.js +118 -0
  54. package/server/lib/errors.js +145 -0
  55. package/server/lib/fs-probe.js +25 -0
  56. package/server/lib/logger.js +68 -0
  57. package/server/lib/runtime-guard.js +49 -0
  58. package/server/lib/throttle.js +42 -0
  59. package/server/plugins/capabilities/password/index.js +300 -0
  60. package/server/plugins/capabilities/password/methods/aes256.js +31 -0
  61. package/server/plugins/capabilities/password/methods/zip20.js +29 -0
  62. package/server/plugins/formats/_shared/createGuards.js +26 -0
  63. package/server/plugins/formats/_shared/entrySafety.js +174 -0
  64. package/server/plugins/formats/_shared/nameRepair.js +123 -0
  65. package/server/plugins/formats/_shared/streams.js +18 -0
  66. package/server/plugins/formats/_shared/tar/compression.js +114 -0
  67. package/server/plugins/formats/_shared/tar/reader.js +74 -0
  68. package/server/plugins/formats/_shared/tar/writer.js +206 -0
  69. package/server/plugins/formats/_shared/tarFamily.js +207 -0
  70. package/server/plugins/formats/rar/index.js +354 -0
  71. package/server/plugins/formats/rar/js-engine.js +122 -0
  72. package/server/plugins/formats/rar/js-runner.mjs +129 -0
  73. package/server/plugins/formats/rar/unrar.js +266 -0
  74. package/server/plugins/formats/sevenzip/engine.js +355 -0
  75. package/server/plugins/formats/sevenzip/index.js +461 -0
  76. package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
  77. package/server/plugins/formats/tar/index.js +19 -0
  78. package/server/plugins/formats/targz/index.js +19 -0
  79. package/server/plugins/formats/tarzst/index.js +26 -0
  80. package/server/plugins/formats/zip/index.js +587 -0
  81. package/shared/app-version.js +7 -0
  82. package/shared/capabilities.js +21 -0
  83. package/shared/compress-modes.js +38 -0
  84. package/shared/error-codes.js +117 -0
  85. package/shared/format-list.js +101 -0
  86. package/shared/hardlink-hint.js +21 -0
  87. package/shared/task-phase.js +62 -0
  88. package/shared/task-status.js +84 -0
  89. package/web/dist/assets/index-D3zjq7N2.js +131 -0
  90. package/web/dist/assets/index-D9Vdmrb9.css +1 -0
  91. package/web/dist/index.html +18 -0
@@ -0,0 +1,285 @@
1
+ // 删除服务(压缩删原文件 / 解压删压缩包 **共用同一实现**)
2
+ //
3
+ // 契约(docs/M0/数据模型.md §八 delete.mode):
4
+ // - `log-only`(**默认**):只写删除日志、**不真删** —— 开发期用来核对"校验通过 → 触发删除"的时机,
5
+ // 同时保证"配置写错也绝不会真删数据"(安全默认);
6
+ // - `trash`:移动到 storage/trash/<batchId>/ 并保留相对目录结构(可恢复),同时写日志;
7
+ // - 未知值 → 告警并回落 `log-only`。
8
+ //
9
+ // 重要:本模块**不做校验**。调用方(orchestrator)必须先通过 verify(INV-01)才允许调用它。
10
+ // 日志:storage/logs/delete-YYYYMMDD.jsonl(每行一条,可追溯,M5 验收要求"删除有日志")。
11
+ //
12
+ // 结构:状态集中在 ctx,动作拆成模块级小函数(记账 / log-only 拦下 / 移入回收站),
13
+ // deleteTargets 只负责遍历与汇总。
14
+
15
+ import crypto from 'node:crypto';
16
+ import fsp from 'node:fs/promises';
17
+ import path from 'node:path';
18
+
19
+ import { toApiError, apiError, ERROR_CODES } from '../../lib/errors.js';
20
+ import { resolveWithinRoot } from '../fs/safePath.js';
21
+ import { detect as detectSystemTrash, rmdirDir, unlinkFile } from '../fs/systemTrash.js';
22
+
23
+ const VALID_MODES = new Set(['log-only', 'trash']);
24
+
25
+ /**
26
+ * 回收站后端(delete.trashBackend):
27
+ * - auto(默认):探测到群晖 synorecycle 就用系统回收站,否则项目 storage/trash
28
+ * - system:强制系统回收站;**不可用/失败一律回退项目 trash,不报错**
29
+ * - project:强制项目 trash
30
+ * - none:不启用回收站 —— 删除会被拒绝并记 failed(绝不退化成真删)
31
+ */
32
+ const VALID_BACKENDS = new Set(['auto', 'system', 'project', 'none']);
33
+
34
+ /**
35
+ * 解析出实际要用的后端(system/project/none)。
36
+ * 优先级:**环境变量 ZIPCALM_TRASH_BACKEND > 配置 delete.trashBackend > auto**。
37
+ * 为什么环境变量最高:它是进程级临时覆盖,用来让测试/演练固定到项目 trash,
38
+ * 而不必去改用户的 config(改配置会影响真实使用)。非法值回落配置值,不报错。
39
+ */
40
+ function resolveBackend(configured, systemAvailable, envOverride) {
41
+ let wanted = VALID_BACKENDS.has(configured) ? configured : 'auto';
42
+ if (typeof envOverride === 'string' && envOverride.length > 0) {
43
+ if (VALID_BACKENDS.has(envOverride)) wanted = envOverride;
44
+ }
45
+ if (wanted === 'none') return 'none';
46
+ if (wanted === 'project') return 'project';
47
+ if (wanted === 'system') return systemAvailable ? 'system' : 'project';
48
+ return systemAvailable ? 'system' : 'project';
49
+ }
50
+
51
+ /** 删除日志文件名(按**本地日期**分文件;测试脚本拼这个名字时也要用本地日期) */
52
+ function logFileName(date = new Date()) {
53
+ const y = date.getFullYear();
54
+ const m = String(date.getMonth() + 1).padStart(2, '0');
55
+ const d = String(date.getDate()).padStart(2, '0');
56
+ return `delete-${y}${m}${d}.jsonl`;
57
+ }
58
+
59
+ /** 追加一条 JSONL 删除记录;日志失败不影响主流程 */
60
+ async function recordLog(ctx, entry) {
61
+ const file = path.join(ctx.logDir, logFileName(new Date()));
62
+ try {
63
+ await fsp.mkdir(ctx.logDir, { recursive: true });
64
+ await fsp.appendFile(file, `${JSON.stringify(entry)}\n`, 'utf8');
65
+ } catch (error) {
66
+ ctx.logger.error('删除日志写入失败', { error: error.message });
67
+ }
68
+ return file;
69
+ }
70
+
71
+ /** 每条目标共用的日志字段(各分支再补 action 与结果字段) */
72
+ function logBaseOf(ctx, input, target, ts, batchId, mode = ctx.mode) {
73
+ return {
74
+ ts,
75
+ batchId,
76
+ taskId: input.taskId,
77
+ kind: input.kind,
78
+ reason: input.reason,
79
+ mode,
80
+ source: target.userPath,
81
+ sizeBytes: Number.isFinite(target.size) ? target.size : null,
82
+ };
83
+ }
84
+
85
+ /** 跨分区删除的暂存目录名(创建在**源文件同级**,保证 rename 不跨分区) */
86
+ const CROSS_DEVICE_STAGING_DIR = '.zipcalm-cross-device';
87
+
88
+ /** log-only:只记账、不动文件(关键安全行为) */
89
+ async function skipInLogOnly(ctx, input, target, deleteRecord, base) {
90
+ deleteRecord.skipped.push({ path: target.userPath, reason: 'log-only' });
91
+ await recordLog(ctx, { ...base, action: 'skipped-log-only' });
92
+ ctx.logger.info('删除动作被 log-only 模式拦下(文件未动)', { taskId: input.taskId, source: target.userPath });
93
+ }
94
+
95
+ /**
96
+ * EXDEV 回退(跨分区):copyFile → size 校验 → 原件 rename 进**源分区内**的暂存目录。
97
+ * **全程零 unlink**——复制校验失败时只清理本次新建的不完整副本,原件原地不动,绝不走直接删除。
98
+ *
99
+ * 为什么原件也要再挪一步而不是 copy 后留在原地:trash 语义是"原件已入回收站可恢复",
100
+ * 若 copy 成功但原件留在源目录,任务 delete.status=deleted 与磁盘状态不一致(文件还在)。
101
+ * 暂存目录与源同分区,恢复时直接从那里取回。
102
+ */
103
+ async function moveCrossDevice(ctx, target, destination) {
104
+ const sourceStat = await fsp.stat(target.absPath);
105
+ await fsp.copyFile(target.absPath, destination);
106
+ const copiedStat = await fsp.stat(destination);
107
+ if (Number(copiedStat.size) !== Number(sourceStat.size)) {
108
+ // 校验失败:destination 是本次新建的副本,可安全移除;原件不动
109
+ await fsp.rm(destination, { force: true }).catch(() => {});
110
+ throw Object.assign(new Error('跨分区复制校验失败(size 不一致)'), { code: 'INTERNAL_ERROR' });
111
+ }
112
+ // 暂存目录必须在**源所在分区**:放在 trashDir 下的话,对"根在别的分区"的场景(如多根切到 /volumeN)
113
+ // 这一 rename 仍然是 EXDEV,会表现为"回收站里躺着一份副本、原件还在原地、删除记为失败"(实测踩过)。
114
+ const stagingDir = path.join(path.dirname(target.absPath), CROSS_DEVICE_STAGING_DIR);
115
+ await fsp.mkdir(stagingDir, { recursive: true });
116
+ const stagedName = `${Date.now()}-${path.basename(destination)}`;
117
+ await fsp.rename(target.absPath, path.join(stagingDir, stagedName));
118
+ ctx.logger.info('跨分区删除走 copy+verify 回退,原件已移入 .cross-device 暂存', {
119
+ source: target.userPath,
120
+ staged: stagedName,
121
+ });
122
+ }
123
+
124
+ /**
125
+ * 系统回收站(群晖 #recycle):只报告结果,**失败不在这里兜底** —— 由调用方回退项目 trash。
126
+ * 目录用 rmdirDir、文件用 unlinkFile;先用 unlinkFile 试,返回 is_directory 再换 rmdirDir
127
+ * (fs/delete 的 targets 不一定带 kind,用模块自己的类型判定更可靠)。
128
+ */
129
+ async function moveToSystemRecycle(ctx, input, target, deleteRecord, base, batchId) {
130
+ let recycled = await unlinkFile(target.absPath);
131
+ if (!recycled.ok && recycled.reason === 'is_directory') {
132
+ recycled = await rmdirDir(target.absPath);
133
+ }
134
+ if (!recycled.ok) return recycled;
135
+
136
+ // 群晖模式下 batchId 只进日志,不建目录(回收站由 DSM 管理)
137
+ deleteRecord.deleted.push({ path: target.userPath, backend: 'system-recycle', batchId });
138
+ await recordLog(ctx, { ...base, action: 'recycled', backend: 'system-recycle', batchId });
139
+ ctx.logger.info('原文件已移入群晖系统回收站', { taskId: input.taskId, source: target.userPath, batchId });
140
+ return { ok: true };
141
+ }
142
+
143
+ /** trash:移动到回收站(保留相对结构,可恢复;跨分区走 copy+verify 回退,零 unlink) */
144
+ async function moveToTrash(ctx, input, target, deleteRecord, base, batchId) {
145
+ try {
146
+ const segments = String(target.userPath || '').split('/').filter(Boolean);
147
+ const destination = path.join(ctx.trashDir, batchId, ...segments);
148
+ await fsp.mkdir(path.dirname(destination), { recursive: true });
149
+ try {
150
+ await fsp.rename(target.absPath, destination);
151
+ } catch (error) {
152
+ if (error && error.code === 'EXDEV') {
153
+ await moveCrossDevice(ctx, target, destination);
154
+ } else {
155
+ throw error;
156
+ }
157
+ }
158
+ const trashRel = ['trash', batchId, ...segments].join('/');
159
+ deleteRecord.deleted.push({ path: target.userPath, trashPath: trashRel, backend: 'project-trash', batchId });
160
+ await recordLog(ctx, { ...base, action: 'trashed', backend: 'project-trash', trashPath: trashRel });
161
+ ctx.logger.info('原文件已移入回收站', { taskId: input.taskId, source: target.userPath, trashPath: trashRel });
162
+ } catch (error) {
163
+ const code = toApiError(error).code;
164
+ deleteRecord.failed.push({ path: target.userPath, code });
165
+ await recordLog(ctx, { ...base, action: 'failed', backend: 'project-trash', error: code });
166
+ ctx.logger.error('删除失败', { taskId: input.taskId, source: target.userPath, code });
167
+ }
168
+ }
169
+
170
+ /**
171
+ * 执行删除(或 log-only 记账)。
172
+ * @param {object} ctx 服务上下文(mode/logger/logDir/trashDir/projectRoot/rootAbs)
173
+ * @param {{taskId: string, kind: 'compress'|'extract', reason: string, targets: Array<{userPath: string, absPath: string, kind: string, size?: number}>}} input
174
+ */
175
+ async function deleteTargets(ctx, input) {
176
+ // 按调用覆盖 mode(缺省沿用服务配置):
177
+ // 压缩后自动删除默认走 log-only(安全默认,需要用户可回到"只记账");
178
+ // 但**用户手动删除**(fs/delete)语义不同 —— 他明确点了删除,log-only 会表现为"点了没反应",
179
+ // 所以那条路径显式传 mode:'trash'(仍然进回收站可恢复,不是永真删除)。
180
+ const mode = input.mode && VALID_MODES.has(input.mode) ? input.mode : ctx.mode;
181
+ // INV-06 复核基准:默认用服务级根;多根下由调用方传"这次操作所属的根"
182
+ const rootAbs = input.rootAbs || ctx.rootAbs;
183
+ const batchId = crypto.randomUUID();
184
+ const ts = new Date().toISOString();
185
+ const logFile = path.join(ctx.logDir, logFileName(new Date()));
186
+ const deleteRecord = {
187
+ batchId,
188
+ mode,
189
+ requested: input.targets.length,
190
+ deleted: [],
191
+ skipped: [],
192
+ failed: [],
193
+ logFile: path.relative(ctx.projectRoot || process.cwd(), logFile).split(path.sep).join('/'),
194
+ };
195
+
196
+ // INV-06:deleter 是独立模块,不信任调用方传入的 absPath——入口对每个 userPath 重跑
197
+ // 白名单校验并**自行推导** absPath;与调用方传入值交叉断言,不一致按单条失败隔离
198
+ // (单条不整批崩,其余条目照删,避免一个坏 target 阻断整批)。
199
+ let verifiedTargets = input.targets;
200
+ if (rootAbs) {
201
+ verifiedTargets = [];
202
+ for (const target of input.targets) {
203
+ try {
204
+ const { absPath: derived } = resolveWithinRoot(rootAbs, target.userPath);
205
+ if (target.absPath && derived !== target.absPath) {
206
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, 'absPath 与 userPath 推导结果不一致', {
207
+ userPath: target.userPath,
208
+ callerAbsPath: target.absPath,
209
+ });
210
+ }
211
+ verifiedTargets.push({ ...target, absPath: derived });
212
+ } catch (error) {
213
+ const code = toApiError(error).code;
214
+ deleteRecord.failed.push({ path: target.userPath, code });
215
+ ctx.logger.error('删除目标未通过白名单校验,该条目被隔离', { taskId: input.taskId, source: target.userPath, code });
216
+ }
217
+ }
218
+ }
219
+
220
+ for (const target of verifiedTargets) {
221
+ const base = logBaseOf(ctx, input, target, ts, batchId, mode);
222
+ if (mode === 'log-only') {
223
+ await skipInLogOnly(ctx, input, target, deleteRecord, base);
224
+ continue;
225
+ }
226
+ if (ctx.backend === 'none') {
227
+ // 明确关掉了回收站:拒绝删除并记账 —— 宁可"没删掉",也绝不退化成真删
228
+ deleteRecord.failed.push({ path: target.userPath, code: 'TRASH_DISABLED' });
229
+ await recordLog(ctx, { ...base, action: 'failed', backend: 'none', error: 'TRASH_DISABLED' });
230
+ ctx.logger.error('回收站后端为 none,删除被拒绝(未动文件)', { taskId: input.taskId, source: target.userPath });
231
+ continue;
232
+ }
233
+ if (ctx.backend === 'system') {
234
+ const recycled = await moveToSystemRecycle(ctx, input, target, deleteRecord, base, batchId);
235
+ if (recycled.ok) continue;
236
+ // 回退:系统回收站失败(权限/共享名/命令异常)→ 项目 trash,**绝不直接删**
237
+ ctx.logger.warn('系统回收站不可用,回退项目回收站', {
238
+ taskId: input.taskId, source: target.userPath, reason: recycled.reason, detail: recycled.detail,
239
+ });
240
+ }
241
+ await moveToTrash(ctx, input, target, deleteRecord, base, batchId);
242
+ }
243
+
244
+ return deleteRecord;
245
+ }
246
+
247
+ export function createDeleteService(options) {
248
+ const configuredMode = options.config && options.config.delete ? options.config.delete.mode : undefined;
249
+ const mode = VALID_MODES.has(configuredMode) ? configuredMode : 'log-only';
250
+ if (!VALID_MODES.has(configuredMode)) {
251
+ options.logger.warn('delete.mode 非法,已回落 log-only(安全默认)', { configured: String(configuredMode) });
252
+ }
253
+
254
+ const configuredBackend = options.config && options.config.delete ? options.config.delete.trashBackend : undefined;
255
+ const systemAvailable = detectSystemTrash().available;
256
+ if (configuredBackend !== undefined && !VALID_BACKENDS.has(configuredBackend)) {
257
+ options.logger.warn('delete.trashBackend 非法,已回落 auto', { configured: String(configuredBackend) });
258
+ }
259
+ const envBackend = process.env.ZIPCALM_TRASH_BACKEND;
260
+ if (typeof envBackend === 'string' && envBackend.length > 0 && VALID_BACKENDS.has(envBackend) && envBackend !== configuredBackend) {
261
+ options.logger.warn('delete.trashBackend 被环境变量覆盖(进程级临时生效)', { env: envBackend, configured: String(configuredBackend) });
262
+ }
263
+ const backend = resolveBackend(configuredBackend, systemAvailable, envBackend);
264
+
265
+ const ctx = {
266
+ mode,
267
+ backend,
268
+ logger: options.logger,
269
+ logDir: options.logDir,
270
+ trashDir: options.trashDir,
271
+ projectRoot: options.projectRoot,
272
+ // 存储根(INV-06:入口白名单复核与 absPath 自推导的基准);未传时跳过复核保持向后兼容
273
+ rootAbs: options.rootAbs,
274
+ };
275
+
276
+ return {
277
+ mode,
278
+ /** 实际生效的回收站后端(system / project / none),验收与日志用 */
279
+ backend,
280
+ deleteTargets: (input) => deleteTargets(ctx, input),
281
+ logFileName,
282
+ /** 便于测试/验收直接读当天的删除日志路径 */
283
+ currentLogFile: () => path.join(ctx.logDir, logFileName(new Date())),
284
+ };
285
+ }
@@ -0,0 +1,67 @@
1
+ // 硬链接统计(预检用):把"哪些文件其实是同一份数据"算清楚
2
+ //
3
+ // 判定依据只有一条:**同 dev 且同 ino** 才是硬链接。只比 ino 是错的——不同文件系统上
4
+ // 出现相同的 ino 完全可能(跨分区/跨设备),把它们当成硬链接会给出错误的空间提示。
5
+ //
6
+ // 为什么单独成模块:这段逻辑是纯计算(不碰文件系统),单独放便于直接单测跨设备场景;
7
+ // 预检只负责"遍历时喂数据 + 用结果拼响应"。
8
+
9
+ /**
10
+ * 累加器:`seen` 按 absPath 去重(用户可能同时选了一个目录和它里面的文件),
11
+ * `groups` 按 `dev:ino` 归组。
12
+ */
13
+ export function createHardlinkAccumulator() {
14
+ return { seen: new Set(), groups: new Map() };
15
+ }
16
+
17
+ /**
18
+ * 记录一个文件。dev/ino 缺失(极少数文件系统不给)时无法判定,直接忽略。
19
+ * @param {{seen: Set<string>, groups: Map<string, {count: number, size: number}>}} accumulator
20
+ * @param {{absPath: string, dev: number, ino: number, size: number}} file
21
+ */
22
+ export function accumulateHardlink(accumulator, file) {
23
+ if (!file || typeof file.absPath !== 'string') return;
24
+ if (accumulator.seen.has(file.absPath)) return;
25
+ accumulator.seen.add(file.absPath);
26
+ if (!Number.isFinite(file.dev) || !Number.isFinite(file.ino)) return;
27
+ const key = `${file.dev}:${file.ino}`;
28
+ const existing = accumulator.groups.get(key);
29
+ if (existing) {
30
+ existing.count += 1;
31
+ return;
32
+ }
33
+ accumulator.groups.set(key, { count: 1, size: Number(file.size) || 0 });
34
+ }
35
+
36
+ /**
37
+ * 把一个不会进包的文件(如命中黑名单被跳过)从统计里扣掉。
38
+ * 不扣的话会出现"两个成员都被跳过,却提示 ZIP 会存两份"的错误提示。
39
+ */
40
+ export function discardHardlink(accumulator, file) {
41
+ if (!file || typeof file.absPath !== 'string') return;
42
+ if (!accumulator.seen.delete(file.absPath)) return;
43
+ if (!Number.isFinite(file.dev) || !Number.isFinite(file.ino)) return;
44
+ const key = `${file.dev}:${file.ino}`;
45
+ const group = accumulator.groups.get(key);
46
+ if (!group) return;
47
+ group.count -= 1;
48
+ if (group.count <= 0) accumulator.groups.delete(key);
49
+ }
50
+
51
+ /**
52
+ * 汇总:组数 / 组内文件数 / "多存"的字节。
53
+ * 同组各路径指向同一份数据、size 相同,所以 (count-1) 份就是多出来的量。
54
+ * @returns {{groups: number, files: number, duplicatedBytes: number}}
55
+ */
56
+ export function finalizeHardlinks(accumulator) {
57
+ let groups = 0;
58
+ let files = 0;
59
+ let duplicatedBytes = 0;
60
+ for (const group of accumulator.groups.values()) {
61
+ if (group.count < 2) continue;
62
+ groups += 1;
63
+ files += group.count;
64
+ duplicatedBytes += (group.count - 1) * group.size;
65
+ }
66
+ return { groups, files, duplicatedBytes };
67
+ }