Files
BakNRet/README.md
T
Shuery 2cbcaaee48 chore(license): 补 Apache License 2.0
许可证正文**逐字采用** ASF 的 LICENSE-2.0.txt,只把 APPENDIX 里的版权占位行填成
"Copyright 2026 Shuery" —— 正文一个字节都没改。

为此先下载规范文本再打补丁,而不是凭记忆抄一遍:许可证抄错一个词就不再是那个许可证了。
校验方式是逐行对比本仓 LICENSE 与规范文本,结果只差版权那一行(规范文本 11358 字节、
SHA256 cfc7749b…;本仓 11337 字节,差额 21 字节正好等于两行文本的长度差)。

选 Apache-2.0 而非 MIT,主要差别在第 3 节的**明示专利授权**:贡献者授予专利许可,
而不只是版权许可。

同时:
  * README 的许可证章节从「尚未声明」改成实际条款摘要 + 徽章(License: Apache-2.0)
  * BakNRet.psd1 的 PrivateData.PSData 登记 LicenseUri 与 Copyright,Import-Module
    之后可直接读到许可信息
  * 不加 NOTICE:第 4(d) 节只在作品本身带 NOTICE 时才要求向下传递,而本仓库不分发第三方
    代码(Pester / PSScriptAnalyzer 只放在 .tools/ 供本地测试,既进 gitignore 也不进分发产物)
  * .scratch/ci-cd 的两份报告补了带日期的「后续更新」注记,关掉许可证合规的 L-1 与依赖
    安全扫描的 R-1,避免后来的人读到「主许可证未声明」这个已经过期的结论

验收:test.ps1 9/9 全绿(5.1 与 7);静态分析 80 条、0 Error(与改动前持平);
README 相对链接 0 失效、新增外部链接 2/2 返回 200;双宿主解析 psd1 零错、89 个导出不变。
2026-10-02 01:34:45 +08:00

