@mison/wecom-cleaner 1.0.0 → 1.2.0

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/src/cleanup.js CHANGED
@@ -36,11 +36,342 @@ function escapePathForName(srcPath) {
36
36
  return base || 'unknown';
37
37
  }
38
38
 
39
+ function resolveCleanupTargetRoot(target = {}) {
40
+ const accountPath = String(target.accountPath || '').trim();
41
+ const categoryPath = String(target.categoryPath || '').trim();
42
+ if (accountPath && categoryPath) {
43
+ return path.resolve(accountPath, categoryPath);
44
+ }
45
+ if (accountPath) {
46
+ return path.resolve(accountPath);
47
+ }
48
+ if (target.path) {
49
+ return path.dirname(path.resolve(target.path));
50
+ }
51
+ return null;
52
+ }
53
+
54
+ function createBreakdownRow(seed = {}) {
55
+ return {
56
+ ...seed,
57
+ totalCount: 0,
58
+ totalBytes: 0,
59
+ successCount: 0,
60
+ successBytes: 0,
61
+ skippedCount: 0,
62
+ skippedBytes: 0,
63
+ failedCount: 0,
64
+ failedBytes: 0,
65
+ dryRunCount: 0,
66
+ dryRunBytes: 0,
67
+ };
68
+ }
69
+
70
+ function applyBreakdownStatus(row, statusKey, sizeBytes) {
71
+ const bytes = Number(sizeBytes || 0);
72
+ row.totalCount += 1;
73
+ row.totalBytes += bytes;
74
+ if (statusKey === 'success') {
75
+ row.successCount += 1;
76
+ row.successBytes += bytes;
77
+ return;
78
+ }
79
+ if (statusKey === 'skipped') {
80
+ row.skippedCount += 1;
81
+ row.skippedBytes += bytes;
82
+ return;
83
+ }
84
+ if (statusKey === 'failed') {
85
+ row.failedCount += 1;
86
+ row.failedBytes += bytes;
87
+ return;
88
+ }
89
+ row.dryRunCount += 1;
90
+ row.dryRunBytes += bytes;
91
+ }
92
+
93
+ function pushTopPathSample(samples, sample, limit = 20) {
94
+ samples.push(sample);
95
+ samples.sort((a, b) => Number(b.sizeBytes || 0) - Number(a.sizeBytes || 0));
96
+ if (samples.length > limit) {
97
+ samples.length = limit;
98
+ }
99
+ }
100
+
101
+ function createCleanupBreakdownTracker(topPathLimit = 20) {
102
+ return {
103
+ byCategory: new Map(),
104
+ byMonth: new Map(),
105
+ byRoot: new Map(),
106
+ status: {
107
+ success: { count: 0, bytes: 0 },
108
+ skipped: { count: 0, bytes: 0 },
109
+ failed: { count: 0, bytes: 0 },
110
+ dryRun: { count: 0, bytes: 0 },
111
+ },
112
+ topPaths: [],
113
+ topPathLimit,
114
+ };
115
+ }
116
+
117
+ function updateCleanupBreakdown(tracker, target, statusKey, statusLabel) {
118
+ const bytes = Number(target?.sizeBytes || 0);
119
+
120
+ if (!tracker.status[statusKey]) {
121
+ tracker.status[statusKey] = { count: 0, bytes: 0 };
122
+ }
123
+ tracker.status[statusKey].count += 1;
124
+ tracker.status[statusKey].bytes += bytes;
125
+
126
+ const categoryKey = String(target?.categoryKey || 'unknown');
127
+ if (!tracker.byCategory.has(categoryKey)) {
128
+ tracker.byCategory.set(
129
+ categoryKey,
130
+ createBreakdownRow({
131
+ categoryKey,
132
+ categoryLabel: target?.categoryLabel || categoryKey,
133
+ })
134
+ );
135
+ }
136
+ applyBreakdownStatus(tracker.byCategory.get(categoryKey), statusKey, bytes);
137
+
138
+ const monthKey = String(target?.monthKey || '非月份目录');
139
+ if (!tracker.byMonth.has(monthKey)) {
140
+ tracker.byMonth.set(monthKey, createBreakdownRow({ monthKey }));
141
+ }
142
+ applyBreakdownStatus(tracker.byMonth.get(monthKey), statusKey, bytes);
143
+
144
+ const rootPath = resolveCleanupTargetRoot(target);
145
+ const rootKey = rootPath || '(unknown)';
146
+ if (!tracker.byRoot.has(rootKey)) {
147
+ tracker.byRoot.set(
148
+ rootKey,
149
+ createBreakdownRow({
150
+ rootPath: rootPath || null,
151
+ rootType: target?.isExternalStorage ? 'external' : 'profile',
152
+ })
153
+ );
154
+ }
155
+ applyBreakdownStatus(tracker.byRoot.get(rootKey), statusKey, bytes);
156
+
157
+ pushTopPathSample(
158
+ tracker.topPaths,
159
+ {
160
+ path: target?.path || null,
161
+ sizeBytes: bytes,
162
+ status: statusLabel,
163
+ categoryKey,
164
+ categoryLabel: target?.categoryLabel || categoryKey,
165
+ monthKey: target?.monthKey || null,
166
+ accountShortId: target?.accountShortId || null,
167
+ isExternalStorage: Boolean(target?.isExternalStorage),
168
+ },
169
+ tracker.topPathLimit
170
+ );
171
+ }
172
+
173
+ function sortBreakdownRowsByBytes(rows = []) {
174
+ return [...rows].sort((a, b) => {
175
+ const bytesDiff = Number(b.totalBytes || 0) - Number(a.totalBytes || 0);
176
+ if (bytesDiff !== 0) {
177
+ return bytesDiff;
178
+ }
179
+ return Number(b.totalCount || 0) - Number(a.totalCount || 0);
180
+ });
181
+ }
182
+
183
+ function sortMonthBreakdownRows(rows = []) {
184
+ const nonMonthKey = '非月份目录';
185
+ return [...rows].sort((a, b) => {
186
+ const aMonth = String(a.monthKey || nonMonthKey);
187
+ const bMonth = String(b.monthKey || nonMonthKey);
188
+ if (aMonth === nonMonthKey && bMonth !== nonMonthKey) {
189
+ return 1;
190
+ }
191
+ if (aMonth !== nonMonthKey && bMonth === nonMonthKey) {
192
+ return -1;
193
+ }
194
+ if (aMonth === bMonth) {
195
+ return Number(b.totalBytes || 0) - Number(a.totalBytes || 0);
196
+ }
197
+ return aMonth.localeCompare(bMonth);
198
+ });
199
+ }
200
+
201
+ function finalizeCleanupBreakdown(tracker) {
202
+ return {
203
+ byStatus: tracker.status,
204
+ byCategory: sortBreakdownRowsByBytes([...tracker.byCategory.values()]),
205
+ byMonth: sortMonthBreakdownRows([...tracker.byMonth.values()]),
206
+ byRoot: sortBreakdownRowsByBytes([...tracker.byRoot.values()]),
207
+ topPaths: [...tracker.topPaths],
208
+ };
209
+ }
210
+
211
+ function normalizeRootList(rootPaths) {
212
+ return [
213
+ ...new Set(
214
+ (rootPaths || [])
215
+ .map((item) => String(item || '').trim())
216
+ .filter(Boolean)
217
+ .map((item) => path.resolve(item))
218
+ ),
219
+ ];
220
+ }
221
+
222
+ function isPathWithinRoot(rootPath, targetPath) {
223
+ const rootAbs = path.resolve(rootPath);
224
+ const targetAbs = path.resolve(targetPath);
225
+ const rel = path.relative(rootAbs, targetAbs);
226
+ if (!rel) {
227
+ return true;
228
+ }
229
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
230
+ }
231
+
232
+ function isPathWithinAnyRoot(rootPaths, targetPath) {
233
+ for (const rootPath of rootPaths || []) {
234
+ if (!rootPath) {
235
+ continue;
236
+ }
237
+ if (isPathWithinRoot(rootPath, targetPath)) {
238
+ return true;
239
+ }
240
+ }
241
+ return false;
242
+ }
243
+
244
+ async function safeRealpath(targetPath) {
245
+ try {
246
+ return await fs.realpath(targetPath);
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ async function resolveExistingAncestorRealpath(targetPathAbs) {
253
+ let current = path.resolve(targetPathAbs);
254
+ while (true) {
255
+ const real = await safeRealpath(current);
256
+ if (real) {
257
+ return {
258
+ ancestorPath: current,
259
+ ancestorRealPath: real,
260
+ };
261
+ }
262
+ const parent = path.dirname(current);
263
+ if (parent === current) {
264
+ return null;
265
+ }
266
+ current = parent;
267
+ }
268
+ }
269
+
270
+ async function resolvePathForBoundaryCheck(targetPath) {
271
+ const targetAbs = path.resolve(targetPath);
272
+ const stat = await fs.lstat(targetAbs).catch(() => null);
273
+ if (stat) {
274
+ const targetReal = await safeRealpath(targetAbs);
275
+ if (!targetReal) {
276
+ return {
277
+ ok: false,
278
+ };
279
+ }
280
+ return {
281
+ ok: true,
282
+ resolvedPath: targetReal,
283
+ };
284
+ }
285
+
286
+ const ancestor = await resolveExistingAncestorRealpath(targetAbs);
287
+ if (!ancestor) {
288
+ return {
289
+ ok: false,
290
+ };
291
+ }
292
+
293
+ const rel = path.relative(ancestor.ancestorPath, targetAbs);
294
+ return {
295
+ ok: true,
296
+ resolvedPath: path.resolve(ancestor.ancestorRealPath, rel || '.'),
297
+ };
298
+ }
299
+
300
+ function isPathWithinResolvedRoot(rootPathResolved, targetPathResolved) {
301
+ const rel = path.relative(rootPathResolved, targetPathResolved);
302
+ if (!rel) {
303
+ return true;
304
+ }
305
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
306
+ }
307
+
308
+ function isPathWithinAnyResolvedRoot(rootPathsResolved, targetPathResolved) {
309
+ for (const rootPathResolved of rootPathsResolved || []) {
310
+ if (!rootPathResolved) {
311
+ continue;
312
+ }
313
+ if (isPathWithinResolvedRoot(rootPathResolved, targetPathResolved)) {
314
+ return true;
315
+ }
316
+ }
317
+ return false;
318
+ }
319
+
320
+ async function resolveRootsRealpath(rootPaths) {
321
+ const resolved = [];
322
+ for (const rootPath of normalizeRootList(rootPaths)) {
323
+ const real = await safeRealpath(rootPath);
324
+ if (!real) {
325
+ continue;
326
+ }
327
+ resolved.push(real);
328
+ }
329
+ return [...new Set(resolved)];
330
+ }
331
+
332
+ async function buildCleanupValidationState(allowedRoots) {
333
+ const allowedRootsRaw = normalizeRootList(allowedRoots);
334
+ const allowedRootsReal = await resolveRootsRealpath(allowedRootsRaw);
335
+ return {
336
+ allowedRootsRaw,
337
+ allowedRootsReal,
338
+ };
339
+ }
340
+
341
+ async function validateCleanupTargetPath(targetPath, validationState) {
342
+ if (!validationState || !Array.isArray(validationState.allowedRootsRaw)) {
343
+ return 'missing_allowed_root';
344
+ }
345
+ if (validationState.allowedRootsReal.length === 0) {
346
+ return 'missing_allowed_root';
347
+ }
348
+
349
+ const sourceChecked = await resolvePathForBoundaryCheck(targetPath);
350
+ if (!sourceChecked.ok) {
351
+ return 'source_path_unresolvable';
352
+ }
353
+
354
+ const sourceInside = isPathWithinAnyResolvedRoot(
355
+ validationState.allowedRootsReal,
356
+ sourceChecked.resolvedPath
357
+ );
358
+ if (sourceInside) {
359
+ return null;
360
+ }
361
+
362
+ const rawInside = isPathWithinAnyRoot(validationState.allowedRootsRaw, targetPath);
363
+ if (rawInside) {
364
+ return 'source_symlink_escape';
365
+ }
366
+ return 'source_outside_allowed_root';
367
+ }
368
+
39
369
  export async function executeCleanup({
40
370
  targets,
41
371
  recycleRoot,
42
372
  indexPath,
43
373
  dryRun,
374
+ allowedRoots = [],
44
375
  scope = 'cleanup_monthly',
45
376
  shouldSkip,
46
377
  onProgress,
@@ -60,6 +391,8 @@ export async function executeCleanup({
60
391
  reclaimedBytes: 0,
61
392
  errors: [],
62
393
  };
394
+ const validationState = await buildCleanupValidationState(allowedRoots);
395
+ const breakdownTracker = createCleanupBreakdownTracker();
63
396
 
64
397
  const total = targets.length;
65
398
 
@@ -75,6 +408,7 @@ export async function executeCleanup({
75
408
  }
76
409
  if (typeof skipByPolicy === 'string' && skipByPolicy) {
77
410
  summary.skippedCount += 1;
411
+ updateCleanupBreakdown(breakdownTracker, target, 'skipped', skipByPolicy);
78
412
  await appendJsonLine(indexPath, {
79
413
  action: 'cleanup',
80
414
  time: Date.now(),
@@ -102,6 +436,7 @@ export async function executeCleanup({
102
436
  const exists = await pathExists(target.path);
103
437
  if (!exists) {
104
438
  summary.skippedCount += 1;
439
+ updateCleanupBreakdown(breakdownTracker, target, 'skipped', 'skipped_missing_source');
105
440
  await appendJsonLine(indexPath, {
106
441
  action: 'cleanup',
107
442
  time: Date.now(),
@@ -126,9 +461,40 @@ export async function executeCleanup({
126
461
  continue;
127
462
  }
128
463
 
464
+ const invalidPathReason = await validateCleanupTargetPath(target.path, validationState);
465
+ if (invalidPathReason) {
466
+ summary.skippedCount += 1;
467
+ updateCleanupBreakdown(breakdownTracker, target, 'skipped', 'skipped_invalid_path');
468
+ await appendJsonLine(indexPath, {
469
+ action: 'cleanup',
470
+ time: Date.now(),
471
+ scope,
472
+ batchId,
473
+ sourcePath: target.path,
474
+ recyclePath: null,
475
+ accountId: target.accountId,
476
+ accountShortId: target.accountShortId,
477
+ userName: target.userName,
478
+ corpName: target.corpName,
479
+ categoryKey: target.categoryKey,
480
+ categoryLabel: target.categoryLabel,
481
+ monthKey: target.monthKey,
482
+ sizeBytes: target.sizeBytes,
483
+ targetKey: target.targetKey || null,
484
+ tier: target.tier || null,
485
+ status: 'skipped_invalid_path',
486
+ error_type: ERROR_TYPES.PATH_VALIDATION_FAILED,
487
+ invalid_reason: invalidPathReason,
488
+ allowed_roots: validationState.allowedRootsRaw,
489
+ dryRun: Boolean(dryRun),
490
+ });
491
+ continue;
492
+ }
493
+
129
494
  if (dryRun) {
130
495
  summary.successCount += 1;
131
496
  summary.reclaimedBytes += target.sizeBytes;
497
+ updateCleanupBreakdown(breakdownTracker, target, 'dryRun', 'dry_run');
132
498
  await appendJsonLine(indexPath, {
133
499
  action: 'cleanup',
134
500
  time: Date.now(),
@@ -159,6 +525,7 @@ export async function executeCleanup({
159
525
  await movePath(target.path, recyclePath);
160
526
  summary.successCount += 1;
161
527
  summary.reclaimedBytes += target.sizeBytes;
528
+ updateCleanupBreakdown(breakdownTracker, target, 'success', 'success');
162
529
 
163
530
  const now = Date.now();
164
531
  await appendJsonLine(indexPath, {
@@ -188,6 +555,7 @@ export async function executeCleanup({
188
555
  path: target.path,
189
556
  message,
190
557
  });
558
+ updateCleanupBreakdown(breakdownTracker, target, 'failed', 'failed');
191
559
  await appendJsonLine(indexPath, {
192
560
  action: 'cleanup',
193
561
  time: Date.now(),
@@ -213,5 +581,6 @@ export async function executeCleanup({
213
581
  }
214
582
  }
215
583
 
584
+ summary.breakdown = finalizeCleanupBreakdown(breakdownTracker);
216
585
  return summary;
217
586
  }