改造开始前的完整状态,作为可回退的基点。此提交之后:Pester 175 项、零依赖套件 101 项全绿;PowerShell 5.1 尚不可用(源文件无 BOM)。 包含此前未提交的在制品:安全描述符套件、Hyper-V 实验环境(tools/lab)、agent 约定(AGENTS.md 与 docs/agents)。 .gitignore 增加 *.key / *.pfx:BackupConfig.psd1 的 PasswordFile 此前默认指向仓库内的 baknret.key,一次 git add -A 就会把口令提交进版本库。默认值在后续提交中改为空。
205 lines
14 KiB
Markdown
205 lines
14 KiB
Markdown
# 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-Log` 走 `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 下,便于事后排查;确认不要了再手工删该目录
|
||
``` |