953 lines
66 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](https://www.7-zip.org/) 打包进 `Backups/`,并且能用 `Restore-Data.ps1` 原样恢复的 Windows 备份工具。**
> [!NOTE]
>
> 清单里**直接写软件名**(如 `Edge`)即可,软件名到真实路径的映射维护在 `SoftwareCatalog.psd1` 里。
> 一个软件一个归档,归档名就是软件名;归档内按名录里的 **Slot** 分层,所以同一个软件里两个都叫
> `persist` 的目录不会再撞在一起。
[![PowerShell 5.1 | 7.x](https://img.shields.io/badge/PowerShell-5.1%20%7C%207.x-5391FE?logo=powershell&logoColor=white)](#-环境要求)
[![Platform: Windows](https://img.shields.io/badge/Platform-Windows-0078D6?logo=windows&logoColor=white)](#-环境要求)
[![7-Zip: required](https://img.shields.io/badge/7--Zip-required-000000?logo=7zip&logoColor=white)](#-环境要求)
[![Runtime deps: 0](https://img.shields.io/badge/%E8%BF%90%E8%A1%8C%E6%97%B6%E4%BE%9D%E8%B5%96-0-brightgreen)](#-环境要求)
[![Public functions: 89](https://img.shields.io/badge/%E5%AF%B9%E5%A4%96%E5%87%BD%E6%95%B0-89-blue)](#-架构)
[![Pester: 183 passed](https://img.shields.io/badge/Pester-183%20passed-success)](#-测试)
[![Encoding: UTF--8 with BOM](https://img.shields.io/badge/Encoding-UTF--8%20with%20BOM-orange)](#-编码与风格)
[![License: Apache--2.0](https://img.shields.io/badge/License-Apache%202.0-blue)](./LICENSE)
## ✨ 为什么是它
- 🧩 **清单里直接写软件名**(如 `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` 授权的目录,见[安全描述符](./docs/security-descriptor.md)一节)。
- 🚦 **退出码可靠**:有失败就返回 `1`,计划任务能正确判断成败。
- 🧹 备份结束做**孤儿归档审计**:磁盘上有、但没有任何清单条目指向的归档会被点名(它们恢复不到,别误删)。
- 🛡️ 恢复支持 `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` / `-Skip`;其中三种「只看不写」的模式
(`-WhatIf` / `-DryRun` / `-VerifyOnly`)**一个字节都不写**。
- 📈 动手之前先**预估本次所需空间**并直接判断目标卷够不够(不够只告警、不中断)。
## 📖 目录
- [🚀 快速开始](#-快速开始)
- [📁 文件说明](#-文件说明)
- [🤖 软件名录:软件名 → Slot 组](#-软件名录软件名--slot-组)
- [📝 清单语法](#-清单语法)
- [🗜️ 归档布局、命名与迁移](#-归档布局命名与迁移)
- [🔐 安全描述符(属主 / ACL)](#-安全描述符属主--acl)
- [♻️ 恢复语义](#-恢复语义)
- [📋 manifest.json](#-manifestjson)
- [📈 备份前空间预估](#-备份前空间预估)
- [🪵 日志](#-日志)
- [🛠️ 配置(BackupConfig.psd1)](#-配置backupconfigpsd1)
- [🔑 加密与口令](#-加密与口令)
- [⏰ 计划任务](#-计划任务)
- [🧪 测试](#-测试)
- [🔍 验收与静态分析](#-验收与静态分析)
- [🖥️ 交互界面(TUI)](#-交互界面tui)
- [🏗️ 架构](#-架构)
- [⚖️ 设计取舍(有意为之,不是遗漏)](#-设计取舍有意为之不是遗漏)
- [⚠️ 已知限制](#-已知限制)
- [🧬 编码与风格](#-编码与风格)
- [🤝 贡献](#-贡献)
- [📄 许可证](#-许可证)
## 🚀 快速开始
```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
```
> [!TIP]
>
> 全新上手推荐按这个顺序走:`-DryRun` 看计划 → `-Only` 先备份一项 → `-VerifyOnly` 校验 →
> 最后再放开整份清单。
### 🔧 环境要求
| 项目 | 要求 |
| ---------- | --------------------------------------------------------------------------- |
| 操作系统 | Windows(用到 <abbr title="目录连接点">junction</abbr> 与 NTFS 安全描述符) |
| PowerShell | Windows PowerShell **5.1** 或 PowerShell **7.x**(两套都验过) |
| 压缩工具 | **7-Zip**(`7z.exe`,装在默认路径或塞进 `PATH`) |
| 测试依赖 | 只有跑 Pester 套件才需要 [Pester 5.0+](https://github.com/pester/Pester) |
| 权限 | 备份不需要提权;**恢复安全描述符需要管理员(或 SYSTEM)** |
## 📁 文件说明
| 路径 | 作用 |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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` | 配置管理:清单 / 设置 / 名录三个界面(见[交互界面](#-交互界面tui)) |
| `Backup.ps1` / `Restore.ps1` | **旧名字,转发用的垫片**:内部实现已改名为上面两个,这层只留一轮,删它的时机是大家都改用新名字之后 |
| `BakNRet/` | 模块:`BakNRet.psd1`(清单,`FunctionsToExport` 是显式白名单)+ `BakNRet.psm1`(薄加载器,点源顺序只在这里出现一次)+ `Public/`(89 个对外函数,一函数一文件)+ `Private/`(内部函数与模块级状态) |
| `test.ps1` | **唯一验收入口**:Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍 |
| `PSScriptAnalyzerSettings.psd1` | 静态分析配置(三条有意排除与 160 字符行长,理由见 [ADR-0008](./docs/adr/0008-analyzer-deviations.md)) |
| `CONTEXT.md` | 术语表(本项目里每个概念只有一个叫法) |
| `CHANGELOG.md` | 变更日志 |
| `docs/adr/` | 决策记录(13 条:为什么这么设计、拒绝了什么) |
| `docs/*.md` | 主题文档:软件名录、清单语法、归档布局、安全描述符(从本文件拆出,便于单独引用与评审) |
| `tools/Build-BakNRetModule.ps1` | 把模块的多个源文件按加载器顺序合回单文件(发布形态、代码签名时需要) |
| `tools/Invoke-Analyzer.ps1` | 静态分析门禁(默认规则 + 格式规则) |
| `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 |
| `tools/Rename-Archives.ps1` | 把归档名对齐到当前清单规则(默认试运行) |
| `tools/Install-TestDependencies.ps1` | 把 Pester 与 PSScriptAnalyzer 装到仓库内的 `.tools/`(不动机器上的全局模块) |
| `tools/lab/` | Hyper-V 干净系统实验台(见[在虚拟机里验证](#在-hyper-v-虚拟机里验证)) |
| `Backups/` | 归档与 `manifest.json`(已 gitignore) |
| `logs/` | 每次运行的日志(已 gitignore) |
| `tests/` | 测试:Pester 套件(`*.Tests.ps1`)、零依赖套件、端到端验收、真实归档恢复演练 |
## 🤖 软件名录:软件名 → Slot 组
```powershell
@{
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 是干什么的;运行时逐条打印 |
> [!IMPORTANT]
>
> `Path` 支持 `$( ... )`:会按 PowerShell 求值(求值结果会缓存,不会每个条目重复起进程)。
> 这类写法用了 `+` 拼接字符串时,`Import-PowerShellDataFile` 会拒绝,脚本会自动改用 PowerShell
> 求值 —— 名录与配置是仓库里的本地文件,和脚本同级,信任级别相同。
要点:
- **目录当前不存在也不会被丢掉**:备份时跳过并记 `missing-source`,但恢复时仍然知道「这块内容原本该回到哪个位置」,这正是恢复要用的。
- **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配(只认 `<名>_*` 与 `<名>-*`)。一个 Slot 只能对应一个目录,补全出多个会明确报错并让你拆 Slot。
- **一个软件里不能有两个同名 Slot**,否则归档内会混成一棵树;脚本会明确报错。
**分文件维护**:用 `Includes` 引入其它名录文件(路径相对本文件):
```powershell
@{
Includes = @('SoftwareCatalog.games.psd1')
...
}
```
> [!NOTE]
>
> 完整说明见 [software-catalog.md](./docs/software-catalog.md)。
## 📝 清单语法
```text
[+|-] <软件名 或 绝对路径> [ :: <绝对路径> ] [ :- <模式>[,<模式>...] ] [ :+ <追加项>[,<追加项>...] ]
[ :encrypt | :!encrypt ] [ @ <Key>='<值>' ] [ # 说明 ]
```
修饰符必须是**独立的、前后带空白的记号**,所以路径里出现的 `:-`、`C:\a#b` 之类不会被误切。
```text
# 软件名:用名录里的 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 介绍一起打印
Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档
```
| 写法 | 说明 |
| --------------------- | ------------------------------------------------------------------------------------------ |
| `Edge` | **软件名**:去 `SoftwareCatalog.psd1` 查 Slot 组,**归档名 = 软件名** |
| `C:\Programs\MiFlash` | **手写路径**:含 `\` `/` 或 `%` 就按路径处理,归档名 = `<末级名>_from_<上级路径用 + 连接>` |
| `Edge @pathname` | 软件名 + 强制用路径命名(想换到名录体系但暂时不想改归档名时用) |
| `+` | **仅备份,不恢复**(`Restore-Data.ps1` 会跳过它;归档名照旧算「有主」的,不会被报成孤儿) |
| `-` | **仅恢复,不备份**(`Backup-Data.ps1` 会跳过它;适合放在别处、必要时才还原的目录) |
### 排除模式怎么写
模式匹配的是**归档内的相对路径**,而且**相对本 Slot 的根**(也就是 `<Slot>\` 里面那一层):
| 形态 | 展开成 | 说明 |
| --------------------- | ------------------------ | ---------------------------------------------------------- |
| `<相对路径>` | `-x!<Slot>\<相对路径>` | 锚定在归档根下这一份 |
| `!<通配>` | `-xr!<通配>` | **任意层级**按组件名匹配,`*` `?` 是 7z 通配符(不是正则) |
| `!re:<正则>` | 若干 `-x!<完整路径>` | **正则**:脚本自己遍历源目录把命中的路径展开成精确排除项 |
| `GlobalPersist\steam` | `-x!GlobalPersist\steam` | 第一段是 Slot 名时,只作用在那一个 Slot 上 |
> [!WARNING]
>
> - `!*Cache` 一次覆盖 `Cache` / `Code Cache` / `GPUCache` / `DaemonCache` 等一批以 Cache 结尾的组件名。
> - 模式里**不要自己写引号**;模式里的空格会被自动转成 `?`(7z 的 `-x!` 不接受带空格的模式)。
> - 想按正则排除 `.log` 之类就写 `!re:.*\.log$`;命中的目录会整棵剪掉,命中数超过 300 条会明确报错(命令行长度有限),这时应改用更粗的通配模式。
**实测效果**(本机真实 Edge 配置,源 4619.9 MB):
| Edge 归档 | 大小 | 条目数 |
| -------------- | --------- | ------ |
| 排除规则生效前 | 1781 MB | 27961 |
| 排除规则生效后 | **72 MB** | 2294 |
书签、密码(`Login Data`)、`Cookies`、偏好、历史、`IndexedDB`、`Local Storage` 全部保留;
缓存、组件缓存、Service Worker、扩展本体、遥测与优化数据全部排除。
```dsh-ui
{
"title": "Edge 归档:排除规则的效果",
"gap": 12,
"items": [
{
"type": "chart",
"kind": "bars",
"data": [
{ "label": "排除生效前", "value": 1781 },
{ "label": "排除生效后", "value": 72 }
]
},
{
"type": "text",
"content": "源 4619.9 MB → 归档 1781 MB → **72 MB**;条目数 27961 → 2294。保留书签 / 密码 / Cookies / 偏好 / 历史,排除缓存、Service Worker、扩展本体与遥测。"
}
]
}
```
## 🗜️ 归档布局、命名与迁移
### 包内长什么样
| 条目类型 | 归档内部 |
| ----------------------- | ------------------------------------------------------------------ |
| 软件名 + Slot 目录 | `<Slot>\<该 Path 的内容>` |
| 软件名 + Slot 文件 | 一个名为 `<Slot>` 的文件(没有扩展名,恢复时还原成 Path 里的原名) |
| 手写路径(目录) | `<路径末级名>\...`(与历史归档一致) |
| 手写路径(文件) | 一个名为 `<路径末级名>` 的文件 |
| `:+` / `Include` 追加项 | 你写的那个 `<归档内相对路径>`(目录就是目录,文件就是那个文件) |
7z 没有「入库时改名」的能力,所以打包前会建一个**暂存目录**:目录项用 junction、文件项用硬链接
(不可用时退回复制)按归档内的名字挂进去,打完立刻拆掉。建不出连接点时会**明确报错**,
不会悄悄换成另一种布局 —— 布局一变恢复就对不上了。
### 归档名
| 条目类型 | 归档名 |
| -------------------- | ---------------------------------------------------------- |
| 软件名 | `<软件名>.7z` |
| 字面路径 | `<末级名>_from_<上级路径用 + 连接>.7z`(`:` 归一化成 `_`) |
| 软件名 + `@pathname` | 同字面路径 |
> [!NOTE]
>
> `::` / `@ Path=` 只改**从哪儿读**,不改归档名:软件名条目仍然叫 `<软件名>.7z`。
> 想换归档名就用 `@pathname`,或者干脆把条目写成绝对路径。
### 从旧版迁移(重要)
1. **包内布局变了。** 重构前生成的归档,包内顶层是源目录名;现在软件名条目多了一层 Slot。
`Restore-Data.ps1` 会识别这种情况(归档里没有该 Slot 时打印告警并按旧布局解),
所以**旧归档仍然恢复得出来**;但要让包内结构统一,跑一次 `.\Backup-Data.ps1 -Force` 重打即可
(`-Force` 会忽略「源未更新」判断)。
1. **手写路径条目的归档名可能变了。** 清单里把原来的软件名改成绝对路径之后,归档名会从
`<软件名>` 变成 `<末级名>_from_<...>`。用重命名工具对齐(**默认试运行**、逐份大小校验、
重建 manifest,只改名不搬数据):
```powershell
.\tools\Rename-Archives.ps1 # 先看计划
.\tools\Rename-Archives.ps1 -Apply # 确认后执行
```
1. **名录里的 `Encrypt` 现在生效。** 如果某个 Slot 写了 `Encrypt = $true`(或清单里写了
`:encrypt`),但运行时取不到口令,该条目会**明确失败**,绝不会退化成明文归档。
先准备好 `$env:BAKNRET_PASSWORD` 或用 `-KeyFile` 指定密码文件再跑。
1. **孤儿归档审计**会在每次备份后点名「磁盘上有、但清单里没有任何条目指向」的归档
(旧名字没迁移、条目被删掉或改名都会这样)。确认新归档校验通过之后再删旧文件。
> [!NOTE]
>
> 完整说明见 [archive-layout.md](./docs/archive-layout.md) 与 [backup-list-syntax.md](./docs/backup-list-syntax.md)。
## 🔐 安全描述符(属主 / ACL)
**问题**:归档格式装不下 NTFS 安全描述符 —— 7-Zip 的 `-sni`(Store NT security information)
官方文档写明「当前版本只能写进 WIM 归档」,`.7z` 里一个字节的 ACL 都没有。于是「备份 → 恢复」
之后,每个对象的安全描述符都是**新建对象的默认值**:属主是跑恢复脚本的那个进程,DACL 是从
目标父目录继承来的那一套。
**为什么这对 `C:\ProgramData` 是致命的**:那里的目录 ACL 里有
```text
(A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL
```
`CREATOR OWNER`(`S-1-3-0`)不是账户,是**访问检查时才替换的占位符** —— 替换成「被检查对象的
属主」。所以这句话的真实含义是「谁创建的东西谁有全权」。只回放 ACE 文本、不恢复属主,等于把里面的
「谁」换成了跑恢复脚本的账户,**原程序(服务账户 / 专用用户)反而没了读写权限**。
真机实测(`tools\lab\Lab.ps1 acl-test`):
```text
原属主 = 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`,恢复时跳过它并告警 —— 而不是当成「这个对象没有特殊权限」。
```powershell
Security = @{
Mode = 'Full' # Off | Full | Smart | Roots
IncludeSacl = $false # 连审计规则(SACL)一起存取,需要 SeSecurityPrivilege
SidMap = @{} # 跨机恢复的 SID 映射:@{ 'S-1-5-21-旧' = 'S-1-5-21-新' }
FailOnError = $false # sidecar 写不出来时,是否把该条目算作失败
}
```
| `Mode` | 采集范围 | 取舍 |
| -------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------ |
| `Full`(默认) | 每个对象都存 | **正确性优先**,几万文件的树 sidecar 几 MB |
| `Smart` | 只存「继承复现不出来」的对象(protected / 有显式 ACE / 属主属组与父目录不同 / 继承链已脱节) | 判据偏保守,但终究是启发式,所以不是默认 |
| `Roots` | 只存每个归档项的根 | 最省,适合权限只在根上的场景 |
| `Off` | 完全不采集 | 恢复出来的就是新建对象的默认值 |
`Restore-Data.ps1` 另有 `-SkipSecurity` 可以只恢复文件内容。
> [!NOTE]
>
> 完整说明见 [security-descriptor.md](./docs/security-descriptor.md)。
**已知取舍(有意为之)**:
- 归档旁边没有 `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-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(\text{源大小},\ \text{旧归档} \times 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 停在控制台等待输入(在计划任务里那会静默挂起)。
> [!CAUTION]
>
> 7-Zip 只接受命令行口令,口令在本机进程列表里会短暂可见。这是 7z 本身的限制,请自行权衡。
> 另外:`-Verbose` / `-Debug` 打印命令行时,`-p` 参数会被换成占位符,口令不会落进日志。
### 口令放在哪里
口令文件的出厂默认值是**仓库根的 `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')
```
取不到口令时,加密条目**明确失败**,绝不退化成明文归档 —— 这条行为没有放宽。
> [!TIP]
>
> 从旧版本迁移:如果你的口令文件已经在仓库根(`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`,脚本自身写日志并按失败数返回退出码,所以「上次运行结果」在任务计划
程序里可读(`Get-ScheduledTaskInfo -TaskName 'BakNRet Backup'`)。
## 🧪 测试
四套,按「需要多少依赖」分层:
| 套件 | 命令 | 需要什么 | 覆盖 |
| ----------------------- | ------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Pester 套件**(推荐) | `.\tests\Run-Pester.ps1` | Pester 5.0+ 与 7z | 192 项(含安全描述符套件):清单语法(方向 / `::` / `:-` / `:+` / `:encrypt` / `@ Key='值'` / 整行引号与记号边界)、Slot 结构名录、归档命名、排除翻译(`-x!` / `-xr!` / `!re:`)、Slot 前缀分配、暂存、manifest / 配置 / 名录,外加**用子进程真正跑 `Backup-Data.ps1` / `Restore-Data.ps1`** 的端到端与回归 |
| 零依赖套件 | `.\tests\Run-Tests.ps1` | 只要 PowerShell + 7z | 128 项:同样的单元面,适合没装 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 | 含在上面 192 项里:排除判定与 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 宿主一起带走;而且子进程给出的是真正的进程
退出码,正好独立验证「退出码取法」这条修复。
## 🔍 验收与静态分析
一条命令跑完全部验收层次,**两个 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 字符行长上限的理由见 [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 / 零依赖 128 / 端到端 36,全部通过(这是该次 VM 实测的快照;随后套件增至 192 项) |
| `Lab.ps1 backup` | 在 VM 内真跑 `Backup-Data.ps1`(沙盒清单 + 配置) | 退出码 0 |
| `Lab.ps1 restore` | 用真实归档做恢复演练,**逐字节对拍** | 通过 6 / 失败 0(含连接点场景 4/4) |
| `Lab.ps1 acl-test` | 安全描述符:属主 / `CREATOR OWNER` / 安全指纹 | 全部通过 19 项(含负对照) |
`acl-test` 里有一个**负对照**值得留意:只搬文件不回放安全描述符时,属主会变成「跑脚本的账户」
而不是原账户 —— 那条用例能证明这个演练分辨得出对错,而不是一路绿灯。
## 🖥️ 交互界面(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,不会卡住。
设计取舍(为什么零依赖、为什么不做全屏、为什么异常不改退出码)见
[ADR-0010](./docs/adr/0010-zero-dependency-tui.md) ~ [ADR-0013](./docs/adr/0013-tui-writes-config-surgically.md)。
## 🏗️ 架构
`BakNRet/` 是 PowerShell **模块**:既能被 `Import-Module` 直接用,也是四个入口脚本背后的实现层。
`BakNRet.psm1` 是薄加载器 —— **点源顺序只在这里出现一次**;`BakNRet.psd1` 的 `FunctionsToExport`
是**显式白名单**,名字少写一个对应函数就不会被导出(宁可导入方报「找不到命令」,也不要静默少一个函数)。
### 一次备份的数据流
```mermaid
flowchart TD
subgraph repo["仓库(真相)"]
BL["BackupList.txt<br/>要处理什么"]
SC["SoftwareCatalog.psd1<br/>软件名 → Slot 组"]
BC["BackupConfig.psd1<br/>目录 / 校验 / 加密"]
end
BL --> P["解析清单<br/>方向 · 排除 · 追加 · 覆盖"]
SC --> P
BC --> P
P --> PLAN["打印计划 + 空间预估<br/>(只读,-DryRun 到此为止)"]
PLAN --> STAGE
subgraph stage["暂存目录(%TEMP%)"]
STAGE["按归档内的名字挂载<br/>目录走 junction · 文件走硬链接"]
end
STAGE --> SEVENZIP["7z 打包(每次从零,不用更新模式)"]
SEVENZIP --> TMP["写临时归档 .tmp"]
TMP --> VERIFY{"7z t 校验通过?"}
VERIFY -- 否 --> DISCARD["丢弃临时文件<br/>旧归档原封不动"]
VERIFY -- 是 --> SWAP["原子替换<br/>File.Replace"]
SWAP --> DONE["归档 + manifest.json<br/>+ 同名 .acl.json"]
STAGE -. 打完立刻拆掉 .-> DONE
```
### 一次恢复的数据流
```mermaid
flowchart TD
M["BackupList.txt"] --> LOOKUP{"manifest.json 里有?"}
LOOKUP -- 有 --> ARCH["按 manifest 定位归档"]
LOOKUP -- 没有 --> FALLBACK["退回从文件名反推路径"]
ARCH --> KIND{"条目是目录还是文件?"}
FALLBACK --> KIND
KIND -- 目录 --> JUNC["在目标父目录建 junction<br/>7z 直接写穿它(零拷贝)"]
JUNC --> UNPACK["解出这一棵子树<br/>(没有该 Slot 时退回旧布局)"]
UNPACK --> RMDIR["拆掉 junction"]
KIND -- 文件 --> TMPDIR["解到临时目录<br/>再搬到 Path 指定的位置"]
RMDIR --> ACL["按 &lt;归档名&gt;.acl.json 回放<br/>属主 / 属组 / DACL(自顶向下)"]
TMPDIR --> ACL
ACL --> REPORT["打印逐项结果<br/>按失败数 exit"]
```
### 产物布局
```mermaid
flowchart LR
subgraph source["宿主机"]
EDGE["Edge\\User Data"]
SCOOP1["Scoop\\persist"]
SCOOP2["ProgramData\\scoop\\persist"]
end
subgraph backupdir["Backups 目录"]
A["Edge.7z"]
B["Scoop.7z"]
ACLS["同名 .acl.json<br/>属主 / ACL"]
MAN["manifest.json<br/>登记簿"]
end
EDGE --> A
SCOOP1 --> B
SCOOP2 --> B
B --- ACLS
A --- ACLS
MAN -.-> A
MAN -.-> B
```
两个都叫 `persist` 的目录之所以不再冲突,是因为归档内多了一层 Slot:
```text
Scoop.7z
├── DefaultConfig\ <- Slot 名,恢复时回到 %UserProfile%\.config\scoop
├── GlobalPersist\ <- Slot 名,恢复时回到 C:\ProgramData\scoop\persist
└── UserPersist\ <- Slot 名,恢复时回到 %UserProfile%\scoop\persist
```
### 仓库结构
```text
BakNRet/
├── Manage-Backup.ps1 主入口(TUI 菜单 / -Action)
├── Backup-Data.ps1 备份动作(无头)
├── Restore-Data.ps1 恢复动作(无头)
├── Edit-Config.ps1 配置编辑器入口
├── Backup.ps1 / Restore.ps1 旧名字垫片(只留一轮)
├── BackupList.txt 清单:要处理什么
├── SoftwareCatalog.psd1 软件名 → Slot 组
├── BackupConfig.psd1 目录 / 阈值 / 校验 / 加密 / 安全描述符
├── test.ps1 唯一验收入口(Encode+Parse+Unit+Smoke+E2E)
├── PSScriptAnalyzerSettings.psd1
├── CONTEXT.md 术语表 · CHANGELOG.md 变更日志
├── BakNRet/ 模块
│ ├── BakNRet.psd1 模块清单(导出白名单)
│ ├── BakNRet.psm1 薄加载器(点源顺序唯一声明点)
│ ├── Public/ 89 个对外函数,一函数一文件
│ └── Private/ 内部映射/读取辅助 + State.ps1(模块状态)
├── docs/ 主题文档 + adr/(13 条决策记录)
├── tests/ Pester · 零依赖 · 端到端 · 真实归档演练
├── tools/ 构建 · 分析门禁 · 计划任务 · 归档改名 · lab/
├── Backups/ 归档 + manifest.json(gitignore)
└── logs/ 运行日志 + config-backups/(gitignore)
```
> [!NOTE]
>
> **编排逻辑目前仍在 `Backup-Data.ps1` / `Restore-Data.ps1` 里**(`Invoke-BackupItem` 等),
> 把它们下沉进模块是已被记录、尚未实施的下一步:做完之后一份编排就能同时服务 TUI 与无头两条路,
> TUI 也不必再起子进程(见 [ADR-0012](./docs/adr/0012-entry-rename-and-shims.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 是按名字算出来的,跨机一致。
## 🧬 编码与风格
| 约定 | 落地为 | 为什么 |
| ----------------------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| 源码 **UTF-8 with BOM** | `.editorconfig` 的 `charset = utf-8-bom` | 没有 BOM 时 5.1 按 ANSI 解码源码:中文变乱码,全角字符会吃掉引号,整块语法失效(实测全仓 6/6 文件在 5.1 上解析失败) |
| 行尾一律 **LF** | `.gitattributes` 的 `* text=auto eol=lf` | 本机 `core.autocrlf = true` 会制造「除了行尾什么都没改」的巨大伪 diff |
| 缩进 4 空格、不用制表符、结尾留空行 | `.editorconfig` + Encode 层门禁 | 这些东西只能在门禁里检查,靠人盯必漏 |
| 函数命名带 `BakNRet` 前缀 | 模块导出白名单 | 静态分析拓出 `Write-Log` 与本机某个已装模块重名,后果是导入两个模块时一方的命令被静默遮蔽 |
| 模块源码一函数一文件 | `BakNRet/{Public,Private}` + 薄加载器 | 3152 行、66 个函数的 `Common.psm1` 已经改不动了;要发布形态就用 `tools/Build-BakNRetModule.ps1` 合回单文件 |
术语(归档、条目、方向、Slot、覆盖、归档项、旧布局、manifest、孤儿归档……)见 [CONTEXT.md](./CONTEXT.md):
每个概念在本仓库里只有一个叫法,写作与命名都照那里的词来。
## 🤝 贡献
1. **动手前先看约定**:[CONTEXT.md](./CONTEXT.md) 的术语表、`docs/adr/` 里相关的决策记录
—— 很多「奇怪」的写法背后有一条实测结论,别当成可以顺手清理的遗留。
1. **改完跑门禁**:
```powershell
.\test.ps1 # Encode + Parse + Unit + Smoke + E2E,7 与 5.1 各一遍
.\tools\Invoke-Analyzer.ps1 # 静态分析(默认规则 + 格式规则)
```
1. **提改动时说明实测证据**:这个仓库的习惯是「修了什么」配一条可复现的判据,而不是「看起来更规范了」。
1. **别把 `Backups/`、`logs/`、`.tools/` 或任何 `*.key` 提交进来**(都已在 `.gitignore` 里)。
> [!IMPORTANT]
>
> 提交前请确认没有把口令带进仓库:`BackupConfig.psd1` 的 `PasswordFile` 默认值为空,
> 口令应当来自 `-Password` / 环境变量 / 仓库外的文件 —— 改这一块时尤其要小心。
## 📄 许可证
本项目以 **Apache License 2.0** 发布,完整条款见 [LICENSE](./LICENSE)。
该文件**逐字采用** ASF 的 [LICENSE-2.0.txt](https://www.apache.org/licenses/LICENSE-2.0.txt),
只把 APPENDIX 里的版权占位行填成了实际的版权声明 —— 许可证正文一个字节都没改。
```text
Copyright 2026 Shuery
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
要点:
- **宽松许可**:允许商用、修改、再分发,也允许闭源衍生 —— 与 MIT 同属宽松阵营。
- **要保留声明**:必须保留版权与许可声明,并对修改过的文件作出醒目说明(第 4(b)(c) 节)。
- **含明示的专利授权**(第 3 节):这是 Apache-2.0 相对 MIT 的主要差别 —— 贡献者授予专利许可,
而不是只给版权许可。
- **没有 `NOTICE` 文件**:第 4(d) 节只在作品本身带 `NOTICE` 时才要求向下传递。本仓库不分发
第三方代码(Pester 与 PSScriptAnalyzer 只放在 `.tools/` 供本地测试,既进 `.gitignore`、
也不进分发产物),因此无需 `NOTICE`。
- 模块清单 `BakNRet/BakNRet.psd1` 的 `PrivateData.PSData` 里也登记了 `LicenseUri` 与
`Copyright`,便于 `Import-Module` 之后直接读到许可信息。
## 🙏 致谢
- [7-Zip](https://www.7-zip.org/) —— 归档与校验的实际执行者。
- [Pester](https://github.com/pester/Pester) —— 单元与集成测试框架。
- [PSScriptAnalyzer](https://github.com/PowerShell/PSScriptAnalyzer) —— 静态分析与格式规则门禁。
---
<sub>变更记录见 [CHANGELOG.md](./CHANGELOG.md);决策记录见 [docs/adr/](./docs/adr/);术语表见 [CONTEXT.md](./CONTEXT.md)。</sub>