Files
BakNRet/README.md
T
Shuery 7173e8ae10 备份前空间预估;manifest 的 archive 字段只在文件真的存在时才写
需求
- 写满盘这件事只做一件事:**备份前预测本次所需大小,并提示用户存储够不够**,
  不再往"更复杂的占用控制"方向做。

实现
- Backup.ps1 新增只读的"备份前空间预估",在动手之前按清单顺序模拟一遍:
  * 逐个条目枚举源目录得到真实源大小/文件数,并读取现有归档大小;
  * 沿用主循环那套"源未更新就跳过"的判断,所以列出来的就是**本次真的会重打**的条目;
  * 估算模型:临时归档写完时旧归档还在,那一刻占用 = 当前累计净增量 + 本次预估;
    原子替换后净增量 = 预估 − 旧归档大小;峰值取整个过程的最大值;
  * 预估归档大小:有历史归档取 min(源大小, 旧归档 × 1.3),没有则按"不压缩"的悲观值;
  * 打印:可用空间、要重打的条目数(及跳过/缺源的数量)、逐条目明细(前 15 条)、
    预计峰值新增与净增量,最后给一句结论——"空间足够"或"空间可能不够!…差 Z GB"。
  * 不够时**只告警、不中断**:真正放不下的条目仍由逐条目守卫跳过。
- 新增 Sync-BaknretManifestArchive(Common.psm1,Backup/Restore 都在写 manifest 前调用):
  维持不变式"manifest 里写了 archive 的记录,磁盘上就一定有那个文件",
  把指向不存在归档的 archive 字段清空,但保留 source / action / 历史计数。
  这样"源不存在的条目"不会再让 Restore 反复报"manifest 记录的归档不存在",
  人工删掉归档(例如把它并进别的条目)之后记录也会自我纠正。

整理(本机备份集)
- 按用户确认,删除了已被 scoop.7z 覆盖的 scoop-config.7z 与 scoop-persist.7z
  (删除前先 7z t 复验 scoop.7z:233 MB / 29478 项 / 顶层 [persist, scoop]),
  并清掉 manifest 里这两条历史记录;24 条记录的 archive 现在全部存在于磁盘上。

测试
- Pester 88 项、零依赖单元 49 项、端到端 23 项,全部通过。
  新增覆盖:对象数组名录、两种写法 × :+/-、同名目录拒绝执行、行尾 # 说明、
  孤儿审计(含"manifest 里还有历史记录"的说明)、archive 字段一致性、空间预估输出。
2026-09-22 08:26:09 +08:00

