dbc0c00554828b58dac7ac14aace74cff49297fe
修复(P0) - 退出码:改用 .NET Process 继承控制台启动外部命令。Start-Process -PassThru 的 ExitCode 在 PowerShell 7.7.0-preview.4 上恒为 $null,会把成功的压缩判成失败, 并让 "exit 2 -> 删档重试" 的自愈分支永远不可达。 - BackupList.txt 解析:先按第一个 :: 切开再处理引号,修正整行被引号包住时 排除表被吞进路径的问题(该条目此前被静默跳过,其 2.8 GB 归档成了孤儿)。 - 排除分隔符同时接受 , 与 ;:此前解析器只认 ; 而清单里写的是 ,, 等于所有排除规则都没生效。 - 7z 排除参数不再嵌引号,含空格的模式自动转成 ?:旧写法 -x!"路径" 会让引号 成为模式的一部分,导致排除对所有条目都失效。 加固(P1) - 先写临时归档 -> 7z t 校验 -> 原子替换,中断不再污染正式归档。 - 放弃 7z 的更新模式 u:固实压缩下收益极小,却让排除规则改动与已删文件 永远进不了归档。 - 新增 Backups/manifest.json 与 logs/*.log,跳过/失败有据可查。 - 结尾按失败数 exit;恢复支持 -WhatIf / -DryRun / -VerifyOnly / -Only。 - 恢复优先用 manifest 定位归档,并精确比较 BaseName(不再用 -Filter 通配)。 - 修正 tar 分支用 $LASTEXITCODE 判断成功与否的缺陷。 - 有警告(文件被占用)时拒绝用不完整的归档覆盖完整归档,需显式 -AcceptWarnings。 策略与安全(P2) - Edge 条目加排除规则:解压后 4.22 GB 中 3.79 GB 是可再生的缓存/遥测/扩展本体, 保留书签、密码、偏好、历史与站点数据。 - 可选 7z 加密(@encrypt 标记或全局开关),取不到口令时明确失败,绝不写明文。 - 磁盘空间守卫:放不下就跳过该条目,低于阈值告警。 工程化(P3) - 新增 BackupConfig.psd1、README.md、.gitignore。 - tests/Run-Tests.ps1(32 项)与 tests/Run-E2E.ps1(16 项端到端验收)。 - tools/Register-BackupTask.ps1 注册每日计划任务。 - 归档命名算法保持不变,已有归档不会失联。
BakNRet
把 BackupList.txt 里列出的目录 / 文件用 7-Zip 打包进 Backups/,并且能用 Restore.ps1 原样恢复的 Windows 备份工具。
- 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,无需安装模块。
- 每个归档写完后做
7z t内容校验,先写临时文件、校验通过再原子替换。 - 每次运行产出可核对的
Backups/manifest.json与logs/*.log。 - 退出码可靠:有失败就返回
1,计划任务能正确判断成败。
快速开始
# 1. 先试运行:只打印计划,不写任何文件
.\Backup.ps1 -DryRun
# 2. 正式备份
.\Backup.ps1
# 3. 强制重打(忽略"源未更新"判断)
.\Backup.ps1 -Force
# 3b. 确认可以接受"有文件被占用而没打进归档"时,允许覆盖完整归档
.\Backup.ps1 -Force -AcceptWarnings
# 4. 只备份 / 只恢复某几项(通配符匹配路径或归档名)
.\Backup.ps1 -Only '*.ssh','C:\Programs\FooClolor'
.\Restore.ps1 -Only '*Edge*' -Force
# 5. 恢复前先看计划(恢复会覆盖真实目录,务必先看一眼)
.\Restore.ps1 -DryRun
# 6. 只校验所有归档完整性,不解压(只读,安全)
.\Restore.ps1 -VerifyOnly
文件说明
| 路径 | 作用 |
|---|---|
BackupList.txt |
备份 / 恢复共用的清单,唯一的"要备份什么"来源 |
BackupConfig.psd1 |
目录、空间阈值、校验、加密等配置 |
Backup.ps1 |
备份入口 |
Restore.ps1 |
恢复入口 |
Common.psm1 |
公共模块(日志、外部命令、解析、manifest) |
Backups/ |
归档与 manifest.json(已 gitignore) |
logs/ |
每次运行的日志(已 gitignore) |
tests/ |
单元测试与端到端验收 |
tools/Register-BackupTask.ps1 |
注册 / 移除计划任务 |
BackupList.txt 语法
<路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]
| 部分 | 说明 |
|---|---|
| 路径 | 支持 %环境变量%;可用双引号包裹(引号只包路径);/ 与 \ 等价;# 开头是注释 |
| 排除模式 | 相对归档根目录(源目录的末级名)。分隔符 , 与 ; 都可以 |
! 前缀 |
表示"任意层级下匹配这个名字",翻译成 7z 的 -xr!,例:!*Cache |
| 标记 | encrypt = 用 7z 加密该归档,见下文「加密」 |
几条已经踩过的坑(工具会处理,写的时候知道就行):
- 模式里不要写引号。
-x!"路径"会让引号成为模式的一部分,结果是永不匹配。 - 模式里的空格会被自动转成
?。 7z 的排除模式不支持空格:Default\Code Cache匹配不到任何东西,Default\Code?Cache才可以。 - 以第一个
::为界切分。:在 Windows 路径里只可能是盘符,::不会出现在真实路径里,所以整行被一对引号包住的历史写法("路径 :: 排除表")也能正确解析。 - 改名即换归档。 归档名由路径生成:
<末级名>_from_<上级路径用 + 连接>。改动路径会生成新归档,旧归档需靠manifest.json找回。
恢复语义
- 用
7z x解压到目标的父目录,覆盖同名文件。 - 不做镜像同步:目标目录里多出来的文件不会被删除。想得到"完全等于归档"的目录,请先清空目标。
- 目标目录比归档新时默认跳过,需要覆盖就加
-Force。 -WhatIf/-DryRun只打印计划;-VerifyOnly只跑7z t。- 排除规则只在下一份归档里生效:已经生成的归档不会因为改了排除表而"变干净",需要重跑备份才会生成新归档。
manifest.json
Backups/manifest.json 以归档基础名为键记录每个条目:
| 字段 | 含义 |
|---|---|
source / resolvedSource |
清单里的原始路径(未展开环境变量)/ 实际路径 |
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 |
源文件数、源大小、归档大小 |
startedAt / finishedAt / durationSec |
时间与耗时 |
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'
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 里写成:
# %UserProfile%/.ssh @encrypt
# 方式二:全部加密,改配置
# Encryption = @{ Enabled = $true; PasswordFile = 'D:\secret\baknret.key' }
# 口令来源(二者取其一)
$env:BAKNRET_PASSWORD = '...' # 或
.\Backup.ps1 -KeyFile 'D:\secret\baknret.key' # 文件首行即口令
要求加密但取不到口令时,该条目会明确失败,绝不会退化成明文归档。 恢复加密归档时同理:取不到口令就直接失败,不会让 7z 停在控制台等待输入(在计划任务里那会静默挂起)。
⚠️ 7-Zip 只接受命令行口令,口令在本机进程列表里会短暂可见。这是 7z 本身的限制,请自行权衡。加密保护的是"归档落在盘上之后"。
计划任务
# 注册:每天 21:30 备份(默认用最高权限运行,因为部分目录需要管理员)
.\tools\Register-BackupTask.ps1 -At '21:30'
# 只看将要注册什么
.\tools\Register-BackupTask.ps1 -At '21:30' -DryRun
# 移除
.\tools\Register-BackupTask.ps1 -Remove
任务会调用 Backup.ps1,脚本自身写日志并按失败数返回退出码,所以「上次运行结果」在任务计划程序里是可读的。
测试
# 单元测试:解析、命名、排除参数翻译、命令行拼接、manifest、配置 + 真实 7z 集成
.\tests\Run-Tests.ps1
# 端到端验收:备份 -> 验证排除 -> 删源 -> 恢复 -> 逐字节对拍(全程在临时目录)
.\tests\Run-E2E.ps1
零依赖,不需要 Pester(本机只有 3.4.0,Should -Be 会直接语法错误)。
相对旧版修了什么
| 问题 | 旧行为 | 现行为 |
|---|---|---|
Start-Process -PassThru 的 ExitCode 在 PowerShell 7.7.0-preview.4 上恒为 $null |
压缩明明成功(Everything is Ok)却报"压缩失败",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、没有测试、没有 README、不是 git 仓库 | — | 都有 |
设计取舍(有意为之,不是遗漏)
- 放弃 7z 的更新模式(
u)。 7z 默认固实压缩,u本来就要重压大部分数据,收益很小,却让"排除规则改动"和"源里删掉的文件"永远进不了归档。现在每次都从零打包,代价是改动的条目会全量重压,换来的是归档与清单语义一致。 - 不捕获压缩工具的输出。 结构化记录交给日志与
manifest.json;捕获子进程 stdio 需要额外管道,在受限环境里会直接失败,而实时进度对交互式使用更有用。需要安静就跑-QuietTool。 - 有警告(退出码 1)时不覆盖完整的归档。 被占用的文件(最典型的是正在运行的 Edge / 浏览器)会让 7z 返回 1,此时新归档是不完整的。实测:Edge 运行时打包,118 个文件读不到,其中包含
Login Data(密码)、Cookies、History、Web Data—— 恰恰是最不可再生的那部分。所以只要在位的归档是完整的(manifestwarnings=false),脚本就保留它、报失败、退出码 1,不会用残缺归档把它换掉。确认可以接受再显式加-AcceptWarnings。 - 源路径不存在只算"跳过",不算失败。 清单里留着已不存在的路径(例如换过盘的
E:\CodeSpace)是正常的,它会以missing-source记进 manifest。失败只统计真正打不开的条目。 DefaultExcludes只影响打包,不影响恢复。
已知限制
- 归档名与路径强耦合:改清单里的路径写法会生成新归档名。恢复时 manifest 能兜底,但别轻易改已备份条目的路径写法。
- 路径里本来就含
+或_from_时,仅靠文件名无法可靠反推路径,此时依赖manifest.json。 -Snapshot目前是"复制一份带时间戳的副本",不做自动轮转清理(KeepCount/KeepDays已保留在配置里,清理逻辑尚未实现)。- 加密归档的常规备份/恢复不依赖
RAR;RAR与内置ZIP分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。
Languages
PowerShell
100%