P0-P3 全量重构:退出码 / 解析修复、manifest 与 7z t 校验、干跑、排除规则、日志、测试与计划任务

P0 正确性
- 退出码:改用 .NET Process 直接启动、让子进程继承控制台,不再用 Start-Process -PassThru
  (在 7.7.0-preview.4 上 ExitCode 恒为 $null,会把成功的压缩判成失败);
  7z / RAR / tar 三条解压分支统一走同一个取退出码的封装。
- BackupList 解析:先按第一个 :: 切段再处理引号(整行被一对引号包住的写法不再把排除表
  吞进路径);排除表同时接受 , 与 ;(旧实现只认 ;,导致排除从未生效);支持 :- / :+ / @flag。
- 补回 .ssh 与孤儿归档:.ssh 进清单;孤儿归档在备份端也做审计并点名;
  带 -Only / -Skip 时不再把未选中的归档误报成孤儿。
- Resolve-BackupEntry 里 $rootName 在赋值前被引用(会读到外层作用域残留值),已提前赋值。

P1 归档可靠性
- 每个条目写进 manifest.json:源、归档、时间、退出码、校验结果、失败原因,
  并区分 warnings(在位归档)与 attemptWarnings(本次尝试)。
- 归档后做 7z t 内容校验,先写 .tmp、校验通过再原子替换(File.Move overwrite)。
- manifest.roots 记录归档内**真实**的顶层条目名(原先记的是软件名,Edge 实际是 "User Data")。

P2 可用性
- Restore 支持 -WhatIf / -DryRun / -VerifyOnly / -Only / -Skip;
  这三种"只看不写"的模式一个字节都不写(原先会写回 manifest.json)。
- Edge 等高缓存条目加排除规则并实测:1781 MB / 27961 项 -> 72 MB / 2294 项;
  书签、密码、Cookies、偏好、历史、IndexedDB、Local Storage 全部保留。
  普通模式是相对归档根目录锚定的,嵌套的那些(如 OneAuth\WebView2 里的 Crashpad)
  改用 ! 组件形式才会命中。
- 日志落盘 logs/<backup|restore>-<时间戳>.log;退出码按失败数返回。
- tools/Register-BackupTask.ps1 注册每日计划任务;tools/Rename-Archives.ps1 迁移旧归档名。
- root= 标记此前静默失效,现在明确告警(该功能尚未实现)。

P3 测试与验证
- tests/BakNRet.Tests.ps1:Pester 5 套件 62 项(含用子进程跑 Backup.ps1 / Restore.ps1
  的端到端与针对上述缺陷的回归)。
- tests/Run-Pester.ps1 + tools/Install-TestDependencies.ps1:把 Pester 装到仓库内 .tools/,
  不动机器上的全局模块(系统自带的 3.4.0 缺 Should -Be)。
- tests/Restore-Drill.ps1:真实归档恢复演练,明确区分"源在备份后变过"与"归档/解压有问题"。
- tests/Run-Tests.ps1(49 项,零依赖)与 tests/Run-E2E.ps1(23 项)继续可用;三套共 134 项全通过。

真实机器验证
- 生产归档 22/22 通过 7z t;-VerifyOnly 不再改动 manifest.json(SHA256 前后一致)。
- 真实恢复演练 12/12 通过,27,670 个文件与活源逐字节一致。
- 修复了生产 scoop-persist.7z:原先只有 90 字节(空归档)而源有 1.3 GB,
  重打包后 233 MB,恢复演练 26981/26981 全部一致。
This commit is contained in:
Shuery committed 2026-09-21 23:02:47 +08:00
1 parent 045d51ac9c
commit e114cae8c8
13 files changed
+1761 -142

No files matched your search

