Files
Shuery 120cf3584b fix: 清掉改造过程留下的陈旧引用(代码改了、文档还写着旧的)
这一轮做的是交付一致性检查:把这一路改掉/移走/删掉的每个符号在全仓(含文档)扫一遍。42 处命中里 38 处是正当的 —— CHANGELOG 与 ADR 里讲“原来是什么”属于历史叙述,test 里的 Assert-FileExists 是因为我只抑制了误判而没有改名。剩下的 4 处是真陈旧:

  * README 还在配置表里写着 CatalogMaxDepth,而那个配置项上一轮已经整条移除;

  * Get-BakNRetItemArchiveName 的 param 里还留着一个内联的 [int]$MaxDepth = 5(上一轮的删除按行匹配,没覆盖到这种写在同一行的参数),它已经没有任何调用方传值;

  * tools\lab\README.md 与 docs\agents\domain.md 还写着模块叫 Common.psm1。

这正是这次改造从头到尾在抓的毛病:文档承诺的东西,代码已经不做了。区别是多了一个可执行的检查 —— 符号改名/删除之后,全仓扫一遍旧名字。

验收:test.ps1 9/9 全绿(7 与 5.1)、真实清单只读冒烟 4/4。
2026-09-27 10:49:21 +08:00
..

tools\lab —— BakNRet 的隔离测试环境(Hyper-V 真机级 VM)

在宿主机之外的 Windows 虚拟机里跑 BakNRet 的备份 / 恢复 / 测试。宿主机仓库、Backups\、 logs\ 在本环境里只被读取,从不写入;VM 内也没有挂载宿主机的任何目录(一切交互走 PowerShell Direct,也就是 VMBus,不需要网络共享)。

宿主机                                    隔离 VM(BakNRet-Lab)
──────────────────────────────            ─────────────────────────────────────────
D:\Workspace\Temp\BakNRet  ← 仓库(只读)  ──sync──▶  C:\BakNRet   仓库副本(每次覆盖)
D:\VMs\BakNRet-Lab                        C:\BakNRet-Lab      工具负载 + 沙盒 + 日志
  ├─ vhdx\BakNRet-Lab.vhdx  系统盘          ├─ payload\        7-Zip 26.03 / pwsh 7 / Pester 5.9.1
  ├─ state\credentials.json lab 口令        ├─ sources\        带刺的假数据(见下)
  ├─ logs\                  全流程日志      ├─ Backups\        沙盒归档 + manifest.json
  └─ stage\repo.zip         仓库快照        └─ logs\           脚本日志与重定向输出

为什么用它

真机语义是单元测试造不出来的。这套环境里能真正跑到:

形态 说明
真 NTFS 连接点(junction) sources\JunctionToData 指向 AppMultiSlot\Data;真实源目录里不该造这种东西,VM 内的沙盒源可以随便折腾
被占用文件 AppLocked\locked.bin 由后台进程持句柄,用来压「有文件没打进归档」的告警路径
长路径 / 深目录 10 层嵌套、112 字符路径
中文 + 空格 + 点的路径 sources\软件 目录.甲,归档名同样是中文
多 Slot / 单文件 Slot 一个软件多个 Slot(<Slot>\<内容>)与文件 Slot(包内是名为 Slot 的文件)
排除与追加 :- 的 Slot 前缀形式与 ! 任意层级形式;:+ Modules:<路径> 追加映射
覆盖 Path 清单里的 :: <路径> 覆盖名录里故意写错的 Path
源不存在的条目 记 missing-source、退出码仍为 0
方向标记 行首 +(仅备份)与 -(仅恢复)
增量判断 第二次备份对未变更的源报「源目录未更新」并跳过,-Force 强制重打
计划任务 / 重启持久性 真机环境,可注册计划任务、可重启后继续验证

搭建