422 lines
25 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 套件(`*.Tests.ps1`)、零依赖套件、端到端验收、真实归档恢复演练 |
| `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 |
| `tools/Rename-Archives.ps1` | 把按路径命名的旧归档重命名成软件名(默认试运行) |
| `tools/Install-TestDependencies.ps1` | 把 Pester 5 装到仓库内的 `.tools/`(不动机器上的全局模块) |
## SoftwareCatalog.psd1 —— 软件名 → 目录
```powershell
@{
# 1) 一个目录,直接写字符串
FooClolor = 'C:\Programs\FooClolor'
# 2) 一个目录 + 介绍(运行时会打印出来,推荐)
Kazumi = @{
Path = '%AppData%\com.example\Kazumi'
Description = 'Kazumi 的观看记录与设置'
}
# 3) 一个软件 = 多个目录:写成对象数组,每个目录各自带说明
scoop = @(
@{
Path = '%UserProfile%\scoop\persist'
Description = 'scoop 各应用的持久化数据(重装应用就会丢)'
}
@{
Path = '%UserProfile%\.config\scoop'
Description = 'scoop 自身的配置'
}
)
# 含 - 或 . 的键必须加引号
'.ssh' = @{ Path = '%UserProfile%\.ssh'; Description = 'SSH 私钥(不可再生)' }
}
```
要点:
- **含 `-` 或 `.` 的键一定要加引号**,否则 PowerShell 会把 `a-b` 解析成减法表达式并报
`Missing '=' operator after key in hash literal`。这是最容易踩的一个坑。
- 数组元素也接受**纯字符串**(`scoop = @('D:\a', 'D:\b')`),以及旧的
`@{ Dirs = @(...) }` / `@{ Variants = @(...) }` 写法 —— 三种都能用。
- **目录当前不存在也不会被丢掉**:备份时跳过并记 `missing-source`,但恢复时仍然知道
"这块内容原本该回到哪个位置",这正是恢复要用的。
- **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配。
只认 `<名>_*` 与 `<名>-*`,不会把 `Legendary` 误配成 `LegendarySomething`。
- **一个软件里不能有两个同名目录**(例如两个 `persist`):归档内的顶层名就是目录名,
那样会在包里混成一棵树。脚本会明确报错(退出码 1)让你拆成两个条目。
**分文件维护**:用 `Includes` 引入其它名录文件(路径相对本文件):
```powershell
@{
Includes = @('SoftwareCatalog.games.psd1')
...
}
```
## BackupList.txt 语法
```text
<软件名 或 手写目录> [ :+ <再追加一个目录/软件名> ... ] [ :- <排除模式>[,<排除模式>...] ] [ @<标记> ]
```
### 两种写法(混用没问题)
| 写法 | 说明 |
| --- | --- |
| `FooClolor` | **软件名**:去 `SoftwareCatalog.psd1` 查目录,**归档名 = 软件名**。一个软件可以挂多个目录 |
| `%UserProfile%\Documents\PowerShell` | **手写目录**:含 `\` `/` 或 `%` 就按路径处理,归档名沿用 `<末级名>_from_<上级路径>` |
| `FooClolor @pathname` | 软件名 + 强制用路径命名。适合想换到名录体系但暂时不想改归档名的条目 |
| 标记 | 作用 |
| --- | --- |
| `encrypt` | 用 7z 加密该归档,见下文「加密」 |
| `pathname` | 用路径命名算法而不是软件名 |
| `root=<名>` | **尚未实现**:归档内的根目录始终是源目录名。用了会打印告警,不会静默失效 |
### 两种写法都支持追加(`:+`)与排除(`:-`)
| 记号 | 作用 |
| --- | --- |
| `:+` | **追加**一个目录;写成软件名时会按名录展开成它的全部目录。可以写多个、位置随意,追加进来的目录与主目录一起打进同一个归档 |
| `:-` | **排除**模式(`::` 是历史别名,等价)。`,` 与 `;` 都当分隔符 |
```text
# 软件名 + 追加 + 排除
scoop :- !*Cache :+ D:\scoop-extra
# 手写目录 + 追加 + 排除
C:\Programs\MiFlash :+ MiFlash_Unlock :- MiFlash\logs\
```
> 手写目录的 `:+` 以前会被整段丢掉(只有软件名写法才生效),现在已经修好。
### 行尾可以写"为什么"
行尾的 ` # 说明` 会被解析出来,运行时和目录介绍一起打印:
```text
Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档
```
`#` 必须前面有空白才算注释,所以路径里的 `C:\a#b` 不受影响。
### 运行时会把每个条目的目录逐条介绍出来
目录介绍来自 `SoftwareCatalog.psd1`,追加/排除的**来源**来自清单:
```text
[INFO] 条目:scoop
[INFO] 归档:scoop
[INFO] 说明:scoop 各应用的持久化数据 + scoop 自身配置
[INFO] 目录 1/2:C:\Users\Shuery\scoop\persist
[INFO] 来源:软件名录;存在,会打包
[INFO] 介绍:scoop 里各应用的持久化数据(重装应用就会丢,必须备份)
[INFO] 目录 2/2:C:\Users\Shuery\.config\scoop
[INFO] 来源:软件名录;存在,会打包
[INFO] 介绍:scoop 自身的配置(源、代理、已安装清单)
[INFO] 排除 2 条(来自 BackupConfig.psd1 的 DefaultExcludes):!Thumbs.db、!desktop.ini
```
`Restore.ps1` 也会打印"哪棵子树还原到哪个目录、会新建还是覆盖"。
### 几个必须知道的约束
- **同一条目里不能有两个同名目录。** 归档内的顶层名就是目录自己的名字,两个 `persist`
在包里会混成一棵树。脚本会在打包前明确报错(退出码 1)并让你拆成两个条目,不会静默混淆。
- **多目录条目恢复时只解出各自那棵子树**,不会再出现"把兄弟目录也复制到别的父目录下"。
- **归档名重复会直接报错。** 归档名就是软件名,所以同一个软件写两遍会让两个条目互相覆盖。
排除模式本身的坑(工具会处理,写的时候知道就行):
- **模式里不要写引号。** `-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 # 确认后执行
```
> **合并条目 = 换归档名。** 例如把 `scoop-config` / `scoop-persist` 合成一个 `scoop`
> 数组条目后,归档名从两个变成 `scoop.7z`;旧的 `scoop-config.7z` / `scoop-persist.7z`
> 就**没有清单条目指向了**(会出现在孤儿归档审计里)。确认新的 `scoop.7z` 校验通过之后
> 再删旧的 —— 重命名工具只改名,不会合并归档内容。
## 恢复语义
- 用 `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` 自动重建。
## 备份前空间预估
每次备份在**动手之前**先按清单顺序模拟一遍,把"这次要写多少、盘够不够"直接打出来:
```text
[INFO] ==== 备份前空间预估(只读)====
[INFO] 目标卷可用空间:7.37 GB
[INFO] 本次要重打 8 个条目(另有 7 个源未更新会跳过、9 个源不存在)
[INFO] 新归档合计约 2.44 GB;其中会替换掉的旧归档 1.89 GB
[INFO] - Edge 源 2,303.8 MB / 11354 文件 现有 1,781.3 MB 预估 2,303.8 MB
[INFO] - MiFlash_Unlock 源 231.3 MB / 153 文件 现有 70.0 MB 预估 91.0 MB
[INFO] ...
[INFO] 预计峰值新增占用:2.25 GB(全程净增量 0.55 GB)
[INFO] 结论:空间足够(预计用 2.25 GB / 可用 7.37 GB)
[INFO] ============================
```
- **估算模型**:临时归档写完时旧归档还在,那一刻占用"当前累计净增量 + 本次预估",
原子替换之后本次净增量 = 预估 − 旧归档大小。峰值取整个过程的最大值。
- **预估归档大小**:有历史归档时取 `min(源大小, 旧归档 × 1.3)`;没有历史归档时按
"完全不压缩"的悲观值估 —— 宁可报多不报少。
- **结论只有两种**:空间足够,或者"空间可能不够!预计需要 X GB,可用 Y GB,差 Z GB"。
不够时**只告警、不中断** —— 真正放不下的条目会被逐条目守卫跳过;想稳妥就腾空间或加 `-Only` / `-Skip` 分批。
- 这一步是只读的,不改任何文件;`-DryRun` 也照跑。
## 日志
`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 | 88 项:解析、命名、排除翻译、命令行拼接、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` | 在赋值之前就被引用,会读到外层作用域残留的值 | 提前赋值,回归测试钉死 |
| 手写目录的 `:+` 追加 | 被整段丢掉(只有软件名写法才生效),既没人报错也没人知道 | 两种写法都生效,追加项还会标出来源(名录展开 / 字面路径) |
| 软件名录的多目录写法 | 只有 `@{ Dirs = @(...) }`,没有"这个目录是干什么的" | 支持**对象数组**(`Path` + `Description`),运行时逐条介绍 |
| 多目录条目的恢复 | 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 | 每个源只解出**它自己那棵子树** |
| 同一条目里两个同名目录 | 静默混成一棵树,两边的数据都错 | 打包前明确报错(退出码 1)并提示拆成两个条目 |
| 运行时的可解释性 | 只有一行"开始备份: X" | 逐条打印目录、来源、介绍、排除/追加的出处与理由;备份前还会预估所需空间并判断够不够 |
| manifest 的 `archive` 字段 | 源不存在的条目也留着归档名,指向一个根本不存在的文件;恢复时白报"归档不存在" | 只在文件真的存在时才写;删掉归档后同步一次就自我纠正 |
| 没有名录、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` 是告警阈值。
想稳妥跑完就先腾空间,或用 `-Only` / `-Skip` 分批。