# 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 前缀形式与 `!` 任意层级形式;`:+ 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\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` 白名单)。## 拆掉 ```powershell gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 destroy -Confirm # 删 VM 与系统盘 # 日志、凭据、仓库快照留在 D:\VMs\BakNRet-Lab 下,便于事后排查;确认不要了再手工删该目录 ```