缺陷:Backup.ps1 用 `$stagingRoot = $null` + try/finally 清理暂存目录,而 New-BaknretArchiveStaging 中途抛错时没有返回值 —— 赋值没发生,finally 拿到的还是 $null, 而 Remove-BaknretArchiveStaging 对 $null 直接 return。结果是已经建好的 junction 与临时 目录永久留在 %TEMP%,而那些 junction 指向真实数据;临时目录迟早会被某次 Remove-Item -Recurse 扫到,那一下就会走进真实数据。全仓唯一会伤到数据的缺陷。 修法:把清理责任放回函数自己身上 —— 循环包进 try,catch 先调 Remove-BaknretArchiveStaging 清掉已经建出来的东西,再 throw 原始错误。调用方的 finally 保持不动(它管的是"暂存建好之后下游才失败"那条路)。自清理失败时只告警并点名残留路径, 不覆盖真正的失败原因 —— 那才是排查需要的。 回归断言(零依赖与 Pester 各一份):第一项走 junction 成功挂上,第二项因源文件不 存在必然抛错;然后断言沙盒里不留任何条目,尤其不留 junction。 断言的有效性做了红绿证明:把 catch 里那行清理临时停用后,同一段场景残留 1 个 junction(.\sandbox\stage\Good,指向真实目录)→ 断言变红;还原后文件哈希一致、断言转绿。 一个不会红的断言不算保护。 验收:test.ps1 9/9 全绿(7 与 5.1);tests\Run-RealSmoke.ps1 4/4 全绿。
BakNRet
把 BackupList.txt 里列出的软件 / 目录用 7-Zip 打包进 Backups/,并且能用 Restore.ps1 原样恢复的 Windows 备份工具。
- 清单里直接写软件名即可(如
Edge),目录映射维护在SoftwareCatalog.psd1里。 - 一个软件一个归档:归档名 = 软件名(
Edge.7z),归档内按名录里的 Slot 分层 (<Slot>\<该路径的内容>),所以同一个软件里两个都叫persist的目录不会再撞在一起。 - 清单行首
+= 仅备份、-= 仅恢复;两条路径共用同一份清单。 - 排除 / 追加 / 加密都能写在
SoftwareCatalog.psd1的 Slot 上,清单行里可以按条目覆盖。 - 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,运行备份/恢复不需要任何模块(只有跑 Pester 测试才需要 Pester 5)。
- 每个归档写完后做
7z t内容校验,先写临时文件、校验通过再原子替换。 - 每次运行产出可核对的
Backups/manifest.json与logs/*.log。 - 归档之外还保存 NTFS 安全描述符(属主 / 属组 / DACL):每个归档旁边一份
<归档名>.acl.json,恢复时按它回放。这是"恢复之后原程序还能不能读写"的关键 (C:\ProgramData下那些靠CREATOR OWNER授权的目录,见「安全描述符」一节)。 - 退出码可靠:有失败就返回
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 'Edge','OpenSSH'
.\Restore.ps1 -Only 'Edge' -Force
# 5. 恢复前先看计划(恢复会覆盖真实目录,务必先看一眼)
.\Restore.ps1 -DryRun
# 6. 只校验所有归档完整性,不解压(只读,安全)
.\Restore.ps1 -VerifyOnly
文件说明
| 路径 | 作用 |
|---|---|
SoftwareCatalog.psd1 |
软件名 → Slot 组的映射:每个 Slot 是一个目录/文件,以及它的排除、追加、加密、说明 |
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 —— 软件名 → Slot 组
@{
Edge = @{
# Slot = 归档内的一层目录:内容进 DefaultData\,恢复时整棵回到这个 Path
DefaultData = @{
Path = '%LocalAppData%\Microsoft\Edge\User Data'
Exclude = '!*Cache,!Crashpad,Default\Extensions,Default\Service Worker'
Description = 'Edge 用户数据:书签/密码/偏好/历史,以及站点数据'
}
}
Scoop = @{
# 一个软件可以有多个 Slot;两个都叫 persist 的目录因此不再冲突
DefaultConfig = @{
Path = '%UserProfile%\.config\scoop'
Encrypt = $true
Description = 'scoop 自身的配置'
}
UserPersist = @{
Path = '$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })\persist'
Encrypt = $true
Description = 'scoop 各应用的持久化数据'
}
}
WindowsTerminal = @{
# Path 指向文件时,归档里就是一个名为 DefaultData 的文件(没有扩展名)
DefaultData = @{
Path = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json'
Encrypt = $true
Description = 'Windows Terminal 的设置文件'
}
}
}
字段:
| 字段 | 说明 |
|---|---|
| Slot 名 | 归档内的一层目录。内容进 <Slot>\;Path 是文件时就是名为 <Slot> 的文件。同一软件里不能重名 |
Path |
宿主机上的绝对路径。支持 %变量% 与 $( ... ) 子表达式 |
Exclude |
排除模式,相对本 Slot 的根,逗号分隔。! 打头 = 任意层级(7z 通配符),!re:<正则> = 正则 |
Include |
追加项,<归档内相对路径>:<宿主机绝对路径>,逗号分隔 |
Encrypt |
该归档是否加密,默认 $false。同一软件里若各 Slot 不一致,整个归档按加密处理 |
Description |
这个 Slot 是干什么的;运行时逐条打印 |
要点:
Path支持$( ... ):$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })会按 PowerShell 求值(求值结果会缓存,不会每个条目重复起进程)。 这类写法用了+拼接字符串时,Import-PowerShellDataFile会拒绝,脚本会自动改用 PowerShell 求值——名录与配置是仓库里的本地文件,和脚本同级,信任级别相同。- 目录当前不存在也不会被丢掉:备份时跳过并记
missing-source,但恢复时仍然知道 "这块内容原本该回到哪个位置",这正是恢复要用的。 - 前缀补全:写
D:\Programs\legendary,实际目录是legendary_2.0.4时会自动匹配 (只认<名>_*与<名>-*)。一个 Slot 只能对应一个目录,补全出多个会明确报错并让你拆 Slot。 - 一个软件里不能有两个同名 Slot,否则归档内会混成一棵树;脚本会明确报错。
分文件维护:用 Includes 引入其它名录文件(路径相对本文件):
@{
Includes = @('SoftwareCatalog.games.psd1')
...
}
BackupList.txt 语法
[+|-] <软件名 或 绝对路径> [ :: <绝对路径> ] [ :- <模式>[,<模式>...] ] [ :+ <追加项>[,<追加项>...] ]
[ :encrypt | :!encrypt ] [ @ <Key>='<值>' ] [ # 说明 ]
修饰符必须是独立的、前后带空白的记号,所以路径里出现的 :-、C:\a#b 之类不会被误切。
目标(二选一)
| 写法 | 说明 |
|---|---|
Edge |
软件名:去 SoftwareCatalog.psd1 查 Slot 组,归档名 = 软件名 |
C:\Programs\MiFlash |
手写路径:含 \ / 或 % 就按路径处理,归档名 = <末级名>_from_<上级路径用 + 连接> |
Edge @pathname |
软件名 + 强制用路径命名(想换到名录体系但暂时不想改归档名时用) |
行首方向标记
| 标记 | 作用 |
|---|---|
+ |
仅备份,不恢复(Restore.ps1 会跳过它;归档名照旧算"有主"的,不会被报成孤儿) |
- |
仅恢复,不备份(Backup.ps1 会跳过它;适合放在别处、必要时才还原的目录) |
| 无 | 既能备份也能恢复(默认) |
修饰符
| 修饰符 | 等价写法 | 作用 |
|---|---|---|
:: <绝对路径> |
@ Path='<绝对路径>' |
覆盖 Path(软件名条目只有一个 Slot 时可用) |
:- <模式>[,...] |
@ Exclude='<模式>' |
排除模式(, ; 都当分隔符),覆盖名录里各 Slot 的 Exclude |
:+ <追加项>[,...] |
@ Include='<追加项>' |
追加项,语法 <归档内相对路径>:<宿主机绝对路径>,覆盖名录里的 Include |
:encrypt |
@ Encrypt='$true' |
该条目加密 |
:!encrypt |
@ Encrypt='$false' |
该条目不加密 |
@ <Key>='<值>' |
— | 覆盖名录里的默认字段(目前支持 Path / Exclude / Include / Encrypt) |
兼容的历史写法仍然认:@encrypt / @!encrypt / @pathname / @root=<名>(root= 已废弃,只会打印告警)。
# 软件名:用名录里的 Slot 与排除;再把额外目录放进包内 Modules\ 位置
Scoop :- GlobalPersist\steam\steamapps
# 手写目录 + 排除
C:\Programs\MiFlash :- MiFlash\logs\
# 追加映射:把宿主机的 D:\extra\ps-modules 放到包内 Modules\ 下
PowerShell :+ Modules:D:\extra\ps-modules
# 覆盖加密(名录里默认加密时特别有用)
PowerShell @ Encrypt='$false'
WindowsPowerShell :!encrypt
模式(排除 / 追加)怎么写
模式匹配的是归档内的相对路径,而且相对本 Slot 的根(也就是 <Slot>\ 里面那一层):
| 形态 | 展开成 | 说明 |
|---|---|---|
<相对路径> |
-x!<Slot>\<相对路径> |
锚定在归档根下这一份 |
!<通配> |
-xr!<通配> |
任意层级按组件名匹配,* ? 是 7z 通配符(不是正则) |
!re:<正则> |
若干 -x!<完整路径> |
正则:脚本自己遍历源目录把命中的路径展开成精确排除项 |
GlobalPersist\steam |
-x!GlobalPersist\steam |
第一段是 Slot 名时,只作用在那一个 Slot 上 |
!*Cache一次覆盖Cache/Code Cache/GPUCache/DaemonCache等一批以 Cache 结尾的组件名。- 模式里不要自己写引号;模式里的空格会被自动转成
?(7z 的-x!不接受带空格的模式)。 - 想把
.log之类按正则排除就写!re:.*\.log$;命中的目录会整棵剪掉,命中数超过 300 条会明确报错 (命令行长度有限),这时应该改用更粗的通配模式。 - 不带
!的模式是锚定的:Edge 的OneAuth\WebView2\EBWebView\里还有一整套自己的Crashpad/BrowserMetrics/ProvenanceData/optimization_guide,锚定模式碰不到它们, 这些可再生的东西一律用!<组件名>才会在任意层级命中。
实测效果(本机真实 Edge 配置,源 4619.9 MB):
| Edge 归档 | 大小 | 条目数 |
|---|---|---|
| 排除规则生效前 | 1781 MB | 27961 |
| 排除规则生效后 | 72 MB | 2294 |
书签、密码(Login Data)、Cookies、偏好、历史、IndexedDB、Local Storage 全部保留;
缓存、组件缓存、Service Worker、扩展本体、遥测与优化数据全部排除。
行尾可以写"为什么"
行尾的 # 说明 会被解析出来,运行时和 Slot 介绍一起打印:
Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档
# 必须前面有空白才算注释,所以路径里的 C:\a#b 不受影响。
运行时会把每个条目的归档项逐条介绍出来
说明来自 SoftwareCatalog.psd1 的 Slot,追加/排除的来源来自清单:
[INFO] 条目:Scoop
[INFO] 归档:Scoop.7z;方向:备份 + 恢复;加密:是
[INFO] 归档项 1/3:DefaultConfig <- C:\Users\Shuery\.config\scoop
[INFO] 来源:软件名录;存在,会打包;目录
[INFO] 介绍:Scoop 配置。
[INFO] 归档项 2/3:GlobalPersist <- C:\ProgramData\scoop\persist
[INFO] 来源:软件名录;存在,会打包;目录
[INFO] 排除 1 条(来自清单的 :- / @ Exclude):GlobalPersist\steam\steamapps
[INFO] 排除 2 条(来自 BackupConfig.psd1 的 DefaultExcludes):!Thumbs.db、!desktop.ini
Restore.ps1 也会打印"哪一项还原到哪个目录、是文件还是目录、会新建还是覆盖"。
几个必须知道的约束
- 归档名重复会直接报错。 归档名 = 软件名字,所以同一个软件写两遍会让两个条目互相覆盖。
- 同一软件里的 Slot 名不能重复,追加项的归档内路径也不能和 Slot 撞;脚本会在打包前明确报错。
- 一个条目挂多个归档项时,每一项只还原自己那棵子树,不会把兄弟项也复制到别的父目录下。
::现在是"覆盖 Path",不再是:-的历史别名;排除一律写:-。
归档布局、命名与迁移
包内长什么样
| 条目类型 | 归档内部 |
|---|---|
| 软件名 + Slot 目录 | <Slot>\<该 Path 的内容> |
| 软件名 + Slot 文件 | 一个名为 <Slot> 的文件(没有扩展名,恢复时还原成 Path 里的原名) |
| 手写路径(目录) | <路径末级名>\...(与历史归档一致) |
| 手写路径(文件) | 一个名为 <路径末级名> 的文件 |
:+ / Include 追加项 |
你写的那个 <归档内相对路径>(目录就是目录,文件就是那个文件) |
7z 没有"入库时改名"的能力,所以打包前会建一个暂存目录:目录项用 junction、 文件项用硬链接(不可用时退回复制)按归档内的名字挂进去,打完立刻拆掉。 建不出连接点时会明确报错,不会悄悄换成另一种布局——布局一变恢复就对不上了。
归档名
| 条目类型 | 归档名 |
|---|---|
| 软件名 | <软件名>.7z |
| 字面路径 | <末级名>_from_<上级路径用 + 连接>.7z(: 归一化成 _) |
软件名 + @pathname |
同字面路径 |
::/@ Path=只改从哪儿读,不改归档名:软件名条目仍然叫<软件名>.7z。 想换归档名就用@pathname,或者干脆把条目写成绝对路径。
从旧版迁移(重要)
-
包内布局变了。 重构前生成的归档,包内顶层是源目录名;现在软件名条目多了一层 Slot。
Restore.ps1会识别这种情况(归档里没有该 Slot 时打印告警并按旧布局解), 所以旧归档仍然恢复得出来;但要让包内结构统一,跑一次.\Backup.ps1 -Force重打即可 (-Force会忽略"源未更新"判断)。 -
手写路径条目的归档名可能变了。 清单里把原来的软件名改成绝对路径之后, 归档名会从
<软件名>变成<末级名>_from_<...>。用重命名工具对齐(默认试运行、 逐份大小校验、重建 manifest,只改名不搬数据):.\tools\Rename-Archives.ps1 # 先看计划 .\tools\Rename-Archives.ps1 -Apply # 确认后执行它会先用当前规则算出目标名,再从"路径命名算法 / 名录里的软件名 / manifest 里记录过的归档名" 里找磁盘上真实存在的旧文件。
-
名录里的
Encrypt现在生效。 如果某个 Slot 写了Encrypt = $true(或清单里写了:encrypt),但运行时取不到口令,该条目会明确失败,绝不会退化成明文归档。 先准备好$env:BAKNRET_PASSWORD或用-KeyFile指定密码文件再跑。 -
孤儿归档审计会在每次备份后点名"磁盘上有、但清单里没有任何条目指向"的归档 (旧名字没迁移、条目被删掉或改名都会这样)。确认新归档校验通过之后再删旧文件。
安全描述符(属主 / ACL)
问题:归档格式装不下 NTFS 安全描述符 —— 7-Zip 的 -sni(Store NT security information)
官方文档写明「当前版本只能写进 WIM 归档」,.7z 里一个字节的 ACL 都没有。
于是"备份 → 恢复"之后,每个对象的安全描述符都是新建对象的默认值:属主是跑恢复脚本的
那个进程,DACL 是从目标父目录继承来的那一套。
为什么这对 C:\ProgramData 是致命的:那里的目录 ACL 里有
(A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL
CREATOR OWNER(S-1-3-0)不是账户,是访问检查时才替换的占位符 —— 替换成
"被检查对象的属主"。所以这句话的真实含义是「谁创建的东西谁有全权」。只回放 ACE 文本、
不恢复属主,等于把里面的"谁"换成了跑恢复脚本的账户,原程序(服务账户 / 专用用户)
反而没了读写权限。真机实测(tools\lab\Lab.ps1 acl-test):
原属主 = S-1-5-18 (NT AUTHORITY\SYSTEM)
恢复后属主 = S-1-5-18 ← 正确恢复(要靠显式启用的 SeRestorePrivilege)
负对照属主 = S-1-5-32-544 ← 只搬文件、不回放安全描述符时,属主落到"跑脚本的账户"
怎么做:
- 备份时把每个对象的 SDDL(
Get-Acl的原文,含O:/G:/D:)写进旁挂文件Backups/<归档名>.acl.json,键是归档内相对路径(目标机器上%UserProfile%和 名录的前缀补全都会变,只有归档内路径两端同源)。 - SDDL 里的 SID 是数值形式,
CO/OW这类占位符原样保留。全程不做账户名解析 —— 名字解析会把占位符映射成当前用户,或者直接抛IdentityNotMappedException, 那正是"权限落到脚本头上"的另一种成因。 - 恢复时在解压之后、对真实目标路径自顶向下回放:父目录先写,子对象的继承才收敛。
原本不
protected的 DACL 只写显式 ACE,其余交给父目录重新继承(保住活继承语义);protected的原样写。 - 写属主要
SeRestorePrivilege,而且必须显式启用:管理员的过滤令牌里它默认是 disabled,Set-Acl/SetAccessControl都不会替你打开。所以恢复要在管理员(或 SYSTEM)下跑, 脚本启动时会明确告警"属主将无法恢复,只能恢复 DACL"。 - 写失败有三级回退:
属主+属组+DACL→属主+DACL→仅 DACL(属组常常是最先失败的那个, 而它对访问判定几乎没影响,不能因为它把属主一起丢掉)。 - 对象的安全描述符读不到(系统目录里很常见)时带错误记账、写进 sidecar 并计入 manifest
的
security.errors,恢复时跳过它并告警 —— 而不是当成"这个对象没有特殊权限"。
配置在 BackupConfig.psd1:
Security = @{
Mode = 'Full' # Off | Full | Smart | Roots
IncludeSacl = $false # 连审计规则(SACL)一起存取,需要 SeSecurityPrivilege
SidMap = @{} # 跨机恢复的 SID 映射:@{ 'S-1-5-21-旧' = 'S-1-5-21-新' }
FailOnError = $false # sidecar 写不出来时,是否把该条目算作失败
}
Full(默认):每个对象都存。正确性优先,几万文件的树 sidecar 几 MB。Smart:只存"继承复现不出来"的对象(protected / 有显式 ACE / 属主属组与父目录不同 / 继承链已脱节)。判据偏保守,但终究是启发式,所以不是默认。Roots:只存每个归档项的根,最省。Off:完全不采集,恢复出来的就是新建对象的默认值。
Restore.ps1 另有 -SkipSecurity 可以只恢复文件内容。
已知取舍(有意为之):
- 归档旁边没有
acl.json的旧归档照常恢复,只是打一行告警说明"属主/ACL 是默认值"。 - 陈旧继承 ACE 会被"冻结":如果某个对象的 DACL 里留着已经没有任何出处的继承 ACE (父目录改过权限、Windows 自己也不会再传播它),那它靠继承复现不出来,只能整套冻结成 显式 ACE 并置 protected —— 这是唯一"既不丢 ACE、也不产生重复 ACE"的做法(实测: 目标上原本就留着那条陈旧 ACE,再补一条显式 ACE 会让同一条 ACE 出现两次)。 代价是这个对象从此不跟随父目录,而它本来就已经跟父目录脱节了。
- ACL 只跟着归档旁边的
acl.json走:搬归档时要把同名的.acl.json一起搬。
恢复语义
- 每一项只解出它自己那棵子树(
<Slot>/<末级名>),不会把兄弟项也复制到别的父目录下。 - 目录项:在目标的父目录下建一个指向目标目录的 junction,让 7z 直接写穿它落地(零拷贝), 解完立刻拆掉连接点。建不出连接点(父目录里已有同名实体、目标卷不支持等)时, 退回"先解到临时目录再逐项合并"——只慢不错。
- 文件项:解到临时目录后把文件搬到
Path指定的位置(恢复原名)。 - 旧布局兜底:归档里没有该 Slot 时(重构前的归档)会打印告警,退回到旧布局 (把目标的末级名直接解到目标的父目录),与重构前的恢复语义一致。
- 不做镜像同步:目标目录里多出来的文件不会被删除。想得到"完全等于归档"的目录,请先清空目标。
- 行首
+(仅备份)的条目不恢复;行首-(仅恢复)的条目照常恢复。 - 目标目录比归档新时默认跳过,需要覆盖就加
-Force。 -WhatIf/-DryRun只打印计划;-VerifyOnly只跑7z t。 这三种模式一个字节都不写(manifest.json也不会被碰)。- 加密归档取不到口令时直接失败,不会让 7z 停在控制台等输入(在计划任务里那会静默挂起)。
- 排除规则只在下一份归档里生效:已经生成的归档不会因为改了排除表而"变干净"。
manifest.json
以归档基础名为键记录每个条目:
| 字段 | 含义 |
|---|---|
source |
清单里的原始写法(软件名或路径) |
resolvedSource |
展开后的路径 |
roots |
归档内真实的顶层条目名(就是 Slot 名 / 源目录名 / 追加项的归档内路径;只统计真实存在的项)。每次重新处理该条目时刷新 |
layouts |
每个归档项的 { name, kind }(dir / file),恢复端在目标还不存在时靠它判断"该还原成目录还是文件" |
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 自动重建。
备份前空间预估
每次备份在动手之前先按清单顺序模拟一遍,把"这次要写多少、盘够不够"直接打出来:
[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)
@{
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 指定其它配置文件。
加密
默认关闭 —— 一旦开启而口令丢失,备份就再也解不开。
# 方式一:给某个 Slot 加密(私钥、浏览器数据这类最典型)
# SoftwareCatalog.psd1:
# OpenSSH = @{ DefaultData = @{ Path = '%UserProfile%\.ssh'; Encrypt = $true } }
# 或在 BackupList.txt 的条目上写:
# Edge :encrypt
# PowerShell @ Encrypt='$false' # 反过来,关掉名录里的默认加密
# 方式二:全部加密,改配置
# Encryption = @{ Enabled = $true; PasswordFile = 'D:\secret\baknret.key' }
# 口令来源(二者取其一)
$env:BAKNRET_PASSWORD = '...' # 或
.\Backup.ps1 -KeyFile 'D:\secret\baknret.key' # 文件首行即口令
一个软件一个归档:名录里各 Slot 的 Encrypt 不一致时,整个归档按加密处理(宁可多加密,不可漏加密),
并打印告警。要求加密但取不到口令时,该条目会明确失败,绝不会退化成明文归档。
恢复加密归档时同理:取不到口令就直接失败,不会让 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 | 150 项:清单语法(方向 / :: / :- / :+ / :encrypt / @ Key='值' / 整行引号与记号边界)、Slot 结构名录、归档命名、排除翻译(-x! / -xr! / !re:)、Slot 前缀分配、暂存、manifest / 配置 / 名录,外加用子进程真正跑 Backup.ps1 / Restore.ps1 的端到端与回归 |
| 零依赖套件 | .\tests\Run-Tests.ps1 |
只要 PowerShell + 7z | 101 项:同样的单元面,适合没装 Pester 的机器 |
| 端到端验收 | .\tests\Run-E2E.ps1 |
只要 PowerShell + 7z | 36 项:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍,含 <Slot>\ 布局、文件 Slot、方向标记与旧布局回退 |
| 真实归档恢复演练 | .\tests\Restore-Drill.ps1 |
只要 PowerShell + 7z | 12 个真实归档:解到临时目录再和活源逐字节对拍(全程不碰真实目录) |
| 安全描述符套件 | .\tests\Run-Pester.ps1(内含 BakNRet.Security.Tests.ps1) |
Pester 5 + 7z | 25 项:排除判定与 7z -x!/-xr! 语义对齐、SID 映射边界(前缀 SID 不被误伤)、采集与 sidecar 往返、回放(CREATOR OWNER + 孤儿 SID + protected 逐字节一致)、以及真的用子进程跑 Backup.ps1/Restore.ps1 做端到端 |
演练会把"源在备份之后变过"和"归档/解压有问题"分开:内容不一致时看活源文件的修改时间, 晚于归档时间就算"源变了"(只提示),不晚于归档时间却内容不同才算失败。真实机器上的归档 常常是几周前的,不这样区分就天天报假失败。
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 归档成了孤儿 | 先按空白分词切出修饰符,再处理引号 |
| 归档名由路径拼出 | 加一条备份要自己算名字,名字随路径变动 | 清单写软件名,归档名就是软件名 |
一个软件里两个同名目录(例如两个 persist) |
静默混成一棵树,两边的数据都错 | 名录改成 Slot 结构,每个 Slot 是归档内的一层目录,同名不再冲突 |
| 清单只能"备份 + 恢复"一把抓 | 想只备份的、只恢复的条目得另开文件 | 行首 + / - 直接标方向,两条路径共用一份清单 |
:: 既是"排除"又是历史别名 |
语义含糊::: 一会儿是排除、一会儿是路径 |
:: 只表示覆盖 Path,排除一律写 :- |
加密只能靠裸标记 @encrypt |
名录里的加密意图没法表达 | :encrypt / :!encrypt / @ Encrypt='$false',名录的 Slot 也能写 Encrypt |
| 排除/追加只能写在清单行里 | 名录里的 Slot 光有路径,规则全堆在清单里 | Exclude / Include / Encrypt 都可以写在 Slot 上,清单按需覆盖 |
! 只能按通配符匹配 |
想按正则排除做不到 | 新增 !re:<正则>(脚本遍历源目录翻译成精确排除项) |
名录路径只支持 %变量% |
scoop prefix xxx 这类动态路径写不出来 |
Path 支持 $( ... ) 子表达式,并在一次运行内缓存求值结果 |
| 名录每解析一个条目就重新 Import 一次 | 同一个文件被反复读取、$( ... ) 被反复执行 |
按内容指纹缓存,一次运行只读一次 |
直接更新已有归档(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 时不再误报;+ / - 的条目也算"有主" |
手写目录的 :+ 追加 |
被整段丢掉(只有软件名写法才生效),既没人报错也没人知道 | 两种写法都生效,追加项还会标出来源(名录 / 追加项) |
| 软件名录的多目录写法 | 一个软件可以挂多个目录,但目录名不能重复,否则包内混成一棵树 | 改成 Slot 结构:每个 Slot 是包内一层目录,同名目录(两个 persist)不再冲突 |
| 一个条目挂多个目录的恢复 | 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 | 每个归档项只解出它自己那棵子树 |
| 归档内路径冲突 | 静默混成一棵树,两边的数据都错 | 打包前明确报错(退出码 1)并提示改 Slot 名 / 归档内相对路径 |
| 运行时的可解释性 | 只有一行"开始备份: X" | 逐条打印目录、来源、介绍、排除/追加的出处与理由;备份前还会预估所需空间并判断够不够 |
manifest 的 archive 字段 |
源不存在的条目也留着归档名,指向一个根本不存在的文件;恢复时白报"归档不存在" | 只在文件真的存在时才写;删掉归档后同步一次就自我纠正 |
| 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 |
设计取舍(有意为之,不是遗漏)
- 放弃 7z 的更新模式(
u)。 7z 默认固实压缩,u本来就要重压大部分数据,收益很小,却让"排除规则改动"和"源里删掉的文件"永远进不了归档。 - 包内用 Slot 分层,靠暂存目录改名。 7z 没有"入库时改名"的能力,所以打包前建一个暂存目录,
把每个归档项按包内名字挂进去(目录走 junction、文件走硬链接/复制),打完立刻拆掉。
代价是每份归档多一次 junction 开销;收益是一个软件可以有多个目录而不怕重名
(scoop 的用户
persist与全局persist就属于这种),恢复时也能精确地"只解这一棵子树"。 建不出连接点时明确报错,不悄悄退化成另一种布局。顺带一提,7z 的-spf不是干这个的 (它是 use fully qualified file paths)。 - 恢复用 junction 零拷贝落地。 目标父目录下建一个指向目标的 junction,让 7z 直接写穿它, 解完立刻拆掉;建不出来就退回"先解到临时目录再合并"。这样不必把大归档整体搬两遍。
- 不捕获压缩工具的输出。 结构化记录交给日志与
manifest.json;捕获子进程 stdio 需要额外管道,在受限环境里会直接失败。 - 有警告(退出码 1)时不覆盖完整的归档。 被占用的文件会让 7z 返回 1,此时新归档是不完整的。实测 Edge 运行时打包,118 个文件读不到,其中包含
Login Data(密码)、Cookies、History、Web Data。所以在位归档完整时脚本保留它、报失败、退出码 1,确认可以接受再显式加-AcceptWarnings。 - 名录里的路径不存在时,恢复仍然可用。 源被删掉正是要恢复的场景,所以解析器照旧给出
Items,备份端则据此跳过。 - 源路径不存在只算"跳过",不算失败。 会以
missing-source记进 manifest。失败只统计真正打不开的条目。 @ Path=覆盖只允许单 Slot 条目。 多 Slot 时"覆盖"根本没有唯一含义,直接报错比猜一个 Slot 好。- 旧归档用"旧布局兜底"而不是拒绝恢复。 重构前的归档包内没有 Slot 层, 恢复时按 Slot 解会失败,脚本捕获后按旧布局(目标的末级名)再试一次, 并在日志里说清楚——旧备份仍然救得回来。
已知限制
- 改软件名 / 改 Slot 名等于换归档结构。 改名后旧归档不会被自动迁移,用
tools/Rename-Archives.ps1或手动改名, 并注意 manifest 里会留下旧键;Slot 名变了则需要重打(-Force)。 - 路径里本来就含
+或_from_时,仅靠文件名无法可靠反推路径,此时依赖manifest.json。 -Snapshot目前是"复制一份带时间戳的副本",不做自动轮转清理(KeepCount/KeepDays尚未实现)。- 加密归档的常规备份/恢复不依赖
RAR;RAR与内置ZIP分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。 内置 ZIP 分支也不支持排除规则(Compress-Archive没有对应开关),只保证内容完整。 - 暂存改名需要能建目录连接点(junction)。 暂存目录在
%TEMP%(NTFS 即可),目标源目录跨盘也没问题; 建不出连接点时该条目会明确失败,而不会静默换成别的布局。恢复时的 junction 建不出来会自动退回"临时目录 + 合并"。 - 一个 Slot 只能对应一个目录。 前缀补全命中多个候选(同名目录分散在多处)时会报错并让你拆成多个 Slot, 而不是任选一个。
!re:有量级上限。 正则命中的路径超过 300 条、或排除参数超过命令行安全长度时会明确失败; 这种场景应改用更粗的通配模式。root=<名>标记已废弃。 包内的一层目录现在由 Slot 决定;写了该标记只会打印告警。- 空间只做"预估 + 提示",不做全局拦截。 备份前会打印预计峰值新增和"够不够"的结论;
不够时只告警不中断,真正放不下的条目交给逐条目守卫跳过。
MinFreeSpaceGB是告警阈值。 想稳妥跑完就先腾空间,或用-Only/-Skip分批。 - 恢复安全描述符需要管理员(或 SYSTEM)。 非提权时属主写不进去(
SeRestorePrivilege不在令牌里),脚本会退化到"只恢复 DACL"并明确告警 —— 那不是失败,但CREATOR OWNER会判给"当前属主",所以依赖它的程序可能仍然没权限。 acl.json要跟归档一起搬。 它不在归档里(7z 装不下),改名 / 迁移归档时要用tools\Rename-Archives.ps1或手工把同名旁挂文件一起改。- 7z 会跟随 junction(不是存成链接,因为
-snl只对 WIM/TAR 生效):所以 scoop 那种apps\<app>\current的连接点,备份时会把目标内容一并收进归档(体积翻倍),恢复后current变成真实目录。功能上仍然可用(current\bin\...路径还在),但要心里有数。 - 跨机恢复要配
Security.SidMap:本机不存在的 SID 写进 DACL 是安全的(那条 ACE 只是 永不匹配),但写进属主会让谁都没有合理所有权 —— 换域 / 换机时请给映射,或接受 "属主未恢复"的告警。服务账户(NT SERVICE\X)的 SID 是按名字算出来的,跨机一致。