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 全部一致。
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)一个字节都不写。
快速开始
# 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 —— 软件名 → 目录
@{
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。这是最容易踩的一个坑。
两个便利特性:
- 前缀补全:写
D:\Programs\legendary,实际目录是legendary_2.0.4时会自动匹配。 只认<名>_*与<名>-*,不会把Legendary误配成LegendarySomething。 - 同名目录在多处时显式列出,所有位置都会打进同一个归档:
ImHex = @{
Path = 'D:\Hex\ImHex'
Variants = @('D:\Hex\ImHex', 'E:\Backup\ImHex')
}
分文件维护:用 Includes 引入其它名录文件(路径相对本文件):
@{
Includes = @('SoftwareCatalog.games.psd1')
...
}
BackupList.txt 语法
<软件名 或 路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]
三种写法可以混用:
| 写法 | 说明 |
|---|---|
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):
.\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)
@{
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 指定其它配置文件。
加密
默认关闭 —— 一旦开启而口令丢失,备份就再也解不开。
# 方式一:只为个别条目加密(.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 本身的限制,请自行权衡。
计划任务
.\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 结束并打印安装命令。两种装法:
.\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只是告警阈值;真正拦条目的是"剩余空间 < 该条目预估大小"。往接近写满的卷上备份时请自己留意总用量。