Files
BakNRet/README.md
T
Shuery 2446c7c5c7 docs: 把"前缀补全只搜一层、且不提供深度开关"写成决策记录
深度递归不是"补上一个没实现的功能",而是引入一个具体的错。写成 ADR-0009 连同实测数据:26 条真实 Path 里 19 条直接命中、3 条补全救不了(软件没装)、1 条(%UserProfile%\fnm)在 5 层内会命中 AppData\Local\fnm_multishells 这个临时目录 —— 静默备份错的东西还报成功。

同时在 Find-BakNRetChildDirectoryByName 的注释里指回 ADR,免得下一个人把它当"漏了的功能"补回去。

顺带修掉 README 里一句与实现不符的话:原先写"前缀补全命中多个候选(同名目录分散在多处)",而只搜一层时多个候选只可能来自同一个父目录(既有 X 又有 X_后缀)。

全仓复查:MaxDepth / CatalogMaxDepth / 最大深度 / 向下找几层 除 ADR-0009 的历史叙述外 0 处。

验收:test.ps1 9/9 全绿(7 与 5.1)。
2026-09-27 11:34:34 +08:00

361 lines
24 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 备份工具。
- 清单里**直接写软件名**即可(如 `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`)**一个字节都不写**。
- 动手之前先**预估本次所需空间**并直接判断目标卷够不够(不够只告警、不中断)。
---
## 快速开始
```powershell
# 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` | 备份 / 恢复入口 |
| `BakNRet/` | 模块:`BakNRet.psd1`(清单,`FunctionsToExport` 是显式白名单)+ `BakNRet.psm1`(薄加载器,点源顺序只在这里出现一次)+ `Public/`(62 个对外函数,一函数一文件)+ `Private/`(内部函数与模块级状态) |
| `test.ps1` | **唯一验收入口**:Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍 |
| `PSScriptAnalyzerSettings.psd1` | 静态分析配置(三条有意排除与 160 字符行长,理由见 `docs/adr/0008`) |
| `CONTEXT.md` | 术语表(本项目里每个概念只有一个叫法) |
| `CHANGELOG.md` | 变更日志 |
| `docs/adr/` | 决策记录(8 条:为什么这么设计、拒绝了什么) |
| `docs/*.md` | 主题文档:软件名录、清单语法、归档布局、安全描述符(从本文件拆出,便于单独引用与评审) |
| `tools/Build-BakNRetModule.ps1` | 把模块的多个源文件按加载器顺序合回单文件(发布形态、代码签名时需要) |
| `tools/Invoke-Analyzer.ps1` | 静态分析门禁(默认规则 + 格式规则) |
| `Backups/` | 归档与 `manifest.json`(已 gitignore) |
| `logs/` | 每次运行的日志(已 gitignore) |
| `tests/` | 测试:Pester 套件(`*.Tests.ps1`)、零依赖套件、端到端验收、真实归档恢复演练 |
| `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 |
| `tools/Rename-Archives.ps1` | 把归档名对齐到当前清单规则(默认试运行) |
| `tools/Install-TestDependencies.ps1` | 把 Pester 与 PSScriptAnalyzer 装到仓库内的 `.tools/`(不动机器上的全局模块) |
## SoftwareCatalog.psd1 —— 软件名 → Slot 组
## SoftwareCatalog.psd1 —— 软件名 → Slot 组
软件名录的语法:一个软件由若干 Slot 组成,每个 Slot 能写路径、排除、追加、加密与说明。详见 [software-catalog.md](./docs/software-catalog.md)。
## BackupList.txt 语法
## BackupList.txt 语法
清单每一行的完整语法:方向标记、修饰符、引号与记号边界。详见 [backup-list-syntax.md](./docs/backup-list-syntax.md)。
## 归档布局、命名与迁移
## 归档布局、命名与迁移
归档内的层级、归档名怎么来、以及改名与迁移时要注意什么。详见 [archive-layout.md](./docs/archive-layout.md)。
## 安全描述符(属主 / ACL)
## 安全描述符(属主 / ACL)
属主与 ACL 为什么不是放进归档、而是旁挂一份 sidecar,以及恢复时怎么回放。详见 [security-descriptor.md](./docs/security-descriptor.md)。
## 恢复语义
- 每一项只解出**它自己那棵子树**(`<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` 自动重建。
## 备份前空间预估
每次备份在**动手之前**先按清单顺序模拟一遍,把"这次要写多少、盘够不够"直接打出来:
```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'
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
# 方式一:给某个 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 本身的限制,请自行权衡。
## 口令放在哪里
**口令文件必须放在仓库之外。** `BackupConfig.psd1` 的 `PasswordFile` 默认留空,这不是遗漏:
出厂默认值指向仓库里的某个文件,等于鼓励把口令提交进版本库。`.gitignore` 里排除 `*.key` 只是
第二道防线。三种给它口令的方式:
```
powershell
# A. 环境变量(计划任务用这个最省事)
$env:BAKNRET_PASSWORD = '...'
# B. 配置文件里指到一个仓库外的文件
Encryption = @{ Enabled = $false; PasswordFile = (Join-Path $env:USERPROFILE '.baknret.key'); EncryptHeaders = $true }
# C. 单次指定
.\Backup.ps1 -KeyFile (Join-Path $env:USERPROFILE '.baknret.key')
```
取不到口令时,加密条目**明确失败**,绝不退化成明文归档 —— 这条行为没有放宽。
> 从旧版本迁移的人注意:如果口令文件原先就放在仓库根(`baknret.key`),请把它移到仓库之外,
> 再按上面任一种方式指过去。搬之前可以先用 `pwsh -File .\Restore.ps1 -VerifyOnly -Only "WindowsTerminal" -KeyFile <新路径>`
> 验一下口令对不对(那份归档是加密的,口令不对会报错)。
## 计划任务
```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 | 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 结束并打印安装命令。两种装法:
```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 宿主一起带走;而且子进程给出的是
真正的进程退出码,正好独立验证"退出码取法"这条修复。
## 相对旧版修了什么
见 [CHANGELOG.md](./CHANGELOG.md)。本次改造(2026-09)的修复、结构变化与规范落地都在那里。
## 验收与静态分析
一条命令跑完全部验收层次,**两个 PowerShell 版本各跑一遍**:
```
powershell
.\test.ps1 # Encode + Parse + Unit + Smoke + E2E
.\test.ps1 -Suite Parse # 只跑一层
.\test.ps1 -Suite E2E -PSVersion 5.1
.\tests\Run-RealSmoke.ps1 # 拿真实清单与真实归档做只读冒烟
```
| 层次 | 内容 | 依赖 |
| --- | --- | --- |
| Encode | 受管文件的 BOM / 行尾 / 制表符 —— 丢了 BOM 只在 5.1 上出错,所以这条必须单独检查 | 无 |
| Parse | 全仓 `.ps1` / `.psm1` / `.psd1` 在 5.1 与 7 上解析零错 | 无 |
| Unit | Pester 套件(单元面) | Pester 5+ |
| Smoke | 零依赖套件(关键冒烟) | PowerShell + 7z |
| E2E | 真的调用 7z 打包 → 删源 → 恢复 → 逐字节对拍 | 7z |
| Drill | 真实归档恢复演练(只读,要显式点名) | 本机真实归档 |
`Run-RealSmoke.ps1` 刻意独立于上面几层:其它套件都在临时目录里自造夹具,跑得快、可重复;
它专门跑真实清单,用来挡住"夹具全绿、真实数据全废"。它检查四件事:方向标记全部被剥掉、
没有软件名退化成"名录里没有"、两个只读模式退出 0、`manifest.json` 的 SHA256 前后不变。
静态分析是**独立门禁**,不塞进上面几层(套件跑一次二十多秒,混进去会让"测试红了"这句话
失去分辨力):
```
powershell
.\tools\Invoke-Analyzer.ps1 # 默认规则 + 格式规则
.\tools\Invoke-Analyzer.ps1 -Quiet # 只看按规则汇总
```
那 6 条格式规则(括号、缩进、空格、对齐、大小写)在 PSScriptAnalyzer 里**默认是 Disabled** ——
不带 `-Settings` 的 `Invoke-ScriptAnalyzer -Severity Warning,Error` 会静默漏掉全部排版问题。
三条有意排除与 160 字符行长上限的理由见 [docs/adr/0008](./docs/adr/0008-analyzer-deviations.md)。
## 设计取舍(有意为之,不是遗漏)
- **放弃 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 只能对应一个目录。** 前缀补全命中多个候选(同一个父目录下既有 `X` 又有 `X_后缀`)时会报错并让你拆成多个 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 是按名字算出来的,跨机一致。