deepclean-mydisk 0.0.0-stage → 0.12.1

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/lib/clean.js ADDED
@@ -0,0 +1,537 @@
1
+ /**
2
+ * 清理能力(移动 + 撤销 + 审计 + 显式永久删除批次)—— 默认只移动,不自动永久删除。
3
+ *
4
+ * 安全契约:
5
+ * - 只接受「最近一次扫描结果」允许列表里的绝对路径;
6
+ * - 符号链接一律拒绝(避免移到链接目标);
7
+ * - 目标名冲突时自动加序号,绝不覆盖;
8
+ * - 撤销只回滚仍存在、且原位置已空的条目。
9
+ */
10
+ import fs from 'node:fs'
11
+ import crypto from 'node:crypto'
12
+ import os from 'node:os'
13
+ import path from 'node:path'
14
+
15
+ export const MAX_BATCH = 200
16
+
17
+ export function trashDir(now = Date.now()) {
18
+ // 一律返回绝对路径:相对 DEEPCLEAN_TRASH_DIR 会让审计里的 to 变成相对路径,
19
+ // 跨重启后 restore/purge 的归属校验会对不上。
20
+ if (process.env.DEEPCLEAN_TRASH_DIR) return path.resolve(process.env.DEEPCLEAN_TRASH_DIR)
21
+ if (process.platform === 'win32') {
22
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
23
+ return path.resolve(base, 'deepclean-mydisk', 'trash', String(now))
24
+ }
25
+ if (process.platform === 'darwin') return path.resolve(os.homedir(), '.Trash')
26
+ return path.resolve(os.homedir(), '.local', 'share', 'deepclean-mydisk', 'trash', String(now))
27
+ }
28
+
29
+ export function auditLogPath() {
30
+ const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
31
+ return path.join(home, 'deepclean-audit.log')
32
+ }
33
+
34
+ // 超过轮转阈值时保留近期记录,同时保留所有仍可恢复批次的完整恢复依据。
35
+ // 未处理批次很多时可超过阈值:不能为了压缩日志丢失用户文件的恢复入口。
36
+ const AUDIT_MAX_BYTES = 5 * 1024 * 1024
37
+
38
+ function auditMaxBytes() {
39
+ const n = Number(process.env.DEEPCLEAN_AUDIT_MAX_BYTES)
40
+ return n > 0 ? n : AUDIT_MAX_BYTES
41
+ }
42
+
43
+ function rotateAuditIfNeeded(file) {
44
+ try {
45
+ if (fs.statSync(file).size <= auditMaxBytes()) return
46
+ const lines = fs.readFileSync(file, 'utf8').split('\n')
47
+ const entries = lines.map(line => { try { return JSON.parse(line) } catch { return null } })
48
+ const batches = new Map(), owners = new Map(), restored = new Set()
49
+ for (const entry of entries) {
50
+ if (entry?.action === 'clean' && Array.isArray(entry.moved)) {
51
+ batches.set(entry.batchId, entry.moved)
52
+ for (const item of entry.moved) owners.set(item.to, item)
53
+ } else if (entry?.action === 'restore' && Array.isArray(entry.results)) {
54
+ for (const item of batches.get(entry.batchId) || []) {
55
+ if (entry.results.some(r => r.ok && r.path === item.from && (!r.to || r.to === item.to))) restored.add(item)
56
+ }
57
+ }
58
+ }
59
+ const pending = new Set([...batches].filter(([, items]) => items.some(item =>
60
+ !restored.has(item) && owners.get(item.to) === item && canRestoreMoved(item))).map(([id]) => id))
61
+ let size = 0
62
+ let tail = lines.length
63
+ while (tail > 0 && size <= auditMaxBytes() / 2) {
64
+ tail -= 1
65
+ size += Buffer.byteLength(lines[tail]) + 1
66
+ }
67
+ const kept = lines.filter((line, i) => i >= tail || pending.has(entries[i]?.batchId))
68
+ const tmp = file + '.' + crypto.randomUUID() + '.tmp'
69
+ try {
70
+ fs.writeFileSync(tmp, kept.join('\n'), { encoding: 'utf8', flag: 'wx', mode: 0o600 })
71
+ fs.renameSync(tmp, file)
72
+ } finally {
73
+ try { fs.unlinkSync(tmp) } catch { /* renamed or never created */ }
74
+ }
75
+ } catch { /* 轮转失败不阻断清理 */ }
76
+ }
77
+
78
+ // 返回是否写入成功:审计失败时清理/恢复仍会完成,但调用方必须把「历史里可能看不到」
79
+ // 如实告诉用户,而不是继续承诺「可在历史恢复」。
80
+ export function appendAudit(entry) {
81
+ try {
82
+ const file = auditLogPath()
83
+ fs.mkdirSync(path.dirname(file), { recursive: true })
84
+ fs.appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...entry }) + '\n', 'utf8')
85
+ rotateAuditIfNeeded(file)
86
+ return true
87
+ } catch {
88
+ return false
89
+ }
90
+ }
91
+
92
+ function fileIdentity(file) {
93
+ const st = fs.lstatSync(file, { bigint: true })
94
+ if (st.isSymbolicLink()) throw new Error('符号链接不允许恢复')
95
+ return { dev: String(st.dev), ino: String(st.ino), birthtimeNs: String(st.birthtimeNs) }
96
+ }
97
+
98
+ function sameIdentity(a, b) {
99
+ return a.dev === b.dev && a.ino === b.ino && a.birthtimeNs === b.birthtimeNs
100
+ }
101
+
102
+ export function canRestoreMoved(item) {
103
+ try {
104
+ const actual = fileIdentity(item.to)
105
+ return !item.identity || sameIdentity(item.identity, actual)
106
+ } catch { return false }
107
+ }
108
+
109
+ function uniqueDest(dir, base) {
110
+ let dest = path.join(dir, base)
111
+ let i = 1
112
+ while (fs.existsSync(dest)) {
113
+ dest = path.join(dir, `${base}.${i}`)
114
+ i += 1
115
+ if (i > 1000) throw new Error('目标目录里同名文件过多')
116
+ }
117
+ return dest
118
+ }
119
+
120
+ // 恢复时如果原位置已有同名文件,按「名称 (restored)」另存,而不是覆盖。
121
+ function restoredName(dir, base) {
122
+ let dest = path.join(dir, `${base} (restored)`)
123
+ let i = 1
124
+ while (fs.existsSync(dest)) {
125
+ if (i > 1000) throw new Error('目标目录里同名文件过多')
126
+ dest = path.join(dir, `${base} (restored ${i++})`)
127
+ }
128
+ return dest
129
+ }
130
+
131
+ function restoreWord() {
132
+ return process.platform === 'darwin' ? '废纸篓' : '隔离区'
133
+ }
134
+
135
+ function restoreFail(code, message) {
136
+ const err = new Error(message)
137
+ err.code = code
138
+ return err
139
+ }
140
+
141
+ /** 逐项结果汇总,供宿主/客户端如实展示。 */
142
+ export function summarizeResults(results) {
143
+ const list = Array.isArray(results) ? results : []
144
+ const ok = list.filter((r) => r.ok).length
145
+ const codes = {}
146
+ for (const r of list) {
147
+ if (r.ok) continue
148
+ const code = r.code || 'io'
149
+ codes[code] = (codes[code] || 0) + 1
150
+ }
151
+ return { ok, failed: list.length - ok, codes }
152
+ }
153
+
154
+ function existsNoFollow(p) {
155
+ try { fs.lstatSync(p); return true } catch { return false }
156
+ }
157
+
158
+ // 只为审计原路径重建父目录,创建前检查全部既存祖先。
159
+ function ensureRestoreParent(target) {
160
+ assertRestoreParentSafe(target)
161
+ const parent = path.dirname(target)
162
+ fs.mkdirSync(parent, { recursive: true })
163
+ }
164
+
165
+ /**
166
+ * 把 paths 移到废纸篓(macOS/Windows/其他隔离目录)。
167
+ * @param {string[]} paths 绝对路径
168
+ * @param {Set<string>} allowed 本次扫描允许的路径集合
169
+ * @param {{annotations?: Map<string, {kb?: number|null, category?: string|null}>}} [options]
170
+ * `annotations` 只用于审计记录(该路径在扫描报告里的大小/分类),不参与放行判断。
171
+ * @returns {{batchId:string, moved:Array<{from:string,to:string}>, results:Array<object>}}
172
+ */
173
+ export function moveToTrash(paths, allowed, options = {}) {
174
+ if (!Array.isArray(paths) || paths.length === 0) throw new Error('没有要清理的条目')
175
+ if (paths.length > MAX_BATCH) throw new Error(`一次最多清理 ${MAX_BATCH} 项`)
176
+ if (!allowed || typeof allowed.has !== 'function') throw new Error('缺少允许列表')
177
+ const annotations = options && options.annotations instanceof Map ? options.annotations : null
178
+ const now = Date.now()
179
+ const batchId = crypto.randomUUID()
180
+ const trash = trashDir(now)
181
+ fs.mkdirSync(trash, { recursive: true })
182
+ // Isolate batches without lengthening the original filename.
183
+ const dir = path.join(trash, `deepclean-${batchId}`)
184
+ fs.mkdirSync(dir)
185
+ const moved = []
186
+ const results = []
187
+ for (const p of paths) {
188
+ try {
189
+ if (typeof p !== 'string' || !path.isAbsolute(p)) throw new Error('路径必须是绝对路径')
190
+ if (!allowed.has(p)) throw new Error('不在最近一次扫描的允许列表中')
191
+ const st = fs.lstatSync(p)
192
+ if (st.isSymbolicLink()) throw new Error('符号链接不允许移动')
193
+ if (!st.isDirectory() && !st.isFile()) throw new Error('不支持的条目类型')
194
+ const dest = uniqueDest(dir, path.basename(p))
195
+ const identity = fileIdentity(p)
196
+ fs.renameSync(p, dest)
197
+ const annotation = annotations ? annotations.get(p) : undefined
198
+ moved.push({
199
+ from: p,
200
+ to: dest,
201
+ identity,
202
+ ...(annotation ? { kb: Number.isFinite(annotation.kb) ? annotation.kb : null, category: annotation.category || null } : {}),
203
+ })
204
+ results.push({ path: p, ok: true, to: dest })
205
+ } catch (err) {
206
+ results.push({ path: p, ok: false, error: String(err && err.message ? err.message : err) })
207
+ }
208
+ }
209
+ const auditOk = appendAudit({ action: 'clean', batchId, platform: process.platform, trash: dir, moved, results })
210
+ return { batchId, moved, results, auditOk }
211
+ }
212
+
213
+ /**
214
+ * 撤销一个清理批次:把仍在废纸篓、原位置已空的条目移回。
215
+ */
216
+ export function undoBatch(batch, opts = {}) {
217
+ if (!batch || !Array.isArray(batch.moved) || batch.moved.length === 0) {
218
+ throw new Error('没有可撤销的批次')
219
+ }
220
+ return restoreMoved(batch.moved, batch.batchId, opts)
221
+ }
222
+
223
+ function assertRestoreParentSafe(target) {
224
+ // Keep lexical ancestors too: resolving '..' first could hide a linked component.
225
+ let parent = path.dirname(target)
226
+ for (;;) {
227
+ try {
228
+ const st = fs.lstatSync(parent)
229
+ if (st.isSymbolicLink()) throw restoreFail('symlink', '原路径的祖先目录是符号链接,拒绝恢复')
230
+ if (!st.isDirectory()) throw restoreFail('io', '原路径的祖先不是目录,拒绝恢复')
231
+ } catch (err) {
232
+ if (err.code !== 'ENOENT') throw err
233
+ }
234
+ const next = path.dirname(parent)
235
+ if (next === parent) break
236
+ parent = next
237
+ }
238
+ }
239
+
240
+ /** ok / notfound / changed(identity 不一致,区别于文件缺失)。 */
241
+ function restoreItemState(item) {
242
+ let actual
243
+ try { actual = fileIdentity(item.to) } catch { return 'notfound' }
244
+ if (!item.identity) return 'ok'
245
+ return sameIdentity(item.identity, actual) ? 'ok' : 'changed'
246
+ }
247
+
248
+ const ERRNO_CODES = {
249
+ EACCES: 'eacces', EPERM: 'eacces', EROFS: 'eacces',
250
+ EXDEV: 'exdev', ENOENT: 'enoent', EEXIST: 'exists', ENOTDIR: 'io', EISDIR: 'io',
251
+ ELOOP: 'symlink', ENOSPC: 'io', EBUSY: 'io',
252
+ }
253
+
254
+ function normalizeRestoreCode(err) {
255
+ const code = err && err.code ? String(err.code) : ''
256
+ if (['exists', 'notfound', 'changed', 'eacces', 'exdev', 'enoent', 'symlink', 'io'].includes(code)) return code
257
+ return ERRNO_CODES[code] || 'io'
258
+ }
259
+
260
+ /**
261
+ * 从审计日志的 moved 列表恢复(跨重启可用)。
262
+ *
263
+ * 每项结果带 code,调用方据此给出「怎么办」:
264
+ * notfound = 隔离区/废纸篓里已没有该条目(被清空/被移走)
265
+ * changed = 条目仍在但已被替换(identity 不一致)
266
+ * exists = 原位置已有同名文件(onConflict==='rename' 时会另存为副本)
267
+ * enoent = 原路径父目录不存在且不允许重建
268
+ * eacces / exdev / symlink / io = 权限、跨卷、符号链接、其他 IO 失败
269
+ *
270
+ * @param {Array<{from:string,to:string,identity?:object}>} moved
271
+ * @param {string} batchId
272
+ * @param {{onConflict?: 'fail'|'rename', recreateParents?: boolean}} [opts]
273
+ */
274
+ export function restoreMoved(moved, batchId = '', opts = {}) {
275
+ const onConflict = opts.onConflict === 'rename' ? 'rename' : 'fail'
276
+ const recreateParents = opts.recreateParents !== false
277
+ const roots = trashRoots()
278
+ const results = []
279
+ for (const item of [...moved].reverse()) {
280
+ try {
281
+ // 恢复源必须仍在「本插件批次目录」内,且 realpath 不能借中间符号链接逃逸。
282
+ if (!item || typeof item.from !== 'string' || !path.isAbsolute(item.from)
283
+ || typeof item.to !== 'string' || !path.isAbsolute(item.to)
284
+ || !underTrash(item.to, roots) || !inPluginBatch(item.to) || !realpathInsideTrash(item.to, roots)) {
285
+ throw restoreFail('notfound', `${restoreWord()}条目不在可恢复范围内,拒绝恢复`)
286
+ }
287
+ const state = restoreItemState(item)
288
+ if (state === 'notfound') throw restoreFail('notfound', `${restoreWord()}条目不存在,拒绝恢复`)
289
+ if (state === 'changed') throw restoreFail('changed', `${restoreWord()}条目已被替换,拒绝恢复`)
290
+ // 父目录存在时也要查:只在「父目录不存在」时才检查会漏掉父目录本身是符号链接的情况。
291
+ assertRestoreParentSafe(item.from)
292
+ const parent = path.dirname(item.from)
293
+ if (!existsNoFollow(parent)) {
294
+ if (!recreateParents) throw restoreFail('enoent', '原路径的目录已不存在,无法恢复')
295
+ ensureRestoreParent(item.from)
296
+ }
297
+ let target = item.from
298
+ if (existsNoFollow(target)) {
299
+ if (onConflict !== 'rename') throw restoreFail('exists', '原位置已存在同名文件,拒绝覆盖')
300
+ target = restoredName(parent, path.basename(item.from))
301
+ }
302
+ fs.renameSync(item.to, target)
303
+ results.push({ path: item.from, to: item.to, restoredTo: target, renamed: target !== item.from, ok: true })
304
+ } catch (err) {
305
+ results.push({
306
+ path: item && typeof item.from === 'string' ? item.from : '',
307
+ to: item && typeof item.to === 'string' ? item.to : '',
308
+ ok: false,
309
+ code: normalizeRestoreCode(err),
310
+ error: String(err && err.message ? err.message : err),
311
+ })
312
+ }
313
+ }
314
+ const auditOk = appendAudit({ action: 'restore', batchId, results })
315
+ return { batchId, results, auditOk }
316
+ }
317
+
318
+ function trashRoots() {
319
+ const roots = []
320
+ if (process.env.DEEPCLEAN_TRASH_DIR) roots.push(path.resolve(process.env.DEEPCLEAN_TRASH_DIR))
321
+ roots.push(path.resolve(os.homedir(), '.Trash'))
322
+ if (process.platform === 'win32') {
323
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
324
+ roots.push(path.resolve(base, 'deepclean-mydisk', 'trash'))
325
+ } else {
326
+ roots.push(path.resolve(os.homedir(), '.local', 'share', 'deepclean-mydisk', 'trash'))
327
+ }
328
+ return roots
329
+ }
330
+
331
+ function samePath(a, b) {
332
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
333
+ }
334
+
335
+ function startsWithPath(target, base) {
336
+ const t = process.platform === 'win32' ? target.toLowerCase() : target
337
+ const b = process.platform === 'win32' ? base.toLowerCase() : base
338
+ return t.startsWith(b + path.sep)
339
+ }
340
+
341
+ // 严格后代:废纸篓/隔离区根目录本身永远不被当成批次条目(否则可一次删掉整个废纸篓)。
342
+ function underTrash(p, roots) {
343
+ const resolved = path.resolve(p)
344
+ return roots.some((root) => startsWithPath(resolved, path.resolve(root)))
345
+ }
346
+
347
+ // 条目必须位于本插件的 deepclean-<uuid> 批次目录里(restore/purge 都要求)。
348
+ function inPluginBatch(p) {
349
+ return /^deepclean-/.test(path.basename(path.dirname(path.resolve(p))))
350
+ }
351
+
352
+ // realpath 归属校验:批次目录中间若被塞入符号链接指向外部,字符串前缀检查会被绕过。
353
+ function realpathInsideTrash(p, roots) {
354
+ let real
355
+ try { real = fs.realpathSync(p) } catch { return false }
356
+ for (const root of roots) {
357
+ let realRoot
358
+ try { realRoot = fs.realpathSync(root) } catch { continue }
359
+ if (startsWithPath(real, realRoot)) return true
360
+ }
361
+ return false
362
+ }
363
+
364
+ function dirSizeBytes(dir) {
365
+ let total = 0
366
+ let entries
367
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return 0 }
368
+ for (const entry of entries) {
369
+ if (entry.isSymbolicLink()) continue
370
+ const p = path.join(dir, entry.name)
371
+ try {
372
+ if (entry.isDirectory()) total += dirSizeBytes(p)
373
+ else if (entry.isFile()) total += fs.lstatSync(p).size
374
+ } catch { /* ignore */ }
375
+ }
376
+ return total
377
+ }
378
+
379
+ /**
380
+ * 显式永久删除某个清理批次(用户二次确认后调用)。
381
+ * 只允许删除隔离区/废纸篓内、由本插件移动的条目;符号链接拒绝。
382
+ */
383
+ export function purgeMoved(items, batchId = '') {
384
+ if (!Array.isArray(items) || items.length === 0) throw new Error('没有可永久删除的条目')
385
+ const roots = trashRoots()
386
+ const results = []
387
+ let purged = 0
388
+ let freedBytes = 0
389
+ for (const item of items) {
390
+ try {
391
+ const to = item && item.to
392
+ if (!to || !path.isAbsolute(to)) throw new Error('条目路径无效')
393
+ if (!underTrash(to, roots) || !inPluginBatch(to) || !realpathInsideTrash(to, roots)) {
394
+ throw new Error('不在隔离区/废纸篓的本插件批次目录内,拒绝删除')
395
+ }
396
+ let st
397
+ try { st = fs.lstatSync(to) } catch { throw new Error('条目已不存在(可能已被清空)') }
398
+ if (st.isSymbolicLink()) throw new Error('符号链接拒绝永久删除')
399
+ freedBytes += st.isDirectory() ? dirSizeBytes(to) : st.size
400
+ fs.rmSync(to, { recursive: true, force: true })
401
+ purged += 1
402
+ results.push({ from: item.from, to, ok: true })
403
+ } catch (err) {
404
+ results.push({ from: item && item.from, to: item && item.to, ok: false, error: String(err && err.message ? err.message : err) })
405
+ }
406
+ }
407
+ // 删完后把空的批次目录一起收掉,避免废纸篓/隔离区里留下 deepclean-<uuid> 空壳。
408
+ for (const dir of new Set(items.filter((i) => i && typeof i.to === 'string').map((i) => path.dirname(i.to)))) {
409
+ try { fs.rmdirSync(dir) } catch { /* 仍有余项或已被删除 */ }
410
+ }
411
+ const auditOk = appendAudit({ action: 'purge', batchId, results, purged, freedBytes })
412
+ return { batchId, purged, freedBytes, results, auditOk }
413
+ }
414
+
415
+ // 隔离区/废纸篓的「本插件批次」根目录(不含系统废纸篓里的其他文件)。
416
+ export function quarantineRoot() {
417
+ if (process.env.DEEPCLEAN_TRASH_DIR) return path.resolve(process.env.DEEPCLEAN_TRASH_DIR)
418
+ if (process.platform === 'win32') {
419
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
420
+ return path.resolve(base, 'deepclean-mydisk', 'trash')
421
+ }
422
+ if (process.platform === 'darwin') return path.resolve(os.homedir(), '.Trash')
423
+ return path.resolve(os.homedir(), '.local', 'share', 'deepclean-mydisk', 'trash')
424
+ }
425
+
426
+ function budgetedUsage(dir, deadline) {
427
+ let bytes = 0
428
+ let files = 0
429
+ let truncated = false
430
+ const stack = [dir]
431
+ while (stack.length) {
432
+ if (Date.now() > deadline) { truncated = true; break }
433
+ const cur = stack.pop()
434
+ let entries
435
+ try { entries = fs.readdirSync(cur, { withFileTypes: true }) } catch { continue }
436
+ for (const entry of entries) {
437
+ if (entry.isSymbolicLink()) continue
438
+ const child = path.join(cur, entry.name)
439
+ try {
440
+ if (entry.isDirectory()) stack.push(child)
441
+ else if (entry.isFile()) { bytes += fs.lstatSync(child).size; files += 1 }
442
+ } catch { /* ignore */ }
443
+ }
444
+ }
445
+ return { bytes, files, truncated }
446
+ }
447
+
448
+ /**
449
+ * 隔离区/废纸篓里「本插件移入」的批次占用(只读统计,带时间预算)。
450
+ * 布局:macOS 与 DEEPCLEAN_TRASH_DIR 下是 <root>/deepclean-<uuid>;
451
+ * Windows/其他平台默认是 <root>/<时间戳>/deepclean-<uuid>。
452
+ */
453
+ export function quarantineSummary(opts = {}) {
454
+ const budgetMs = Number.isFinite(opts.budgetMs) && opts.budgetMs > 0 ? opts.budgetMs : 1500
455
+ const deadline = Date.now() + budgetMs
456
+ const root = quarantineRoot()
457
+ const batches = []
458
+ let kb = 0
459
+ let files = 0
460
+ let truncated = false
461
+ const addBatch = (dir) => {
462
+ const usage = budgetedUsage(dir, deadline)
463
+ truncated = truncated || usage.truncated
464
+ const batchKb = Math.round(usage.bytes / 1024)
465
+ kb += batchKb
466
+ files += usage.files
467
+ let at = 0
468
+ try { at = fs.lstatSync(dir).mtimeMs } catch { /* ignore */ }
469
+ batches.push({ batchId: path.basename(dir).replace(/^deepclean-/, ''), path: dir, kb: batchKb, files: usage.files, at })
470
+ }
471
+ let entries = []
472
+ try { entries = fs.readdirSync(root, { withFileTypes: true }) } catch { /* 没有隔离区 */ }
473
+ for (const entry of entries) {
474
+ if (entry.isSymbolicLink()) continue
475
+ if (!entry.isDirectory()) continue
476
+ const child = path.join(root, entry.name)
477
+ if (entry.name.startsWith('deepclean-')) { addBatch(child); continue }
478
+ // Windows/其他平台:<root>/<时间戳>/deepclean-<uuid>
479
+ let nested = []
480
+ try { nested = fs.readdirSync(child, { withFileTypes: true }) } catch { continue }
481
+ for (const sub of nested) {
482
+ if (!sub.isDirectory() || sub.isSymbolicLink() || !sub.name.startsWith('deepclean-')) continue
483
+ addBatch(path.join(child, sub.name))
484
+ }
485
+ }
486
+ batches.sort((a, b) => b.at - a.at)
487
+ return { root, kb, files, batches, truncated }
488
+ }
489
+
490
+ function hashFile(file) {
491
+ return new Promise((resolve, reject) => {
492
+ const hash = crypto.createHash('sha256')
493
+ const stream = fs.createReadStream(file)
494
+ stream.on('data', chunk => hash.update(chunk))
495
+ stream.on('end', () => resolve(hash.digest('hex')))
496
+ stream.on('error', reject)
497
+ })
498
+ }
499
+
500
+ // Only writes new, exclusively owned files on the destination filesystem.
501
+ export async function moveAcrossDevices(source, target) {
502
+ const size = fs.statSync(source).size
503
+ const staging = fs.mkdtempSync(path.join(path.dirname(target), '.deepclean-'))
504
+ const tmp = path.join(staging, 'data')
505
+ let publishedIdentity = null
506
+ try {
507
+ fs.copyFileSync(source, tmp, fs.constants.COPYFILE_EXCL)
508
+ const before = await hashFile(source)
509
+ if (before !== await hashFile(tmp) || fs.statSync(tmp).size !== size) {
510
+ throw new Error('跨盘校验失败,原文件已保留')
511
+ }
512
+ try {
513
+ // Atomic no-replace publication on APFS/NTFS and other hard-link-capable volumes.
514
+ fs.linkSync(tmp, target)
515
+ } catch (err) {
516
+ if (!['ENOTSUP', 'EOPNOTSUPP', 'EPERM', 'ENOSYS', 'EXDEV'].includes(err.code)) throw err
517
+ // FAT/exFAT may not support hard links. COPYFILE_EXCL still refuses existing entries.
518
+ fs.copyFileSync(tmp, target, fs.constants.COPYFILE_EXCL)
519
+ }
520
+ publishedIdentity = fileIdentity(target)
521
+ if (before !== await hashFile(target) || fs.statSync(target).size !== size) {
522
+ throw new Error('跨盘校验失败,原文件已保留')
523
+ }
524
+ // Do not remove the source if a destination entry was replaced during validation.
525
+ if (!sameIdentity(publishedIdentity, fileIdentity(target))) throw new Error('目标文件已变化,原文件已保留')
526
+ fs.unlinkSync(source)
527
+ } catch (err) {
528
+ if (publishedIdentity) {
529
+ try {
530
+ if (sameIdentity(publishedIdentity, fileIdentity(target))) fs.unlinkSync(target)
531
+ } catch { /* Never remove a replacement that we do not own. */ }
532
+ }
533
+ throw err
534
+ } finally {
535
+ try { fs.rmSync(staging, { recursive: true, force: true }) } catch { /* A failed cleanup cannot invalidate a successful move. */ }
536
+ }
537
+ }