前提:Windows 10/11 专业版或更高(需要 Hyper-V)、管理员权限、一个 Windows 安装 ISO。 默认读 F:\Images\Windows\Win11_25H2_Chinese_Simplified_x64_v2.iso(可在 Lab-Common.ps1 的 $LabConfig 里改)。

# 0. 先看 ISO 里有哪些版本(记住要装的索引,默认 4 = 专业版)
gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1 -ListImages

# 1. 一次搭完:建 VHDX -> 分区 -> DISM 展开 -> 注入负载与无人值守文件 -> bcdboot
#              -> 建 VM -> 首启无人值守 -> 等供给完成 -> 打 clean-baseline 检查点
gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1

全程不需要点任何安装向导,也不需要 VM 的图形界面:Windows 是用 DISM 离线展开进 VHDX 的, 首次启动由 payload\unattend.xml(放进 C:\Windows\Panther\)无人值守走完 specialize + OOBE, 再由 payload\provision.ps1 把 7-Zip / PowerShell 7 / Pester 装好并写上 PATH。

分阶段重跑(排查用):-Stage disk / -Stage vm / -Stage provision。

VM 规格:Gen2、8 vCPU、12 GB 静态内存、Default Switch(NAT,可联网)、80 GB 动态 VHDX (实际占用约 15 GB,另有检查点差异盘)。lab 账户口令随机生成,只写在 D:\VMs\BakNRet-Lab\state\credentials.json(仓库之外),不进程版本库。

日常使用

gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 status     # 一眼看状态
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 sync       # 把当前工作树推给 VM
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 seed -Force # 重建带刺假数据
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 backup     # VM 内真跑 Backup.ps1(沙盒清单+配置)
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 restore    # 用真实归档做恢复演练(逐字节对拍)
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 acl-test   # 安全描述符演练(scoop/vscode + ProgramData 属主)
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 acl-test -SkipScoop   # 只跑 ProgramData 那段(不下载 vscode)
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 test -Suite all   # 三套仓库自带测试
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 shell      # 进去自己敲(exit 出来)
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 reset      # 秒回 clean-baseline

动词一览:status / start / stop / wait / sync / seed / backup / restore / acl-test / test / shell / console / checkpoint / reset / destroy。

backup 支持 -DryRun / -Force / -AcceptWarnings;restore 支持 -Entries @('AppMultiSlot','C:\BakNRet-Lab\sources\AppBig') 指定条目;test 支持 -Suite pester|zero|e2e;acl-test 支持 -SkipScoop / -KeepWork。

acl-test:安全描述符演练(payload\run-acl-scenario.ps1)

两段,都在 VM 里真跑(不是模拟),宿主侧退出码由动词 throw 回传:

  • A. 用户级真实场景:默认方式装 scoop(提权会话按官方写法加 -RunAsAdmin,目录仍是 %USERPROFILE%\scoop)→ scoop install git → bucket add extras → scoop install vscode → 改 vscode 的 settings.json → 备份 → 删源 → 恢复 → 断言:CLI 仍可执行、改过的配置原样 读得回、数据目录可写、app/persist 的安全指纹与备份前一致。 两个实测坑写在脚本注释里:extras 的 vscode 清单没有 bin 条目(所以没有 shims\code.cmd, CLI 在 apps\vscode\current\bin\code.cmd);code --version 拉起的 Code.exe 会锁住文件, 删源前必须先清进程。
  • B. 权限现场:C:\ProgramData\baknret-acl-lab\data,属主设成 SYSTEM、DACL 是 protected 且只有 (A;OICIIO;GA;;;CO) + SYSTEM/Administrators/Users —— 就是 ProgramData 下那些目录的形态。备份 / 删源 / 恢复后断言:属主仍是 SYSTEM、CREATOR OWNER 的 inherit-only ACE 还在、逐对象安全指纹与备份前一致;外加一条负对照(只搬文件、不回放 安全描述符)证明属主会落到"跑脚本的账户"头上。

沙盒清单 / 名录 / 配置

