Repository navigation
Expand file tree
/
Copy pathProgram.cs
More file actions
1680 lines (1543 loc) · 95 KB
/
Copy pathProgram.cs
File metadata and controls
1680 lines (1543 loc) · 95 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
using DiskGrowthMonitor.Cli;
using DiskGrowthMonitor.Models;
using DiskGrowthMonitor.Native;
using DiskGrowthMonitor.Services;
using DiskGrowthMonitor.Util;
namespace DiskGrowthMonitor;
internal static class Program
{
/// <summary>
/// 入口只做一件事:兜底。任何逃出 <see cref="Run"/> 的异常都在这里落盘 ——
/// 2026-09-24 的 0xE0434352 事故(提权档接管父控制台后崩溃,窗口全黑、日志只剩握手行)
/// 正是因为异常输出既进不了窗口、也没进日志,事后只能靠推断定位根因。
/// </summary>
private static int Main(string[] args)
{
// 🔴 语言判定必须是本进程的**第一件事**,而且必须只走 P/Invoke:
// ① 它决定之后所有提示文本的措辞,包括「参数错误」与崩溃报告,因此不能晚于任何输出;
// ② Run() 开头会把控制台输出代码页切成 UTF-8(65001),一旦切过,GetConsoleOutputCP
// 就再也读不到中文代码页(936/950/54936),「界面中文 且 代码页中文」这个双条件
// 将永远不成立 —— 2026-09-24 第 57 轮实施时实测确认,故顺序不可调换;
// ③ 不得触碰任何 Console 成员(理由见 SystemNative.TryGetLanguageInfo 的注释)。
try { Lang.Initialize(LanguageProbe.Resolve(args)); }
catch { /* 语言探查失败:保留默认档(英文),不影响扫描主流程 */ }
try
{
return Run(args);
}
catch (Exception ex)
{
ReportFatal(args, ex);
return 1;
}
finally
{
// 第 57 轮 R3 修法:Run() 开头的 Console.OutputEncoding=UTF8 会把**控制台窗口**的输出
// 代码页永久改成 65001(同一个窗口里之后再启动的进程都会读到 65001),退出前写回原值。
// 🔴 必须是本进程的最后一步:恢复之后 Console 的编码器与代码页就不一致了,再输出会乱码。
// ReportFatal 在此**之前**已完成(它在 catch 里,finally 之后才轮到这一行)。
//
// 🔴 第 69 轮修正:提权子进程接管的是父进程的控制台,代码页归父进程所有。
// 子进程入口读到的「原始代码页 936」来自它自己那个新控制台(已随 FreeConsole 销毁),
// 此时 SetConsoleOutputCP(936) 作用的是父窗口,会把父进程设的 65001 打回 936 ——
// 父进程随后用 UTF-8 输出的崩溃摘要被 GBK 解码,即「崩溃后屏幕乱码」。
// 故提权子进程退出时不恢复,由父进程自己在退出前恢复。
if (!HasFlag(args, "--elevated"))
try { LanguageProbe.RestoreConsoleCodePage(); } catch { }
}
}
/// <summary>
/// 崩溃兜底:把「类型 + 消息 + 堆栈」写到所有还活着的输出目标上。自身绝不抛异常,
/// 否则连退出码都保不住。
/// </summary>
private static void ReportFatal(string[] args, Exception ex)
{
string text = Lang.T("[崩溃] ", "[CRASH] ") + ex.GetType().FullName + Lang.T(":", ": ") + ex.Message
+ Environment.NewLine + ex.StackTrace + Environment.NewLine;
// 1) 屏幕:控制台可能已 FreeConsole 或句柄已失效,打印失败属预期情况,忽略
try { Console.Error.WriteLine(); Console.Error.WriteLine(text); } catch { }
// 2) 文件:提权档写进父进程正在中继的日志(父进程见到退出码非 0 会把它打出来);
// 普通档没有该参数,退化写 exe 同级 crash.log —— 保证任何崩溃都留下证据
try
{
string? path = FindArgValue(args, "--console-log");
// 🔴 编码必须分流:
// · 无 --console-log(本地 crash.log):用系统 ANSI(中文 Windows = GBK/936),
// 记事本默认按 ANSI 读,中文直接可读 —— 修掉 2026-10 的「crash.log 乱码」问题;
// · 有 --console-log(提权中继):父进程 RelayConsoleLog/DumpAttachLog 按 UTF-8
// 解码,必须保持 UTF-8 无 BOM,否则中继侧反而产生新乱码。
Encoding enc;
if (path == null)
{
path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "crash.log");
enc = Encoding.Default;
}
else
{
enc = new UTF8Encoding(false);
}
File.AppendAllText(path, text, enc);
}
catch { /* 磁盘满 / 无权限:已尽力,不再冒险 */ }
}
/// <summary>取 <c>--name value</c> 形式的开关取值(只认两参数写法)。</summary>
private static string? FindArgValue(string[] args, string name)
{
for (int i = 0; i + 1 < args.Length; i++)
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) return args[i + 1];
return null;
}
/// <summary>判断命令行是否含某个无值的开关(如 <c>--elevated</c>)。</summary>
private static bool HasFlag(string[] args, string name)
{
for (int i = 0; i < args.Length; i++)
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
/// <summary>
/// 开发者模式(<c>--dev</c>)是否开启。由 <see cref="Run"/> 在解析参数后立即赋值,
/// 供后续各处的 <see cref="Dev(string)"/> 判定。
/// </summary>
private static bool DevMode;
/// <summary>
/// 仅在开发者模式下输出的诊断行。
///
/// 这些信息面向开发与排障(环境明细、增量决策依据、估算与探测过程、知识库/跳过清单记账),
/// 对「这次扫了多少、涨在哪」这个核心问题没有贡献,却能把关键数字淹没在几十行里 ——
/// 因此默认关闭,用 <c>--dev</c> 打开。
/// 注意:这里只是不打印,统计与判定逻辑一律照常执行,报告与数据库内容不受影响。
/// </summary>
private static void Dev(string message)
{
if (DevMode) Console.WriteLine(message);
}
/// <summary>
/// 解析数据目录。默认(无 --data-dir)返回 <paramref name="baseDir"/>(exe 目录,向后兼容);
/// 指定时 GetFullPath + 不存在则创建,随后若非 baseDir 则尝试搬迁旧库。
/// 任何失败都打印警告并回退 <paramref name="baseDir"/> —— 保持可用,绝不崩。
/// </summary>
private static string ResolveDataDir(Options opt, string baseDir)
{
if (string.IsNullOrWhiteSpace(opt.DataDir))
return baseDir;
string dataDir;
try
{
dataDir = Path.GetFullPath(opt.DataDir.Trim().Trim('"'));
}
catch (Exception ex)
{
Console.WriteLine(Lang.F("[提示] 数据目录路径无效 {0}:{1},已回退 exe 目录。",
"[Note] Invalid data directory {0}: {1}; falling back to the exe folder.",
opt.DataDir!, ex.Message));
return baseDir;
}
// 规范化后与 baseDir 相同(大小写不敏感)→ 等价于没指定,直接返回
if (string.Equals(
dataDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
baseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase))
return baseDir;
try
{
if (!Directory.Exists(dataDir))
Directory.CreateDirectory(dataDir);
}
catch (Exception ex)
{
Console.WriteLine(Lang.F("[提示] 无法创建数据目录 {0}:{1},已回退 exe 目录。",
"[Note] Could not create the data directory {0}: {1}; falling back to the exe folder.",
dataDir, ex.Message));
return baseDir;
}
// 旧库搬迁:指定了新目录但其中无库、exe 同级有旧库时,自动搬到新目录
if (!MigrateDbIfNeeded(baseDir, dataDir))
return baseDir; // 搬迁失败:回退 exe 目录,保持可用
return dataDir;
}
/// <summary>
/// 旧库搬迁:仅当 <paramref name="dataDir"/> != <paramref name="baseDir"/> 且
/// 目标无 disk_growth.db、源有旧库时触发。搬迁 db + -wal + -shm(存在的都搬)。
/// 成功 → 删除源、打印提示、返回 true;任一步失败 → 清理目标半成品、源保留、
/// 打印警告、返回 false(由调用方回退 baseDir)。
/// </summary>
private static bool MigrateDbIfNeeded(string baseDir, string dataDir)
{
string srcDb = Path.Combine(baseDir, "disk_growth.db");
string dstDb = Path.Combine(dataDir, "disk_growth.db");
// 目标已有库,或源无旧库 → 无需搬迁
if (File.Exists(dstDb) || !File.Exists(srcDb))
return true;
// 需要搬迁的三个文件(-wal/-shm 可能不存在,跳过即可)
string[] names = { "disk_growth.db", "disk_growth.db-wal", "disk_growth.db-shm" };
var copied = new List<string>();
try
{
foreach (var name in names)
{
string src = Path.Combine(baseDir, name);
string dst = Path.Combine(dataDir, name);
if (!File.Exists(src)) continue;
File.Copy(src, dst, false);
copied.Add(dst);
}
// 验证:目标 db 存在且长度与源一致
var srcInfo = new FileInfo(srcDb);
var dstInfo = new FileInfo(dstDb);
if (!dstInfo.Exists || dstInfo.Length != srcInfo.Length)
throw new IOException(Lang.T("搬迁后校验失败(长度不一致)",
"post-move verification failed (length mismatch)"));
// 搬迁成功:删除源文件,释放原盘空间
foreach (var name in names)
{
string src = Path.Combine(baseDir, name);
try { if (File.Exists(src)) File.Delete(src); }
catch { /* 源删除失败不影响新库使用,仅告警 */ }
}
Console.WriteLine(Lang.F("✓ 旧库已从 {0} 搬迁到 {1}",
"Moved the old database from {0} to {1}", baseDir, dataDir));
return true;
}
catch (Exception ex)
{
// 清理目标半成品
foreach (var p in copied)
{
try { if (File.Exists(p)) File.Delete(p); }
catch { }
}
Console.WriteLine(Lang.F("[提示] 旧库搬迁失败 {0}:{1},已回退 exe 目录继续使用旧库。",
"[Note] Failed to move the old database {0}: {1}; falling back to the exe folder.",
srcDb, ex.Message));
return false;
}
}
private static int Run(string[] args)
{
// 把控制台切到 UTF-8,保证中文输出不依赖代码页。
// ⚠ 这一行**必须**留在 Main 的语言探查之后:它会改变控制台输出代码页(→ 65001),
// 而语言判定要用「控制台本来的代码页是否是中文代码页」当条件之一。
// 退出前由 Main 的 finally 恢复原代码页(见 LanguageProbe.RestoreConsoleCodePage)。
try { Console.OutputEncoding = Encoding.UTF8; }
catch { /* 部分终端不支持切换编码,忽略即可 */ }
var opt = Options.Parse(args);
if (opt.Error != null)
{
Console.Error.WriteLine(Lang.T("参数错误:", "Argument error: ") + opt.Error);
Console.Error.WriteLine();
Console.Error.Write(Options.Help());
return 2;
}
if (opt.ShowHelp)
{
Console.Write(Options.Help());
return 0;
}
// 开发者模式须尽早生效:后面从「环境」诊断行开始就要按它决定是否打印
DevMode = opt.DevMode;
// ---------- 提权档输出接管(子进程侧,方案 B:AttachConsole 回父窗口) ----------
// 带 --attach-parent 时:先按 PoC(_optdb/attachpoc/)验证过的四步接管父进程控制台
// (FreeConsole → AttachConsole → SetStdHandle → Console.SetOut 重挂)。
// 成功 ⇒ Console.Out 直达父窗口,进度条保持完整交互;日志文件只写一行 [attach] ok
// 握手标记(父进程据此转为静默等待,避免心跳行插进子进程的原地重绘块)。
// 失败 ⇒ 写 [attach] fail,退回下面的「输出落盘 + 父进程中继」(方案 A)行为。
bool attachAttempted = false;
bool attachOk = false;
if (opt.AttachParentPid is { Length: > 0 } && int.TryParse(opt.AttachParentPid, out int attachPid))
{
attachAttempted = true;
attachOk = ConsoleAttach.TryAttach(attachPid);
if (opt.ConsoleLogPath is { Length: > 0 })
{
try { File.WriteAllText(opt.ConsoleLogPath, attachOk ? "[attach] ok\n" : "[attach] fail\n", new UTF8Encoding(false)); }
catch { /* 标记写不进:父进程等不到标记会保持中继模式;attach 已成功时输出本身直达父窗口,无碍 */ }
}
}
// ---------- 提权档日志中继(子进程侧) ----------
// 仅在 attach 未接管成功时才需要:本进程输出无法进入父进程的窗口
// (Verb="runas" 只能 UseShellExecute=true,.NET Framework 禁止该模式重定向流)。
// 因此把全部控制台输出转写进日志文件,由父进程轮询回显;同时隐藏自带的新控制台
// 窗口(内容都进日志了,留着空壳只会让人以为程序卡死)。
// 打不开日志时退回「写自己的控制台」= 修复前的行为,父进程会检测到日志不增长并提示。
StreamWriter? consoleLog = null;
if (!attachOk && opt.ConsoleLogPath is { Length: > 0 })
{
try
{
// 显式不带 BOM:父进程按 UTF-8 增量解码,BOM 字节会被当成正文首字符。
// 已尝试过 attach 时用追加模式:文件里已有握手标记行,截断会让父进程读取位置错位
consoleLog = new StreamWriter(opt.ConsoleLogPath, attachAttempted, new UTF8Encoding(false)) { AutoFlush = true };
Console.SetOut(consoleLog);
Console.SetError(consoleLog);
ConsoleWindow.TryHide();
}
catch (Exception ex)
{
consoleLog = null;
// 走到这里说明「输出既没接管进父窗口、也写不进日志」:若之前已 FreeConsole,
// 本进程此刻没有任何控制台,直接写 Console 会二次失败。补一个自带窗口兜底。
if (attachAttempted && !attachOk) ConsoleAttach.EnsureConsole();
try
{
Console.Error.WriteLine(Lang.F("[提权] 无法创建输出日志 {0}:{1}",
"[Elevate] Could not create the output log {0}: {1}", opt.ConsoleLogPath, ex.Message));
Console.Error.WriteLine(Lang.T(" 将改用本进程自带窗口直接输出(父窗口将看不到本次结果)。",
" Falling back to this process's own window (the parent window will not see the result)."));
}
catch { /* 连兜底控制台也不可用:宁可静默,也不能因打印错误信息而崩 */ }
}
}
// ---------- 以管理员身份自举 ----------
// 只在「本次确实要扫描」时尝试:--list-* / --reset-db 这类管理命令不需要管理员权限,
// 无谓地弹一次 UAC 只会打扰用户。
// 三个跳过条件(任一成立即不重启):
// · --no-elevate:用户显式关闭;
// · --elevated:本进程已是自举产物(硬保险,防御「提权未生效」时无限重启);
// · 输出被重定向:提权后进程运行在新控制台,日志进不了当前重定向流 ——
// 与其让脚本拿到「有退出码但没日志」的结果,不如明确提示后按原权限跑完。
bool willScan = opt.DriveSpecs.Count > 0 || opt.RootDirs.Count > 0;
if (willScan && !opt.NoElevate && !opt.ElevatedChild && !ElevationHelper.IsElevated())
{
if (Console.IsOutputRedirected)
{
Console.WriteLine(Lang.T("[提权] 检测到输出已重定向(脚本调用),跳过自举:",
"[Elevate] Output is redirected (script invocation); skipping self-elevation:"));
Console.WriteLine(Lang.T(" 提权后的进程运行在新控制台,日志无法进入当前重定向流。",
" The elevated process runs in a new console, so its log cannot reach the current redirected stream."));
Console.WriteLine(Lang.T(" 如需管理员权限,请手动以管理员身份运行本程序。",
" To run with administrator rights, start this program as administrator manually."));
}
else if (ElevationHelper.TryRelaunchElevated(args, out int childExit))
{
// 子进程已在新窗口跑完;本进程的唯一职责就是把退出码原样传回去
return childExit;
}
Console.WriteLine();
}
// 数据库默认放 exe 所在目录,可用 --data-dir 迁移到其它盘(典型:扫 C 盘时把数据放 D 盘,
// 避免数据库写满系统盘)。用 AppDomain.CurrentDomain.BaseDirectory 而非 Assembly.Location:
// 后者在影子复制等场景下不是 exe 目录。注意不能用 AppContext.BaseDirectory —— 它是
// .NET Framework 4.6 才引入的,net45 目标取不到(编译期即报错)。
string baseDir = AppDomain.CurrentDomain.BaseDirectory;
string dataDir = ResolveDataDir(opt, baseDir);
string dbPath = Path.Combine(dataDir, "disk_growth.db");
// 颜色先于首行输出启用:交互模式下才开启(日志中继/重定向会禁用,避免 ANSI 码污染),
// 终端不支持 VT(WS2012 等)时 ConsoleStyle 内部自动降级为纯文本。
ConsoleStyle.Enable(consoleLog == null);
Console.WriteLine(ConsoleStyle.Section(Lang.T("DiskGrowthMonitor · 磁盘空间增长监控",
"DiskGrowthMonitor - disk space growth monitor")));
Console.WriteLine();
if (opt.ShowEnv)
{
RunShowEnv(opt);
return 0;
}
if (opt.ListDrives)
{
PrintDrives();
return 0;
}
using var db = new DatabaseService(dbPath);
Dev(Lang.F("[环境] 数据库:{0}", "[Env] Database: {0}", db.DatabasePath));
if (!string.Equals(dataDir, baseDir, StringComparison.OrdinalIgnoreCase))
Dev(Lang.F("[环境] 数据目录:{0}", "[Env] Data directory: {0}", dataDir));
// 权限状态显式打印:它是「USN 增量能否生效」的唯一决定因素。
// 以前只能从「状态与上一轮不对应」这类间接提示里倒推,现在第一眼就能看到。
Dev(Lang.F("[环境] 权限:{0}", "[Env] Privilege: {0}",
ElevationHelper.IsElevated()
? Lang.T("管理员(可读 USN 变更日志 ⇒ 增量可用)",
"Administrator (the USN change journal is readable => incremental available)")
: Lang.T("普通用户(USN 增量不可用 ⇒ 每轮全量扫描)",
"Standard user (USN incremental unavailable => full scan every run)")));
if (opt.DbArgSeen)
Console.WriteLine(Lang.T(
"[提示] --db 开关已取消:数据库固定为 exe 目录下的 disk_growth.db,本次已忽略你传入的路径。",
"[Note] The --db switch has been removed: the database is fixed to disk_growth.db next to the exe; "
+ "the path you passed was ignored."));
// 目录用途知识库:首次运行/程序升级时补齐缺失的内置条目(只增不改,用户编辑不会被覆盖)
var knowledge = new KnowledgeService(db);
if (knowledge.SeedInserted > 0)
Dev(Lang.F("[知识库] 已补齐 {0} 条内置目录用途条目,当前生效 {1} 条。",
"[Knowledge] Seeded {0} built-in directory-purpose entries; {1} in effect.",
knowledge.SeedInserted, knowledge.EffectiveCount));
// ---------- --reset-db:清空数据库(交互确认 → 重置 → 退出,不扫描) ----------
if (opt.ResetDb)
return RunReset(db);
if (opt.ResetSkips)
{
int removed = db.ResetPersistentSkips();
Console.WriteLine(Lang.F(
"[跳过清单] 已清空 {0} 条「权限不足 / IO 错误」记录,本次将重新尝试扫描这些目录。",
"[Skip list] Cleared {0} \"access denied / IO error\" records; these directories will be retried this run.",
FormatUtil.Count(removed)));
}
if (opt.ListSkips)
{
PrintSkips(db, opt.NoKnowledgeRescan ? null : knowledge);
return 0;
}
if (opt.ListKnowledge)
{
PrintKnowledge(db);
return 0;
}
// ---------- 解析扫描根 ----------
if (opt.DriveSpecs.Count == 0 && opt.RootDirs.Count == 0)
{
Console.Error.WriteLine(Lang.T(
"错误:必须用 -d/--drive 指定盘符,或用 -r/--root 指定扫描目录。使用 --help 查看用法。",
"Error: specify a drive with -d/--drive, or a directory with -r/--root. Use --help for usage."));
return 2;
}
var roots = ResolveRoots(opt);
if (roots.Count == 0)
{
Console.Error.WriteLine(Lang.T("错误:没有解析到任何有效的扫描根。",
"Error: no valid scan root could be resolved."));
return 2;
}
Console.WriteLine(Lang.F(" 扫描范围 {0}", " Scan roots {0}",
string.Join(Lang.T("、", ", "), roots.Select(r => r.DisplayName))));
// 环境探测:把「这台机器上应该怎么扫」从编译期常量变成运行期判断。
// 三档目标环境(WS2012 / Win10 / Win11)能力差异不小,写死参数只能取三者交集、
// 白白丢掉新系统与新硬件的收益。此处已完成提权自举,介质探测能拿到真实结果。
// 语言探查结果由 Main 传入(此处不重新探测:控制台代码页早已被切成 UTF-8,重探会得到错的结论)。
var sysProfile = EnvironmentProbe.Probe(roots.Select(r => r.Path), Lang.Info);
var sysStrategy = EnvironmentProbe.Decide(sysProfile, opt.EnumEngine);
Dev(Lang.T("[环境] ", "[Env] ") + EnvironmentProbe.FormatBrief(sysProfile, sysStrategy));
// 语言行把「结论 + 原始探测值」一并打出(下放 --dev):语言判错的唯一表现就是「整篇都是英文」,
// 不给出原始值就只能靠猜(尤其猜不到「界面中文 + 代码页 65001 → 判英文」这种组合)。
var langInfo = sysProfile.Language;
Dev(Lang.F(
"[环境] 语言:{0}(界面 {1} · 控制台代码页 输出 {2} / 输入 {4}{3})",
"[Env] Language: {0} (UI {1} - console code page out {2} / in {4}{3})",
Lang.DescribeLanguage(langInfo?.Language ?? Lang.Current),
string.IsNullOrEmpty(langInfo?.UiCultureName) ? Lang.T("未知", "unknown") : langInfo!.UiCultureName,
langInfo?.ConsoleOutputCodePage ?? 0,
(langInfo?.IsForced ?? false) ? Lang.T(" · 由 --lang 指定", " - forced by --lang") : string.Empty,
langInfo?.ConsoleInputCodePage ?? 0));
// 运行方式一行:管理员 + 三个开关。默认排除必须点明「知识库豁免」的存在——
// 默认排除里的系统目录一旦命中知识库就会被放行,只说「默认排除」会与实际行为对不上
// (2026-09-24 用户反馈第 3 条)。
string modeAdmin = ElevationHelper.IsElevated()
? Lang.T("管理员(增量可用)", "admin (incremental available)")
: Lang.T("普通用户(全量扫描)", "standard user (full scan)");
string modeExcl = opt.NoDefaultExclude
? Lang.T("无默认排除", "no default exclusion")
: opt.NoKnowledgeRescan
? Lang.T("默认排除", "default exclusions")
: Lang.T("默认排除(知识库命中仍扫描)", "default exclusions (knowledge-base hits still scanned)");
string modeDedup = opt.NoDedup ? Lang.T("不去重", "no dedup") : Lang.T("硬链去重", "hardlink dedup");
string modeReparse = opt.FollowReparse ? Lang.T("跟随符号链接", "follow reparse points") : Lang.T("不跟随符号链接", "no reparse follow");
Console.WriteLine(Lang.F(" 运行方式 {0} · {1} · {2} · {3}",
" Mode {0} - {1} - {2} - {3}", modeAdmin, modeExcl, modeDedup, modeReparse));
Dev(Lang.F("[环境] 枚举引擎:{0}", "[Env] Enum engine: {0}", sysStrategy.EnumEngine));
Console.WriteLine();
// ---------- 阶段一:建立「上一轮」对比基准 ----------
var prevRun = db.GetLatestRun();
DateTime? prevScanTime = prevRun?.ScanTime;
long previousRunId = prevRun?.Id ?? 0;
Console.WriteLine(ConsoleStyle.Section(Lang.T("▸ 1/5 建立对比基准", "▸ 1/5 Establish comparison baseline")));
var baselines = db.BeginPrevSnapshot(roots);
long prevRows = baselines.Sum(b => b.CopiedRows);
bool isFirstRun = prevRows == 0;
// 按根交代基准来源:dir_snapshots 按「根」轮换,每个根的基准都是它自己最近一次扫描
// 的快照 —— 多盘轮换(-d c,d → -d c → -d c,d)时,扩回的根基准可能来自更早的批次,
// 这里与报告一样如实标注,绝不把它说成「上一轮」。
foreach (var b in baselines)
{
var root = roots.First(r => r.Key.Equals(b.RootKey, StringComparison.OrdinalIgnoreCase));
if (b.CopiedRows > 0)
{
string when = b.BaselineRunId.HasValue
? Lang.F("第 {0} 轮 · {1}", "run #{0} - {1}",
b.BaselineRunId.Value, FormatUtil.Timestamp(b.BaselineTime ?? DateTime.MinValue))
: Lang.T("时间未知(旧版本数据库)", "unknown (database from an older version)");
Console.WriteLine(Lang.F(
" {0} 已固化 {1} 条基准(上次 {2})",
" {0} pinned {1} directory records as baseline (last {2})",
root.DisplayName, FormatUtil.Count(b.CopiedRows), when));
}
else
{
Console.WriteLine(Lang.F(
" {0} 首次运行(本次结果将作为基准)",
" {0} first run (the result becomes its baseline)",
root.DisplayName));
}
}
Console.WriteLine();
// ---------- 增量(USN)与复用策略准备 ----------
// 关键约束:USN 对比区间 [基准位置, 现在] 必须覆盖「该根基准行采集之后」的全部变更。
// USN 状态在扫描完成后记录(state.RunId = 记录它的批次),而 dir_snapshots 按根轮换、
// 每根的基准行来自该根最近一次扫描(root_last_scan.run_id)。因此按根放宽后的判据是:
// state.RunId ≤ 该根最近扫描批次
// USN 区间起点早于基准采集点时只是「多含一段变更」(多余子树会被重扫,安全);
// 反之若 USN 基准晚于基准行,中间的变更不在区间内,复用会漏检 ⇒ 必须全量。
var settings = ScanSettings.From(opt);
var stageWriter = new StageWriter(db);
var policies = new Dictionary<string, ReusePolicy>(StringComparer.OrdinalIgnoreCase);
var pendingUsn = new List<(char Letter, ulong JournalId, long NextUsn)>();
if (isFirstRun)
{
Dev(Lang.T("[增量] 首次运行无对比基准,本次全量扫描(扫描完成后记录 USN 状态,下次运行起可增量)。",
"[Incremental] First run has no baseline; full scan this time (USN state is recorded "
+ "afterwards, so the next run can be incremental)."));
}
else if (settings.ForceFullScan)
{
Dev(Lang.T("[增量] 已指定 --full,本次强制全量扫描。",
"[Incremental] --full was specified; a full scan is forced for this run."));
}
else
{
var lastScans = db.GetRootLastScans();
bool UsnCoversRoot(ScanRoot root, DatabaseService.VolumeStateInfo s) =>
lastScans.TryGetValue(root.Key, out var ls) && s.RunId <= ls.RunId;
foreach (var letter in roots
.Select(r => RootVolumeLetter(r.Path))
.Where(l => l.HasValue)
.Select(l => l!.Value)
.Distinct())
{
var saved = db.GetVolumeState(letter);
if (saved == null)
{
Dev(Lang.F("[增量] {0}: 暂无上次 USN 日志状态,本次全量扫描(下次运行起可增量)。",
"[Incremental] {0}: no saved USN journal state; full scan this time "
+ "(the next run can be incremental).", letter));
continue;
}
var volRoots = roots.Where(r => RootVolumeLetter(r.Path) == letter).ToList();
if (!volRoots.Any(r => UsnCoversRoot(r, saved)))
{
// 注意:最常见的原因不是「根集合变化」,而是上一轮没有以管理员身份运行 ——
// 读 USN 变更日志要先打开卷设备 \\.\X:(需要管理员权限),打不开就拿不到新的
// (JournalId, NextUsn),卷状态写不回去 ⇒ 保存值永远停在最后一次管理员运行的位置。
// 所以这里必须把这条原因排在前面,否则会把排查方向带偏。
Dev(Lang.F("[增量] {0}: USN 状态与各扫描根的最近扫描批次不对应,本次全量扫描。",
"[Incremental] {0}: the USN state does not line up with any scanned root's "
+ "latest run; full scan this time.", letter));
Dev(Lang.T(" 最常见的原因是上一轮未以管理员身份运行(读 USN 变更日志需要管理员权限,"
+ "卷状态写不回去);其次才是上次扫描曾回退或扫描根集合变化。",
" The most common cause is that the previous run was not elevated (reading the USN "
+ "change journal needs administrator rights, so the volume state could not be saved); "
+ "less often, the last scan was rolled back or the set of scan roots changed."));
continue;
}
var change = UsnJournalService.Collect(letter, saved.JournalId, saved.NextUsn,
msg => Dev(Lang.F(" [增量:{0}:] {1}", " [Incremental:{0}:] {1}", letter, msg)));
if (change == null) continue; // 卷不可读,原因已输出
pendingUsn.Add((letter, change.JournalId, change.NextUsn));
if (change.FullRescan)
{
// FullRescanReason 是 string?;`FullRescan = true` 的 11 处都同时赋了原因,
// 这里的 ?? 只为一处不落(旧写法是 $"" 插值,null 本来也渲染成空串,行为不变)
Dev(Lang.F(" [增量:{0}:] {1},本次全量扫描。",
" [Incremental:{0}:] {1}; full scan this time.",
letter, change.FullRescanReason ?? string.Empty));
continue;
}
foreach (var root in volRoots)
{
if (!UsnCoversRoot(root, saved))
{
Dev(Lang.F(" [增量:{0}:] 「{1}」的最近一次扫描早于该卷的 USN 基准批次(或无记录),"
+ "该根本次全量扫描。",
" [Incremental:{0}:] \"{1}\" was last scanned before this volume's USN "
+ "baseline run (or has no record); this root is fully rescanned.",
letter, root.DisplayName));
continue;
}
// 路径盘符 ≠ 真实卷(根位于 junction / 符号链接 / 挂载点之后):按盘符读
// USN 日志会读到别的卷 ⇒ 变更集恒为空 ⇒ 会被误判「无变更」并整根复用
// 过期快照。故该根直接放弃增量(不登记复用策略 ⇒ 本轮全量扫描)。
if (!IsOnDriveRootVolume(root.Path, letter))
{
Dev(Lang.F(" [增量:{0}:] 「{1}」位于 junction / 符号链接 / 挂载点之后"
+ "(真实卷不是 {0}:),按盘符读 USN 日志会读到别的卷,该根本次全量扫描。",
" [Incremental:{0}:] \"{1}\" lies behind a junction / symlink / mount point "
+ "(its real volume is not {0}:), so reading the USN journal by drive letter would "
+ "read another volume; this root is fully rescanned.",
letter, root.DisplayName));
continue;
}
if (change.NoChanges)
Dev(Lang.F(" [增量:{0}:] 自上次扫描以来无任何文件系统变更,「{1}」将整体复用上一轮快照。",
" [Incremental:{0}:] no file-system changes since the last scan; \"{1}\" "
+ "reuses the previous snapshot as a whole.", letter, root.DisplayName));
else
Dev(Lang.F(" [增量:{0}:] 检测到 {1} 处变更,仅重扫涉及的 {2} 个目录子树。",
" [Incremental:{0}:] {1} changes detected; only the {2} affected directory "
+ "subtrees are rescanned.",
letter,
FormatUtil.Count(change.ChangedRecords),
FormatUtil.Count(change.DirtyPaths.Count)));
// 该根必须有基准行才允许复用(无基准时无从复用)
if (db.CountPrevRows(root) > 0)
policies[root.Key] = new ReusePolicy(change.DirtyPaths, root.Path);
}
}
}
Console.WriteLine();
// ---------- 阶段二:扫描并写入临时表 ----------
Console.WriteLine(ConsoleStyle.Section(Lang.T("▸ 2/5 扫描", "▸ 2/5 Scan")));
db.CreateStageTable();
var skip = new SkipListService(settings.UseDefaultExclude, settings.FollowReparse, opt.Excludes,
opt.NoKnowledgeRescan ? null : knowledge);
var loadedSkips = db.GetSkipRecords();
skip.LoadPersistent(loadedSkips);
if (skip.PersistentSkipCount > 0)
{
int knowledgeHit = skip.KnowledgeRescanEnabled
? loadedSkips.Count(r => SkipRecord.IsPersistentSkip(r.Reason) && skip.IsKnowledgeRescued(r.Path))
: 0;
Dev(knowledgeHit > 0
? Lang.F(" 已加载 {0} 条历史跳过记录(权限不足/IO 错误);其中 {1} 条命中知识库,本次仍会重新尝试。",
" Loaded {0} historical skip records (access denied / IO error); {1} of them match the "
+ "knowledge base and will still be retried.",
FormatUtil.Count(skip.PersistentSkipCount), FormatUtil.Count(knowledgeHit))
: Lang.F(" 已加载 {0} 条历史跳过记录(权限不足/IO 错误),本次将直接跳过。",
" Loaded {0} historical skip records (access denied / IO error); they will be skipped.",
FormatUtil.Count(skip.PersistentSkipCount)));
}
if (skip.KnowledgeRescanEnabled)
Dev(Lang.T(" 知识库豁免:命中目录用途知识库的目录(含其祖先目录)每轮都重新尝试扫描。",
" Knowledge-base rescue: directories matching the directory-purpose knowledge base "
+ "(and their ancestors) are retried every run."));
var startedAt = DateTime.Now;
// 进度显示:每个扫描根 2 行(进度条+当前路径)+ 1 行总计,原地重绘不刷屏。
// 分母优先级:上轮快照目录数(最准、零成本)→ MFT 精确估算(首次扫描 + 管理员权限)
// → 卷已用容量(首次扫描 + 非管理员,仅整卷根适用)。
var progressRoots = BuildProgressEstimates(db, roots, opt, knowledge, out string? estimateNotice);
if (estimateNotice != null) Console.WriteLine(estimateNotice);
// 进度条只在「输出直达控制台」时绘制:日志中继模式下 Console.Out 指向文件,
// 光标控制字符混进日志会被父进程原样回显成乱码碎片
var progressDisplay = new ConsoleProgressDisplay(progressRoots, interactive: consoleLog == null);
progressDisplay.Start();
var scanner = new DiskScanner(settings, skip, stageWriter,
p => progressDisplay.Update(p.RootKey, p.RootDirs, p.RootBytes, p.CurrentPath));
var stats = scanner.Scan(roots, policies);
progressDisplay.Finish();
// 后台写库线程上报的错误:中止本轮提交(暂存表属一次性数据,下轮 CreateStageTable 会重建,
// 上一轮快照不受影响),把失败原因明确告诉用户,而不是带着残缺数据继续对比
if (!string.IsNullOrEmpty(stats.WriteError))
{
Console.Error.WriteLine(" " + stats.WriteError);
Console.Error.WriteLine(Lang.T(" 本次结果未入库,上一轮快照不受影响;请检查磁盘空间后重试。",
" The result was not committed; the previous snapshot is unaffected. "
+ "Check free disk space and retry."));
return 121;
}
Console.WriteLine(ConsoleStyle.Key(Lang.F(" ✓ 扫描完成 {0} 目录 · {1} 文件 · {2} · {3}",
" ✓ Scan complete {0} dirs - {1} files - {2} - {3}",
FormatUtil.Count(stats.TotalDirs), FormatUtil.Count(stats.TotalFiles),
FormatUtil.Bytes(stats.TotalBytes), FormatUtil.Duration(stats.ElapsedMs))));
// 并行 / 增量复用 / 硬链去重 合并为一行(各自条件成立才出现,用 · 连接)
var meta = new List<string>();
if (stats.UsedThreads > 1)
meta.Add(Lang.F("并行 {0} 根", "parallel {0} roots", stats.UsedThreads));
if (stats.ReusedDirs > 0)
meta.Add(Lang.F("增量复用 {0} 目录", "incremental reuse {0} dirs", FormatUtil.Count(stats.ReusedDirs)));
if (stats.DedupedFileCount > 0)
meta.Add(Lang.F("硬链去重 {0} 文件({1})", "hardlink dedup {0} files ({1})",
FormatUtil.Count(stats.DedupedFileCount), FormatUtil.Bytes(stats.DedupedBytes)));
if (meta.Count > 0)
Console.WriteLine(" " + string.Join(Lang.T(" · ", " - "), meta));
// 默认排除与自定义排除分开报:合并成「排除 N」时,一旦默认排除目录命中知识库被豁免放行,
// 就会出现「环境行写着默认排除启用、这里却是 排除 0」的自相矛盾数字(2026-09-24 用户反馈第 3 条)
Console.WriteLine(Lang.F(
" 跳过 默认排除 {0} · 自定义 {1} · 权限不足 {2} · 符号链接 {3} · IO {4}",
" Skipped default {0} - custom {1} - access denied {2} - reparse {3} - IO {4}",
stats.DefaultExcludedCount, stats.ExcludedCount - stats.DefaultExcludedCount,
stats.AccessDeniedCount, stats.ReparseCount, stats.ErrorCount));
if (stats.KnowledgeRescuedCount > 0)
Dev(Lang.F(" 知识库豁免:{0} 个目录虽命中排除/跳过规则,但因命中目录用途知识库,本次已重新尝试扫描。",
" Knowledge-base rescue: {0} directories matched exclusion/skip rules but were retried "
+ "because they match the directory-purpose knowledge base.",
FormatUtil.Count(stats.KnowledgeRescuedCount)));
if (stats.EnumEngineSummary.Length > 0)
Dev(Lang.F(" 枚举引擎:{0}", " Enum engine: {0}", stats.EnumEngineSummary));
foreach (var note in stats.EnumEngineNotes)
Dev(" · " + note);
// 「扫描统计 vs 卷已用」的口径对照:只对「整卷扫描、且数据真在该卷上」的根成立。
// 谓词顺带排除了 junction / 符号链接之后的根(路径盘符 ≠ 真实卷)——缺了这个守卫,
// 就会拿 A 卷的统计值去减 B 卷的已用量,得出一个看似精确的错误结论。
var volumeGaps = VolumeGap.Build(roots, stats.Roots, p =>
{
char? letter = RootVolumeLetter(p);
return letter.HasValue && IsOnDriveRootVolume(p, letter.Value);
});
PrintVolumeGap(volumeGaps, stats);
Console.WriteLine();
// ---------- 阶段三:单事务提交换入新快照 ----------
Console.WriteLine(ConsoleStyle.Section(Lang.T("▸ 3/5 写入数据库(两阶段提交)",
"▸ 3/5 Commit to database (two-phase)")));
var run = new ScanRun
{
ScanTime = startedAt,
Roots = string.Join(",", roots.Select(r => r.Key)),
TotalFiles = stats.TotalFiles,
TotalDirs = stats.TotalDirs,
TotalBytes = stats.TotalBytes,
ElapsedMs = stats.ElapsedMs,
IsFirstRun = isFirstRun
};
db.CommitStaging(roots, run);
Console.WriteLine(ConsoleStyle.Success(Lang.F(" ✓ 已提交 · 批次 #{0}", " ✓ Committed - run #{0}", run.Id)));
// 只在扫描根集合与本轮不同时提示:其余根各自最近一轮的快照被保留,
// 之后这些根再次被扫到时即可直接与自己的上次结果对比(不再判「首次运行」)。
if (db.LastRetainedRows > 0)
Console.WriteLine(ConsoleStyle.Muted(Lang.F(
" 已保留其他扫描根的历史快照 {0} 行(各自对应其最近一次扫描,扩回扫描范围时继续作为基准)",
" Kept {0} snapshot rows of other scan roots (each from its own latest scan; they remain the baseline)",
FormatUtil.Count(db.LastRetainedRows))));
// ---------- 保存 USN 状态(供下次增量) ----------
// 增量路径:保存 Collect 返回的新位置;全扫路径:查询当前位置保存。
// 查询需要管理员权限(要先打开卷设备 \\.\X:):失败时卷状态写不回去,会导致下一轮
// 又判定「不对应」而继续全量扫描 —— 所以这里必须把 Collect 给出的原因原样输出,
// 不能再用空 lambda 吞掉,否则用户完全看不到「其实是没有管理员权限」这个真因。
var stateSavedLetters = new HashSet<char>();
foreach (var (letter, journalId, nextUsn) in pendingUsn)
{
db.SaveVolumeState(letter, journalId, nextUsn, run.Id);
stateSavedLetters.Add(letter);
}
foreach (var letter in roots
.Select(r => RootVolumeLetter(r.Path))
.Where(l => l.HasValue).Select(l => l!.Value).Distinct())
{
if (stateSavedLetters.Contains(letter)) continue;
var cur = UsnJournalService.Collect(letter, 0, 0,
msg => Dev(Lang.F(" [USN状态:{0}:] {1}", " [USN state:{0}:] {1}", letter, msg)));
if (cur != null)
db.SaveVolumeState(letter, cur.JournalId, cur.NextUsn, run.Id);
}
var pendingSkips = skip.TakePending();
if (pendingSkips.Count > 0)
{
db.SaveSkipRecords(pendingSkips, run.Id);
Dev(Lang.F(" 跳过清单已更新 {0} 条记录。",
" Skip list updated with {0} records.", FormatUtil.Count(pendingSkips.Count)));
}
Console.WriteLine();
// ---------- 阶段四:对比分析 ----------
Console.WriteLine(ConsoleStyle.Section(Lang.T("▸ 4/5 对比上一轮快照", "▸ 4/5 Compare with previous snapshot")));
var analyzer = new GrowthAnalyzer(db);
var analysis = analyzer.Analyze(
roots,
opt.Top,
opt.MinBytes,
opt.MinPercent,
opt.DetailTop,
opt.DetailMinBytes,
isFirstRun,
prevScanTime,
baselines);
// 为进入报告候选集的目录(榜单 / 新增消失 / 明细)标注已知用途。
// 只作用于少量候选行,不触碰扫描、快照与对比链路。
int noted = knowledge.Annotate(analysis);
if (noted > 0)
Dev(Lang.F(" 目录用途标注:{0} 个候选目录命中知识库。",
" Directory-purpose annotation: {0} candidate directories matched the knowledge base.",
noted));
// 首次运行的结果是基准而非增长,不写入增长历史,避免污染后续回溯
if (!isFirstRun && analysis.GrowthTop.Count > 0)
db.InsertGrowthHistory(run.Id, analysis.GrowthTop);
Console.WriteLine();
PrintConsoleSummary(analysis, isFirstRun, opt.MinBytes);
if (opt.NoReport)
{
Console.WriteLine(ConsoleStyle.Section(Lang.T("▸ 5/5 已跳过报告生成(--no-report)",
"▸ 5/5 Report generation skipped (--no-report)")));
return 0;
}
// ---------- 阶段五:生成 HTML 报告 ----------
Console.WriteLine(ConsoleStyle.Section(Lang.T("▸ 5/5 生成 HTML 报告", "▸ 5/5 Generate HTML report")));
string reportPath = opt.OutputPath is { Length: > 0 }
? Path.GetFullPath(opt.OutputPath)
: Path.Combine(dataDir, "reports",
$"disk_growth_report_{FormatUtil.FileStamp(DateTime.Now)}.html");
string? reportDir = Path.GetDirectoryName(reportPath);
if (!string.IsNullOrEmpty(reportDir) && !Directory.Exists(reportDir))
Directory.CreateDirectory(reportDir);
// 跳过清单里的哪些记录会因命中知识库而每轮重试(供报告打标记)
var finalSkips = db.GetSkipRecords();
var rescuedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (skip.KnowledgeRescanEnabled)
{
foreach (var r in finalSkips)
if (skip.IsKnowledgeRescued(r)) rescuedPaths.Add(r.Path);
}
// 「可安全清理」清单:复用知识库匹配遍历本轮快照,挑出可清理性为「安全」的目录并合计
var cleanable = CleanupAdvisor.Find(db, knowledge, roots);
var model = new ReportModel
{
Analysis = analysis,
Statistics = stats,
Roots = roots,
SkipRecords = finalSkips,
GeneratedAt = DateTime.Now,
ScanTime = startedAt,
StartedAt = startedAt,
RunId = run.Id,
PreviousRunId = previousRunId,
MinBytes = opt.MinBytes,
MinPercent = opt.MinPercent,
Top = opt.Top,
DetailTop = opt.DetailTop,
DetailMinBytes = opt.DetailMinBytes,
UseDefaultExclude = settings.UseDefaultExclude,
DedupEnabled = settings.Dedup,
FollowReparse = settings.FollowReparse,
KnowledgeRescanEnabled = skip.KnowledgeRescanEnabled,
KnowledgeRescuedPaths = rescuedPaths,
Cleanable = cleanable,
VolumeGaps = volumeGaps,
DatabasePath = db.DatabasePath
};
string html = ReportBuilder.Build(model);
File.WriteAllText(reportPath, html, new UTF8Encoding(false));
Console.WriteLine(ConsoleStyle.Success(Lang.F(" ✓ 已生成 {0} ({1})", " ✓ Written {0} ({1})",
reportPath, FormatUtil.Bytes(new FileInfo(reportPath).Length))));
if (opt.OpenAfterReport)
{
try
{
Process.Start(new ProcessStartInfo(reportPath) { UseShellExecute = true });
Console.WriteLine(Lang.T(" 已用默认浏览器打开报告。",
" Report opened in the default browser."));
}
catch (Exception ex)
{
Console.WriteLine(Lang.T(" 打开浏览器失败:", " Failed to open the browser: ") + ex.Message);
}
}
Console.WriteLine();
Console.WriteLine(Lang.T("完成。", "Done."));
// 输出标记行,便于外部脚本/自动化抓取报告路径(机器可读,恒定 ASCII、不随语言变化)
Console.WriteLine("REPORT_PATH=" + reportPath);
return 0;
}
// ------------------------------------------------------------- 增量辅助
/// <summary>从完整路径提取盘符字母("C:\…" → 'C');非盘符路径返回 null。</summary>
private static char? RootVolumeLetter(string path)
=> path.Length >= 2 && path[1] == ':' ? char.ToUpperInvariant(path[0]) : null;
/// <summary>
/// 扫描根是否**真的**位于 <paramref name="letter"/> 卷上(按 junction / 符号链接 /
/// 挂载点解析后的真实卷判断,不是看路径里的盘符)。
///
/// 为什么必须查:增量(USN)是按盘符读变更日志的。若扫描根位于 junction 之后,
/// 路径盘符与数据实际所在卷不符,就会去读**别的卷**的日志 ⇒ 变更集恒为空 ⇒
/// 整根复用并上报「无变更」,是静默错报(2026-09-24 usntest 实测:改了 1 MB 仍报 0 B)。
/// 这类根本轮放弃增量、直接全量扫描 —— 宁可慢,也不能报错数。
/// 解析失败时同样返回 false(保守:宁可不增量)。
/// </summary>
private static bool IsOnDriveRootVolume(string path, char letter)
=> FileSystemNative.TryGetRealVolumeLetter(path, out char real) && real == letter;
private static string BuildScanModeNote(ScanSettings settings, ScanStatistics stats)
{
string note;
if (settings.ForceFullScan)
note = Lang.T("全量扫描(--full 强制)", "full scan (forced by --full)");
else if (stats.ReusedDirs > 0)
note = Lang.F("USN 增量:复用 {0} 个目录的上一轮快照,其余部分实扫",
"USN incremental: {0} directories reused from the previous snapshot, the rest scanned",
FormatUtil.Count(stats.ReusedDirs));
else
note = Lang.T("全量扫描", "full scan");
if (stats.ReusedDirs > 0 && settings.Dedup)
note += Lang.T(
"(提示:增量复用与硬链接去重并用时,跨「扫描区/复用区」的同一硬链接组存在理论上的重复计数可能,如需绝对精确请用 --full)",
" (note: when incremental reuse and hardlink dedup are combined, a hardlink group spanning the "
+ "scanned/reused regions can theoretically be double-counted; use --full for absolute precision)");
return note;
}
// ------------------------------------------------------------- 扫描根解析
/// <summary>
/// 为每个扫描根确定进度百分比的分母。优先级:
/// 1) 上一轮快照的目录行数(最准,零成本);
/// 2) MFT 精确估算的目录总数(首次扫描 + 管理员权限,子目录根同样支持);
/// 3) 卷已用容量(首次扫描 + 非管理员,仅整卷根适用——子目录根与卷容量无关,
/// 强行套用只会得到一个永远接近 0 的假进度)。
/// </summary>
private static List<(string Key, string Label, long EstimatedDirs, long VolumeUsedBytes)>
BuildProgressEstimates(DatabaseService db, List<ScanRoot> roots, Options opt, KnowledgeService knowledge,
out string? notice)
{
var list = new List<(string Key, string Label, long EstimatedDirs, long VolumeUsedBytes)>(roots.Count);
var estimatedParts = new List<string>();
var estimateDetails = new List<string>();
var unavailableParts = new List<string>();
long estimatedMs = 0;
foreach (var r in roots)
{
long prevRows = db.CountPrevRows(r);
if (prevRows > 0)
{
list.Add((r.Key, r.DisplayName, prevRows, 0));
continue;
}
// 首次扫描(该根无历史基准)才需要估算;先探测卷能否打开,再给提示
if (VolumeDirEstimator.CanEnumerate(r))
Dev(Lang.F(" 正在枚举 MFT 统计 {0} 的目录总数(用于进度百分比,约 1~3 秒)…",
" Enumerating the MFT to count directories on {0} (for the progress percentage, ~1-3 s)...",
r.Key));
// 豁免口径必须与扫描器一致:扫描器会因命中知识库而重新扫描默认排除的目录
// (如 C:\Windows),分母也必须计入它们,否则进度条会提前冲到 100%(实测差 3.3 倍)
var est = VolumeDirEstimator.TryEstimate(r, !opt.NoDefaultExclude, opt.Excludes, knowledge.ShouldRescan);
if (est.Ok)
{
list.Add((r.Key, r.DisplayName, est.DirCount, 0));
estimatedParts.Add(Lang.F("{0} {1} 个", "{0} {1} dirs", r.Key, FormatUtil.Count(est.DirCount)));
estimateDetails.Add(r.Key + Lang.T(":", ": ") + est.Detail);
estimatedMs += est.ElapsedMs;
continue;
}
unavailableParts.Add(r.Key + DescribeEstimatorFailure(est.Stage, est.LastError, est.Detail));
list.Add((r.Key, r.DisplayName, 0, r.IsDrive ? GetVolumeUsedBytes(r.Key) : 0));
}
var notices = new List<string>();
// 成功是常态,只在 --dev 下回显分母来源与枚举自证信息(缓冲布局 / 记录版本 / 输入结构)
if (estimatedParts.Count > 0 && DevMode)
{