结构改成开源社区通行的形态:徽章区(PowerShell 双版本、Windows、7-Zip、零运行时依赖、
89 个对外函数、Pester 条数、UTF-8 BOM)、目录、带 emoji 的章节标题、13 处
> [!NOTE] / [!TIP] / [!WARNING] / [!CAUTION] 提示块,以及 3 张 Mermaid 图
(备份数据流 / 恢复数据流 / 产物布局,含"两个都叫 persist 的目录为何不冲突")。
3 张图不是"看着像能渲染"就交:用 headless Edge + CDP 在真实浏览器里以 Mermaid 11 渲染
验证过(能解析 != 能读,所以看的是渲染结果),截图留在 .scratch/_verify/mermaid.png。
顺带修正两处事实错误 —— 都是本次 CI/CD 流水线的文档核对暴露出来的:
* 零依赖套件条数 111 -> 128(实跑 Run-Tests.ps1 得到)
* Pester 条数 183 -> 192(本轮补测后)
校验:Prettier 3.9.9 通过;markdownlint 只剩 10 条 MD051,那是假阳性 —— markdownlint
的锚点生成器不做 emoji 剥离,而 GitHub 会(`## 🚀 快速开始` -> `#-快速开始`)。用独立
脚本按 GitHub 规则复算,63 个标题锚点全部可解析,故保留 emoji 标题。外部链接 11/11 返回
200,内部相对链接全部存在。
926 lines
65 KiB
Markdown
926 lines
65 KiB
Markdown
# BakNRet
|
||
|
||
**把 `BackupList.txt` 里列出的软件与目录用 [7-Zip](https://www.7-zip.org/) 打包进 `Backups/`,并且能用 `Restore-Data.ps1` 原样恢复的 Windows 备份工具。**
|
||
|
||
> [!NOTE]
|
||
>
|
||
> 清单里**直接写软件名**(如 `Edge`)即可,软件名到真实路径的映射维护在 `SoftwareCatalog.psd1` 里。
|
||
> 一个软件一个归档,归档名就是软件名;归档内按名录里的 **Slot** 分层,所以同一个软件里两个都叫
|
||
> `persist` 的目录不会再撞在一起。
|
||
|
||
[](#-环境要求)
|
||
[](#-环境要求)
|
||
[](#-环境要求)
|
||
[](#-环境要求)
|
||
[](#-架构)
|
||
[](#-测试)
|
||
[](#-编码与风格)
|
||
[](#-许可证)
|
||
|
||
## ✨ 为什么是它
|
||
|
||
- 🧩 **清单里直接写软件名**(如 `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["按 <归档名>.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` / 环境变量 / 仓库外的文件 —— 改这一块时尤其要小心。
|
||
|
||
## 📄 许可证
|
||
|
||
<!-- TODO: 补充 -->
|
||
|
||
本仓库目前**没有 `LICENSE` 文件**,因此尚未声明开源许可证 —— 按默认著作权保留处理。
|
||
如果打算公开分发,请补一份许可证(例如 MIT / Apache-2.0)并在这里指向它。
|
||
|
||
## 🙏 致谢
|
||
|
||
- [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>
|