docs: 决策记录 8 条、变更日志、README 的验收与口令两节

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 去掉。
This commit is contained in:
Shuery committed 2026-09-27 10:01:54 +08:00
1 parent 42f02d0eca
commit 3a3a57a6a1
12 files changed
+282 -33

No files matched your search

+14
View File
@@ -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/` 目录"。
代价:日常要跳文件;顺序被隐式固化在加载器里(改顺序要改加载器)。
@@ -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 里也写明了)。
+9
View File
@@ -0,0 +1,9 @@
# 不使用 7z 的更新模式(`u`),每次都从零打包
7z 默认是固实压缩,`u` 更新本来就要重压大部分数据,收益极小;但它让两件事**永远无法生效**:
排除规则的改动,以及源目录里已删除的文件。旧归档会一直留着已被删掉的东西,而用户以为
排除规则改了。
所以每次都从零打包到临时文件、校验通过再原子替换。代价是大归档每次都要重压一遍
(`Scoop` 那份 4.6 GB 就是最明显的例子),换来的是"归档内容 = 当前清单与规则的结果"这条
可验证的等价关系。
+13
View File
@@ -0,0 +1,13 @@
# Slot 决定归档内的顶层目录名,靠暂存目录 + junction 实现
7z 没有"入库时改名"的能力:加进归档的名字就是文件系统上的名字。而一个软件可以有多块内容
(`Scoop` 既有自己的配置又有各应用的 `persist`),两块都可能叫 `persist` 之类的同名目录 ——
直接打包会在归档里撞在一起。
所以名录里给每块内容起一个 Slot 名,打包前用暂存目录把每个 Slot 以正确的名字挂进去
(目录项用 junction、文件项优先硬链接),打包后立刻拆掉。归档内因此永远是
`<Slot>\<内容>` 这一层结构,恢复端可以按 Slot 逐棵子树解出来(零拷贝时也靠 junction)。
代价:备份过程多一个暂存目录,而暂存目录**必须保证被拆掉** —— 里面的 junction 指向真实数据,
残留它等于在 `%TEMP%` 里留下一堆指回真实目录的连接点。所以 `New-BakNRetArchiveStaging`
自己带 try/catch 自清理,而不依赖调用方(调用方在函数抛错时拿不到返回值)。
+18
View File
@@ -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 工具不喜欢这些文件。
两者都是刻意的。
+19
View File
@@ -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,所有夹具测试全绿**。只有拿真实清单跑一遍才看得见。
+22
View File
@@ -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/` 被手工删除时锁也随之消失(这没关系,它本来就是运行期的)。
+20
View File
@@ -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 时那份清单就在分析器的输出里。