Files
BakNRet/README.md
T
Shuery dbc0c00554 重构为可核对、可恢复的备份工具(P0-P3)
修复(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 注册每日计划任务。
- 归档命名算法保持不变,已有归档不会失联。
2026-09-21 20:10:18 +08:00

204 lines
12 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 备份工具。
- 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,无需安装模块。
- 每个归档写完后做 `7z t` 内容校验,**先写临时文件、校验通过再原子替换**。
- 每次运行产出可核对的 `Backups/manifest.json` 与 `logs/*.log`。
- 退出码可靠:有失败就返回 `1`,计划任务能正确判断成败。
---
## 快速开始
```powershell
# 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 语法
```text
<路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]
```
| 部分 | 说明 |
| --- | --- |
| 路径 | 支持 `%环境变量%`;可用双引号包裹(**引号只包路径**);`/` 与 `\` 等价;`#` 开头是注释 |
| 排除模式 | 相对归档根目录(源目录的末级名)。分隔符 `,` 与 `;` 都可以 |
| `!` 前缀 | 表示"任意层级下匹配这个名字",翻译成 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)
```powershell
@{
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` 指定其它配置文件。
## 加密
加密是**按需开启**的,默认关闭——一旦开启而口令丢失,备份就再也解不开。
```powershell
# 方式一:只为个别条目加密(.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 本身的限制,请自行权衡。加密保护的是"归档落在盘上之后"。
## 计划任务
```powershell
# 注册:每天 21:30 备份(默认用最高权限运行,因为部分目录需要管理员)
.\tools\Register-BackupTask.ps1 -At '21:30'
# 只看将要注册什么
.\tools\Register-BackupTask.ps1 -At '21:30' -DryRun
# 移除
.\tools\Register-BackupTask.ps1 -Remove
```
任务会调用 `Backup.ps1`,脚本自身写日志并按失败数返回退出码,所以「上次运行结果」在任务计划程序里是可读的。
## 测试
```powershell
# 单元测试:解析、命名、排除参数翻译、命令行拼接、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` —— 恰恰是最不可再生的那部分。所以只要在位的归档是完整的(manifest `warnings=false`),脚本就**保留它、报失败、退出码 1**,不会用残缺归档把它换掉。确认可以接受再显式加 `-AcceptWarnings`。
- **源路径不存在只算"跳过",不算失败。** 清单里留着已不存在的路径(例如换过盘的 `E:\CodeSpace`)是正常的,它会以 `missing-source` 记进 manifest。失败只统计真正打不开的条目。
- **`DefaultExcludes` 只影响打包,不影响恢复。**
## 已知限制
- 归档名与路径强耦合:改清单里的路径写法会生成新归档名。恢复时 manifest 能兜底,但**别轻易改已备份条目的路径写法**。
- 路径里本来就含 `+` 或 `_from_` 时,仅靠文件名无法可靠反推路径,此时依赖 `manifest.json`。
- `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 已保留在配置里,清理逻辑尚未实现)。
- 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。