2 Commits
Author SHA1 Message Date
chuzhongzai 7680b77c40 发布 0.1.1:登录自启权限问题的真因与修复
真因:exe 所在的目录带 Low 强制完整性标签(如开发目录 D:\dsh workspace),
Windows 用低完整性令牌运行其中的程序,它写不了中完整性的 %LOCALAPPDATA%,
登录自启时第一步写日志就 UnauthorizedAccessException,托盘起不来。

- FileLog fail-safe:首选目录失败依次回退 LocalLow / TEMP / ProgramData,永不抛异常
- 启动自检写明数据目录可写性与实际日志目录;交互式启动时直接弹窗给出处理办法
- 新增 --logcheck 只读自检命令(程序路径 / 数据目录可写 / 实际日志目录 / 结论)
- 版本号 0.1.1;README 增加放置目录说明;validation.md 记录第八轮
- 测试:核心 49/49、探测 16/16;Release 构建 0 警告 0 错误
2026-10-24 11:20:45 +08:00
chuzhongzai 545d96174d 修复登录自启的权限问题:低完整性目录、日志兜底与数据目录自检
登录任务本身没问题(InteractiveToken + HighestAvailable + --minimized),
ICACLS 也没有拒绝 ACE;真因是 exe 所在的 D:\dsh workspace 带 Low 强制标签,
Windows 用低完整性令牌运行这里的 exe,它写不了中完整性的 %LOCALAPPDATA%,
于是第一步写日志就 UnauthorizedAccessException,托盘起不来。

- FileLog 改为 fail-safe:首选目录失败依次回退 LocalLow / TEMP / ProgramData,
  缓存待写行,永不抛异常,暴露 ActiveDirectory 与 LastFailure;
