Files
BakNRet/README.md
T
Shuery e114cae8c8 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 全部一致。
2026-09-21 23:02:47 +08:00

316 lines
19 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.
# BakNRet
把 `BackupList.txt` 里列出的软件 / 目录用 **7-Zip** 打包进 `Backups/`,并且能用 `Restore.ps1` 原样恢复的 Windows 备份工具。
- 清单里**直接写软件名**即可(如 `FooClolor`),目录映射维护在 `SoftwareCatalog.psd1` 里。
- 归档名就是软件名(`FooClolor.7z`),不再是 `FooClolor_from_C_+Programs.7z`。
- 只依赖 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`)**一个字节都不写**。
---
## 快速开始
```powershell
# 1. 先试运行:只打印计划,不写任何文件
.\Backup.ps1 -DryRun
# 2. 正式备份
.\Backup.ps1
# 3. 强制重打(忽略"源未更新"判断)
.\Backup.ps1 -Force
# 3b. 确认可以接受"有文件被占用而没打进归档"时,允许覆盖完整归档
.\Backup.ps1 -Force -AcceptWarnings
# 4. 只备份 / 只恢复某几项(通配符匹配清单条目或归档名)
.\Backup.ps1 -Only 'FooClolor','.ssh'
.\Restore.ps1 -Only 'Edge' -Force
# 5. 恢复前先看计划(恢复会覆盖真实目录,务必先看一眼)
.\Restore.ps1 -DryRun
# 6. 只校验所有归档完整性,不解压(只读,安全)
.\Restore.ps1 -VerifyOnly
```
## 文件说明
| 路径 | 作用 |
| --- | --- |
| `SoftwareCatalog.psd1` | **软件名 → 目录**的映射,清单里写软件名的依据 |
| `BackupList.txt` | 备份 / 恢复共用的清单,唯一的"要备份什么"来源 |
| `BackupConfig.psd1` | 目录、空间阈值、校验、加密等配置 |
| `Backup.ps1` / `Restore.ps1` | 备份 / 恢复入口 |
| `Common.psm1` | 公共模块(日志、外部命令、解析、名录、manifest) |
| `Backups/` | 归档与 `manifest.json`(已 gitignore) |
| `logs/` | 每次运行的日志(已 gitignore) |
| `tests/` | 测试:Pester 套件、零依赖套件、端到端验收、真实归档恢复演练 |
| `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 |
| `tools/Rename-Archives.ps1` | 把按路径命名的旧归档重命名成软件名(默认试运行) |
| `tools/Install-TestDependencies.ps1` | 把 Pester 5 装到仓库内的 `.tools/`(不动机器上的全局模块) |
## SoftwareCatalog.psd1 —— 软件名 → 目录
```powershell
@{
FooClolor = 'C:\Programs\FooClolor'
Kazumi = '%AppData%\com.example\Kazumi'
'scoop-config' = '%UserProfile%\.config\scoop' # 含 - 或 . 的键必须加引号
'.ssh' = '%UserProfile%\.ssh'
}
```
**含 `-` 或 `.` 的键一定要加引号**,否则 PowerShell 会把 `a-b` 解析成减法表达式并报
`Missing '=' operator after key in hash literal`。这是最容易踩的一个坑。
两个便利特性:
1. **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配。
只认 `<名>_*` 与 `<名>-*`,不会把 `Legendary` 误配成 `LegendarySomething`。
2. **同名目录在多处**时显式列出,所有位置都会打进同一个归档:
```powershell
ImHex = @{
Path = 'D:\Hex\ImHex'
Variants = @('D:\Hex\ImHex', 'E:\Backup\ImHex')
}
```
**分文件维护**:用 `Includes` 引入其它名录文件(路径相对本文件):
```powershell
@{
Includes = @('SoftwareCatalog.games.psd1')
...
}
```
## BackupList.txt 语法
```text
<软件名 或 路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]
```
三种写法可以混用:
| 写法 | 说明 |
| --- | --- |
| `FooClolor` | 软件名。去名录查目录,**归档名 = 软件名** |
| `%UserProfile%\Documents\PowerShell` | 字面路径(含 `\` `/` 或 `%` 就按路径处理),归档名沿用 `<末级名>_from_<上级路径>` |
| `FooClolor @pathname` | 软件名 + 强制用路径命名。适合想换到名录体系但暂时不想改归档名的条目 |
| 标记 | 作用 |
| --- | --- |
| `encrypt` | 用 7z 加密该归档,见下文「加密」 |
| `pathname` | 用路径命名算法而不是软件名 |
| `root=<名>` | **尚未实现**:归档内的根目录始终是源目录名。用了会打印告警,不会静默失效 |
排除模式:相对归档根目录。以 `!` 开头表示"任意层级下匹配这个组件名"(7z 的 `-xr!`)。
分隔符 `,` 与 `;` 都可以。**不要自己写引号**;模式里的空格会被自动转成 `?`。
几条已经踩过的坑(工具会处理,写的时候知道就行):
- **模式里不要写引号。** `-x!"路径"` 会让引号成为模式的一部分,结果是**永不匹配**。
- **模式里的空格会被自动转成 `?`。** 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、扩展本体、遥测与优化数据全部排除。
## 归档命名与迁移
| 条目类型 | 归档名 |
| --- | --- |
| 软件名 | `<软件名>.7z` |
| 字面路径 | `<末级名>_from_<上级路径用 + 连接>.7z` |
| 软件名 + `@pathname` | 同字面路径 |
从旧版本升级时用重命名工具把存量归档搬过来(**默认试运行**、逐份大小校验、重建 manifest):
```powershell
.\tools\Rename-Archives.ps1 # 先看计划
.\tools\Rename-Archives.ps1 -Apply # 确认后执行
```
## 恢复语义
- 用 `7z x` 解压到目标的**父目录**,覆盖同名文件。
- **归档内部布局与历史完全一致**:根目录仍是源目录名(软件名只用于归档文件名)。
所以恢复逻辑不需要"剥掉一层",现有归档也不会因为重命名而解不开。
- **不做镜像同步**:目标目录里多出来的文件不会被删除。想得到"完全等于归档"的目录,请先清空目标。
- 目标目录比归档新时**默认跳过**,需要覆盖就加 `-Force`。
- `-WhatIf` / `-DryRun` 只打印计划;`-VerifyOnly` 只跑 `7z t`。
这三种模式**一个字节都不写**(`manifest.json` 也不会被碰)。
- **排除规则只在下一份归档里生效**:已经生成的归档不会因为改了排除表而"变干净"。
## manifest.json
以归档基础名为键记录每个条目:
| 字段 | 含义 |
| --- | --- |
| `source` | 清单里的原始写法(软件名或路径) |
| `resolvedSource` | 展开后的路径 |
| `roots` | 归档内**真实**的顶层条目名(就是源目录 / 源文件名;只统计真实存在的源)。每次重新处理该条目时刷新 |
| `catalog` | 名录里记录的路径(便于追溯软件名到底指向哪) |
| `archive` | 归档文件名 |
| `action` | `backed-up` / `skip-unchanged` / `missing-source` / `invalid-path` / `failed` / `planned` |
| `reason` | 跳过或失败的原因 |
| `exitCode` / `verified` / `warnings` | 压缩工具退出码、是否通过 `7z t`、**当前在位归档**是否有警告 |
| `attemptWarnings` | **本次尝试**是否报了警告(与 `warnings` 区分:保留旧归档时前者为 true、后者仍为 false) |
| `sourceFiles` / `sourceBytes` / `archiveBytes` | 源文件数、源大小、归档大小 |
| `lastSuccessAt` / `successCount` / `failCount` / `lastRestoreAt` / `encrypted` | 历史与安全标记 |
`Restore.ps1` **优先用 manifest 定位归档**,查不到才退回"从文件名反推路径"。
如果 `BackupList.txt` 丢了,`Restore.ps1` 会优先用 manifest 里的 `source` 自动重建。
## 日志
`logs/backup-<时间戳>.log` / `logs/restore-<时间戳>.log`,与控制台内容一致。
压缩工具自身的实时输出直接进控制台,不进日志(见「设计取舍」)。
## 配置(BackupConfig.psd1)
```powershell
@{
BackupDir = 'Backups' # 相对路径按脚本所在目录解析
LogDir = 'logs'
SnapshotDir = 'Backups\snapshots'
SoftwareCatalog = 'SoftwareCatalog.psd1'
CatalogMaxDepth = 5 # 前缀补全时最多向下找几层
MinFreeSpaceGB = 5
VerifyArchive = $true # 归档后跑 7z t
ComputeHash = $false # 是否额外算 SHA256(大归档很慢)
CompressionLevel = 9
ToolOutput = 'live' # live | quiet
Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 }
Encryption = @{ Enabled = $false; PasswordFile = ''; EncryptHeaders = $true }
DefaultExcludes = @('!Thumbs.db', '!desktop.ini')
}
```
优先级:**命令行参数 > `BackupConfig.psd1` > 代码内置默认值**,也可以用 `-ConfigPath` 指定其它配置文件。
## 加密
默认关闭 —— 一旦开启而口令丢失,备份就再也解不开。
```powershell
# 方式一:只为个别条目加密(.ssh 里是私钥,最典型)
# 在 BackupList.txt 里写成:
# .ssh @encrypt
# 方式二:全部加密,改配置
# Encryption = @{ Enabled = $true; PasswordFile = 'D:\secret\baknret.key' }
# 口令来源(二者取其一)
$env:BAKNRET_PASSWORD = '...' # 或
.\Backup.ps1 -KeyFile 'D:\secret\baknret.key' # 文件首行即口令
```
要求加密但取不到口令时,该条目会**明确失败**,绝不会退化成明文归档。
恢复加密归档时同理:取不到口令就直接失败,不会让 7z 停在控制台等待输入(在计划任务里那会静默挂起)。
> ⚠️ 7-Zip 只接受命令行口令,口令在本机进程列表里会短暂可见。这是 7z 本身的限制,请自行权衡。
## 计划任务
```powershell
.\tools\Register-BackupTask.ps1 -At '21:30' -DryRun # 先看将要注册什么
.\tools\Register-BackupTask.ps1 -At '21:30' # 注册
.\tools\Register-BackupTask.ps1 -Remove # 移除
```
任务调用 `Backup.ps1`,脚本自身写日志并按失败数返回退出码,所以「上次运行结果」在任务计划程序里可读。
## 测试
四套,按"需要多少依赖"分层:
| 套件 | 命令 | 需要什么 | 覆盖 |
| --- | --- | --- | --- |
| **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
.\tools\Install-TestDependencies.ps1 # 只装进仓库内的 .tools/(推荐,不动机器上的全局模块)
# 或者
Install-Module Pester -Scope CurrentUser -MinimumVersion 5.0.0
```
`Run-Pester.ps1` 优先使用 `.tools/` 里的本地副本,其次是机器上已装的 5.x;
`.tools/` 已进 `.gitignore`。
Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore.ps1` 的,原因有二:
两个脚本结尾都会 `exit`,同进程 `&` 调用会把 Pester 宿主一起带走;而且子进程给出的是
真正的进程退出码,正好独立验证"退出码取法"这条修复。
## 相对旧版修了什么
| 问题 | 旧行为 | 现行为 |
| --- | --- | --- |
| `Start-Process -PassThru` 的 `ExitCode` 在 PowerShell 7.7.0-preview.4 上恒为 `$null` | 压缩明明成功却报"压缩失败",`exit 2 → 删档重试` 的自愈分支永远不可达 | 用 `.NET Process` 继承控制台启动,退出码可靠 |
| 排除模式写成 `-x!"路径"` | 引号成为模式的一部分,**排除对所有条目都失效** | 不再嵌引号;含空格自动转 `?`,`!` 前缀走 `-xr!` |
| 解析器用 `;` 分隔,清单里写的是 `,` | 整串被当成一个模式,等于没有排除 | `,` 与 `;` 都支持 |
| `^"([^"]+)"` 贪婪匹配 | 整行加引号的写法把排除表吞进路径 → 该条目被静默跳过,2.8 GB 归档成了孤儿 | 先按 `::` 切分再处理引号 |
| 归档名由路径拼出 | 加一条备份要自己算名字,名字随路径变动 | 清单写软件名,归档名就是软件名 |
| 直接更新已有归档(7z `u`) | 固实归档下收益极小,且排除规则与"源里已删的文件"永远反映不到归档里 | 临时文件 → `7z t` 校验 → 原子替换 |
| 没有校验、没有记录 | 中断留下的半个归档会被下次 `u` 续写;跳过/失败只有一行滚过去的 WARN | 校验 + 原子替换 + `manifest.json` + 日志文件 |
| 结尾不 `exit` | 全部失败也返回 0,计划任务永远显示成功 | 有失败返回 1 |
| 恢复用 `-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 仓库 | — | 都有 |
## 设计取舍(有意为之,不是遗漏)
- **放弃 7z 的更新模式(`u`)。** 7z 默认固实压缩,`u` 本来就要重压大部分数据,收益很小,却让"排除规则改动"和"源里删掉的文件"永远进不了归档。
- **归档内部不套一层软件名目录。** 考虑过用暂存目录(硬链/复制)把归档根目录改成软件名,代价是多一次链接开销、实现复杂度上升,收益只是"解开包第一层好看"。归档名已经是软件名,包内保持源目录名也便于确认内容来源。顺带一提,7z 的 `-spf` 不是干这个的(它是 *use fully qualified file paths*)。
- **不捕获压缩工具的输出。** 结构化记录交给日志与 `manifest.json`;捕获子进程 stdio 需要额外管道,在受限环境里会直接失败。
- **有警告(退出码 1)时不覆盖完整的归档。** 被占用的文件会让 7z 返回 1,此时新归档是**不完整**的。实测 Edge 运行时打包,118 个文件读不到,其中包含 `Login Data`(密码)、`Cookies`、`History`、`Web Data`。所以在位归档完整时脚本**保留它、报失败、退出码 1**,确认可以接受再显式加 `-AcceptWarnings`。
- **名录里的路径不存在时,恢复仍然可用。** 源被删掉正是要恢复的场景,所以解析器照旧给出 `Sources`,备份端则据此跳过。
- **源路径不存在只算"跳过",不算失败。** 会以 `missing-source` 记进 manifest。失败只统计真正打不开的条目。
## 已知限制
- **改软件名等于换归档名。** 改名后旧归档不会被自动迁移,用 `tools/Rename-Archives.ps1` 或手动改名,并注意 manifest 里会留下旧键。
- 路径里本来就含 `+` 或 `_from_` 时,仅靠文件名无法可靠反推路径,此时依赖 `manifest.json`。
- `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 尚未实现)。
- 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。
- `Variants`(同名目录分散在多处)当前打包第一个位置,恢复时逐个位置各解压一份。
- **`root=<名>` 标记尚未实现。** 归档内的根目录始终是源目录名(见「设计取舍」)。7z 命令行没有"入库时改名"的能力;用了该标记会打印告警,不会静默失效。
- **磁盘空间守卫是逐条目判断的**,不预留"本次运行后续条目"的空间。`MinFreeSpaceGB` 只是告警阈值;真正拦条目的是"剩余空间 < 该条目预估大小"。往接近写满的卷上备份时请自己留意总用量。