+55 -7
View File
@@ -4,10 +4,12 @@
- 清单里**直接写软件名**即可(如 `FooClolor`),目录映射维护在 `SoftwareCatalog.psd1` 里。
- 归档名就是软件名(`FooClolor.7z`),不再是 `FooClolor_from_C_+Programs.7z`。
- 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,无需安装模块。
- 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,**运行备份/恢复不需要任何模块**(只有跑 Pester 测试才需要 Pester 5)。
- 每个归档写完后做 `7z t` 内容校验,**先写临时文件、校验通过再原子替换**。
- 每次运行产出可核对的 `Backups/manifest.json` 与 `logs/*.log`。
- 退出码可靠:有失败就返回 `1`,计划任务能正确判断成败。
- 备份结束做**孤儿归档审计**:磁盘上有、但没有任何清单条目指向的归档会被点名(它们恢复不到,别误删)。
- 恢复支持 `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` / `-Skip`;其中三种"只看不写"的模式(`-WhatIf` / `-DryRun` / `-VerifyOnly`)**一个字节都不写**。
---
@@ -48,9 +50,10 @@
| `Common.psm1` | 公共模块(日志、外部命令、解析、名录、manifest) |
| `Backups/` | 归档与 `manifest.json`(已 gitignore) |
| `logs/` | 每次运行的日志(已 gitignore) |
| `tests/` | 单元测试与端到端验收 |
| `tests/` | 测试:Pester 套件、零依赖套件、端到端验收、真实归档恢复演练 |
| `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 |
| `tools/Rename-Archives.ps1` | 把按路径命名的旧归档重命名成软件名(默认试运行) |
| `tools/Install-TestDependencies.ps1` | 把 Pester 5 装到仓库内的 `.tools/`(不动机器上的全局模块) |
## SoftwareCatalog.psd1 —— 软件名 → 目录
@@ -106,7 +109,7 @@
| --- | --- |
| `encrypt` | 用 7z 加密该归档,见下文「加密」 |
| `pathname` | 用路径命名算法而不是软件名 |
| `root=<名>` | 覆盖归档内的根目录名(默认源目录名) |
| `root=<名>` | **尚未实现**:归档内的根目录始终是源目录名。用了会打印告警,不会静默失效 |
排除模式:相对归档根目录。以 `!` 开头表示"任意层级下匹配这个组件名"(7z 的 `-xr!`)。
分隔符 `,` 与 `;` 都可以。**不要自己写引号**;模式里的空格会被自动转成 `?`。
@@ -117,6 +120,22 @@
- **模式里的空格会被自动转成 `?`。** 7z 的排除模式不支持空格:`Default\Code Cache` 匹配不到任何东西,`Default\Code?Cache` 才可以。
- **以第一个 `::` 为界切分。** `:` 在 Windows 路径里只可能是盘符,`::` 不会出现在真实路径里,所以整行被一对引号包住的历史写法也能正确解析。
- **归档名重复会直接报错。** 归档名就是软件名,所以同一个软件写两遍会让两个条目互相覆盖 —— 脚本拒绝执行并提示。
- **不带 `!` 的普通模式是"相对归档根目录"锚定的**(展开成 `-x!<归档内完整路径>`),所以只排除根目录下那一份。
Edge 的 `OneAuth\WebView2\EBWebView\` 里还藏着一整套自己的 `Crashpad` / `BrowserMetrics` /
`ProvenanceData` / `optimization_guide`,根锚定模式碰不到它们 —— 这类可再生的东西要用
`!<组件名>`(展开成 `-xr!`)才会在任意层级命中。
- **`!` 是按"路径组件"精确匹配,不是子串。** `!Crashpad` 不会误伤 `CrashpadMetrics.pma`
或 `ProvenanceDataTensors`,也不会漏掉嵌套的 `...\EBWebView\Crashpad\`。
实测效果(本机真实 Edge 配置,源 4619.9 MB):
| Edge 归档 | 大小 | 条目数 |
| --- | --- | --- |
| 排除规则生效前 | 1781 MB | 27961 |
| 排除规则生效后 | 72 MB | 2294 |
书签、密码(`Login Data`)、`Cookies`、偏好、历史、`IndexedDB`、`Local Storage` 全部保留;
缓存、组件缓存、Service Worker、扩展本体、遥测与优化数据全部排除。
## 归档命名与迁移
@@ -141,6 +160,7 @@
- **不做镜像同步**:目标目录里多出来的文件不会被删除。想得到"完全等于归档"的目录,请先清空目标。
- 目标目录比归档新时**默认跳过**,需要覆盖就加 `-Force`。
- `-WhatIf` / `-DryRun` 只打印计划;`-VerifyOnly` 只跑 `7z t`。
这三种模式**一个字节都不写**(`manifest.json` 也不会被碰)。
- **排除规则只在下一份归档里生效**:已经生成的归档不会因为改了排除表而"变干净"。
## manifest.json
@@ -151,7 +171,7 @@
| --- | --- |
| `source` | 清单里的原始写法(软件名或路径) |
| `resolvedSource` | 展开后的路径 |
| `roots` | 归档内的根目录名 |
| `roots` | 归档内**真实**的顶层条目名(就是源目录 / 源文件名;只统计真实存在的源)。每次重新处理该条目时刷新 |
| `catalog` | 名录里记录的路径(便于追溯软件名到底指向哪) |
| `archive` | 归档文件名 |
| `action` | `backed-up` / `skip-unchanged` / `missing-source` / `invalid-path` / `failed` / `planned` |
@@ -225,12 +245,34 @@ $env:BAKNRET_PASSWORD = '...' # 或
## 测试
四套,按"需要多少依赖"分层:
| 套件 | 命令 | 需要什么 | 覆盖 |
| --- | --- | --- | --- |
| **Pester 套件**(推荐) | `.\tests\Run-Pester.ps1` | Pester 5.0+ 与 7z | 62 项:解析、命名、排除翻译、命令行拼接、manifest / 配置 / 名录,外加**用子进程真正跑 `Backup.ps1` / `Restore.ps1`** 的端到端与回归 |
| 零依赖套件 | `.\tests\Run-Tests.ps1` | 只要 PowerShell + 7z | 49 项:同样的单元面,适合没装 Pester 的机器 |
| 端到端验收 | `.\tests\Run-E2E.ps1` | 只要 PowerShell + 7z | 23 项:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍 |
| **真实归档恢复演练** | `.\tests\Restore-Drill.ps1` | 只要 PowerShell + 7z | 把 `Backups/` 里**真实的那批归档**解到临时目录,再和活源逐字节对拍(全程不碰真实目录) |
演练会把"源在备份之后变过"和"归档/解压有问题"分开:内容不一致时看活源文件的修改时间,
晚于归档时间就算"源变了"(只提示),不晚于归档时间却内容不同才算失败。真实机器上的归档
常常是几周前的,不这样区分就天天报假失败。
Pester 套件要求 **5.0+**。系统自带的是 3.4.0,没有 `Should -Be`,套件会直接语法错误,
所以 `Run-Pester.ps1` 会先查版本,查不到就以退出码 2 结束并打印安装命令。两种装法:
```powershell
.\tests\Run-Tests.ps1 # 单元测试 42 项
.\tests\Run-E2E.ps1 # 端到端验收 23 项
.\tools\Install-TestDependencies.ps1 # 只装进仓库内的 .tools/(推荐,不动机器上的全局模块)
# 或者
Install-Module Pester -Scope CurrentUser -MinimumVersion 5.0.0
```
零依赖,不需要 Pester(本机只有 3.4.0,`Should -Be` 会直接语法错误)。
`Run-Pester.ps1` 优先使用 `.tools/` 里的本地副本,其次是机器上已装的 5.x;
`.tools/` 已进 `.gitignore`。
Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore.ps1` 的,原因有二:
两个脚本结尾都会 `exit`,同进程 `&` 调用会把 Pester 宿主一起带走;而且子进程给出的是
真正的进程退出码,正好独立验证"退出码取法"这条修复。
## 相对旧版修了什么
@@ -247,6 +289,10 @@ $env:BAKNRET_PASSWORD = '...' # 或
| 恢复用 `-Filter "$baseName.*"` | 含 `[` `]` 的路径会失配 | 精确比较 `BaseName`,且优先查 manifest |
| tar 分支 `$LASTEXITCODE -ne 0 -and $proc.ExitCode -ne 0` | `$LASTEXITCODE` 是上一条原生命令的残留值,恰为 0 时把解压失败吞掉 | 三条分支统一走同一个取退出码的封装 |
| 恢复没有干跑 | 直接覆盖 `E:\CodeSpace`、Edge User Data 这类真实目录 | `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` |
| `manifest.json` 的 `roots` | 记的是软件名,与归档里真实的顶层目录对不上(`Edge` vs `User Data`) | 记归档内真实的顶层条目名,并且和归档内容对账过 |
| `-DryRun` / `-WhatIf` / `-VerifyOnly` | 仍然写回 `manifest.json`,违背"不会写入任何文件" | 只有真的恢复成功了才写回(用 manifest 的 SHA256 前后对比验证) |
| 孤儿归档 | 只在恢复时列一下;带 `-Only` 时还会把未选中的归档误报成孤儿,吓得人不敢删 | 备份端也做孤儿审计;`-Only` / `-Skip` 时不再误报 |
| `Resolve-BackupEntry` 里的 `$rootName` | 在赋值之前就被引用,会读到外层作用域残留的值 | 提前赋值,回归测试钉死 |
| 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 |
## 设计取舍(有意为之,不是遗漏)
@@ -265,3 +311,5 @@ $env:BAKNRET_PASSWORD = '...' # 或
- `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 尚未实现)。
- 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。
- `Variants`(同名目录分散在多处)当前打包第一个位置,恢复时逐个位置各解压一份。
- **`root=<名>` 标记尚未实现。** 归档内的根目录始终是源目录名(见「设计取舍」)。7z 命令行没有"入库时改名"的能力;用了该标记会打印告警,不会静默失效。
- **磁盘空间守卫是逐条目判断的**,不预留"本次运行后续条目"的空间。`MinFreeSpaceGB` 只是告警阈值;真正拦条目的是"剩余空间 < 该条目预估大小"。往接近写满的卷上备份时请自己留意总用量。