- 启动自检增加数据目录可写性探测,致命对话框写出真实日志目录与低完整性判断;
- 新增 --logcheck 只读自检:输出程序路径、数据目录可写性、实际日志目录与结论;
- README 增加放置目录说明,validation.md 记录第八轮;
- 新增两条 FileLog 回归测试(本地 49/49,探测 16/16,Release 0 警告 0 错误)。
2026-10-24 11:18:09 +08:00
7 changed files with 224 additions and 12 deletions
+7 -1
View File
@@ -6,7 +6,7 @@ Windows 上的主路由 / 旁路由保活工具。旁路由连续检测失败时
## 当前版本 ## 当前版本
这是 `0.1.0` 初版,包含托盘面板、中文设置、分层探测、自动策略、可补偿的路由/DNS切换、快照恢复、日志与最高权限登录任务。 这是 `0.1.1`,包含托盘面板、中文设置、分层探测、自动策略、可补偿的路由/DNS切换、快照恢复、日志与最高权限登录任务。
默认未配置且暂停。首次运行必须显式选择网卡并保存,程序不会按网卡名字猜测并立即修改网络。只管理选择网卡的 IPv4 默认路径;IPv6、VPN 路由和其他网卡不接管。 默认未配置且暂停。首次运行必须显式选择网卡并保存,程序不会按网卡名字猜测并立即修改网络。只管理选择网卡的 IPv4 默认路径;IPv6、VPN 路由和其他网卡不接管。
@@ -33,6 +33,12 @@ Windows 上的主路由 / 旁路由保活工具。旁路由连续检测失败时
手动启动时一定会弹出面板(登录任务用 `--minimized` 启动,只驻留托盘),不会再让人以为程序没启动而反复双击。 手动启动时一定会弹出面板(登录任务用 `--minimized` 启动,只驻留托盘),不会再让人以为程序没启动而反复双击。
### 放在哪个目录运行
程序要改路由和 DNS,会以管理员身份运行,请把它放在自己的用户目录下,例如 `%LOCALAPPDATA%\Programs\GatewayKeepalive\`。
不要从“低完整性”目录(沙箱、共享、被打了 Low 标签的目录,例如本机开发目录 `D:\dsh workspace` 下的产物)启动:Windows 会用低完整性令牌运行那里的 exe,它连自己的 `%LOCALAPPDATA%` 都写不了,登录自启就会停在“Access to the path … is denied”。判断办法是跑一次 `.\GatewayKeepalive.exe --logcheck`,看输出里的“数据目录 … 可写=True/False”和“实际目录”。程序也有兜底:首选日志目录写不了会依次退到 `%LOCALAPPDATA%Low`、`%TEMP%`、`%ProgramData%`,并在日志里写明“日志目录改为 …”,但数据目录(`session.json`、`settings.json`)必须是可写的用户目录。
- 左键图标:显示/隐藏面板;Esc 或点外部:收起面板。 - 左键图标:显示/隐藏面板;Esc 或点外部:收起面板。
- 自动:按连续阈值和冷却切换;直连/旁路由:固定模式,失败时提示。 - 自动:按连续阈值和冷却切换;直连/旁路由:固定模式,失败时提示。
- 暂停:继续监测,停止自动改网络。 - 暂停:继续监测,停止自动改网络。
+23
View File
@@ -2,6 +2,29 @@
日期:2026-10-23(初版)、2026-10-24(第二轮修复)。环境:Windows 10/11 x64,开发机网卡 `以太网`(索引 16),IPv4 地址 `192.168.6.119/24`,主路由 `192.168.6.1`。 日期:2026-10-23(初版)、2026-10-24(第二轮修复)。环境:Windows 10/11 x64,开发机网卡 `以太网`(索引 16),IPv4 地址 `192.168.6.119/24`,主路由 `192.168.6.1`。
## 第八轮修复(2026-10-24,重启后登录自启报“Access to the path … is denied”)
症状:重启电脑后没有托盘图标,只弹一个对话框「程序无法继续:Access to the path 'C:\Users\emm\AppData\Local\GatewayKeepalive\logs\gateway-2026-10-24.log' is denied.」。
排查与真因:
- 计划任务本身没问题:`schtasks /Query /XML` 显示 `UserId=S-1-5-21-1629374972-835245509-3346384658-1001`、`LogonType=InteractiveToken`、`RunLevel=HighestAvailable`、动作 `GatewayKeepalive.exe --minimized`,触发器等登录用户。`schtasks /Run` 能复现同样的对话框。
- 权限表也没问题:日志目录、日志文件、以及 `C:\Users\emm\AppData\Local` 三层 `icacls` 全都有 `DESKTOP\emm:(F)`,没有被拒绝的 ACE;同样的脚本用同一个账号可以写。
- 真因在**所在目录的完整性级别**:`D:\dsh workspace` 被打了 Low 强制标签(`icacls` → `Mandatory Label\Low Mandatory Level:(OI)(CI)(NW)`),存在这里的 exe 会被 Windows 用**低完整性令牌**运行。低完整性进程写不了中完整性的 `%LOCALAPPDATA%`,于是第一步写日志就抛 `UnauthorizedAccessException`。把同一个 exe 复制到 `%LOCALAPPDATA%\Programs\GatewayKeepalive\` 再跑,`--logcheck` 立刻变成“数据目录 可写=True / 结论=写入成功”。
- 也就是说:这不是提权问题、不是任务配置问题、不是 ACL 问题,而是“程序放在哪运行”。修了两头——给出正确的放置位置,并让程序不再因为写不了日志而整个起不来。
修法:
- `FileLog` 改成 fail-safe:写入先进缓冲区,按「首选目录 → `%LOCALAPPDATA%Low` → `%TEMP%` → `%ProgramData%`」依次尝试,任何一步失败都不抛异常;`ActiveDirectory` / `LastFailure` 暴露真实落点与失败原因,切换目录时在日志里写一行“日志目录改为 …”。
- 启动时探测数据目录是否真的可写(建临时文件再删),自检行里带上「数据目录=… 可写=… 日志目录=…」;致命对话框不再只说一句错误,而是写出实际生效的日志目录与“程序可能放在低完整性目录里”的判断。
- 新增 `--logcheck`:只用当前身份验证“能不能写日志/数据目录”,不起托盘、不提权,输出「程序路径 / 数据目录 可写 / 首选目录 / 实际目录 / 结论」,方便用户自查。
本轮验证:
- 新增两条回归测试:首选目录可写时就用首选且不记失败;首选目录不可用(被同名文件占住)时不抛异常、回退到可用目录并把失败原因写进 `LastFailure`。本地测试总数 47 → 49,全绿;探测离线测试 16/16;Release 构建 0 警告 0 错误。
- 实测三种落点:`%LOCALAPPDATA%\Programs\GatewayKeepalive\GatewayKeepalive.exe --logcheck` → 可写=True、实际目录=`…\AppData\Local\GatewayKeepalive\logs`、写入成功;`artifacts\publish`(低完整性)下的同一 exe → 可写=False、实际目录=`…\AppData\LocalLow\GatewayKeepalive\logs`、写入成功(已回退),日志里确有该行。
- **未验证**:桌面托盘与登录任务仍需把 exe 换到用户目录后由用户重启确认一次(本机命令行没有提权能力,不能改计划任务的动作路径)。
## 第七轮修复(2026-10-24,关闭登录自启必报错:按 HRESULT 而不是按异常类型判断) ## 第七轮修复(2026-10-24,关闭登录自启必报错:按 HRESULT 而不是按异常类型判断)
症状:点「登录后自动启动」必弹「开机自启设置失败:找不到文件或目录:系统找不到指定的文件。(0x80070002) 已回滚到原设置。」,这个开关因此永远关不掉。 症状:点「登录后自动启动」必弹「开机自启设置失败:找不到文件或目录:系统找不到指定的文件。(0x80070002) 已回滚到原设置。」,这个开关因此永远关不掉。
+87 -6
View File
@@ -35,21 +35,102 @@ public sealed class JsonStore(string directory)
public void Delete(string file) => File.Delete(Path.Combine(DirectoryPath, file)); public void Delete(string file) => File.Delete(Path.Combine(DirectoryPath, file));
} }
/// <summary>
/// 追加式日志。日志本身永远不能让程序停下:首选目录写不进去就换下一个候选目录,
/// 并把失败原因(路径 + 异常类型 + Win32 错误码)记在 <see cref="LastFailure"/> 里,
/// 由界面/诊断输出展示,而不是把异常抛给启动流程。
/// </summary>
public sealed class FileLog(string directory) public sealed class FileLog(string directory)
{ {
private const int PendingLimit = 200;
private readonly object _gate = new(); private readonly object _gate = new();
private readonly List<string> _pending = [];
private string? _active;
private string? _failure;
/// <summary>当前真正写入的日志目录(首选目录可用时就是构造参数)。</summary>
public string ActiveDirectory { get { lock (_gate) return _active ?? directory; } }
/// <summary>最近一次写入失败的原因;全部候选都失败时才非空。</summary>
public string? LastFailure { get { lock (_gate) return _failure; } }
public void Write(string message) public void Write(string message)
{ {
var line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}";
lock (_gate) lock (_gate)
{ {
Directory.CreateDirectory(directory); _pending.Add(line);
var today = DateTime.Now; if (_pending.Count > PendingLimit) _pending.RemoveRange(0, _pending.Count - PendingLimit);
File.AppendAllText(Path.Combine(directory, $"gateway-{today:yyyy-MM-dd}.log"), $"[{today:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}"); TryFlush();
foreach (var file in Directory.EnumerateFiles(directory, "gateway-*.log"))
if (File.GetLastWriteTimeUtc(file) < DateTime.UtcNow.AddDays(-14))
try { File.Delete(file); } catch (IOException) { }
} }
} }
private void TryFlush()
{
var failures = new List<string>(3);
foreach (var candidate in Candidates())
{
if (Append(candidate, _pending, out var error))
{
var switched = _active is not null && !string.Equals(_active, candidate, StringComparison.OrdinalIgnoreCase);
_active = candidate;
_pending.Clear();
_failure = failures.Count == 0 ? null : string.Join(";", failures);
if (switched)
Append(candidate, [$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] 日志目录改为 {candidate}{Environment.NewLine}"], out _);
Prune(candidate);
return;
}
failures.Add(error!);
}
_failure = string.Join(";", failures);
}
private IEnumerable<string> Candidates()
{
yield return directory;
// 低完整性(低 IL)进程写不了 LOCALAPPDATA/TEMP,但 LocalLow 正是为它们准备的。
foreach (var low in LowIntegrityDirectories()) yield return low;
var temp = Environment.GetEnvironmentVariable("TEMP");
if (!string.IsNullOrWhiteSpace(temp)) yield return Path.Combine(temp, "GatewayKeepalive", "logs");
var common = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
if (!string.IsNullOrWhiteSpace(common)) yield return Path.Combine(common, "GatewayKeepalive", "logs");
}
private static IEnumerable<string> LowIntegrityDirectories()
{
var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (!string.IsNullOrWhiteSpace(profile)) yield return Path.Combine(profile, "AppData", "LocalLow", "GatewayKeepalive", "logs");
yield return Path.Combine(Path.GetTempPath(), "Low", "GatewayKeepalive", "logs");
}
private static bool Append(string target, List<string> lines, out string? error)
{
error = null;
try
{
Directory.CreateDirectory(target);
var path = Path.Combine(target, $"gateway-{DateTime.Now:yyyy-MM-dd}.log");
File.AppendAllText(path, string.Concat(lines));
return true;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException or ArgumentException)
{
error = $"{target}({exception.GetType().Name} 0x{exception.HResult:X8} {exception.Message.Trim()})";
return false;
}
}
private static void Prune(string target)
{
try
{
foreach (var file in Directory.EnumerateFiles(target, "gateway-*.log"))
if (File.GetLastWriteTimeUtc(file) < DateTime.UtcNow.AddDays(-14))
try { File.Delete(file); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { }
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException) { }
}
} }
public sealed record SwitchJournal(NetworkSnapshot Before, NetworkSnapshot Intended, string Stage, DateTimeOffset StartedAt); public sealed record SwitchJournal(NetworkSnapshot Before, NetworkSnapshot Intended, string Stage, DateTimeOffset StartedAt);
@@ -4,7 +4,7 @@
<UseWindowsForms>true</UseWindowsForms> <UseWindowsForms>true</UseWindowsForms>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyName>GatewayKeepalive</AssemblyName> <AssemblyName>GatewayKeepalive</AssemblyName>
<Version>0.1.0</Version> <Version>0.1.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../GatewayKeepalive.Core/GatewayKeepalive.Core.csproj" /> <ProjectReference Include="../GatewayKeepalive.Core/GatewayKeepalive.Core.csproj" />
+61 -4
View File
@@ -21,9 +21,29 @@ internal static class Program
return 0; return 0;
} }
if (args.Any(x => x is "--diagnose" or "--dry-run" or "--probe")) return DiagnosticCommands.RunAsync(args).GetAwaiter().GetResult(); if (args.Any(x => x is "--diagnose" or "--dry-run" or "--probe")) return DiagnosticCommands.RunAsync(args).GetAwaiter().GetResult();
if (args.Contains("--logcheck"))
{
// 只验证“这个身份、这个时机能不能写日志”,不启动托盘、不申请提权。
using var who = WindowsIdentity.GetCurrent();
var target = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GatewayKeepalive", "logs");
var check = new FileLog(target);
check.Write($"日志自检:用户={who.Name} 管理员={new WindowsPrincipal(who).IsInRole(WindowsBuiltInRole.Administrator)}");
var dataDir = Path.GetDirectoryName(target)!;
var dataWritable = CanWrite(dataDir, out var dataError);
Console.WriteLine($"程序路径={Environment.ProcessPath}");
Console.WriteLine($"数据目录={dataDir} 可写={dataWritable}" + (dataWritable ? string.Empty : $" 原因={dataError}"));
var fellBack = !string.Equals(Path.GetFullPath(check.ActiveDirectory), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase);
var verdict = check.LastFailure is null
? "写入成功(首选目录)"
: fellBack ? "写入成功(首选目录失败,已回退)" : "写入失败";
Console.WriteLine($"首选目录={target}");
Console.WriteLine($"实际目录={check.ActiveDirectory}");
Console.WriteLine($"结论={verdict}" + (check.LastFailure is null ? string.Empty : $";首选原因={check.LastFailure}"));
return check.LastFailure is null || fellBack ? 0 : 1;
}
if (args.Contains("--help")) if (args.Contains("--help"))
{ {
MessageBox.Show("网关保活\n直接运行:托盘程序\n--minimized:仅驻留托盘\n--diagnose --output report.json:只读诊断\n--dry-run --adapter GUID:只生成切换计划\n--preview 目录:离屏界面预览\n控制台详细输出请使用 GatewayKeepalive.Cli。", "网关保活"); MessageBox.Show("网关保活\n直接运行:托盘程序\n--minimized:仅驻留托盘\n--logcheck:检查当前身份能否写日志(控制台输出)\n--diagnose --output report.json:只读诊断\n--dry-run --adapter GUID:只生成切换计划\n--preview 目录:离屏界面预览\n控制台详细输出请使用 GatewayKeepalive.Cli。", "网关保活");
return 0; return 0;
} }
var backend = new WindowsNetworkBackend(); var backend = new WindowsNetworkBackend();
@@ -62,16 +82,30 @@ internal static class Program
var logger = new FileLog(Path.Combine(directory, "logs")); var logger = new FileLog(Path.Combine(directory, "logs"));
try try
{ {
var writable = CanWrite(directory, out var writeError);
logger.Write($"数据目录:{directory} 可写={writable}" + (writable ? string.Empty : $"({writeError})") +
(string.Equals(logger.ActiveDirectory, Path.Combine(directory, "logs"), StringComparison.OrdinalIgnoreCase) ? string.Empty : $" 日志改写至:{logger.ActiveDirectory}"));
if (!writable && !args.Contains("--minimized"))
{
// 只砸在交互式启动上;登录自启别弹窗,日志里有记录就够了。
MessageBox.Show(
"无法写入数据目录:" + directory + "\n原因:" + writeError +
"\n\n最常见的原因是程序放在了低完整性(沙箱)目录里,例如 D:\\dsh workspace 下的产物:" +
"Windows 会用低完整性令牌运行这里的 exe,于是写不进当前用户目录。\n\n" +
"处理办法:把 GatewayKeepalive.exe 复制到普通目录(例如 %LOCALAPPDATA%\\Programs\\GatewayKeepalive)后再运行," +
"然后在设置里重新开启一次开机自启。\n\n本次日志写到:" + logger.ActiveDirectory,
"网关保活", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
var store = new JsonStore(directory); var store = new JsonStore(directory);
var startup = new LoginStartup(logger.Write); var startup = new LoginStartup(logger.Write);
// 每次启动先留一行自检:出问题时光看日志就知道是不是提权/自启任务的问题。 // 每次启动先留一行自检:出问题时光看日志就知道是不是提权/自启任务的问题。
try try
{ {
logger.Write($"启动自检:管理员={backend.IsAdministrator} 登录自启={startup.IsEnabled} 进程={Environment.ProcessPath} 参数={(args.Length == 0 ? "(无)" : string.Join(' ', args))}"); logger.Write($"启动自检:管理员={backend.IsAdministrator} 用户={identity.Name} 登录自启={startup.IsEnabled} 进程={Environment.ProcessPath} 参数={(args.Length == 0 ? "(无)" : string.Join(' ', args))} 日志目录={logger.ActiveDirectory}");
} }
catch (Exception exception) catch (Exception exception)
{ {
logger.Write("启动自检失败:" + exception.Message); logger.Write("启动自检失败:" + exception);
} }
// 校正旧任务指向要连 COM、必要时还会拉起 schtasks,放到后台做,别让托盘图标准备好之前先卡住。 // 校正旧任务指向要连 COM、必要时还会拉起 schtasks,放到后台做,别让托盘图标准备好之前先卡住。
_ = Task.Run(() => _ = Task.Run(() =>
@@ -104,13 +138,36 @@ internal static class Program
catch (Exception e) catch (Exception e)
{ {
try { logger.Write("启动/退出失败:" + e); } catch (Exception) { } try { logger.Write("启动/退出失败:" + e); } catch (Exception) { }
MessageBox.Show("程序无法继续:" + e.Message + "\n配置与恢复记录保留在:\n" + directory, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error); var logNote = logger.LastFailure is null ? "" : "\n日志写入问题:" + logger.LastFailure + "\n实际日志目录:" + logger.ActiveDirectory;
MessageBox.Show("程序无法继续:" + e.Message + logNote + "\n配置与恢复记录保留在:\n" + directory, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error);
return 1; return 1;
} }
} }
private static void ShowExisting(TrayApplicationContext app) => app.ShowPanel(); private static void ShowExisting(TrayApplicationContext app) => app.ShowPanel();
/// <summary>
/// 判断数据目录是否真的能写。低完整性进程(例如 exe 落在沙箱目录里)会在
/// LOCALAPPDATA 上拿到 UnauthorizedAccessException,这不是配置问题,得说清楚。
/// </summary>
private static bool CanWrite(string directory, out string error)
{
try
{
Directory.CreateDirectory(directory);
var probe = Path.Combine(directory, $".write-probe-{Environment.ProcessId}");
File.WriteAllText(probe, "ok");
File.Delete(probe);
error = string.Empty;
return true;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException)
{
error = $"{exception.GetType().Name} 0x{exception.HResult:X8} {exception.Message.Trim()}";
return false;
}
}
/// <summary> /// <summary>
/// Single-file apps extract the WinForms theming manifest to the temp directory. If that /// Single-file apps extract the WinForms theming manifest to the temp directory. If that
/// location is restricted, visual styles are decorative only for this fully custom-drawn UI; /// location is restricted, visual styles are decorative only for this fully custom-drawn UI;
@@ -0,0 +1,43 @@
using GatewayKeepalive.Core;
namespace GatewayKeepalive.Tests;
internal static class LogFallbackTests
{
public static Task WritablePrimaryDirectoryIsUsedAsync()
{
using var temp = new TempDirectory();
var target = Path.Combine(temp.DirectoryPath, "logs");
var log = new FileLog(target);
log.Write("测试:首选目录可写");
Check.Equal(Path.GetFullPath(target), Path.GetFullPath(log.ActiveDirectory), "A writable primary directory must stay active");
Check.True(log.LastFailure is null, "No failure should be recorded when the primary directory works");
var file = Path.Combine(target, $"gateway-{DateTime.Now:yyyy-MM-dd}.log");
Check.True(File.Exists(file), "The log file should exist in the primary directory");
Check.Contains("测试:首选目录可写", File.ReadAllText(file), "The line should land in the primary log");
return Task.CompletedTask;
}
public static Task UnusablePrimaryDirectoryFallsBackAsync()
{
using var temp = new TempDirectory();
var blocked = Path.Combine(temp.DirectoryPath, "blocked");
File.WriteAllText(blocked, "a file, so no directory can be created here");
var log = new FileLog(blocked);
log.Write("测试:日志回退");
Check.True(
!string.Equals(Path.GetFullPath(blocked), Path.GetFullPath(log.ActiveDirectory), StringComparison.OrdinalIgnoreCase),
"An unusable primary directory must be replaced by a fallback");
Check.True(log.LastFailure is not null, "The unusable primary directory should be reported as a failure");
Check.Contains("blocked", log.LastFailure!, "The failure text should name the unusable directory");
var fallback = Path.Combine(log.ActiveDirectory, $"gateway-{DateTime.Now:yyyy-MM-dd}.log");
Check.True(File.Exists(fallback), "The line should be written to the fallback directory");
Check.Contains("测试:日志回退", File.ReadAllText(fallback), "The line should be present in the fallback log");
return Task.CompletedTask;
}
}
+2
View File
@@ -50,6 +50,8 @@ internal static class Program
("NetworkComparison: every owned route, DNS, and network field participates", NetworkComparisonTests.EachOwnedFieldParticipatesAsync), ("NetworkComparison: every owned route, DNS, and network field participates", NetworkComparisonTests.EachOwnedFieldParticipatesAsync),
("NetworkComparison: transaction recovery accepts only exact before/intended rows", NetworkComparisonTests.RecoveryRejectsForeignRowsAsync), ("NetworkComparison: transaction recovery accepts only exact before/intended rows", NetworkComparisonTests.RecoveryRejectsForeignRowsAsync),
("JsonStore: round trip, backup, and failed serialization preserve the last complete file", JsonStoreTests.AtomicReplacementAsync), ("JsonStore: round trip, backup, and failed serialization preserve the last complete file", JsonStoreTests.AtomicReplacementAsync),
("FileLog: a writable primary directory is used and reports no failure", LogFallbackTests.WritablePrimaryDirectoryIsUsedAsync),
("FileLog: an unusable primary directory falls back instead of throwing", LogFallbackTests.UnusablePrimaryDirectoryFallsBackAsync),
("Windows NativeLayout: x64 structure sizes and metric offsets", NativeLayoutTests.X64LayoutAsync), ("Windows NativeLayout: x64 structure sizes and metric offsets", NativeLayoutTests.X64LayoutAsync),
("LoginStartup: logon task XML starts minimized with highest privileges", LoginStartupTests.LogonTaskXmlAsync), ("LoginStartup: logon task XML starts minimized with highest privileges", LoginStartupTests.LogonTaskXmlAsync),
("LoginStartup: missing-task detection follows HRESULT, not exception type", LoginStartupTests.TaskMissingDetectionAsync), ("LoginStartup: missing-task detection follows HRESULT, not exception type", LoginStartupTests.TaskMissingDetectionAsync),