From 3a3a57a6a132faab94179918ad68921b60861639 Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Sun, 27 Sep 2026 10:01:54 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=86=B3=E7=AD=96=E8=AE=B0=E5=BD=95=20?= =?UTF-8?q?8=20=E6=9D=A1=E3=80=81=E5=8F=98=E6=9B=B4=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E3=80=81README=20=E7=9A=84=E9=AA=8C=E6=94=B6=E4=B8=8E=E5=8F=A3?= =?UTF-8?q?=E4=BB=A4=E4=B8=A4=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0007 与 0008 是本次改造新产生的决策(原计划 6 条)。它们同样满足「后人会问为什么」:把运行锁改成互斥体、或给库里 27 个函数补上 SupportsShouldProcess,都是看起来更规范的错法。 CHANGELOG.md:把 README 里那 33 行「相对旧版修了什么」整节搬过去,并补上本次改造的记录(11 条修复 + 6 条结构与规范,每条修复都写明现象与现状)。README 那一节换成指针。 README 新增两节:「验收与静态分析」(六个层次、Run-RealSmoke 为什么独立存在、以及那 6 条格式规则默认 Disabled 这个容易漏掉的事实);「口令放在哪里」(默认留空不是遗漏,附迁移与验证命令)。 .markdownlint.json 照 PowerShell 主仓库的实践(default true、行长 240),只把 MD024 从关闭改成「仅同级不重复」,因为变更日志需要重复标题。顺带按 .editorconfig 把 .md 的 BOM 去掉。 --- .markdownlint.json | 14 +++ CHANGELOG.md | 69 +++++++++++++ CONTEXT.md | 2 +- README.md | 102 +++++++++++++------ docs/adr/0001-module-source-layout.md | 14 +++ docs/adr/0002-security-descriptor-sidecar.md | 13 +++ docs/adr/0003-no-7z-update-mode.md | 9 ++ docs/adr/0004-slot-layout-and-staging.md | 13 +++ docs/adr/0005-dual-powershell-support.md | 18 ++++ docs/adr/0006-testing-strategy.md | 19 ++++ docs/adr/0007-run-lock-via-file-handle.md | 22 ++++ docs/adr/0008-analyzer-deviations.md | 20 ++++ 12 files changed, 282 insertions(+), 33 deletions(-) create mode 100644 .markdownlint.json create mode 100644 CHANGELOG.md create mode 100644 docs/adr/0001-module-source-layout.md create mode 100644 docs/adr/0002-security-descriptor-sidecar.md create mode 100644 docs/adr/0003-no-7z-update-mode.md create mode 100644 docs/adr/0004-slot-layout-and-staging.md create mode 100644 docs/adr/0005-dual-powershell-support.md create mode 100644 docs/adr/0006-testing-strategy.md create mode 100644 docs/adr/0007-run-lock-via-file-handle.md create mode 100644 docs/adr/0008-analyzer-deviations.md diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..8c370a5 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,14 @@ +{ + "default": true, + "MD004": false, + "MD007": { "indent": 4 }, + "MD013": { "line_length": 240, "code_blocks": false, "tables": false }, + "MD024": { "siblings_only": true }, + "MD026": { "punctuation": ".,;:!" }, + "MD029": { "style": "one" }, + "MD033": false, + "MD034": false, + "MD038": false, + "MD042": false, + "no-hard-tabs": true +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3cf199b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# 变更日志 + +面向使用者的变更记录。更早的历史在文末「相对旧版修了什么」一节。 + +## 2026-09 强化与重构 + +本次改造的每一处修复都有实测证据,不是"看起来更规范了"。 + +### 修复 + +| 问题 | 现象 | 现在 | +| --- | --- | --- | +| 源文件是无 BOM 的 UTF-8 | **6/6 个文件在 Windows PowerShell 5.1 上连语法都过不去** —— 5.1 没有 BOM 就按 ANSI 解码源码,中文变乱码、全角问号吃掉引号 | 全仓改存 UTF-8 with BOM;100 个文件在 5.1 与 7 上解析零错 | +| 带 `[CmdletBinding()]` 的脚本在 5.1 上,`param()` 默认值拿不到 `$PSScriptRoot` | `Backup.ps1` 在 5.1 上不传路径参数直接报错 —— 而计划任务恰恰不传 | 8 处默认值移到 `param()` 之后解析 | +| 行首方向标记贴在目标上时不被识别(`+WindowsTerminal`) | 清单 28 条里 **27 条被当成"源不存在"静默跳过**,退出码仍是 0;只有不写标记的 `Scoop` 真被备份 | 两种写法都认;干跑从"重打 1 个"变成"重打 11 个" | +| 暂存目录半途失败会留下 junction | `%TEMP%` 里留下**指向真实数据**的连接点,而临时目录迟早会被 `Remove-Item -Recurse` 扫到 | 建暂存的函数自己带 try/catch 自清理,不依赖调用方 | +| 口令随 `-Verbose` 落进日志 | DEBUG 级打印整条命令行,而 7z 只接受命令行口令 | 打印前把 `-p` 参数换成占位符;新增断言 + 红绿证明 | +| `manifest.json` 先删后移 | `Move-Item` 一失败,旧账本已经没了 | 复用原子写;目标存在时用 `File.Replace` | +| 5.1 上拿不到原子替换 | 三参数 `File.Move` 是 .NET Core 3.0+ 才有的重载,5.1 上必然退化成"先删后移" | 改用 `File.Replace`(.NET Framework 上同样可用) | +| 空目录的 `TotalSize` 是 `$null` | `$null / 1GB` 得 0,而空间守卫判的是 `-gt 0` —— **空间不足时不再拦截** | 补成整数 0 | +| 计划任务与手动运行撞车 | 两边互踩 `manifest.json`,还会抢同一个 `<归档>.tmp` | 备份目录上加一把跨进程锁(独占文件句柄,进程崩溃自动释放) | +| 口令文件的出厂默认值指向仓库内 | 一次 `git add -A` 就把口令提交进版本库 | 默认留空;口令只来自 `-Password` / 环境变量 / 仓库外的文件 / 交互询问 | +| lab 的 ACL 场景在 5.1 上自爆 | 清理函数偏偏在它要处理的"悬空连接点"场景里被原生命令的 stderr 中断 | 原生命令统一走 `Invoke-NativeTolerant` | + +### 结构与规范 + +| 变化 | 说明 | +| --- | --- | +| 模块源码拆分 | 3152 行、66 个函数的 `Common.psm1` → `BakNRet/{Public,Private}` 一函数一文件 + 薄加载器 + 显式导出白名单;`tools/Build-BakNRetModule.ps1` 可随时合回单文件 | +| 函数命名 | 16 个公共函数补 `BakNRet` 前缀、全仓大小写统一。这不是审美:静态分析拓出 `Write-Log` 与本机某个已装模块**重名**,后果是导入两个模块时一方的命令被静默遮蔽 | +| 静态分析 | 装上 PSScriptAnalyzer 并全仓重排:**706 → 71 条告警**。注意那 6 条格式规则默认是 Disabled —— 不带 `-Settings` 的调用会静默漏掉全部排版问题 | +| 编码与行尾 | `.editorconfig`(含 `charset = utf-8-bom`)与 `.gitattributes` 把约定钉成可执行的文件 | +| 验收入口 | `test.ps1` 是唯一入口(Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍);`tests/Run-RealSmoke.ps1` 拿**真实清单**跑只读冒烟 | +| 术语与决策 | `CONTEXT.md` 术语表 + `docs/adr/` 8 条决策记录 | + +# 相对旧版修了什么(更早的历史) + +| 问题 | 旧行为 | 现行为 | +| --- | --- | --- | +| `Start-Process -PassThru` 的 `ExitCode` 在 PowerShell 7.7.0-preview.4 上恒为 `$null` | 压缩明明成功却报"压缩失败",`exit 2 → 删档重试` 的自愈分支永远不可达 | 用 `.NET Process` 继承控制台启动,退出码可靠 | +| 排除模式写成 `-x!"路径"` | 引号成为模式的一部分,**排除对所有条目都失效** | 不再嵌引号;含空格自动转 `?`,`!` 前缀走 `-xr!` | +| 解析器用 `;` 分隔,清单里写的是 `,` | 整串被当成一个模式,等于没有排除 | `,` 与 `;` 都支持 | +| `^"([^"]+)"` 贪婪匹配 | 整行加引号的写法把排除表吞进路径 → 该条目被静默跳过,2.8 GB 归档成了孤儿 | 先按空白分词切出修饰符,再处理引号 | +| 归档名由路径拼出 | 加一条备份要自己算名字,名字随路径变动 | 清单写软件名,归档名就是软件名 | +| 一个软件里两个同名目录(例如两个 `persist`) | 静默混成一棵树,两边的数据都错 | 名录改成 **Slot 结构**,每个 Slot 是归档内的一层目录,同名不再冲突 | +| 清单只能"备份 + 恢复"一把抓 | 想只备份的、只恢复的条目得另开文件 | 行首 `+` / `-` 直接标方向,两条路径共用一份清单 | +| `::` 既是"排除"又是历史别名 | 语义含糊:`::` 一会儿是排除、一会儿是路径 | `::` 只表示**覆盖 Path**,排除一律写 `:-` | +| 加密只能靠裸标记 `@encrypt` | 名录里的加密意图没法表达 | `:encrypt` / `:!encrypt` / `@ Encrypt='$false'`,名录的 Slot 也能写 `Encrypt` | +| 排除/追加只能写在清单行里 | 名录里的 Slot 光有路径,规则全堆在清单里 | `Exclude` / `Include` / `Encrypt` 都可以写在 Slot 上,清单按需覆盖 | +| `!` 只能按通配符匹配 | 想按正则排除做不到 | 新增 `!re:<正则>`(脚本遍历源目录翻译成精确排除项) | +| 名录路径只支持 `%变量%` | `scoop prefix xxx` 这类动态路径写不出来 | `Path` 支持 `$( ... )` 子表达式,并在一次运行内缓存求值结果 | +| 名录每解析一个条目就重新 Import 一次 | 同一个文件被反复读取、`$( ... )` 被反复执行 | 按内容指纹缓存,一次运行只读一次 | +| 直接更新已有归档(7z `u`) | 固实归档下收益极小,且排除规则与"源里已删的文件"永远反映不到归档里 | 临时文件 → `7z t` 校验 → 原子替换 | +| 没有校验、没有记录 | 中断留下的半个归档会被下次 `u` 续写;跳过/失败只有一行滚过去的 WARN | 校验 + 原子替换 + `manifest.json` + 日志文件 | +| 结尾不 `exit` | 全部失败也返回 0,计划任务永远显示成功 | 有失败返回 1 | +| 恢复用 `-Filter "$baseName.*"` | 含 `[` `]` 的路径会失配 | 精确比较 `BaseName`,且优先查 manifest | +| tar 分支 `$LASTEXITCODE -ne 0 -and $proc.ExitCode -ne 0` | `$LASTEXITCODE` 是上一条原生命令的残留值,恰为 0 时把解压失败吞掉 | 三条分支统一走同一个取退出码的封装 | +| 恢复没有干跑 | 直接覆盖 `E:\CodeSpace`、Edge User Data 这类真实目录 | `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` | +| `manifest.json` 的 `roots` | 记的是软件名,与归档里真实的顶层目录对不上(`Edge` vs `User Data`) | 记归档内真实的顶层条目名,并且和归档内容对账过 | +| `-DryRun` / `-WhatIf` / `-VerifyOnly` | 仍然写回 `manifest.json`,违背"不会写入任何文件" | 只有真的恢复成功了才写回(用 manifest 的 SHA256 前后对比验证) | +| 孤儿归档 | 只在恢复时列一下;带 `-Only` 时还会把未选中的归档误报成孤儿,吓得人不敢删 | 备份端也做孤儿审计;`-Only` / `-Skip` 时不再误报;`+` / `-` 的条目也算"有主" | +| 手写目录的 `:+` 追加 | 被整段丢掉(只有软件名写法才生效),既没人报错也没人知道 | 两种写法都生效,追加项还会标出来源(名录 / 追加项) | +| 软件名录的多目录写法 | 一个软件可以挂多个目录,但目录名不能重复,否则包内混成一棵树 | 改成 **Slot 结构**:每个 Slot 是包内一层目录,同名目录(两个 `persist`)不再冲突 | +| 一个条目挂多个目录的恢复 | 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 | 每个归档项只解出**它自己那棵子树** | +| 归档内路径冲突 | 静默混成一棵树,两边的数据都错 | 打包前明确报错(退出码 1)并提示改 Slot 名 / 归档内相对路径 | +| 运行时的可解释性 | 只有一行"开始备份: X" | 逐条打印目录、来源、介绍、排除/追加的出处与理由;备份前还会预估所需空间并判断够不够 | +| manifest 的 `archive` 字段 | 源不存在的条目也留着归档名,指向一个根本不存在的文件;恢复时白报"归档不存在" | 只在文件真的存在时才写;删掉归档后同步一次就自我纠正 | +| 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 | + diff --git a/CONTEXT.md b/CONTEXT.md index 702f7d4..dcc3773 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,4 +1,4 @@ -# BakNRet +# BakNRet 一个 Windows 备份 / 恢复工具:把机器上指定的软件与目录收进归档,并能把它们放回原位。本文件是本项目的术语表——「要处理什么」「东西放在哪」这些概念在本仓库里只有一个叫法,写作与命名都照这里的词来。 diff --git a/README.md b/README.md index 36633fc..f45069a 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,20 @@ | `BackupList.txt` | 备份 / 恢复共用的清单,唯一的"要处理什么"来源 | | `BackupConfig.psd1` | 目录、空间阈值、校验、加密等配置 | | `Backup.ps1` / `Restore.ps1` | 备份 / 恢复入口 | -| `Common.psm1` | 公共模块(日志、外部命令、清单与名录解析、归档布局、暂存、manifest) | +| `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 条:为什么这么设计、拒绝了什么) | +| `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 5 装到仓库内的 `.tools/`(不动机器上的全局模块) | +| `tools/Install-TestDependencies.ps1` | 把 Pester 与 PSScriptAnalyzer 装到仓库内的 `.tools/`(不动机器上的全局模块) | ## SoftwareCatalog.psd1 —— 软件名 → Slot 组 @@ -488,6 +495,30 @@ $env:BAKNRET_PASSWORD = '...' # 或 > ⚠️ 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 @@ -532,37 +563,44 @@ Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore ## 相对旧版修了什么 -| 问题 | 旧行为 | 现行为 | +见 [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 # 拿真实清单与真实归档做只读冒烟 +``` + +| 层次 | 内容 | 依赖 | | --- | --- | --- | -| `Start-Process -PassThru` 的 `ExitCode` 在 PowerShell 7.7.0-preview.4 上恒为 `$null` | 压缩明明成功却报"压缩失败",`exit 2 → 删档重试` 的自愈分支永远不可达 | 用 `.NET Process` 继承控制台启动,退出码可靠 | -| 排除模式写成 `-x!"路径"` | 引号成为模式的一部分,**排除对所有条目都失效** | 不再嵌引号;含空格自动转 `?`,`!` 前缀走 `-xr!` | -| 解析器用 `;` 分隔,清单里写的是 `,` | 整串被当成一个模式,等于没有排除 | `,` 与 `;` 都支持 | -| `^"([^"]+)"` 贪婪匹配 | 整行加引号的写法把排除表吞进路径 → 该条目被静默跳过,2.8 GB 归档成了孤儿 | 先按空白分词切出修饰符,再处理引号 | -| 归档名由路径拼出 | 加一条备份要自己算名字,名字随路径变动 | 清单写软件名,归档名就是软件名 | -| 一个软件里两个同名目录(例如两个 `persist`) | 静默混成一棵树,两边的数据都错 | 名录改成 **Slot 结构**,每个 Slot 是归档内的一层目录,同名不再冲突 | -| 清单只能"备份 + 恢复"一把抓 | 想只备份的、只恢复的条目得另开文件 | 行首 `+` / `-` 直接标方向,两条路径共用一份清单 | -| `::` 既是"排除"又是历史别名 | 语义含糊:`::` 一会儿是排除、一会儿是路径 | `::` 只表示**覆盖 Path**,排除一律写 `:-` | -| 加密只能靠裸标记 `@encrypt` | 名录里的加密意图没法表达 | `:encrypt` / `:!encrypt` / `@ Encrypt='$false'`,名录的 Slot 也能写 `Encrypt` | -| 排除/追加只能写在清单行里 | 名录里的 Slot 光有路径,规则全堆在清单里 | `Exclude` / `Include` / `Encrypt` 都可以写在 Slot 上,清单按需覆盖 | -| `!` 只能按通配符匹配 | 想按正则排除做不到 | 新增 `!re:<正则>`(脚本遍历源目录翻译成精确排除项) | -| 名录路径只支持 `%变量%` | `scoop prefix xxx` 这类动态路径写不出来 | `Path` 支持 `$( ... )` 子表达式,并在一次运行内缓存求值结果 | -| 名录每解析一个条目就重新 Import 一次 | 同一个文件被反复读取、`$( ... )` 被反复执行 | 按内容指纹缓存,一次运行只读一次 | -| 直接更新已有归档(7z `u`) | 固实归档下收益极小,且排除规则与"源里已删的文件"永远反映不到归档里 | 临时文件 → `7z t` 校验 → 原子替换 | -| 没有校验、没有记录 | 中断留下的半个归档会被下次 `u` 续写;跳过/失败只有一行滚过去的 WARN | 校验 + 原子替换 + `manifest.json` + 日志文件 | -| 结尾不 `exit` | 全部失败也返回 0,计划任务永远显示成功 | 有失败返回 1 | -| 恢复用 `-Filter "$baseName.*"` | 含 `[` `]` 的路径会失配 | 精确比较 `BaseName`,且优先查 manifest | -| tar 分支 `$LASTEXITCODE -ne 0 -and $proc.ExitCode -ne 0` | `$LASTEXITCODE` 是上一条原生命令的残留值,恰为 0 时把解压失败吞掉 | 三条分支统一走同一个取退出码的封装 | -| 恢复没有干跑 | 直接覆盖 `E:\CodeSpace`、Edge User Data 这类真实目录 | `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` | -| `manifest.json` 的 `roots` | 记的是软件名,与归档里真实的顶层目录对不上(`Edge` vs `User Data`) | 记归档内真实的顶层条目名,并且和归档内容对账过 | -| `-DryRun` / `-WhatIf` / `-VerifyOnly` | 仍然写回 `manifest.json`,违背"不会写入任何文件" | 只有真的恢复成功了才写回(用 manifest 的 SHA256 前后对比验证) | -| 孤儿归档 | 只在恢复时列一下;带 `-Only` 时还会把未选中的归档误报成孤儿,吓得人不敢删 | 备份端也做孤儿审计;`-Only` / `-Skip` 时不再误报;`+` / `-` 的条目也算"有主" | -| 手写目录的 `:+` 追加 | 被整段丢掉(只有软件名写法才生效),既没人报错也没人知道 | 两种写法都生效,追加项还会标出来源(名录 / 追加项) | -| 软件名录的多目录写法 | 一个软件可以挂多个目录,但目录名不能重复,否则包内混成一棵树 | 改成 **Slot 结构**:每个 Slot 是包内一层目录,同名目录(两个 `persist`)不再冲突 | -| 一个条目挂多个目录的恢复 | 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 | 每个归档项只解出**它自己那棵子树** | -| 归档内路径冲突 | 静默混成一棵树,两边的数据都错 | 打包前明确报错(退出码 1)并提示改 Slot 名 / 归档内相对路径 | -| 运行时的可解释性 | 只有一行"开始备份: X" | 逐条打印目录、来源、介绍、排除/追加的出处与理由;备份前还会预估所需空间并判断够不够 | -| manifest 的 `archive` 字段 | 源不存在的条目也留着归档名,指向一个根本不存在的文件;恢复时白报"归档不存在" | 只在文件真的存在时才写;删掉归档后同步一次就自我纠正 | -| 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 | +| 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)。 ## 设计取舍(有意为之,不是遗漏) diff --git a/docs/adr/0001-module-source-layout.md b/docs/adr/0001-module-source-layout.md new file mode 100644 index 0000000..cc256a0 --- /dev/null +++ b/docs/adr/0001-module-source-layout.md @@ -0,0 +1,14 @@ +# 模块源码拆成 BakNRet/{Public,Private},另提供单文件构建 + +原来是一个 3152 行、66 个函数的 `Common.psm1`。拆成 `BakNRet/BakNRet.psd1`(模块清单, +`FunctionsToExport` 是显式白名单)、`BakNRet/BakNRet.psm1`(薄加载器)、`BakNRet/Public/*.ps1` +(62 个对外函数,一函数一文件,文件名 = 函数名)、`BakNRet/Private/*.ps1`(4 个内部函数 + +`State.ps1` 集中存放模块级状态)。 + +一函数一文件是社区里脚本模块的主流形态(调研实测:winutil 79 个、Terminal-Icons 24 个、 +ModuleBuilder 23 个,全部如此);而"拆了还能合回去"是这套方案成立的前提,所以 +`tools/Build-BakNRetModule.ps1` 从加载器里读出顺序拼回单文件,并且加载器的点源顺序 +**只在加载器里出现一次**——一条断言盯着"点源的文件集合 = 磁盘文件集合"、 +"导出名单 = `Public/` 目录"。 + +代价:日常要跳文件;顺序被隐式固化在加载器里(改顺序要改加载器)。 diff --git a/docs/adr/0002-security-descriptor-sidecar.md b/docs/adr/0002-security-descriptor-sidecar.md new file mode 100644 index 0000000..24c5a09 --- /dev/null +++ b/docs/adr/0002-security-descriptor-sidecar.md @@ -0,0 +1,13 @@ +# 安全描述符不进归档,改为旁挂 `<归档名>.acl.json` + +`.7z` / `.zip` / `.tar` 都不承载 NT 安全描述符。7-Zip 的 `-sni` 官方说明写明"当前版本只能 +写进 WIM 归档",所以归档里一个字节的属主或 DACL 都没有。于是每个归档旁边放一份同名的 +`<归档名>.acl.json`,恢复时按它回放属主 / 属组 / DACL。 + +为什么非要有:`C:\ProgramData` 的 ACL 里有 `(A;OICIIO;GA;;;CO)` —— `CREATOR OWNER` 不是 +账户,而是在访问检查时替换成"被检查对象的属主"。只回放 ACE 文本、不恢复属主,等于把 +"谁创建的东西谁有全权"里的"谁"换成跑恢复脚本的账户,原程序(服务账户 / 专用用户)反而 +失去读写权限。 + +代价:`acl.json` 不在归档里,改名或迁移归档时必须把它一起搬(`tools/Rename-Archives.ps1` +负责这件事,README 里也写明了)。 diff --git a/docs/adr/0003-no-7z-update-mode.md b/docs/adr/0003-no-7z-update-mode.md new file mode 100644 index 0000000..fac2da4 --- /dev/null +++ b/docs/adr/0003-no-7z-update-mode.md @@ -0,0 +1,9 @@ +# 不使用 7z 的更新模式(`u`),每次都从零打包 + +7z 默认是固实压缩,`u` 更新本来就要重压大部分数据,收益极小;但它让两件事**永远无法生效**: +排除规则的改动,以及源目录里已删除的文件。旧归档会一直留着已被删掉的东西,而用户以为 +排除规则改了。 + +所以每次都从零打包到临时文件、校验通过再原子替换。代价是大归档每次都要重压一遍 +(`Scoop` 那份 4.6 GB 就是最明显的例子),换来的是"归档内容 = 当前清单与规则的结果"这条 +可验证的等价关系。 diff --git a/docs/adr/0004-slot-layout-and-staging.md b/docs/adr/0004-slot-layout-and-staging.md new file mode 100644 index 0000000..5a1051b --- /dev/null +++ b/docs/adr/0004-slot-layout-and-staging.md @@ -0,0 +1,13 @@ +# Slot 决定归档内的顶层目录名,靠暂存目录 + junction 实现 + +7z 没有"入库时改名"的能力:加进归档的名字就是文件系统上的名字。而一个软件可以有多块内容 +(`Scoop` 既有自己的配置又有各应用的 `persist`),两块都可能叫 `persist` 之类的同名目录 —— +直接打包会在归档里撞在一起。 + +所以名录里给每块内容起一个 Slot 名,打包前用暂存目录把每个 Slot 以正确的名字挂进去 +(目录项用 junction、文件项优先硬链接),打包后立刻拆掉。归档内因此永远是 +`\<内容>` 这一层结构,恢复端可以按 Slot 逐棵子树解出来(零拷贝时也靠 junction)。 + +代价:备份过程多一个暂存目录,而暂存目录**必须保证被拆掉** —— 里面的 junction 指向真实数据, +残留它等于在 `%TEMP%` 里留下一堆指回真实目录的连接点。所以 `New-BakNRetArchiveStaging` +自己带 try/catch 自清理,而不依赖调用方(调用方在函数抛错时拿不到返回值)。 diff --git a/docs/adr/0005-dual-powershell-support.md b/docs/adr/0005-dual-powershell-support.md new file mode 100644 index 0000000..beffa1d --- /dev/null +++ b/docs/adr/0005-dual-powershell-support.md @@ -0,0 +1,18 @@ +# 同时支持 Windows PowerShell 5.1 与 PowerShell 7.x,源文件一律 UTF-8 with BOM + +README 一开始就承诺"只依赖 PowerShell(5.1 或 7.x)",但实测发现**这个承诺是假的**:全部 +源文件是无 BOM 的 UTF-8,而 5.1 在没有 BOM 时按 ANSI 代码页解码源码 —— 中文变乱码,全角 +问号之类的字节序列吃掉字符串引号,**6/6 个文件在 5.1 上连语法都过不去**。 + +决定继续兑现这个承诺,因为计划任务默认可能就用 `powershell.exe` 启动。具体约定: + +- 含非 ASCII 的源文件一律 **UTF-8 with BOM**(.editorconfig 里钉死 `charset = utf-8-bom`), + 这是同时满足 5.1 与 7 的唯一编码; +- 版本声明写 `#Requires -Version 5.1`,**绝不**写 `#Requires -PSEdition`(两个值互斥, + 写哪个都会把另一半环境排除掉);清单里用 `CompatiblePSEditions = @('Desktop','Core')`; +- 不用 `??` / 三元 / `Join-String` / `-AsHashtable` / 三参数 `File.Move`(它是 .NET Core 3.0+ + 才有的重载,我们改为 `File.Replace`,那个在 .NET Framework 上同样可用); +- 验收门槛在**两个版本上都跑**,而不是只在 7 上跑完宣称兼容。 + +代价:`#Requires -Version 5.1` 意味着放弃 PS 4.0 及以下;BOM 让某些 Unix 工具不喜欢这些文件。 +两者都是刻意的。 diff --git a/docs/adr/0006-testing-strategy.md b/docs/adr/0006-testing-strategy.md new file mode 100644 index 0000000..c16868b --- /dev/null +++ b/docs/adr/0006-testing-strategy.md @@ -0,0 +1,19 @@ +# 测试双轨:Pester 是单元面,零依赖套件是冒烟与 5.1 入口 + +两套套件一度覆盖同一批行为(`tests/Run-Tests.ps1` 96 个用例与 Pester 的 129 个 `It` 逐节 +对应)。留下两套完整副本是纯维护税,但**只留 Pester 也不行**:`.tools/` 是 gitignore 的, +一台新克隆、没网、或只有 5.1 的机器上 `Install-TestDependencies.ps1` 拉不到 Pester。 + +分工: + +- **Pester 套件**(`tests/*.Tests.ps1`)= 单元面,用 `Mock` / `InModuleScope` 测私有函数。 +- **零依赖套件**(`tests/Run-Tests.ps1`)= 关键冒烟:清单与名录解析、归档命名、排除翻译、 + manifest 往返、原子写与运行锁。只要 PowerShell 与 7z,两个版本都能跑。 +- **`tests/Run-E2E.ps1`** = 端到端:真的调用 7z 打包、删源、恢复、逐字节对拍。 +- **`tests/Run-RealSmoke.ps1`** = 拿**真实清单与真实归档**跑只读冒烟(`-DryRun` / + `-VerifyOnly`,并用 manifest 的 SHA256 证明一个字节都没写)。 +- **`test.ps1`** = 唯一入口,把上面几层在 7 与 5.1 上各跑一遍。 + +最后那个"真实清单冒烟"不是锦上添花:清单里 24 条把方向标记贴在目标上(`+WindowsTerminal`), +而解析器当时只认独立记号 —— 于是那些条目被当成"名叫 +WindowsTerminal 的软件名"、静默记成 +`missing-source` 跳过,**备份照常退出 0,所有夹具测试全绿**。只有拿真实清单跑一遍才看得见。 diff --git a/docs/adr/0007-run-lock-via-file-handle.md b/docs/adr/0007-run-lock-via-file-handle.md new file mode 100644 index 0000000..e78c132 --- /dev/null +++ b/docs/adr/0007-run-lock-via-file-handle.md @@ -0,0 +1,22 @@ +# 运行锁用独占文件句柄,而不是命名互斥体 + +`Backup.ps1` 与 `Restore.ps1` 都会写 `manifest.json`,也都会在备份目录里用 `<归档>.tmp` +这个名字生成临时归档。计划任务与手动运行撞在一起时,两边会互相覆盖对方的账本、把彼此的 +临时归档当成自己的。计划任务的 `-MultipleInstances IgnoreNew` 只挡住"计划任务之间", +挡不住手动运行,所以需要一把跨进程的锁。 + +用**独占文件句柄**(`FileShare.None` 打开 `Backups\.baknret.lock`)而不是命名互斥体: + +- 句柄由内核持有,进程被杀 / 崩溃时自动关闭,锁自动释放 —— 不会留下需要人工清理的陈旧锁; +- 命名互斥体要跨会话(计划任务在另一个会话里跑,互斥体是会话局部的)就得用 `Global\` 前缀, + 而那需要额外权限; +- 文件系统的锁不区分会话与终端,计划任务与手动运行天然互相看见。 + +拿不到锁就**直接失败**(退出码 1 + 明确消息),不等待:单个条目压缩可能十几分钟,"等它跑完" +对用户来说和挂住没区别。锁文件里写明持有进程(pid / 起始时间 / 主机 / 用户)——"到底是谁 +占着"这个问题不该靠猜。 + +只读模式不取锁(`-DryRun` / `-WhatIf` / `-VerifyOnly`):它们一个字节都不写。 + +代价:锁文件是备份目录里的一个额外文件(以 `.` 开头,归档枚举与孤儿审计都只看 `*.7z`, +不受影响);`Backups/` 被手工删除时锁也随之消失(这没关系,它本来就是运行期的)。 diff --git a/docs/adr/0008-analyzer-deviations.md b/docs/adr/0008-analyzer-deviations.md new file mode 100644 index 0000000..9069c75 --- /dev/null +++ b/docs/adr/0008-analyzer-deviations.md @@ -0,0 +1,20 @@ +# 静态分析的三条有意排除,以及 160 字符的行长 + +`PSScriptAnalyzerSettings.psd1` 里用 `Rules` 把 6 条格式规则显式打开(它们默认全是 +Disabled —— 不带 `-Settings` 的 `Invoke-ScriptAnalyzer -Severity Warning,Error` +**一个排版问题都不会报**),同时有意排除三条: + +- **`PSAvoidUsingWriteHost`** —— `Write-Log` 的彩色控制台输出是这份工具的刻意设计,不是疏忽。 + 这是架构性选择,所以在配置里排除,而不是逐处 `Suppress` 假装它是例外。 +- **`PSUseShouldProcessForStateChangingFunctions`** —— 报 27 个改状态的函数。给它们都加上 + `SupportsShouldProcess` 会更糟:`WhatIf` 的边界在 `Backup.ps1` / `Restore.ps1`(它们自己管 + `-DryRun` / `-WhatIf`),如果库里 27 个函数也各自实现一遍,那么入口把 `$WhatIfPreference` + 置真之后,它们会**静默跳过自己的工作** —— 备份看起来成功却什么都没做。这是一个"听着更规范、 + 实际更危险"的典型。 +- **`PSAvoidUsingPlainTextForPassword`** —— 7z 只接受命令行口令(7z 自身的限制)。口令在这个 + 工具里必然是明文字符串,规则说的"用 `SecureString`"在这里没有落点。真正要守的两条另有措施: + 口令不进日志(遮蔽 + 断言)、口令不进版本库(`.gitignore` + 出厂默认值留空)。 + +**行长上限设成 160,而不是官方默认的 120。** 120 在这个仓库意味着 270 处改动(主要是中文 +注释与测试夹具里的一行式目录),160 意味着 41 处,而 41 处当时没做完。这是一次有意的偏离: +数字写在配置文件的注释里而不是悄悄放宽,想收紧到 120 时那份清单就在分析器的输出里。