# BakNRet 把 `BackupList.txt` 里列出的软件 / 目录用 **7-Zip** 打包进 `Backups/`,并且能用 `Restore-Data.ps1` 原样恢复的 Windows 备份工具。 - 清单里**直接写软件名**即可(如 `Edge`),目录映射维护在 `SoftwareCatalog.psd1` 里。 - 一个软件一个归档:**归档名 = 软件名**(`Edge.7z`),归档内按名录里的 **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-Data.ps1 -DryRun # 2. 正式备份 .\Backup-Data.ps1 # 3. 强制重打(忽略"源未更新"判断) .\Backup-Data.ps1 -Force # 3b. 确认可以接受"有文件被占用而没打进归档"时,允许覆盖完整归档 .\Backup-Data.ps1 -Force -AcceptWarnings # 4. 只备份 / 只恢复某几项(通配符匹配清单条目或归档名) .\Backup-Data.ps1 -Only 'Edge','OpenSSH' .\Restore-Data.ps1 -Only 'Edge' -Force # 5. 恢复前先看计划(恢复会覆盖真实目录,务必先看一眼) .\Restore-Data.ps1 -DryRun # 6. 只校验所有归档完整性,不解压(只读,安全) .\Restore-Data.ps1 -VerifyOnly ``` ## 文件说明 | 路径 | 作用 | | --- | --- | | `SoftwareCatalog.psd1` | **软件名 → Slot 组**的映射:每个 Slot 是一个目录/文件,以及它的排除、追加、加密、说明 | | `BackupList.txt` | 备份 / 恢复共用的清单,唯一的"要处理什么"来源 | | `BackupConfig.psd1` | 目录、空间阈值、校验、加密等配置 | | `Manage-Backup.ps1` | **主入口**:不带参数进 TUI 菜单;带 `-Action Backup\|Restore\|Config` 直接做该动作(配 `-Quiet` 走无头) | | `Backup-Data.ps1` / `Restore-Data.ps1` | 备份 / 恢复动作本身(无头,可在计划任务里直接调) | | `Edit-Config.ps1` | 配置管理:清单 / 设置 / 名录三个界面(见下节) | | `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 组 软件名录的语法:一个软件由若干 Slot 组成,每个 Slot 能写路径、排除、追加、加密与说明。详见 [software-catalog.md](./docs/software-catalog.md)。 ## BackupList.txt 语法 清单每一行的完整语法:方向标记、修饰符、引号与记号边界。详见 [backup-list-syntax.md](./docs/backup-list-syntax.md)。 ## 归档布局、命名与迁移 归档内的层级、归档名怎么来、以及改名与迁移时要注意什么。详见 [archive-layout.md](./docs/archive-layout.md)。 ## 安全描述符(属主 / ACL) 属主与 ACL 为什么不是放进归档、而是旁挂一份 sidecar,以及恢复时怎么回放。详见 [security-descriptor.md](./docs/security-descriptor.md)。 ## 恢复语义 - 每一项只解出**它自己那棵子树**(`` / `<末级名>`),不会把兄弟项也复制到别的父目录下。 - **目录项**:在目标的父目录下建一个指向目标目录的 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-Data.ps1` **优先用 manifest 定位归档**,查不到才退回"从文件名反推路径"。 如果 `BackupList.txt` 丢了,`Restore-Data.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 = 'baknret.key'; 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-Data.ps1 -KeyFile 'D:\secret\baknret.key' # 文件首行即口令 ``` 一个软件一个归档:名录里各 Slot 的 `Encrypt` 不一致时,**整个归档按加密处理**(宁可多加密,不可漏加密), 并打印告警。要求加密但取不到口令时,该条目会**明确失败**,绝不会退化成明文归档。 恢复加密归档时同理:取不到口令就直接失败,不会让 7z 停在控制台等待输入(在计划任务里那会静默挂起)。 > ⚠️ 7-Zip 只接受命令行口令,口令在本机进程列表里会短暂可见。这是 7z 本身的限制,请自行权衡。 ## 口令放在哪里 口令文件的出厂默认值是**仓库根的 `baknret.key`**,靠 `.gitignore` 的 `*.key` 兜住"不被提交"。 这是一次取舍:留在仓库根最省事(口令与配置在一起,搬家时不会丢),代价是"不提交"这件事 依赖一个规则文件 —— 谁写了 `git add -f`、或把整个目录复制到别处再 `git init`,口令就会跟着走。 **相对路径按仓库根解析,不按当前工作目录。** 计划任务的工作目录通常是 `C:\Windows\System32`, 在那里 `Test-Path baknret.key` 为假 —— 如果按工作目录解析,加密条目会以"拿不到口令"失败, 而配置看上去毫无问题。 三种给它口令的方式(优先级见上一节): ```powershell # A. 环境变量(计划任务用这个最省事,也最不怕仓库被整体复制) $env:BAKNRET_PASSWORD = '...' # B. 配置文件里指到一个仓库外的文件 —— 想更稳就走这条 Encryption = @{ Enabled = $false; PasswordFile = (Join-Path $env:USERPROFILE '.baknret.key'); EncryptHeaders = $true } # C. 单次指定 .\Backup-Data.ps1 -KeyFile (Join-Path $env:USERPROFILE '.baknret.key') ``` 取不到口令时,加密条目**明确失败**,绝不退化成明文归档 —— 这条行为没有放宽。 > 从旧版本迁移:如果你的口令文件已经在仓库根(`baknret.key`),**什么都不用做**。 > 想改用仓库外的位置,把它移走后按上面 B 或 C 指过去,并先用 > `pwsh -File .\Restore-Data.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-Data.ps1`,脚本自身写日志并按失败数返回退出码,所以「上次运行结果」在任务计划程序里可读。 ## 测试 四套,按"需要多少依赖"分层: | 套件 | 命令 | 需要什么 | 覆盖 | | --- | --- | --- | --- | | **Pester 套件**(推荐) | `.\tests\Run-Pester.ps1` | Pester 5.0+ 与 7z | 183 项(含安全描述符套件):清单语法(方向 / `::` / `:-` / `:+` / `:encrypt` / `@ Key='值'` / 整行引号与记号边界)、Slot 结构名录、归档命名、排除翻译(`-x!` / `-xr!` / `!re:`)、Slot 前缀分配、暂存、manifest / 配置 / 名录,外加**用子进程真正跑 `Backup-Data.ps1` / `Restore-Data.ps1`** 的端到端与回归 | | 零依赖套件 | `.\tests\Run-Tests.ps1` | 只要 PowerShell + 7z | 111 项:同样的单元面,适合没装 Pester 的机器 | | 端到端验收 | `.\tests\Run-E2E.ps1` | 只要 PowerShell + 7z | 36 项:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍,含 `\` 布局、文件 Slot、方向标记与旧布局回退 | | **真实归档恢复演练** | `.\tests\Restore-Drill.ps1` | 只要 PowerShell + 7z | 12 个真实归档:解到临时目录再和活源逐字节对拍(全程不碰真实目录) | | **安全描述符套件** | `.\tests\Run-Pester.ps1`(内含 `BakNRet.Security.Tests.ps1`) | Pester 5 + 7z | 含在上面 183 项里:排除判定与 7z `-x!/-xr!` 语义对齐、SID 映射边界(前缀 SID 不被误伤)、采集与 sidecar 往返、回放(`CREATOR OWNER` + 孤儿 SID + `protected` 逐字节一致)、以及真的用子进程跑 `Backup-Data.ps1`/`Restore-Data.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-Data.ps1` / `Restore-Data.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)。 ### 在 Hyper-V 虚拟机里验证 上面几层都在本机跑。真正值得单独跑一遍的是 `tools/lab/`:它把仓库同步进一台**干净系统**的 Hyper-V 虚拟机(`gsudo pwsh -File .\tools\lab\Lab.ps1 ...`),在那里跑完整流程。本机跑不到的 路径只有它能覆盖 —— 最典型的是**安全描述符回放**:需要把某个目录的属主改成 `NT AUTHORITY\SYSTEM`、 再靠 `CREATOR OWNER` 的继承规则判断恢复后归谁,而这件事只有在真 VM 里才敢做。 | 步骤 | 它验证什么 | 最近一次结果 | | --- | --- | --- | | `Lab.ps1 test -Suite all` | 三套仓库测试在干净系统上能不能跑 | Pester 183 / 零依赖 111 / 端到端 36,全部通过 | | `Lab.ps1 backup` | 在 VM 内真跑 `Backup-Data.ps1`(沙盒清单 + 配置) | 退出码 0 | | `Lab.ps1 restore` | 用真实归档做恢复演练,**逐字节对拍** | 通过 6 / 失败 0(含连接点场景 4/4) | | `Lab.ps1 acl-test` | 安全描述符:属主 / `CREATOR OWNER` / 安全指纹 | 全部通过 19 项(含负对照) | `acl-test` 里有一个**负对照**值得留意:只搬文件不回放安全描述符时,属主会变成"跑脚本的账户" 而不是原账户 —— 那条用例能证明这个演练分辨得出对错,而不是一路绿灯。 ## 设计取舍(有意为之,不是遗漏) - **放弃 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\\current` 的连接点,备份时会把目标内容一并收进归档(体积翻倍),恢复后 `current` 变成**真实目录**。功能上仍然可用(`current\bin\...` 路径还在),但要心里有数。 - **跨机恢复要配 `Security.SidMap`**:本机不存在的 SID 写进 DACL 是安全的(那条 ACE 只是 永不匹配),但写进**属主**会让谁都没有合理所有权 —— 换域 / 换机时请给映射,或接受 "属主未恢复"的告警。服务账户(`NT SERVICE\X`)的 SID 是按名字算出来的,跨机一致。 ## 交互界面(TUI) 不带参数运行主入口就会进菜单: ```powershell .\Manage-Backup.ps1 # 主菜单:备份 / 恢复 / 配置 .\Edit-Config.ps1 # 配置菜单:清单 / 设置 / 名录 .\Edit-Config.ps1 -Target List # 直接进某个编辑器 ``` 三个编辑器改配置的方式是**外科式改写**:只动你改的那一行(注释、对齐、`$( )` 表达式、跨行拼接一字节不动), 先校验再落盘,落盘前把原文件按时间戳复制到 `logs\config-backups\`。所以改坏了随时能翻回去。 | 界面 | 改什么 | 说明 | | --- | --- | --- | | 清单 | `BackupList.txt` | 改方向(`both` / `backup` / `restore`),保留你原来的写法风格(贴着写与留空格都原样) | | 设置 | `BackupConfig.psd1` | 单行标量设置;值跨行或本身是集合的(如 `SidMap`、`DefaultExcludes`)**不列出**,因为"改一行"对它们没有明确含义 | | 名录 | `SoftwareCatalog.psd1` | 只有单行的 `Encrypt` 与 `Description` 可改;`Path` 常带动态表达式、`Exclude` 可能是跨行拼接,一律只读 | ### 无头与自动化 - `-Quiet`:不画界面,把被调动作的输出捕获后原样转发出来(计划任务与管道用这个)。 - `-InputScript`:用按键序列驱动界面,门禁就是靠它把"打开菜单 → 选一项 → 改一个值 → 保存"整条流程跑通的。 序列用尽而界面还在等输入时会**报错退出**,绝不退回去读真终端 —— 挂起比失败糟得多。 - 只有真终端才画界面:检测到输出被重定向且没给按键序列时,明确报错并给退出码 2,不会卡住。 设计取舍(为什么零依赖、为什么不做全屏、为什么异常不改退出码)见 `docs/adr/0010` ~ `0013`。