@mison/wecom-cleaner 1.0.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/restore.js ADDED
@@ -0,0 +1,533 @@
1
+ import { promises as fs, createReadStream } from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { appendJsonLine, ensureDir, pathExists } from './utils.js';
5
+ import { classifyErrorType, ERROR_TYPES } from './error-taxonomy.js';
6
+
7
+ async function movePath(src, dest) {
8
+ await ensureDir(path.dirname(dest));
9
+ try {
10
+ await fs.rename(src, dest);
11
+ return;
12
+ } catch (error) {
13
+ if (error?.code !== 'EXDEV') {
14
+ throw error;
15
+ }
16
+ }
17
+ await fs.cp(src, dest, { recursive: true, force: true });
18
+ await fs.rm(src, { recursive: true, force: true });
19
+ }
20
+
21
+ async function removePath(targetPath) {
22
+ await fs.rm(targetPath, { recursive: true, force: true });
23
+ }
24
+
25
+ function buildRenameTarget(originalPath) {
26
+ const ts = Date.now();
27
+ return `${originalPath}.restored-${ts}`;
28
+ }
29
+
30
+ function isPathWithinRoot(rootPath, targetPath) {
31
+ const rootAbs = path.resolve(rootPath);
32
+ const targetAbs = path.resolve(targetPath);
33
+ const rel = path.relative(rootAbs, targetAbs);
34
+ if (!rel) {
35
+ return true;
36
+ }
37
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
38
+ }
39
+
40
+ function isPathWithinAnyRoot(rootPaths, targetPath) {
41
+ for (const rootPath of rootPaths) {
42
+ if (!rootPath) {
43
+ continue;
44
+ }
45
+ if (isPathWithinRoot(rootPath, targetPath)) {
46
+ return true;
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+
52
+ function normalizeRootList(rootPaths) {
53
+ return [
54
+ ...new Set(
55
+ (rootPaths || [])
56
+ .map((item) => String(item || '').trim())
57
+ .filter(Boolean)
58
+ .map((item) => path.resolve(item))
59
+ ),
60
+ ];
61
+ }
62
+
63
+ function isPathWithinResolvedRoot(rootPathResolved, targetPathResolved) {
64
+ const rel = path.relative(rootPathResolved, targetPathResolved);
65
+ if (!rel) {
66
+ return true;
67
+ }
68
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
69
+ }
70
+
71
+ function isPathWithinAnyResolvedRoot(rootPathsResolved, targetPathResolved) {
72
+ for (const rootPathResolved of rootPathsResolved || []) {
73
+ if (!rootPathResolved) {
74
+ continue;
75
+ }
76
+ if (isPathWithinResolvedRoot(rootPathResolved, targetPathResolved)) {
77
+ return true;
78
+ }
79
+ }
80
+ return false;
81
+ }
82
+
83
+ async function safeRealpath(targetPath) {
84
+ try {
85
+ return await fs.realpath(targetPath);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ async function resolveExistingAncestorRealpath(targetPathAbs) {
92
+ let current = path.resolve(targetPathAbs);
93
+ while (true) {
94
+ const real = await safeRealpath(current);
95
+ if (real) {
96
+ return {
97
+ ancestorPath: current,
98
+ ancestorRealPath: real,
99
+ };
100
+ }
101
+ const parent = path.dirname(current);
102
+ if (parent === current) {
103
+ return null;
104
+ }
105
+ current = parent;
106
+ }
107
+ }
108
+
109
+ async function resolvePathForBoundaryCheck(targetPath) {
110
+ const targetAbs = path.resolve(targetPath);
111
+ const stat = await fs.lstat(targetAbs).catch(() => null);
112
+ if (stat) {
113
+ const targetReal = await safeRealpath(targetAbs);
114
+ if (!targetReal) {
115
+ return {
116
+ ok: false,
117
+ reason: 'realpath_failed',
118
+ };
119
+ }
120
+ return {
121
+ ok: true,
122
+ resolvedPath: targetReal,
123
+ source: 'existing',
124
+ };
125
+ }
126
+
127
+ const ancestor = await resolveExistingAncestorRealpath(targetAbs);
128
+ if (!ancestor) {
129
+ return {
130
+ ok: false,
131
+ reason: 'missing_existing_ancestor',
132
+ };
133
+ }
134
+
135
+ const rel = path.relative(ancestor.ancestorPath, targetAbs);
136
+ return {
137
+ ok: true,
138
+ resolvedPath: path.resolve(ancestor.ancestorRealPath, rel || '.'),
139
+ source: 'ancestor',
140
+ };
141
+ }
142
+
143
+ async function resolveRootsRealpath(rootPaths) {
144
+ const resolved = [];
145
+ for (const rootPath of normalizeRootList(rootPaths)) {
146
+ const real = await safeRealpath(rootPath);
147
+ if (!real) {
148
+ continue;
149
+ }
150
+ resolved.push(real);
151
+ }
152
+ return [...new Set(resolved)];
153
+ }
154
+
155
+ async function buildRestoreValidationState({
156
+ profileRoot,
157
+ extraProfileRoots,
158
+ recycleRoot,
159
+ governanceRoot,
160
+ extraGovernanceRoots,
161
+ }) {
162
+ const profileRootsRaw = normalizeRootList([profileRoot, ...(extraProfileRoots || [])]);
163
+ const governanceRootsRaw = normalizeRootList([governanceRoot, ...(extraGovernanceRoots || [])]);
164
+ const recycleRootRaw = normalizeRootList([recycleRoot])[0] || null;
165
+
166
+ const [profileRootsReal, governanceRootsReal, recycleRootReal] = await Promise.all([
167
+ resolveRootsRealpath(profileRootsRaw),
168
+ resolveRootsRealpath(governanceRootsRaw),
169
+ recycleRootRaw ? safeRealpath(recycleRootRaw) : Promise.resolve(null),
170
+ ]);
171
+
172
+ return {
173
+ profileRootsRaw,
174
+ governanceRootsRaw,
175
+ recycleRootRaw,
176
+ profileRootsReal,
177
+ governanceRootsReal,
178
+ recycleRootReal,
179
+ };
180
+ }
181
+
182
+ async function streamJsonRows(filePath, onRow) {
183
+ const exists = await pathExists(filePath);
184
+ if (!exists) {
185
+ return;
186
+ }
187
+
188
+ const input = createReadStream(filePath, { encoding: 'utf-8' });
189
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
190
+
191
+ try {
192
+ for await (const line of rl) {
193
+ const text = String(line || '').trim();
194
+ if (!text) {
195
+ continue;
196
+ }
197
+ try {
198
+ const row = JSON.parse(text);
199
+ await onRow(row);
200
+ } catch {
201
+ // 忽略损坏的 JSONL 行,继续处理后续记录
202
+ }
203
+ }
204
+ } catch {
205
+ // 忽略流读取异常,交由上层根据结果做兜底
206
+ } finally {
207
+ rl.close();
208
+ input.close();
209
+ }
210
+ }
211
+
212
+ async function validateRestoreEntryPath({ originalPath, recyclePath, scope, validationState }) {
213
+ if (typeof originalPath !== 'string' || typeof recyclePath !== 'string' || !originalPath || !recyclePath) {
214
+ return 'invalid_path_record';
215
+ }
216
+
217
+ if (validationState.recycleRootRaw) {
218
+ if (!validationState.recycleRootReal) {
219
+ return 'missing_recycle_root';
220
+ }
221
+
222
+ const recycleChecked = await resolvePathForBoundaryCheck(recyclePath);
223
+ if (!recycleChecked.ok) {
224
+ return 'recycle_path_unresolvable';
225
+ }
226
+
227
+ const recycleInside = isPathWithinResolvedRoot(
228
+ validationState.recycleRootReal,
229
+ recycleChecked.resolvedPath
230
+ );
231
+ if (!recycleInside) {
232
+ const rawInside = isPathWithinRoot(validationState.recycleRootRaw, recyclePath);
233
+ return rawInside ? 'recycle_symlink_escape' : 'recycle_outside_recycle_root';
234
+ }
235
+ }
236
+
237
+ const governanceScope = scope === 'space_governance';
238
+ const sourceRootsRaw = governanceScope
239
+ ? validationState.governanceRootsRaw
240
+ : validationState.profileRootsRaw;
241
+ const sourceRootsReal = governanceScope
242
+ ? validationState.governanceRootsReal
243
+ : validationState.profileRootsReal;
244
+ if (sourceRootsReal.length === 0) {
245
+ return 'missing_allowed_root';
246
+ }
247
+
248
+ const sourceChecked = await resolvePathForBoundaryCheck(originalPath);
249
+ if (!sourceChecked.ok) {
250
+ return 'source_path_unresolvable';
251
+ }
252
+
253
+ const sourceInside = isPathWithinAnyResolvedRoot(sourceRootsReal, sourceChecked.resolvedPath);
254
+ if (!sourceInside) {
255
+ const rawInside = isPathWithinAnyRoot(sourceRootsRaw, originalPath);
256
+ if (rawInside) {
257
+ return 'source_symlink_escape';
258
+ }
259
+ return governanceScope ? 'source_outside_governance_root' : 'source_outside_profile_root';
260
+ }
261
+
262
+ return null;
263
+ }
264
+
265
+ export async function listRestorableBatches(indexPath, options = {}) {
266
+ const recycleRoot = typeof options.recycleRoot === 'string' ? options.recycleRoot : null;
267
+ const restoredSet = new Set();
268
+ const cleanupRows = new Map();
269
+
270
+ await streamJsonRows(indexPath, async (row) => {
271
+ if (!row || typeof row !== 'object') {
272
+ return;
273
+ }
274
+ if (row.action === 'restore' && row.status === 'success' && typeof row.recyclePath === 'string') {
275
+ restoredSet.add(row.recyclePath);
276
+ cleanupRows.delete(row.recyclePath);
277
+ return;
278
+ }
279
+ if (row.action === 'cleanup' && row.status === 'success' && typeof row.recyclePath === 'string') {
280
+ if (!restoredSet.has(row.recyclePath)) {
281
+ cleanupRows.set(row.recyclePath, row);
282
+ }
283
+ }
284
+ });
285
+
286
+ const batches = new Map();
287
+
288
+ for (const row of cleanupRows.values()) {
289
+ if (recycleRoot && !isPathWithinRoot(recycleRoot, row.recyclePath)) {
290
+ continue;
291
+ }
292
+
293
+ const recycleExists = await pathExists(row.recyclePath);
294
+ if (!recycleExists) {
295
+ continue;
296
+ }
297
+
298
+ const batchId = row.batchId || 'unknown';
299
+ if (!batches.has(batchId)) {
300
+ batches.set(batchId, {
301
+ batchId,
302
+ firstTime: row.time || Date.now(),
303
+ entries: [],
304
+ totalBytes: 0,
305
+ });
306
+ }
307
+ const batch = batches.get(batchId);
308
+ batch.firstTime = Math.min(batch.firstTime, row.time || batch.firstTime);
309
+ batch.entries.push(row);
310
+ batch.totalBytes += Number(row.sizeBytes || 0);
311
+ }
312
+
313
+ return [...batches.values()].sort((a, b) => b.firstTime - a.firstTime);
314
+ }
315
+
316
+ export async function restoreBatch({
317
+ batch,
318
+ indexPath,
319
+ onConflict,
320
+ onProgress,
321
+ dryRun = false,
322
+ profileRoot = null,
323
+ extraProfileRoots = [],
324
+ recycleRoot = null,
325
+ governanceRoot = null,
326
+ extraGovernanceRoots = [],
327
+ }) {
328
+ const summary = {
329
+ batchId: batch.batchId,
330
+ successCount: 0,
331
+ skipCount: 0,
332
+ failCount: 0,
333
+ restoredBytes: 0,
334
+ errors: [],
335
+ };
336
+
337
+ const validationState = await buildRestoreValidationState({
338
+ profileRoot,
339
+ extraProfileRoots,
340
+ recycleRoot,
341
+ governanceRoot,
342
+ extraGovernanceRoots,
343
+ });
344
+
345
+ let applyAllAction = null;
346
+ const total = batch.entries.length;
347
+
348
+ for (let i = 0; i < total; i += 1) {
349
+ const entry = batch.entries[i];
350
+ if (typeof onProgress === 'function') {
351
+ onProgress(i + 1, total);
352
+ }
353
+
354
+ const recyclePath = entry.recyclePath;
355
+ const originalPath = entry.sourcePath;
356
+ const scope = typeof entry.scope === 'string' && entry.scope ? entry.scope : 'cleanup_monthly';
357
+ const invalidPathReason = await validateRestoreEntryPath({
358
+ originalPath,
359
+ recyclePath,
360
+ scope,
361
+ validationState,
362
+ });
363
+
364
+ if (invalidPathReason) {
365
+ summary.skipCount += 1;
366
+ await appendJsonLine(indexPath, {
367
+ action: 'restore',
368
+ time: Date.now(),
369
+ scope,
370
+ batchId: batch.batchId,
371
+ recyclePath,
372
+ sourcePath: originalPath,
373
+ status: 'skipped_invalid_path',
374
+ error_type: ERROR_TYPES.PATH_VALIDATION_FAILED,
375
+ invalid_reason: invalidPathReason,
376
+ profile_root: profileRoot,
377
+ extra_profile_roots: extraProfileRoots,
378
+ recycle_root: recycleRoot,
379
+ governance_root: governanceRoot,
380
+ extra_governance_roots: extraGovernanceRoots,
381
+ });
382
+ continue;
383
+ }
384
+
385
+ if (!(await pathExists(recyclePath))) {
386
+ summary.skipCount += 1;
387
+ await appendJsonLine(indexPath, {
388
+ action: 'restore',
389
+ time: Date.now(),
390
+ scope,
391
+ batchId: batch.batchId,
392
+ recyclePath,
393
+ sourcePath: originalPath,
394
+ status: 'skipped_missing_recycle',
395
+ error_type: ERROR_TYPES.PATH_NOT_FOUND,
396
+ profile_root: profileRoot,
397
+ extra_profile_roots: extraProfileRoots,
398
+ recycle_root: recycleRoot,
399
+ governance_root: governanceRoot,
400
+ extra_governance_roots: extraGovernanceRoots,
401
+ });
402
+ continue;
403
+ }
404
+
405
+ let targetPath = originalPath;
406
+ let strategy = applyAllAction;
407
+ let conflictStrategy = null;
408
+ let wouldOverwrite = false;
409
+
410
+ const sourceExists = await pathExists(originalPath);
411
+ if (sourceExists) {
412
+ if (!strategy && typeof onConflict === 'function') {
413
+ const resolved = await onConflict({
414
+ originalPath,
415
+ recyclePath,
416
+ entry,
417
+ });
418
+ strategy = resolved?.action || 'skip';
419
+ if (resolved?.applyToAll) {
420
+ applyAllAction = strategy;
421
+ }
422
+ }
423
+
424
+ if (!strategy) {
425
+ strategy = 'skip';
426
+ }
427
+ conflictStrategy = strategy;
428
+ wouldOverwrite = strategy === 'overwrite';
429
+
430
+ if (strategy === 'skip') {
431
+ summary.skipCount += 1;
432
+ await appendJsonLine(indexPath, {
433
+ action: 'restore',
434
+ time: Date.now(),
435
+ scope,
436
+ batchId: batch.batchId,
437
+ recyclePath,
438
+ sourcePath: originalPath,
439
+ status: 'skipped_conflict',
440
+ error_type: ERROR_TYPES.CONFLICT,
441
+ profile_root: profileRoot,
442
+ extra_profile_roots: extraProfileRoots,
443
+ recycle_root: recycleRoot,
444
+ governance_root: governanceRoot,
445
+ extra_governance_roots: extraGovernanceRoots,
446
+ });
447
+ continue;
448
+ }
449
+
450
+ if (strategy === 'rename') {
451
+ targetPath = buildRenameTarget(originalPath);
452
+ }
453
+ }
454
+
455
+ if (dryRun) {
456
+ summary.successCount += 1;
457
+ summary.restoredBytes += Number(entry.sizeBytes || 0);
458
+
459
+ await appendJsonLine(indexPath, {
460
+ action: 'restore',
461
+ time: Date.now(),
462
+ scope,
463
+ batchId: batch.batchId,
464
+ recyclePath,
465
+ sourcePath: originalPath,
466
+ restoredPath: targetPath,
467
+ status: 'dry_run',
468
+ dryRun: true,
469
+ conflict_strategy: conflictStrategy,
470
+ would_overwrite: wouldOverwrite,
471
+ profile_root: profileRoot,
472
+ extra_profile_roots: extraProfileRoots,
473
+ recycle_root: recycleRoot,
474
+ governance_root: governanceRoot,
475
+ extra_governance_roots: extraGovernanceRoots,
476
+ });
477
+ continue;
478
+ }
479
+
480
+ try {
481
+ if (sourceExists && strategy === 'overwrite') {
482
+ await removePath(originalPath);
483
+ }
484
+ await movePath(recyclePath, targetPath);
485
+ summary.successCount += 1;
486
+ summary.restoredBytes += Number(entry.sizeBytes || 0);
487
+
488
+ await appendJsonLine(indexPath, {
489
+ action: 'restore',
490
+ time: Date.now(),
491
+ scope,
492
+ batchId: batch.batchId,
493
+ recyclePath,
494
+ sourcePath: originalPath,
495
+ restoredPath: targetPath,
496
+ status: 'success',
497
+ dryRun: false,
498
+ profile_root: profileRoot,
499
+ extra_profile_roots: extraProfileRoots,
500
+ recycle_root: recycleRoot,
501
+ governance_root: governanceRoot,
502
+ extra_governance_roots: extraGovernanceRoots,
503
+ });
504
+ } catch (error) {
505
+ summary.failCount += 1;
506
+ summary.errors.push({
507
+ recyclePath,
508
+ sourcePath: originalPath,
509
+ message: error instanceof Error ? error.message : String(error),
510
+ });
511
+
512
+ await appendJsonLine(indexPath, {
513
+ action: 'restore',
514
+ time: Date.now(),
515
+ scope,
516
+ batchId: batch.batchId,
517
+ recyclePath,
518
+ sourcePath: originalPath,
519
+ status: 'failed',
520
+ error_type: classifyErrorType(error instanceof Error ? error.message : String(error)),
521
+ dryRun: false,
522
+ error: error instanceof Error ? error.message : String(error),
523
+ profile_root: profileRoot,
524
+ extra_profile_roots: extraProfileRoots,
525
+ recycle_root: recycleRoot,
526
+ governance_root: governanceRoot,
527
+ extra_governance_roots: extraGovernanceRoots,
528
+ });
529
+ }
530
+ }
531
+
532
+ return summary;
533
+ }