Files
BakNRet/README.md
T
Shuery 43fa4e52dd 清单格式:两种写法(软件名 / 手写目录)都支持 :+ 追加与 :- 排除;名录改用对象数组并逐条介绍目录
需求
- 支持两种条目写法:1) 直接写软件名录里的软件名;2) 用户手写目录。
- 两种写法都必须支持追加(:+)与排除(:-)。
- 软件名录要改进:scoop 合并成"一个软件 + 一个目录数组"。
- 运行时要把"分别是哪些目录、每个目录是干什么的、排除/追加的理由"讲清楚。

实现
- 名录(SoftwareCatalog.psd1 / Get-SoftwareCatalog)
  * 一个软件挂多个目录时写成**对象数组**:@{ Path = '...'; Description = '...' };
    也接受纯字符串数组与旧的 @{ Dirs = ... } / @{ Variants = ... }。
  * 目录说明(Description)一路带到运行日志里。
  * "声明了但当前不存在"的目录不再被丢掉:备份跳过,恢复仍然知道它该回到哪个位置。
  * scoop 合并成一个数组条目(%UserProfile%\scoop\persist + %UserProfile%\.config\scoop);
    ScoopApps-persist 保持独立条目 —— 它和前者末级名同为 persist,并进同一个归档会在包里撞名。
- 解析与解析结果(ConvertFrom-BackupListLine / Resolve-BackupEntry)
  * `:+` 以前只对"软件名且能解析出目录"的写法生效,**手写目录的 :+ 会被整段丢掉**;
    现在统一生效,且 :+ 后面写软件名会按名录展开。
  * 行尾 `# 说明` 解析成 Comment,运行时打印。
- 归档与恢复
  * 同一条目里两个同名目录:打包前明确报错(退出码 1),不再静默混成一棵树。
    (7z 命令行没有"入库改名"的能力,归档内顶层名只能是文件系统上的那个名字。)
  * 恢复时每个源只解出**它自己那棵子树**,不会再往别的父目录里复制兄弟目录。
- 可解释性
  * 新增 Write-BackupEntryPlan:打包前打印条目的目录(含来源与介绍)以及排除/追加的出处;
    Restore.ps1 同样打印"哪棵子树还原到哪、会新建还是覆盖"。
- 孤儿归档审计修正:判据只看当前清单,不再把 manifest 的历史记录当成"已知"。
  否则"条目被合并/改名后留下的旧归档"会被历史记录遮住,永远不会报警。

验证
- Pester 84 项、零依赖单元 49 项、端到端 23 项,全部通过。
- 真实机器:合并后的 scoop.7z 233 MB / 29478 项 / 7z t 通过,manifest.roots=[persist|scoop];
  恢复演练 26982/26982 逐字节一致(.ssh、legendary、Aria 同批通过)。
- 迁移提醒:scoop-config.7z 与 scoop-persist.7z 已无清单条目指向,会出现在孤儿审计里;
  确认 scoop.7z 无误后可以自行删除。
2026-09-22 08:18:16 +08:00

393 lines
23 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` 自动重建。
## 日志
`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 | 82 项:解析、命名、排除翻译、命令行拼接、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、测试、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` 只是告警阈值;真正拦条目的是"剩余空间 < 该条目预估大小"。往接近写满的卷上备份时请自己留意总用量。