三个文件都在 tools\lab\payload\sandbox\,随 sync 进 VM:

  • BackupList.txt —— 沙盒清单,覆盖上面表里的各种形态;
  • SoftwareCatalog.psd1 —— 软件名 → Slot 组,全部指向 C:\BakNRet-Lab\sources;
  • BackupConfig.psd1 —— 归档/日志/快照都落在 VM 内(C:\BakNRet-Lab\Backups), 压缩级别 1(跑得快),ComputeHash = $true(方便对拍)。

踩过的坑(照抄会踩)

  1. SoftwareCatalog 的相对路径是按仓库根解析的,不是按配置文件所在目录;而且路径 不存在时会静默回退到仓库真实的 SoftwareCatalog.psd1。沙盒配置里必须写成仓库根 相对路径(tools\lab\payload\sandbox\SoftwareCatalog.psd1),否则软件名条目会悄悄用错名录。
  2. 子进程被重定向的 stdout 是控制台代码页(中文 Windows 上是 GBK/936),按 UTF-8 读会 整片乱码;Lab.ps1 因此按「替换字符更少」的候选解码。脚本自己写的 logs\backup\backup-*.log 反而是 UTF-8。
  3. ConvertFrom-Json 把 JSON 数组当作一个对象写出,@(...) 会套成嵌套数组;数组参数 经 Invoke-Command -ArgumentList 传到 VM 里再交给 Start-Process -ArgumentList 会报 「无法转换为 System.String」。Lab.ps1 用 JSON 传参 + 显式枚举摊平。
  4. Hyper-V 对新建 VM 默认开自动检查点,会不断堆叠差异盘。New-BakNRetLab.ps1 已关掉 (AutomaticCheckpointsEnabled = $false)。
  5. 中文 Windows 上集成服务名是本地的(「来宾服务接口」而不是 Guest Service Interface), 按名字启用会找不到;脚本改为「把所有未启用的集成服务启用」。
  6. 全新 Gen2 VM 的 NVRAM 是空的,固件会走 UEFI 回退路径 \EFI\Boot\bootx64.efi。 bcdboot /f UEFI 通常会写它;没写时脚本会从 bootmgfw.efi 补一份。
  7. New-Partition -Size 建出来的是普通数据分区,不是 ESP;要按 UEFI 规范用 -GptType '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' 建,事后再用 Set-Partition -GptType 改类型可能被拒(尤其打错分区号时)。Initialize-Disk 还会自带一个 MSR 分区。
  8. exFAT 卷上写不了硬链接:DSH 的 write 工具用「临时目录 + 硬链接」做原子落盘,在 exFAT 上会 直接失败(EISDIR)。仓库已于 2026-09-26 迁到 NTFS(D:\Workspace\Temp\BakNRet),不再受影响; 但 U 盘上的其它数据仍受此限制 —— 改那里的文件要么用 shell 重定向,要么先写 NTFS 再拷。
  9. VM 是未激活的 Windows:会有水印,个性化受限,功能测试不受影响。
  10. PowerShell Direct 的默认端点是 Windows PowerShell 5.1(不是 7)。要在 VM 里跑 7 的代码 必须显式 Start-Process pwsh.exe(Lab.ps1 就是这么做的)。5.1 还读不了仓库里无 BOM 的 UTF-8 脚本(见下「已知问题」),Import-Module C:\BakNRet\BakNRet\BakNRet.psd1 会报一串「缺少右 }」。

已知问题与规避

在 VM 里直接跑 tests\Run-Pester.ps1 会红 8 项(都是中文断言)

tests\BakNRet*.Tests.ps1 里的 Invoke-BakNRetScript 这样抓子进程输出:

cmd /c pwsh -File Backup.ps1 ... > out.txt 2>&1
Get-Content -LiteralPath out.txt -Encoding UTF8

而 Backup.ps1 / Restore.ps1 的 Write-BakNRetLog 走 Write-Host,写进 out.txt 的字节编码取自 [Console]::OutputEncoding:

