Files
BakNRet/tools/lab/README.md
T
Shuery 42f02d0eca refactor: 公共面补 BakNRet 前缀,产品名大小写全仓统一
16 个没有前缀的公共函数补上 BakNRet(Write-Log → Write-BakNRetLog、Resolve-BackupEntry →
Resolve-BakNRetBackupEntry、Find-ChildDirectoryByName → Find-BakNRetChildDirectoryByName 等),
另外把全仓的 Baknret 统一成 BakNRet(47 个文件、940 处、65 个定义文件重命名)。

这不是审美问题:静态分析直接拓出一条实据 —— Write-Log 与本机某个已装模块导出的命令
**重名**(PSAvoidOverwritingBuiltInCmdlets),而重名的后果是导入两个模块时有一方的命令被
静默遮蔽。补前缀正是这条规则的解法,改名后它归零。

为什么敢做这个规模:PowerShell 的函数名解析大小写不敏感,所以 Baknret → BakNRet 在功能
上是零风险;真正要验证的是 16 个补前缀的调用点,而 276 个断言几乎覆盖了每个函数。另外
"名字与文件名一致"这条不变式有断言盯着(加载器点源的文件集合 vs 磁盘)。

踩到并记下的坑:Windows 文件系统大小写不敏感,所以**只改大小写**的重命名会被 Move-Item
当成同一个文件而静默跳过 —— 同一批里同时改了名字的那 16 个文件却成功了,于是"看起来能跑"。
最后用"先移到临时名、再移到目标名"的两步走解决,判断与替换全部改用显式大小写敏感的形式
(-creplace / -cmatch)。

顺带把名录指纹缓存从 MD5 换成 SHA256(PSAvoidUsingBrokenHashAlgorithms):它只是缓存键,
没有兼容负担。

验收:test.ps1 9/9 全绿(7 与 5.1)、100 个文件两版解析零错、276 个断言全过、
构建工具仍能合回单文件(3300 行)。
2026-09-27 09:56:36 +08:00

205 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 里改)。
```powershell
# 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`(仓库之外),不进程版本库。
## 日常使用
```powershell
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\Common.psm1` 会报一串「缺少右 }」。
## 已知问题与规避
### 在 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` / `Common.psm1` 等都没有 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` 白名单)。## 拆掉
```powershell
gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 destroy -Confirm # 删 VM 与系统盘
# 日志、凭据、仓库快照留在 D:\VMs\BakNRet-Lab 下,便于事后排查;确认不要了再手工删该目录
```