# 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` 自动重建。 ## 备份前空间预估 每次备份在**动手之前**先按清单顺序模拟一遍,把"这次要写多少、盘够不够"直接打出来: ```text [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) ```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 | 88 项:解析、命名、排除翻译、命令行拼接、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 的 `archive` 字段 | 源不存在的条目也留着归档名,指向一个根本不存在的文件;恢复时白报"归档不存在" | 只在文件真的存在时才写;删掉归档后同步一次就自我纠正 | | 没有名录、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` 是告警阈值。 想稳妥跑完就先腾空间,或用 `-Only` / `-Skip` 分批。