环境 [Console]::OutputEncoding 结果
宿主机(日常会话) utf-8 文件是 UTF-8,按 UTF-8 读回正确 → 150/150 绿
全新 Windows VM(中文系统) gb2312(936) 文件是 GBK 字节,按 UTF-8 读回得到替换字符 → 8 项中文断言失败

实测:VM 里直接跑是 142 通过 / 8 失败;把控制台输出编码先钉成 UTF-8 后是 150/150。

这是测试环境的编码假设问题,不是产品缺陷(产品行为在两边完全一致)。 Lab.ps1 test 因此会经 payload\run-suite-utf8.ps1 运行套件,不需要改动仓库里的测试代码。

若要在仓库里根治(三选一):

  1. 生成的 .cmd 里先 chcp 65001 >nul;
  2. 子进程改成 pwsh -Command "[Console]::OutputEncoding=[Text.Encoding]::UTF8; & '<脚本>' <参数>";
  3. 读回时按控制台代码页解码,而不是写死 -Encoding UTF8。

名录改了、源没变时:归档与 manifest 会不一致

实测路径(在 VM 里真实撞到过):

  1. 名录里某个条目的 Slot 定义变了(当时是把沙盒名录的路径修对之后);
  2. 源目录一个字节没动;
  3. 下一次 Backup.ps1 按「源未更新」跳过该条目 —— 归档保持旧内容;
  4. 但 manifest 的 roots / layouts 是按当前名录重新算的,于是它描述的内容比归档里实际有的多;
  5. 恢复时才炸:归档 AppFileSlot.7z 里既没有 'Profile',也没有旧布局的 '0'。

报错是清楚的(不是静默错误),修复办法就是重打一次:Lab.ps1 backup -Force (实测重打后 Lab.ps1 restore 立刻变成 6/6 逐字节对拍通过)。

如果希望产品层面自动发现,可以在「源未更新」的判断里带上「本次解析出的 roots/layouts 是否与 manifest 记录的一致」,不一致就不要跳过。### 仓库里的 PowerShell 文件是「UTF-8 无 BOM」

Backup.ps1 / BakNRet\BakNRet.psd1 等都没有 BOM(开头字节是 3C 23 0A = <# + 换行)。 PowerShell 7 默认按 UTF-8 读,没问题;Windows PowerShell 5.1 会把无 BOM 文件按 ANSI(GBK) 读, 中文注释会被拆出错字节,甚至报「语句块或类型定义中缺少右 }」这类假解析错误。 要么给这些文件加 BOM,要么在文档里明确只支持 PowerShell 7。

仓库位置(2026-09-26 已从 U 盘迁到 NTFS)

仓库原在 F:\Backup\BakNRet(exFAT 的 Ventoy U 盘),为了减少 U 盘读写、并且拿回 NTFS 的 ACL / 硬链接支持,已整体搬到 D:\Workspace\Temp\BakNRet(NTFS,561 个文件 / 7.45 GB, 搬迁后做了逐文件 SHA256 对拍,全部一致)。

对这套 lab 没有影响:Lab-Common.ps1 用 $PSScriptRoot 推导 RepoRoot, 搬迁后实测自动指向新路径,脚本无需改动。唯一仍在 U 盘上的是默认安装 ISO (F:\Images\Windows\...),只在重新 -Stage disk 时只读用一次;想彻底不读 U 盘, 把它复制一份到 D: 再改 Lab-Common.ps1 的 IsoPath 即可。

仓库在 NTFS 上还顺带修好了 git:原先 exFAT 不记录属主,git 报 dubious ownership 全部命令失败; 搬迁后 git status / git log 直接可用(不需要 safe.directory 白名单)。## 拆掉

gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 destroy -Confirm   # 删 VM 与系统盘
# 日志、凭据、仓库快照留在 D:\VMs\BakNRet-Lab 下,便于事后排查;确认不要了再手工删该目录