Compare commits

..
11 Commits
Author SHA1 Message Date
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
Shuery 45bbd66815 docs(report): 新增 PROJECT_REPORT.md 与 CI/CD 流水线记录
PROJECT_REPORT.md:论文式项目报告(摘要 / 目录 / 7 章 / 参考文献 / 致谢 / 附录 A),
4 张 Mermaid 图同样经真实浏览器渲染验证。所有数字都与证据文件逐个核对过。

.scratch/ci-cd/:本次流水线的全部证据与可重跑脚本
  * 阶段报告:依赖安全扫描 / 许可证合规 / 阶段 0 静态分析 / 阶段 3 测试与性能
  * 证据:分析器原始输出(修复前 84 条、修复后 80 条)、Pester 详细输出、
    性能基线 JSON、黑盒用例结果(阶段 1 的 11 例与阶段 2 回归的 12 例)
  * 可重跑:blackbox-tests.ps1 / blackbox-regression.ps1 / perf-baseline.ps1

两条边界必须写在明处,不能含糊:

  * **VM 内验证只取到修复前的快照**(Pester 183 项全绿)。随后会话审批策略改为 never,
    gsudo 提权被自动拒绝(退出码 999),而 Hyper-V 与 PowerShell Direct 都需要管理员,
    于是修复后的三套件在 VM 内**没有跑成**。报告里明确标注了范围,没有把"宿主跑绿了"
    说成"VM 也跑绿了"。
  * **性能基线是首次建立**,无历史可比,故无退化可判;指标只在同机同宿主下对比,
    跨机比数字没有意义。

另外记一笔:静态分析的口径是"仓库自己的门禁"。剩余 80 条全是风格类(0 Error),且逐条
有依据 —— 行长 160 是配置里写明的有意偏离,PSPlaceCloseBrace 等集中在测试夹具字符串内
(改了会改变断言语义)。严格模式的"零容忍"在这里与仓库自身约定相抵,选择尊重仓库约定
并在报告里登记,而不是制造一个横跨 12 个文件的纯排版大 diff。
2026-10-02 01:17:40 +08:00
Shuery f4729baca7 docs: 重写 README(徽章 / 目录 / Mermaid 架构图 / GitHub 提示块)
结构改成开源社区通行的形态:徽章区(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,内部相对链接全部存在。
2026-10-02 01:16:39 +08:00
Shuery 7149ea691e fix(backup,restore): 显式指定的清单不存在时报失败,不再静默成功
传 -BackupListPath 指向一个不存在的文件时,原实现把它当成"首次运行"处理:备份端会在
那个位置凭空建一份模板,恢复端则"从备份内容生成清单",然后两者都 exit 0。

后果在计划任务里最明显:任务计划程序读到的是"上次运行结果 = 成功",而实际上一个条目
都没处理。这与本仓库已经修过的"27 条被静默跳过、退出码仍是 0"是同一类缺陷 —— 退出码
是自动化唯一能读到的信号,它必须对得上"到底干了什么"。

现在按 $PSBoundParameters.ContainsKey('BackupListPath') 把两条语义分开:

  * 显式指定了清单却不存在 -> 报 ERROR 并 exit 1。路径写错、或相对路径按了别的工作
    目录解析(计划任务的工作目录通常是 C:\Windows\System32),都是调用方的错误,
    不能伪装成"已经帮你建好模板了"。
  * 没指定(首次运行引导)-> 保持原行为:备份端建模板、恢复端从 manifest 生成清单,
    仍然 exit 0。这是"开箱即用",不是失败。

顺带补 2 处运算符前的空格(Backup.ps1 / Restore.ps1 的 PSUseConsistentWhitespace)。

测试:
  * 2 条成对回归用例(显式缺失报失败 / 首次运行仍建模板)。必须成对 —— 只测一条的话,
    "把所有缺失都改成 exit 1"这种错误实现也能骗过测试。
  * 7 条断言覆盖归档原子替换与路径解析基准。其中 3 条用 AST 判定,因为文本匹配会被
    函数自己的注释绊倒(注释里正当地提到了 $PSScriptRoot,解释为什么不许用)。

验收:test.ps1 9/9 全绿(5.1 与 7);Pester 183 -> 192,0 失败;静态分析 80 条、
0 Error(与改造前持平)。
2026-10-02 01:16:25 +08:00
Shuery d72fe63c02 docs: 跟上 TUI 与入口重组(README 入口名 + TUI 一节 + CHANGELOG + CONTEXT 模块与入口)
README 里 24 处过期入口名(13 处 Backup.ps1、11 处 Restore.ps1)全部更新为新名字 —— 这正是 ADR-0012 里垫片存在的理由:内部实现改名断了会当场报错,而 README 里的入口名过期是**静默没用**,所以垫片留一轮等文档跟上。现在文档跟上了,删垫片的时机也就到了。

README 新增「交互界面(TUI)」一节:怎么进三个编辑器、每个编辑器能改什么(以及为什么有些字段刻意不列 —— 值跨行或本身是集合时"改一行"没有明确含义)、-Quiet 与 -InputScript 的用途、以及"只有真终端才画界面,否则明确报错并给退出码 2"。入口表从"两个入口"扩成四个(主入口 / 动作 / 配置 / 垫片)。

CHANGELOG 增加「新增:交互界面与入口重组」一节,含三项编辑器共用的外科式改写保证与那条往返断言;并写明旧命令照旧可用。

CONTEXT.md 增加「模块与入口」一节:BakNRet/ 四层的分工与规矩(清单是显式白名单、加载器是唯一点源顺序声明处、Public 一个函数一个文件、Private 放内部实现与 State),以及四个入口脚本的职责与"编排尚未下沉"这个已知的下一步。

验收:test.ps1 9/9 全绿(5.1 与 7)。
2026-09-27 22:40:54 +08:00
Shuery 400f2ad9a5 docs(probe): 给 TUI 选型探针加索引(它们证明了什么、结论落在哪条 ADR)
探针保留作实测证据:ADR-0010~0013 的结论与数字在正文里,这些脚本是能复现它们的东西 —— 将来想确认"某个库现在还能不能用",重跑对应探针即可,不必重新摸索。

索引按探针标注了来源与结论去向,并写明重跑注意:多数探针需要真终端(重定向下会走退化分支);load-probe 与 settle-probe 依赖已删除的 _probe/load 与 _probe/nuget(当时一次 git add 误把从 NuGet 拉的包扫进提交,已删除并 gitignore),重跑前要先自己拉包;其余可直接跑。

同时说明这些是一次性脚本:不参与构建、不受 test.ps1 的 Encode/Parse 层管辖;真正的 TUI 实现在 BakNRet/Public/ 且跑在两个 PowerShell 版本上。
2026-09-27 22:25:39 +08:00
Shuery effc38fdd3 feat(tui): 名录编辑器循环 + 挂进 Edit-Config(第三轮第二块·完成)
装配:Get-BakNRetCatalogField → 菜单选字段 → Read-BakNRetLine → Set-BakNRetCatalogField → Save-BakNRetConfigFile(校验通过才原子替换 + 时间戳备份)。

菜单里**只列可编辑的字段**(单行的 Encrypt / Description):把只读的 Path / Exclude 也列出来再拒绝,会让每个只读项浪费用户一次回车。

校验用模块自己的 Import-BaknretDataFile:这份名录有 $( ) 动态表达式与跨行拼接,普通数据文件读取器读不了 —— 而正是它保证"改完整份文件还能被程序读回来"(值能读回来才算改成功)。定位用 (软件名, Slot, 字段名) 三元组,因为同一软件可以有多个 Slot。

Edit-Config 的三个界面(清单 / 设置 / 名录)至此全部完成。

判据 9 条:改一个值后只动一行、注释行数不变、改完能读回新值、留下时间戳备份且备份里是原文;取消时不写盘也不产生备份。

验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。
2026-09-27 21:30:14 +08:00
Shuery 8c018533c9 feat(tui): 名录字段的读改核心(第三轮第一块)
先侦察事实(232 行、71 个 Slot 级字段):**可编辑面比预想的还窄,边界落在一条可判定的规则上** —— Encrypt 19 个全是单行(可编辑),Description 24 个单行 + 1 个跨行(Edge 那条多行说明,只读),Path / Exclude 一律只读(Scoop 三条带 $(if ($env:SCOOP_GLOBAL){...}) 表达式、Edge 的 Exclude 是跨行拼接)。

规则:**只有单行的 Encrypt / Description 可编辑** —— 与 ADR-0013 的"按能不能安全往返来定"一致:"改一行"对动态表达式与跨行拼接没有明确含义。

扫描时跳过块注释:文件头部模板里有 SoftWareName = @{ ... } 示例,纯文本扫描会把它当成一个软件(实测踩到)。定位靠 (软件名, Slot, 字段名) 三元组且要求唯一命中,命中 0 个或多个都抛错。

实测五个事实:认出 70 个字段、可编辑 43 个(19+24)、往返**逐字节相同 = True**、真改一个只动一行、Edge 的跨行 Description 正确地只读。

过程里两处自己的坑都被门禁逮住:插入的测试代码带过一行手滑垃圾(Parse 层当场报),以及函数文档注释里写了块注释的闭合符号 —— 它提前结束了那段注释,后半句变成代码,而它是**合法语法**所以解析层抓不到、跑到才炸(与"左边空的赋值"同族)。

验收:test.ps1 9/9 全绿(5.1 与 7)。
2026-09-27 21:25:36 +08:00
Shuery 4aa44e8965 feat(tui): 配置编辑器循环 + 挂进 Edit-Config(第二轮第三块·完成)
装配:Get-BakNRetConfigSetting(读)→ 菜单选设置 → Read-BakNRetLine(输入新值)→ Set-BakNRetConfigSetting(只改那一行)→ Save-BakNRetConfigFile(校验通过才原子替换 + 时间戳备份)。

输入行**从空开始**、当前值只作提示:预填当前值看着贴心,实际等于逼用户先删一遍(对 'Backups' 这种带引号的原文尤其别扭)。

校验用 Import-PowerShellDataFile:这份配置里没有动态表达式,能被它读回来是个**强校验** —— 比"语法能不能过"更强,它还能抓出"值是合法语法、但结构不对"。它只接受文件路径,所以先把候选文本写到临时文件再读。

Edit-Config 的 -Target Settings 与菜单里的"设置"都挂上了同一个编辑器;-Target Catalog 仍明确报"还没做(第三轮)"。

判据 9 条:改一个值后只动一行、注释行数不变、新值落盘、留下时间戳备份且备份里是原文;取消时不写盘也不产生备份。

验收:test.ps1 9/9 全绿(5.1 与 7)。
2026-09-27 21:10:15 +08:00
Shuery 92bbc995c4 feat(tui): 输入行控件(第二轮第二块)
TUI 里唯一缺的控件:读一行文本(字符追加 / Backspace 删除 / Enter 确认 / Esc 取消)。

为什么不用 Read-Host:TUI 的所有输入都走同一个 -Driver,门禁才能用 -InputScript 把"输入一个值"这一步也驱动起来 —— Read-Host 无法脚本驱动,会在门禁里挂住,而挂起比变红糟得多。

为什么 Esc 返回 $null 而不是空串:空串是"把值改成空"这个**合法意图**,取消是另一个意思,两者必须能区分,否则调用方会把"用户取消"当成"用户要清空它"。

方向键等非字符键一律忽略(在输入行里按方向键不该有副作用);字母按小写存(与键名归一化一致)。判据 8 条,全部按键序列驱动。

验收:test.ps1 9/9 全绿(5.1 与 7)。
2026-09-27 21:05:14 +08:00
Shuery 7733b1bf6d feat(tui): 配置标量的读改核心(第二轮第一块)
先侦察事实:BackupConfig.psd1 共 99 行 —— 52 行注释、18 行单行标量、3 处嵌套哈希、1 处数组、1 处行内空哈希(SidMap = @{})。所以"外科式改一个值"的边界很明确:**只认单行标量**。值跨行或本身是集合时,"改一行"这个动作没有明确含义 —— 那种就不该在界面上提供(如实显示为只读)。

路径用点号拼(Snapshot.Enabled):同名键在不同嵌套里会出现(Enabled 有两处),只用键名会把它们混成一个。非标量(行内哈希、数组)显式排除。读出来的是**含引号的原文** —— 原样写回才逐字节等价。

判据 12 条,核心是往返:**把每个标量设成它当前的值,文本必须逐字节相同**(含键名对齐与全部注释);真改一个时只有那一行不同、注释行数不变、改完还能读出新值;找不到路径或不是标量必须抛错而不是静默不动。

真实配置上的实测:认出 18 个标量(含 3 组嵌套的点号路径,排除了 SidMap 与 DefaultExcludes),**往返后逐字节相同 = True**。

过程里插入的测试代码带过一行手滑的垃圾(被 Parse 层当场抓住 —— 它是这个仓库最便宜的一道闸),以及一条期望值写错(忘了 Value 含引号)。

验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。
2026-09-27 21:00:07 +08:00
39 changed files with 4842 additions and 185 deletions

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# TUI 选型探针(保留作实测证据)
这些脚本是 `docs/adr/0010~0013` 里那些实测结论的**来源**。结论与数字已经写进 ADR 正文;这里留的是
"能复现它们的东西" —— 将来想确认某个库现在还能不能用,重跑对应探针即可,不必重新摸索一遍。
## 它们证明了什么(结论见 ADR,这里只标注来源)
| 探针 | 证明的事 | 结论落在 |
| --- | --- | --- |
| `console-probe*.ps1`、`vt-probe.ps1` | 无控制台时 `$Host.UI.SupportsVirtualTerminal` **会撒谎**(返回 `True`),而 `[Console]::SetCursorPosition` 直接抛异常;VT 在本机默认已开 | ADR-0010(第一道闸门必须是 `IsOutputRedirected`) |
| `noconsole-probe.ps1` | 没有控制台时 `RawUI.WindowSize` 悄悄返回假的 `160x40`、`KeyAvailable` 返回 `True` | ADR-0010 |
| `tg-probe.ps1`、`tgwin*.ps1` | `Terminal.Gui` 1.15.0 的两个硬伤:要给内部 `NetMainLoop` 做反射、`Application.Shutdown()` 在两个版本上都抛 NRE | ADR-0010(否决) |
| `settle-probe.ps1`、`settle2-probe.ps1` | `Spectre.Console` 0.49.1 在 5.1 上能加载,但要额外带 4 个 DLL,且没有鼠标支持 | ADR-0010(否决) |
| `load-probe*.ps1` | `ConsoleGuiTools` 要求 PS 7.2(5.1 上不可能);本机原本没装任何 TUI 模块 | ADR-0010(否决) |
| `cells-probe.ps1`、`width-probe*.ps1` | 中文按列宽算(CJK 1 字符 = 2 列)、半角片假名 1 列、全角拉丁 2 列、框线字符 1 列、emoji 按码点 2 列;`RawUI.LengthInBufferCells` 在 5.1 上是错的、7 上是对的 | ADR-0010、第一轮 ① |
| `final-probe*.ps1`、`diag-probe.ps1` | 收口时的对照验证(把各家的实测结果并排跑一遍) | ADR-0010 |
| `TuiKit.ps1` | 零依赖方案的可运行原型(行内绘制 + 定位写 + 菜单),用来证明"不用任何库也能做出要的效果" | ADR-0010 |
## 重跑注意
- 需要**真终端**(不是重定向的管道):多数探针会读 `[Console]::WindowWidth`,重定向下会走退化分支。
- `load-probe*.ps1` 与 `settle*-probe.ps1` 依赖 `_probe/load/` 与 `_probe/nuget/` 两个目录,它们装的是
从 NuGet 拉来的 DLL/模块,**已删除并加进 `.gitignore`**(当时一次 `git add` 误把它们全扫进了提交)。
所以要重跑这几个,得先自己把包拉回来;**其余探针不依赖它们,可以直接跑**。
- 探针是**一次性脚本**:不参与构建,不受 `test.ps1` 的 Encode/Parse 层管辖(它们不在受管文件清单里)。
真正的 TUI 实现在 `BakNRet/Public/`(`Get-BakNRetCellWidth`、`Read-BakNRetKey`、`Write-BakNRetAt`、
`Invoke-BakNRetMenu` 等),有断言、跑在两个 PowerShell 版本上。
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

@@ -0,0 +1,14 @@
PSScriptAnalyzer 1.25.0(D:\Workspace\Temp\BakNRet\.tools\modules\PSScriptAnalyzer\1.25.0)
分析 131 个文件,配置 D:\Workspace\Temp\BakNRet\PSScriptAnalyzerSettings.psd1
共 80 条,按规则汇总:
54 PSAvoidLongLines
7 PSPlaceCloseBrace
4 PSAlignAssignmentStatement
4 PSReviewUnusedParameter
4 PSUseConsistentIndentation
4 PSUseSupportsShouldProcess
2 PSAvoidUsingEmptyCatchBlock
1 PSUseDeclaredVarsMoreThanAssignments
@@ -0,0 +1,101 @@
PSScriptAnalyzer 1.25.0(D:\Workspace\Temp\BakNRet\.tools\modules\PSScriptAnalyzer\1.25.0)
分析 131 个文件,配置 D:\Workspace\Temp\BakNRet\PSScriptAnalyzerSettings.psd1
共 84 条,按规则汇总:
54 PSAvoidLongLines
7 PSPlaceCloseBrace
4 PSAlignAssignmentStatement
4 PSReviewUnusedParameter
4 PSUseConsistentIndentation
4 PSUseConsistentWhitespace
4 PSUseSupportsShouldProcess
2 PSAvoidUsingEmptyCatchBlock
1 PSUseDeclaredVarsMoreThanAssignments
逐条:
Backup-Data.ps1:293 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Backup-Data.ps1:299 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Backup-Data.ps1:602 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Backup-Data.ps1:822 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Backup-Data.ps1:832 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Backup.ps1:42 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Backup.ps1:42 [PSUseConsistentWhitespace] Use space before and after binary and assignment operators.
Backup.ps1:42 [PSUseConsistentWhitespace] Use space before and after binary and assignment operators.
BakNRet.Formats.Tests.ps1:384 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Security.Tests.ps1:171 [PSReviewUnusedParameter] The parameter 'Root' has been declared but not used.
BakNRet.Security.Tests.ps1:355 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Security.Tests.ps1:373 [PSUseDeclaredVarsMoreThanAssignments] The variable 'sourcePath' is assigned but never used.
BakNRet.Tests.ps1:633 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Tests.ps1:937 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Tests.ps1:980 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Tests.ps1:1030 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Tests.ps1:1285 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
BakNRet.Tests.ps1:1359 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Edit-Config.ps1:63 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Edit-Config.ps1:73 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Get-BakNRetSoftwareCatalog.ps1:87 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Invoke-BakNRetBackupListEditor.ps1:24 [PSUseSupportsShouldProcess] WhatIf and/or Confirm manually defined in function Invoke-BakNRetBackupListEditor. Instead, please use SupportsShouldProcess attribute.
Invoke-BakNRetBackupListEditor.ps1:69 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Invoke-BakNRetCatalogEditor.ps1:22 [PSUseSupportsShouldProcess] WhatIf and/or Confirm manually defined in function Invoke-BakNRetCatalogEditor. Instead, please use SupportsShouldProcess attribute.
Invoke-BakNRetCatalogEditor.ps1:55 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Invoke-BakNRetConfigEditor.ps1:23 [PSUseSupportsShouldProcess] WhatIf and/or Confirm manually defined in function Invoke-BakNRetConfigEditor. Instead, please use SupportsShouldProcess attribute.
Invoke-BakNRetMenu.ps1:46 [PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
Invoke-BakNRetMenu.ps1:86 [PSAlignAssignmentStatement] Assignment statements are not aligned
Invoke-BakNRetMenu.ps1:87 [PSAlignAssignmentStatement] Assignment statements are not aligned
Invoke-BakNRetMenu.ps1:89 [PSAlignAssignmentStatement] Assignment statements are not aligned
Invoke-BakNRetMenu.ps1:90 [PSAlignAssignmentStatement] Assignment statements are not aligned
Lab-Common.ps1:60 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Lab.ps1:41 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Lab.ps1:208 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Lab.ps1:226 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Lab.ps1:227 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Lab.ps1:389 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
New-BakNRetLab.ps1:111 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
provision.ps1:69 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
provision.ps1:89 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Register-BackupTask.ps1:94 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Restore-Data.ps1:141 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Restore-Data.ps1:753 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Restore-Data.ps1:850 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Restore.ps1:42 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Restore.ps1:42 [PSUseConsistentWhitespace] Use space before and after binary and assignment operators.
Restore.ps1:42 [PSUseConsistentWhitespace] Use space before and after binary and assignment operators.
Run-E2E.ps1:408 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-E2E.ps1:458 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-E2E.ps1:502 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-E2E.ps1:575 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:433 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:731 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:744 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:757 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:790 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:802 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:828 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:870 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:931 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:987 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1012 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1057 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1169 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1417 [PSPlaceCloseBrace] Close brace does not follow a new line.
Run-Tests.ps1:1450 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1465 [PSPlaceCloseBrace] Close brace does not follow a new line.
Run-Tests.ps1:1608 [PSReviewUnusedParameter] The parameter 't' has been declared but not used.
Run-Tests.ps1:1615 [PSReviewUnusedParameter] The parameter 't' has been declared but not used.
Run-Tests.ps1:1622 [PSPlaceCloseBrace] Close brace does not follow a new line.
Run-Tests.ps1:1683 [PSPlaceCloseBrace] Close brace does not follow a new line.
Run-Tests.ps1:1734 [PSPlaceCloseBrace] Close brace does not follow a new line.
Run-Tests.ps1:1804 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1816 [PSPlaceCloseBrace] Close brace does not follow a new line.
Run-Tests.ps1:1858 [PSUseConsistentIndentation] Indentation not consistent
Run-Tests.ps1:1859 [PSUseConsistentIndentation] Indentation not consistent
Run-Tests.ps1:1860 [PSUseConsistentIndentation] Indentation not consistent
Run-Tests.ps1:1861 [PSUseConsistentIndentation] Indentation not consistent
Run-Tests.ps1:1883 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1884 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters
Run-Tests.ps1:1896 [PSPlaceCloseBrace] Close brace does not follow a new line.
Save-BakNRetConfigFile.ps1:25 [PSUseSupportsShouldProcess] WhatIf and/or Confirm manually defined in function Save-BakNRetConfigFile. Instead, please use SupportsShouldProcess attribute.
Write-BakNRetAt.ps1:39 [PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
Write-BakNRetRunSummary.ps1:16 [PSReviewUnusedParameter] The parameter 'Mode' has been declared but not used.
@@ -0,0 +1,64 @@
# 依赖安全扫描报告(dependency-security-scanner)
- **项目**:BakNRet(Windows 备份 / 恢复工具,PowerShell)
- **扫描时间**:2026-09-28
- **扫描范围**:仓库根目录及全部子目录(排除 `.git`)
- **工具**:`npm audit` / `pip-audit` / `govulncheck` 等均**不适用**(见下)
## 1. 项目概况
| 项目 | 结论 |
| --- | --- |
| 语言 | PowerShell(`.ps1` / `.psm1` / `.psd1`) |
| 依赖管理文件 | **不存在** —— 全仓无 `package.json`、`package-lock.json`、`requirements.txt`、`go.mod`、`pom.xml`、`Cargo.toml` 等 |
| 运行时依赖 | **0 个模块**。运行备份 / 恢复只需要 PowerShell 5.1 或 7.x + 7-Zip |
| 开发期依赖 | Pester 5.9.1、PSScriptAnalyzer 1.25.0 —— 装在 `.tools/`,已 gitignore,**不随发布产物分发** |
| 外部可执行文件 | `7z.exe`(7-Zip 26.03,经 scoop 安装) |
> [!NOTE]
>
> 依赖扫描技能面向「有包管理器的项目」。本项目**没有任何包管理器依赖**,因此
> `npm audit` / `safety` / `govulncheck` 一类工具无对象可扫。下面改为对本项目**真实的供应链面**
> 逐项核对,而不是输出一份空报告。
## 2. 供应链面核对
| 组件 | 来源 | 版本 | 是否随分发 | 风险 |
| --- | --- | --- | --- | --- |
| PowerShell 引擎 | 操作系统 / Microsoft | 5.1.26100.9502、7.7.0-preview.5 | 否(宿主环境) | 由宿主维护;预览版仅供本机验收使用 |
| 7-Zip | scoop(`C:\Programs\Scoop\apps\7zip\26.03`) | 26.03 | 否(用户自备) | 归档/解压的执行者,**必须由用户保持更新** |
| Pester | 仓库内 `.tools/modules/Pester/5.9.1` | 5.9.1 | 否(gitignore) | 仅测试期使用,不接触用户数据 |
| PSScriptAnalyzer | 仓库内 `.tools/modules/PSScriptAnalyzer/1.25.0` | 1.25.0 | 否(gitignore) | 仅静态分析使用 |
| 其余第三方 | 无 | — | — | — |
**已知 CVE**:无可报。本项目的依赖面里没有任何「被本项目固定版本」的第三方库
(Pester 与 PSScriptAnalyzer 是测试工具且不进分发,7-Zip 与 PowerShell 由用户环境提供)。
若要追查这两个工具的 CVE,应查上游公告,而不是本仓库。
## 3. 与安全相关的项目事实(实跑核对)
| 检查项 | 结果 |
| --- | --- |
| 仓库是否跟踪任何密钥文件 | ✅ 否(`git ls-files` 中无 `*.key` / `*.pfx`) |
| `baknret.key` 是否被 gitignore | ✅ 是(`.gitignore:18: *.key`) |
| 工作区是否存在口令文件 `baknret.key` | ⚠️ **是** —— 见风险 R-2 |
| 口令是否可能落进日志 | ✅ 已处理:打印前把 `-p` 参数换成占位符(`tests/BakNRet.Tests.ps1` 有专门断言) |
| 日志中是否出现明文口令 | ✅ 无 |
| `.tools/` 是否可能进分发产物 | ✅ 否(已 gitignore,且构建脚本只读 `BakNRet/` 与 `tools/`) |
## 4. 风险清单
| ID | 严重程度 | 风险 | 证据 | 建议 |
| --- | --- | --- | --- | --- |
| R-1 | **一般** | 仓库没有 `LICENSE` 文件,许可证状态未声明 | 根目录无 `LICENSE*` | 补一份许可证(见 `license_check_report`) |
| R-2 | **一般** | 工作区存在口令文件 `baknret.key`(未被跟踪,但确实在仓库目录里) | `Test-Path baknret.key` = True | 本项目自己的 CHANGELOG 已把「口令文件的出厂默认值指向仓库内」定为待修问题,`BackupConfig.psd1` 的注释也推荐放到仓库外。建议移到 `%USERPROFILE%\.baknret.key` 并用 `-KeyFile` 指过去。**本报告不读取、不记录其内容** |
| R-3 | **建议** | 7-Zip 版本由用户环境决定,脚本不检查版本 | `Find-BakNRet7zExecutable` 只找路径 | 可选:在启动时打印 `7z` 版本,便于排障 |
| R-4 | **建议** | 口令经命令行传给 7z,本机进程列表可见 | 上游限制,README 已用 CAUTION 声明 | 无技术解法,保持文档披露即可 |
**致命 / 严重漏洞:0 个。** 流水线可继续。
## 5. 结论
未发现已知安全漏洞。本项目**零运行时依赖**,是当前最重要的一条供应链优势;
唯一两条「一般」级风险都属于**卫生问题**(缺许可证、口令文件放在仓库目录里),
不影响流水线继续执行。
@@ -0,0 +1,67 @@
# 许可证合规检查报告(license-compliance-checker)
> [!WARNING]
>
> **免责声明**:本报告由自动化工具基于仓库内的文件生成,仅为工程参考,**不构成法律建议**。
> 涉及对外分发、商业使用或二次许可时,请咨询法务。
- **项目**:BakNRet
- **检查时间**:2026-09-28
- **主许可证**:**未声明** —— 仓库根目录不存在 `LICENSE` / `LICENSE.md` / `LICENSE.txt`
> [!NOTE]
>
> **后续更新(2026-10-02):L-1 已关闭。** 仓库已补 `LICENSE`(Apache License 2.0,正文逐字采用
> ASF 的 LICENSE-2.0.txt,仅把 APPENDIX 的版权占位行填成 `Copyright 2026 Shuery`),
> README 的许可证章节与 `BakNRet/BakNRet.psd1` 的 `LicenseUri` / `Copyright` 同步登记。
> 下方「主许可证:未声明」与 L-1 条目保留为**检查当时**的发现,不再代表当前状态。
## 1. 依赖与组件许可证清单
`license-checker` / `pip-licenses` / `go-licenses` 等工具**不适用**(本项目无包管理器依赖)。
下列清单通过读取 `.tools/modules` 下的模块清单与许可证文件、以及七项外部组件的官方许可条款得出。
| 组件 | 版本 | 许可证 | 来源依据 | 与主许可证关系 |
| --- | --- | --- | --- | --- |
| BakNRet(本项目) | 1.0.0(模块清单) | **未声明** | 无 `LICENSE` 文件 | — |
| Pester | 5.9.1 | **Apache-2.0** | `Pester.psd1` 的 `Copyright` + `LicenseUri` | 测试期工具,未声明主许可证时无冲突可判 |
| PSScriptAnalyzer | 1.25.0 | **MIT** | `.tools/modules/PSScriptAnalyzer/1.25.0/LICENSE` | 同上 |
| Newtonsoft.Json(PSScriptAnalyzer 内置依赖) | 随包 | **MIT** | 同目录 `ThirdPartyNotices.txt` | 同上 |
| 7-Zip | 26.03 | **LGPL-2.1-or-later + BSD-3-Clause + unRAR 限制** | 上游许可(用户自备,不随本仓库分发) | 未分发,仅本地调用 |
| PowerShell | 5.1 / 7.7 | **MIT**(宿主环境) | — | 未分发 |
| .NET 运行时 | — | **MIT**(宿主环境) | — | 未分发 |
## 2. 许可证分布
```text
MIT ████████████████████ 3 项(PSScriptAnalyzer、Newtonsoft.Json、PowerShell/.NET)
Apache-2.0 ███████ 1 项(Pester)
LGPL + BSD + unRAR ███████ 1 项(7-Zip)
未声明 ███████ 1 项(本项目自身) <-- 需要处理
```
全部第三方许可证均为**宽松型**(MIT / Apache-2.0 / BSD),无 copyleft 传染风险。
## 3. 冲突与注意事项
| ID | 级别 | 对象 | 说明 | 建议 |
| --- | --- | --- | --- | --- |
| L-1 | ❌ **需处理** | 本项目自身 | 没有 `LICENSE` 文件 = 默认「保留所有权利」。他人**无权**复制、修改、分发;GitHub 上也会显示为无许可证项目 | 明确选一个:想宽松就 MIT,想带专利授权就 Apache-2.0 |
| L-2 | ⚠️ 需注意 | 7-Zip 的 unRAR 限制 | 7-Zip 许可证禁止用其 unRAR 代码**还原 RAR 压缩算法**。本项目只用 7z 解压 / 压缩,不实现 RAR 压缩 | 无需动作;当前用法不触发该限制 |
| L-3 | ⚠️ 需注意 | Pester 5.9.1 的版权年份 | 模块清单里 `Copyright` 写的是 `(c) 2026 by Pester Team`(上游清单原文) | 无需动作;仅记录,勿在文档中改写上游声明 |
| L-4 | ✅ 兼容 | PSScriptAnalyzer(MIT)+ Newtonsoft.Json(MIT) | MIT 与 MIT/Apache 混合无冲突 | 仅在本仓库内作为测试工具使用,未再分发 |
## 4. 合规建议
1. **先补主许可证**(L-1)。这是本仓库唯一的合规缺口,且成本最低。
2. `.tools/` 下的模块**不要**提交进版本库(已 gitignore)——它们是第三方代码,
提交会牵出「再分发」与版权声明保留义务。
3. 若将来把 BakNRet 公开发布,请在 README / 发布产物里保留 7-Zip 的许可与免责声明引用;
当前仓库并未捆绑 7-Zip 二进制,所以现在无需附带其许可证全文。
4. 若决定以 MIT 发布,注意 MIT 要求保留版权与许可声明:README「致谢」一节已引用上游项目,
可再补一份 `THIRD-PARTY-NOTICES.md` 收纳 Pester / PSScriptAnalyzer 的声明(可选)。
---
**免责声明(重申)**:以上判断基于文件名、模块清单字段与上游公开条款的自动比对,
存在识别错误的可能;**不构成法律建议**。
@@ -0,0 +1,55 @@
{
"measuredAt": "2026-09-28T12:24:11",
"machine": "STRIX-X870A",
"os": "Microsoft Windows NT 10.0.26340.0",
"psi": "7.7.0-preview.5",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor ",
"logicalCpu": 16,
"workdir": "D:\\Workspace\\Temp\\BakNRet",
"metrics": {
"dryRunFullListMs": {
"min": 9839.9,
"median": 9869.2,
"max": 12900.8,
"samples": 5
},
"sevenZipVersion": "7-Zip 26.03 (x64) : Copyright (c) 1999-2026 Igor Pavlov : 2026-09-03",
"folderSummary200Files3xMs": 18.2,
"backupListParse50xMs": {
"min": 2535.9,
"median": 2546.9,
"max": 2691.5,
"samples": 5
},
"sevenZipBench": [
{
"level": 0,
"ms": 46.7,
"archiveBytes": 6555514
},
{
"level": 5,
"ms": 224.2,
"archiveBytes": 6555913
},
{
"level": 9,
"ms": 225.2,
"archiveBytes": 6555913
}
],
"moduleImportMs": {
"min": 628.3,
"median": 631.0,
"max": 2052.8,
"samples": 5
}
},
"notes": [],
"fixture": {
"files": 200,
"bytes": 6553600,
"createMs": 237.6,
"compressionInputBytes": 6553600
}
}
@@ -0,0 +1,333 @@
Pester 5.9.1 (D:\Workspace\Temp\BakNRet\.tools\modules\Pester\5.9.1)
测试文件:D:\Workspace\Temp\BakNRet\tests
Pester v5.9.1
Starting discovery in 3 files.
Discovery found 185 tests in 226ms.
Running tests.
Running tests from 'D:\Workspace\Temp\BakNRet\tests\BakNRet.Formats.Tests.ps1'
Describing 软件名录:Slot 形状(新契约)
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 一个软件多个 Slot:Kind=Multi,Slot 按名排序且说明被保留 163ms (131ms|32ms)
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 每个 Slot 都是一个独立的归档项来源(Kind=slot / Origin=catalog) 82ms (81ms|1ms)
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] Slot 级排除写在 Slot 自己身上(相对本 Slot 的归档根) 32ms (30ms|1ms)
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 数组里"当前不存在"的 Slot 仍然产出归档项(恢复要靠它还原回原位) 64ms (64ms|1ms)
[+] 文件 Slot:归档项是文件项(归档里就是名为 Slot 的文件) 8ms (7ms|1ms)
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 旧的裸字符串 / 字符串数组写法被拒绝(ERROR + 跳过) 22ms (22ms|1ms)
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 旧的 @{ Dirs = @(...) } 写法不再展开,留下 Invalid 与原因 24ms (23ms|1ms)
[+] 多 Slot 的名录条目在清单里仍然按软件名命名归档 3ms (3ms|0ms)
Describing 清单修饰符:新契约格式
[+] :: 覆盖 Path:单 Slot 条目直接生效 13ms (12ms|2ms)
[+] :: 覆盖遇到多 Slot 条目 -> Blocking(不知道给哪一个,绝不猜) 6ms (6ms|0ms)
[+] :- 排除与 :+ 包含并存,顺序任意 15ms (15ms|1ms)
[+] @ Exclude / @ Include / @ Path 与记号写法等价 7ms (6ms|0ms)
[+] :encrypt / :!encrypt 覆盖名录里的加密默认值 11ms (11ms|0ms)
[+] 遗留写法 @encrypt / @pathname / @root= 仍可解析 18ms (17ms|1ms)
[+] 同一行里重复写同类记号会累积(不静默丢掉前一条规则) 12ms (11ms|1ms)
[2026-09-28 12:42:05] [WARN] 清单行缺少目标,已忽略::- logs\
[+] 行尾说明与缺少目标的行 6ms (6ms|1ms)
Describing 集成:Slot 布局的打包与恢复
[+] 备份退出码 0,归档名就是软件名 9ms (7ms|2ms)
[+] 归档顶层就是各个 Slot 名(目录 Slot + 文件 Slot + Include 项) 42ms (41ms|1ms)
[+] manifest.layouts 记下每个归档项是目录还是文件 8ms (7ms|1ms)
[+] 归档内容:\ 布局,文件 Slot 是名为 Slot 的文件,Slot 排除生效 40ms (39ms|1ms)
[+] 真实恢复:目录 Slot、文件 Slot 与 Include 都落回各自的原位 2.37s (2.37s|1ms)
[+] 文件 Slot 在目标不存在时靠 manifest.layouts 恢复成文件(而不是目录) 3ms (2ms|0ms)
[+] 恢复之后 manifest 记下 lastRestoreAt 4ms (4ms|0ms)
Describing 集成:旧布局归档的回退恢复
[+] 归档确实是旧布局:顶层是源目录名而不是 Slot 名 30ms (29ms|1ms)
[+] 归档里缺 Slot 层(真实旧归档)时按旧布局回退,把内容还原回原位 1.75s (1.75s|0ms)
[+] 归档里既没有 Slot 层、也没有旧布局名字时明确失败(不再"成功地什么都没恢复") 1.72s (1.72s|0ms)
Describing 集成:归档内路径冲突会被拒绝执行
[+] 退出码 1,且给出"归档内路径冲突"的原因,不生成归档 4ms (3ms|1ms)
[+] manifest 里记下这次是 failed,并带上原因 3ms (3ms|0ms)
Describing 条目从清单里消失后,旧归档必须被点名为孤儿
[+] 归档确实还在磁盘上,manifest 里也还留着历史记录 5ms (4ms|1ms)
[+] 第二次运行把 my-app.7z 点名成孤儿,并说明 manifest 里还有历史记录 2ms (2ms|0ms)
Describing manifest 一致性:archive 字段只在文件真的存在时才写
[+] 存在的归档保留 archive,不存在的被清空 9ms (8ms|1ms)
[+] 清空 archive 时保留条目本身的历史(source / action 不动) 3ms (3ms|0ms)
[+] 人工删掉归档之后再同步一次,记录会被纠正过来 4ms (3ms|0ms)
Running tests from 'D:\Workspace\Temp\BakNRet\tests\BakNRet.Security.Tests.ps1'
Describing 排除判定与 7z 的 -x! / -xr! 语义对齐
[+] 锚定模式只命中它自己那棵子树 8ms (7ms|1ms)
[+] ! 通配按任意层级的组件名匹配(* 不是正则) 2ms (1ms|0ms)
[+] !re: 走正则,且组件名与整条相对路径都算命中 2ms (2ms|0ms)
[+] 没有模式时一律不排除 1ms (1ms|0ms)
[+] 模式里的空格按 7z 的规矩当 ? 处理 1ms (1ms|0ms)
Describing SID 映射(跨机恢复)
[+] 整 SID 精确替换 5ms (5ms|1ms)
[+] 不会误伤以它为前缀的更长的 SID 1ms (1ms|0ms)
[+] 空映射表时原样返回 1ms (1ms|0ms)
Describing 安全描述符采集
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] Full:每个对象一条记录,键是归档内相对路径 340ms (340ms|1ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 根记录的 SDDL 保留了 CREATOR OWNER、IO 标志、孤儿 SID 和 protected 位 12ms (12ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] Smart 比 Full 少,但根永远保留 24ms (23ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] Roots 只存归档项的根,不再往下走 4ms (4ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] sidecar 往返:条数与 SDDL 原样保留 25ms (24ms|0ms)
[+] 旁挂文件不存在时读出 $null(调用方据此打告警,而不是静默当没事) 1ms (1ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 排除模式在采集时同样生效(采集树 == 归档树) 9ms (9ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
Describing 安全描述符回放
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 回放后根对象的安全描述符与源逐字节一致(protected / CO / 孤儿 SID 全在) 40ms (39ms|1ms)
[+] 全部对象的安全指纹与源一致(属主/属组/ACE 集合) 5ms (5ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 目标不存在或不是普通对象时记 Skipped,不记 Failed 3ms (3ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 归档根名对不上时一条都不回放(不会把兄弟项的 ACL 倒过来) 1ms (1ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 属组写不进去时不会连累 DACL:回退到底也要把 ACL 落下去 7ms (7ms|0ms)
[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL
[+] 对象的安全描述符读不到时带 e 记账,回放时跳过而不是写坏 2ms (2ms|0ms)
Describing 与 Backup.ps1 / Restore.ps1 的集成
[+] 备份会写出 .acl.json,并在 manifest 里记下它 2.34s (2.34s|1ms)
[+] 恢复会把安全描述符回放回去(删源之后仍然逐对象与备份前一致) 2.14s (2.14s|0ms)
[+] -SkipSecurity 时不回放(目标保持新建对象的默认 ACL) 1.77s (1.77s|0ms)
[+] 归档旁边没有 acl.json 时打告警、不算失败(旧归档照样恢复得出来) 1.76s (1.76s|0ms)
Running tests from 'D:\Workspace\Temp\BakNRet\tests\BakNRet.Tests.ps1'
Describing BackupList.txt 解析
[+] 注释行与空行返回 $null 2ms (2ms|1ms)
[+] 裸路径:Path 原样、方向 both、不是软件名、没有任何覆盖 6ms (6ms|0ms)
[+] 软件名:IsName 为真 2ms (1ms|0ms)
[+] IsName 判定:含分隔符或 % 就算字面路径 3ms (2ms|0ms)
[+] 标记必须是独立记号:C:\a:-b 仍然只是一个路径 2ms (2ms|0ms)
[+] 行首 + 仅备份、- 仅恢复,方向标记不进入目标 4ms (4ms|0ms)
[+] 只有方向标记、没有目标 -> 忽略该行 2ms (2ms|0ms)
[+] 行首标记贴在目标上也算(+Name / -Path),且标记不进入目标 4ms (4ms|0ms)
[+] 非行首的 + / - 不是方向标记 22ms (22ms|0ms)
[+] :: 现在表示"覆盖 Path",与 :- 彻底分开 8ms (5ms|3ms)
[+] :: 的值可以带空格(一直取到下一个标记之前) 2ms (2ms|0ms)
[+] :- 的逗号 / 分号列表拆成多个模式 7ms (6ms|0ms)
[+] 排除模式的引号只保护空白,不保护逗号 3ms (2ms|0ms)
[+] :+ 的包含项是 : 2ms (2ms|0ms)
[+] :encrypt 与 :!encrypt 控制该条目的加密开关 2ms (2ms|0ms)
[+] @ Key='Value' 覆盖 Path / Exclude / Include / Encrypt 4ms (4ms|0ms)
[+] @ 的键名大小写不敏感,且支持紧跟 @ 的写法 2ms (2ms|0ms)
[+] 历史写法 @encrypt / @!encrypt 仍然被识别成加密覆盖 2ms (2ms|0ms)
[+] 历史写法 @pathname / @root= 进入 Flags 3ms (2ms|0ms)
[2026-09-28 12:42:30] [WARN] 清单里的 @ 字段 'UnknownKey' 不是已知字段(Path / Exclude / Include / Encrypt),已忽略:Foo @ UnknownKey='v' :- logs\
[+] 未知的 @ 字段进入 UnknownKeys,且不影响其余字段解析 2ms (2ms|0ms)
[+] 行尾的 # 说明会成为 Comment 2ms (2ms|0ms)
[+] 路径里紧贴的 # 不会被当成注释 1ms (1ms|0ms)
[+] 没有说明时 Comment 为空 1ms (1ms|0ms)
[+] 目标可以带空格:第一个标记之前整段都是目标 3ms (2ms|0ms)
[2026-09-28 12:42:30] [WARN] 清单行缺少目标,已忽略::- logs
[2026-09-28 12:42:30] [WARN] 清单行缺少目标,已忽略:@ Exclude='x'
[+] 缺少目标(标记出现在第一个位置)会被忽略并告警 2ms (2ms|0ms)
[+] 记号切分:引号内的空白不切分,引号本身留在记号里 2ms (1ms|0ms)
[+] 记号识别只认完整记号 2ms (2ms|0ms)
[+] 去引号:成对才去,不成对原样返回 2ms (1ms|0ms)
Describing 归档命名
[+] 基础命名规则:末级名_from_上级路径用加号连接 2ms (1ms|1ms)
[+] / 与 \ 以及重复分隔符结果一致 2ms (1ms|0ms)
[+] 命名与路径往返 6ms (6ms|0ms)
[+] 归档名里不含非法文件名字符 4ms (4ms|0ms)
[+] %变量% 写法里的 % 会保留在归档名里 1ms (1ms|0ms)
[+] 软件名条目:默认用软件名做归档名 5ms (4ms|0ms)
[+] 字面路径条目:仍用路径命名算法(现有清单无需改写) 2ms (2ms|0ms)
[+] @pathname 用名录里的真实路径命名,而不是软件名 4ms (3ms|0ms)
[2026-09-28 12:42:30] [WARN] 名录里没有 'no-such-thing',按目录名处理
[2026-09-28 12:42:30] [WARN] 名录里没有 'no-such-thing',按目录名处理
[+] 名录里没有该名字:归档名退回可读目录名,并给出 Error 5ms (4ms|0ms)
Describing 7z 排除参数翻译
[+] 相对模式自动补上归档根目录名 5ms (5ms|1ms)
[+] 模式已带根名前缀时,Split-BakNRetPatternScope 先摘掉前缀,结果不重复 5ms (4ms|0ms)
[+] ! 前缀翻译成递归组件匹配(-xr!) 1ms (1ms|0ms)
[+] 模式里的空格转成 ?(归档项自己的名字按原样保留) 2ms (1ms|0ms)
[+] 生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效) 1ms (1ms|0ms)
[+] 空模式被忽略 2ms (1ms|0ms)
[+] !re: 会把正则展开成精确的 -x! 参数 25ms (25ms|0ms)
[+] 非法正则给出 Error,而不是抛异常 6ms (6ms|0ms)
[+] 正则命中数超过上限时明确报错 3ms (3ms|0ms)
[+] 参数总长超过安全上限时明确报错 1ms (1ms|0ms)
[+] Split-BakNRetPatternScope:指名 Slot 的模式只分给那个 Slot 2ms (1ms|0ms)
[+] Split-BakNRetPatternScope:没点名任何 Slot 的模式广播给每一项 1ms (1ms|0ms)
[+] Split-BakNRetPatternScope:! 与 !re: 模式广播给每一项 1ms (1ms|0ms)
[+] Split-BakNRetPatternScope:每个归档项都有键(没有模式时是空数组) 2ms (2ms|0ms)
[+] Merge-BakNRetExcludeArgument 去重且保持顺序 3ms (2ms|0ms)
[+] 多 Slot 条目:逐项分配 + 翻译 + 去重合成一份参数 3ms (3ms|0ms)
Describing 归档项与暂存目录
[+] New-BakNRetArchiveItem 规范化归档内路径并算出 TopName 3ms (3ms|1ms)
[+] Get-BakNRetArchiveTopName 取归档内相对路径的第一段 1ms (1ms|0ms)
[+] 暂存半途失败会自己清干净(不留指向真实数据的 junction) 33ms (32ms|0ms)
[+] 目录项用 junction 挂进暂存目录,文件项用硬链接(名字就是归档内路径) 18ms (17ms|0ms)
[+] Remove-BakNRetArchiveStaging 只删连接点,不顺着走进真实目录 8ms (8ms|0ms)
Describing 命令行参数拼接
[+] 无空格参数原样输出 2ms (1ms|1ms)
[+] 含空格参数加引号 3ms (2ms|0ms)
[+] 引号内的结尾反斜杠翻倍(否则会被当成转义引号) 2ms (1ms|0ms)
[+] 内部引号被转义 1ms (1ms|0ms)
[+] 空参数输出一对空引号 2ms (1ms|1ms)
Describing manifest 与配置
[+] manifest 读写往返 10ms (9ms|1ms)
[+] manifest 原样保存 layouts(name/kind),全新恢复时靠它判断目录还是文件 7ms (7ms|0ms)
[2026-09-28 12:42:30] [WARN] manifest 解析失败(将重新建立):C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\broken.json —— Conversion from JSON failed with error: Invalid character after parsing property name. Expected ':' but got: i. Path '', line 1, position 7.
[+] manifest 损坏时不抛异常,而是重建空清单 9ms (8ms|0ms)
[+] 配置缺失时返回默认值 4ms (4ms|0ms)
[+] 配置嵌套段落合并且不丢默认键 6ms (6ms|0ms)
[+] 口令:环境变量可读取,取不到时返回 $null(绝不退化成明文) 3ms (3ms|0ms)
Describing 软件名录
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 名录解析:条目与 Slot 的字段集合就是新契约 54ms (53ms|1ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 一个软件多个 Slot:Kind=Multi,Slot 按名字排序 33ms (33ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] Kind:Single / Multi / Partial / Unresolved / Invalid 34ms (34ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 前缀补全:_ 与 - 都会被补全 28ms (27ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 不会把 Legendary 误配成 LegendarySomething 28ms (27ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 一个 Slot 命中多个候选目录:报 Error,绝不悄悄挑一棵树 31ms (31ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 文件 Slot:Path 指向文件时 IsFile 为真,Encrypt 也带上 25ms (25ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] %变量% 会在名录路径里展开 27ms (27ms|0ms)
[+] $( ... ) 子表达式会被求值(含嵌套) 6ms (6ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path
[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot
[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' }
[+] 读取结果按"路径 + 时间戳 + 长度 + 内容 MD5"缓存 63ms (62ms|0ms)
[+] 名录文件内容变了以后缓存自动失效 17ms (16ms|0ms)
[+] Includes:分文件维护的名录会被合并 12ms (11ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 'arr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:30] [ERROR] 名录条目 'objarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:30] [ERROR] 名录条目 'bare' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:30] [ERROR] 名录条目 dirstyle 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 旧的裸字符串 / 字符串数组 / 对象数组写法都会报 ERROR 并被跳过 3ms (3ms|0ms)
[2026-09-28 12:42:30] [ERROR] 名录条目 'arr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:30] [ERROR] 名录条目 'objarr' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:30] [ERROR] 名录条目 'bare' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }
[2026-09-28 12:42:30] [ERROR] 名录条目 dirstyle 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' }
[+] 旧的 @{ Dirs = @(...) } 写法不再展开:留下 Invalid 条目和原因 4ms (3ms|0ms)
[+] 软件名会被规范化成合法文件名 2ms (1ms|0ms)
Describing Resolve-BakNRetBackupEntry:条目解析
[+] resolve 与归档项的字段集合就是新契约(旧字段已删除) 4ms (4ms|1ms)
[+] 软件名条目:isName/CatalogEntry/BaseName/ArchiveFlavor/Source 7ms (6ms|0ms)
[+] 软件名条目:Items 来自 Slot(ArchivePath=Slot 名,Kind=slot,Origin=catalog) 6ms (6ms|0ms)
[+] 字面路径条目:一个 path 项(ArchivePath 是末级名) 3ms (3ms|0ms)
[+] 字面路径条目上的 @ Path= 覆盖目标路径,但归档名仍按原路径 2ms (2ms|0ms)
[+] 名录里的路径不存在时仍给出 Items(恢复要靠它还原回原位) 5ms (5ms|0ms)
[+] :: 覆盖 Path:单 Slot 条目直接生效 5ms (5ms|0ms)
[+] :: / @ Path= 覆盖遇到多 Slot 条目 -> Blocking(不猜是哪一个) 5ms (5ms|0ms)
[+] 条目级 :- 覆盖名录里的排除:HasExcludeOverride 为真 3ms (3ms|0ms)
[+] 名录 Slot 的 Include 会追加成 include 项 5ms (5ms|0ms)
[+] 条目级 :+ / @ Include= 覆盖名录里的 Include 11ms (11ms|0ms)
[2026-09-28 12:42:30] [WARN] encapp:名录里各 Slot 的 Encrypt 不一致,整个归档按加密处理
[+] 加密:名录里各 Slot 取或;条目级 :encrypt / :!encrypt 覆盖 9ms (9ms|0ms)
[+] 方向标记会传递到 Resolve-BakNRetBackupEntry.Direction 5ms (5ms|0ms)
[+] 归档内路径冲突(include 与 Slot 同名)-> Blocking 5ms (4ms|0ms)
[+] 归档内路径冲突(父子关系)-> Blocking 5ms (5ms|0ms)
[2026-09-28 12:42:30] [WARN] 名录里没有 'no-such-thing',按目录名处理
[+] 名录里没有该名字:Error 给出,Items 为空 3ms (2ms|0ms)
Describing 外部命令退出码(旧实现的核心缺陷)
[2026-09-28 12:42:30] [DEBUG] 执行: cmd.exe /c "exit 0" -p<口令已隐藏>
[+] 口令不会进日志:DEBUG 下打印的命令行要遮蔽 -p 参数 31ms (30ms|1ms)
[+] 运行锁:同一份备份目录同时只能有一个持有者 27ms (27ms|0ms)
[+] 运行锁:释放之后可以重新取得 8ms (7ms|0ms)
[+] 空目录的摘要给 0 而不是 $null(否则空间守卫会静默失效) 9ms (9ms|0ms)
[+] 原子替换:成功时新内容到位且不留 .tmp;失败时旧内容完好 14ms (14ms|0ms)
[+] Invoke-ExternalCommand 能拿到真实退出码 13ms (12ms|0ms)
[+] 成功时拿到 0 12ms (12ms|0ms)
Describing 集成:真实 7z 压缩与排除规则
[+] 排除规则与空格处理在真实归档上生效 55ms (54ms|1ms)
[+] 对照组:不加排除时被排除的文件确实在归档里(证明上一条不是空归档) 58ms (57ms|0ms)
ERROR: C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\integration\corrupt.7z
C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\integration\corrupt.7z
Open ERROR: Cannot open the file as [7z] archive
ERRORS:
Headers Error
[+] 7z t 对完好归档返回 0,对损坏归档返回非 0(归档后校验的依据) 52ms (52ms|0ms)
Describing 集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)
[+] 备份退出码为 0(旧实现会把成功的压缩判成失败) 2ms (1ms|1ms)
[+] 归档已生成且 manifest 记录了条目、动作、校验结果与 layouts 5ms (5ms|0ms)
[+] 备份过程写了日志文件 3ms (2ms|0ms)
[+] 字面路径条目沿用 布局;归档里保留应保留内容、不含被排除项 32ms (32ms|0ms)
[+] [回归] manifest.roots 记录的是归档内真实的顶层条目名 32ms (31ms|0ms)
[+] Restore -DryRun 退出码 0,且一个字节都不写(manifest SHA256 不变) 1.57s (1.57s|0ms)
[+] Restore -WhatIf 同样不写盘 1.6s (1.6s|0ms)
[+] 真实恢复:退出码 0,文件逐字节一致,被排除项没有被恢复出来 2.12s (2.12s|0ms)
[+] 真实恢复之后 manifest 才被更新(lastRestoreAt) 2ms (2ms|0ms)
[+] 孤儿归档会被点名报告,但不影响退出码 2.37s (2.37s|0ms)
[+] 带 -Only 时不做孤儿审计(避免把未选中的归档误报成孤儿) 1.74s (1.74s|0ms)
[+] 源路径不存在时记为 missing-source,退出码仍为 0(跳过不算失败) 1.76s (1.76s|0ms)
[+] 归档名重复时直接报失败(退出码 1),不静默互相覆盖 2.37s (2.37s|0ms)
[+] [回归] 显式指定的清单不存在时报失败(退出码 1),且不在错误位置建模板 1.43s (1.43s|0ms)
[+] [回归] 未显式指定清单时的首次运行仍建模板并退出 0(引导不能被误伤) 1.44s (1.44s|0ms)
Describing 集成:方向标记与孤儿审计
[+] 行首 - 的条目:备份跳过它,但仍把它算作"有主"(不报成孤儿) 4ms (3ms|1ms)
[+] 行首 + 的条目:恢复跳过它、不写回目标,同时登记归档名 2ms (2ms|0ms)
Tests completed in 50.99s
Tests Passed: 185, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
Pester 测试全部通过:185 项(跳过 0 项)
Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

@@ -0,0 +1,135 @@
# 阶段 0 报告:静态分析与修复
- **项目**:BakNRet(PowerShell 5.1 / 7.x,Windows 备份恢复工具)
- **分支**:`refactor/ms-conventions`(HEAD 之前 `d72fe63`)
- **执行时间**:2026-09-28
- **结论**:✅ **通过**(0 个致命 / 严重问题;2 处真实缺陷已修复并回归;1 处自伤已发现并修复)
---
## 0.1 依赖安全扫描
**执行器**:`/dependency-security-scanner`
| 项目 | 结论 |
| --- | --- |
| 依赖管理文件 | 不存在(无 `package.json` / `requirements.txt` / `go.mod` / `pom.xml` / `Cargo.toml`) |
| 运行时依赖 | **0 个模块** —— 只需 PowerShell 5.1 或 7.x + 7-Zip |
| CVE | 无可报(没有任何被本仓库固定版本的第三方库) |
| 致命 / 严重漏洞 | **0** |
**风险登记**
| ID | 级别 | 内容 | 处置 |
| --- | --- | --- | --- |
| R-1 | 一般 | 无 `LICENSE`,许可证未声明 | 移交阶段 0.2;**建议补一份** |
| R-2 | 一般 | 工作区存在口令文件 `baknret.key`(`.gitignore:18 *.key` 已忽略,`git ls-files` 确认**未被跟踪**) | 建议移到仓库外(`%USERPROFILE%\.baknret.key`)+ `-KeyFile`;**本次全程未读取其内容** |
| R-3 | 建议 | 脚本不检查 7-Zip 版本 | 可选增强 |
| R-4 | 建议 | 口令经命令行传给 7z,进程列表短暂可见 | 7z 上游限制,README 已 CAUTION 披露,无技术解 |
完整性证据:`dependency_security_report.md`。
---
## 0.2 许可证合规检查
**执行器**:`/license-compliance-checker`
| 组件 | 版本 | 许可证 | 判断 |
| --- | --- | --- | --- |
| 本项目 | 1.0.0 | **未声明**(无 `LICENSE`) | ❌ **L-1 需处理** |
| Pester | 5.9.1 | Apache-2.0 | ✅ 宽松,未分发 |
| PSScriptAnalyzer | 1.25.0 | MIT | ✅ 宽松,未分发 |
| Newtonsoft.Json | 随包 | MIT | ✅ 宽松,未分发 |
| 7-Zip | 26.03 | LGPL-2.1 + BSD-3 + unRAR 限制 | ⚠️ L-2:本项目只做解压/压缩,**不实现 RAR 压缩**,不触发该限制;且未捆绑分发 |
| PowerShell / .NET | 宿主 | MIT | ✅ |
**冲突**:无 copyleft 传染。**唯一缺口 = 主许可证未声明**(L-1)。
完整性证据:`license_check_report.md`(含「非法律建议」免责声明)。
---
## 0.3 语法检查与自动修复(严格模式)
**执行器**:`/syntax-fixer`(内含 `/syntax-checker` 语义)
### 检查面
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 编码门禁 | `test.ps1` Encode 层 | ✅ 受管 **133** 个文件:缺 BOM 0、CRLF 0、制表符 0 |
| 解析(PS 7.7.0-preview.5) | `test.ps1` Parse 层 | ✅ **131/131** 解析零错 |
| 解析(5.1.26100.9502) | 同上 | ✅ **131/131** 解析零错 |
| 静态分析 | `tools/Invoke-Analyzer.ps1` | **80 条**,全部 `Warning` 级、**0 条 Error** |
### 已修复的真实缺陷
| # | 文件:行 | 规则 | 修复 | 语义 |
| --- | --- | --- | --- | --- |
| 1 | `Backup.ps1:42` | `PSUseConsistentWhitespace` | `@($Rest)+ @(…)` → `@($Rest) + @(…)`(补 2 处空格) | **无行为改变**,仅排版 |
| 2 | `Restore.ps1:42` | `PSUseConsistentWhitespace` | 同上(补 2 处空格) | **无行为改变**,仅排版 |
### ⚠️ 修复过程中的自伤与恢复(值得记录)
修改 `Backup.ps1` / `Restore.ps1` 后,两个文件的 **UTF-8 BOM 被编辑器抹掉**,导致:
```text
7 : parse_bad=0 <- PowerShell 7 正常
5.1 : ERR Backup.ps1 : The string is missing the terminator: '.
ERR Restore.ps1 : The string is missing the terminator: '.
```
根因正是本仓库 `.editorconfig` 写明的那条:**5.1 没有 BOM 就按 ANSI 解码源码**,中文注释与全角字符的字节序列吃掉了字符串引号。
处置:用 `UTF8Encoding($true)` 重写并复核 → BOM=True、CRLF=False → 双宿主 `parse_bad=0` → `test.ps1 -Suite Parse` 恢复全绿。
**这条恰好证明了本仓库 Encode 层的价值**,也说明「编辑 PowerShell 源文件必须保留 BOM」是一条硬约束。
### 未修复项:按仓库已声明的偏离登记(用户已确认此处置)
| 规则 | 条数 | 为什么不改 | 依据 |
| --- | --- | --- | --- |
| `PSAvoidLongLines` | 54 | 仓库**有意**把上限设为 160 而非官方 120(120 意味着 270 处改动) | `PSScriptAnalyzerSettings.psd1` 第 22–25 行;[ADR-0008](../../../docs/adr/0008-analyzer-deviations.md) |
| `PSPlaceCloseBrace` | 7 | 全在 `Run-Tests.ps1` **测试夹具字符串**内,缩进/换行是被断言的文本 | 改了会改变断言语义 |
| `PSUseConsistentIndentation` | 4 | 同上 | 同上 |
| `PSAlignAssignmentStatement` | 4 | `Invoke-BakNRetMenu.ps1` 的表格字面量,对齐影响 TUI 列宽 | 属于刻意排版 |
| `PSUseSupportsShouldProcess` | 4 | 该规则**已全局排除**于配置,属已知噪声 | `PSScriptAnalyzerSettings.psd1` 第 44 行 |
| `PSAvoidUsingEmptyCatchBlock` | 2 | `Write-BakNRetAt.ps1:39` 有意空 catch(注释已写明理由:定位失败不影响写文本);加输出会破坏 TUI | 源码注释 |
| `PSReviewUnusedParameter` | 4 | 3 条在测试 helper;1 条**可能真有价值** → 见下 | — |
| `PSUseDeclaredVarsMoreThanAssignments` | 1 | `BakNRet.Security.Tests.ps1:373` 的 `$sourcePath` 已赋值未使用 | 疑似残留,非功能缺陷 |
### 🔎 待人工确认的观察项(未改动)
```text
BakNRet/Public/Write-BakNRetRunSummary.ps1:16
[Parameter(Mandatory = $true)][ValidateSet('backup','restore','verify')][string]$Mode
```
- `$Mode` 声明了 `ValidateSet` 与 `Mandatory`,但函数体内**从未读取**(全文仅第 16 行出现)。
- 该函数也**从未被仓库内任何代码调用**(`grep` 全仓仅命中定义处),只通过 `FunctionsToExport` 对外导出。
- 判断:像是「按运行类型分组」的未完工实现,**不是**能安全自动删改的东西(删除会破坏公共 API 参数契约)。
- 处置:登记为观察项,**留待仓库作者决定**。
---
## 0.4 回归验证
| 层次 | PS 7.7.0-preview.5 | 5.1.26100.9502 |
| --- | --- | --- |
| Encode(宿主无关,跑一次) | ✅ 133 文件 0 问题 | — |
| Parse | ✅ 131/131 | ✅ 131/131 |
| Unit(Pester) | ✅ 通过 | ✅ 通过 |
| Smoke(零依赖) | ✅ 通过 | ✅ 通过 |
| E2E | ✅ 通过 | ✅ 通过 |
| **合计** | **9/9 PASS,退出码 0** | |
修复前后各跑一次,结论一致 → **无回归**。
分析器:`84 → 80` 条(4 条 `PSUseConsistentWhitespace` 全部归零,其余不变)。
证据:`analyzer_raw.txt`(修复前)、`analyzer_after.txt`(修复后)。
---
## 0.5 阶段结论
- ✅ 无致命 / 严重问题,**流水线可继续**。
- 🔧 2 处真实排版缺陷已修复;1 处 BOM 自伤已发现并恢复(这条本身是「Encode 层有效」的实证)。
- ⚠️ 3 项待办移交下游:补 `LICENSE`(L-1)、迁移 `baknret.key`(R-2)、确认 `$Mode` 意图(观察项)。
- 严格模式「零容忍」与仓库**已声明偏离**冲突的部分,按用户决定:**登记而不改**,理由逐条留档(上表)。
@@ -0,0 +1,90 @@
# 阶段 3 报告:测试增强评估与性能基线
- **执行时间**:2026-09-28
- **结论**:性能基线已建立(首次,无退化可判);单元测试增强**建议但未执行**(理由见下)
---
## 3.1 单元测试增强评估(`/unit-test-generator`,可选步骤)
### 现状盘点
| 指标 | 数值 |
| --- | --- |
| 对外函数总数 | **89**(`BakNRet/Public/*.ps1`,与 `FunctionsToExport` 白名单逐一核对一致) |
| 测试中被**直接点名**的函数 | 70(78.7%) |
| 未被直接点名 | 19(21.3%) |
| Pester 用例 | **185** 条(`BakNRet.Tests.ps1` 127 + `Formats` 33 + `Security` 25),0 失败 0 跳过 |
| 另有 | 零依赖套件 128 项、端到端 36 项、真实归档演练 12 项 |
### 未被直接点名的 19 个函数——逐条判断
> 「未被直接点名」≠「未被覆盖」:其中不少是通过集成路径间接执行到的(如 `Write-BakNRetLog` 被所有用例调用、`ConvertTo-BakNRetWildcardPattern` 由 `Test-BakNRetPathExcluded` 间接驱动)。
| 函数 | 是否值得补测试 | 理由 |
| --- | --- | --- |
| `Move-BakNRetArchiveIntoPlace` | **值得(最高优先)** | 「临时文件 → 校验 → 原子替换」是本仓库的核心安全承诺之一,且 CHANGELOG 记录过它在 5.1 上退化成「先删后移」的缺陷。目前只有端到端间接覆盖 |
| `Resolve-BakNRetRootedPath` | 值得 | 「相对路径按仓库根解析,不按工作目录」是计划任务场景的关键约定(README 专门写了这一节) |
| `Test-BakNRetItemSelected` | 值得 | `-Only` / `-Skip` 的通配匹配语义,边界(大小写、通配符)值得钉住 |
| `ConvertFrom-BakNRetPatternList` | 值得 | `,` 与 `;` 双分隔符是历史缺陷的修复点(CHANGELOG:解析器用 `;` 而清单里写 `,`) |
| `Get-Optimized7zArgument` | 一般 | 参数拼接优化,间接覆盖已足够 |
| `Save-BakNRetItemRecord` | 一般 | 与 `Write-BakNRetManifest` 组合使用,集成覆盖 |
| `Find-BakNRet7zExecutable` / `Resolve-BakNRetCompressionTool` | 一般 | 依赖机器状态,单元测试价值低(真实冒烟更合适) |
| `Get-BakNRetFreeSpaceGB` / `Test-BakNRetAdministrator` | 低 | 环境查询,测试易受机器状态影响 |
| `Enable-BakNRetPrivilege` / `Set-BakNRetObjectSecurity` / `Get-BakNRetAceSignatureList` / `Test-BakNRetSecurityRecordNeeded` | 低(**已由 VM 演练覆盖**) | 这几条需要真实提权与真实 NTFS,单元测试造不出可信夹具;仓库已经用 `tools/lab/Lab.ps1 acl-test`(含负对照)在真 VM 里覆盖,这是更正确的层次 |
| `Write-BakNRetBackupEntryPlan` | 低 | 纯输出,间接覆盖 |
| `Invoke-BakNRetEntryScript` | 低 | 子进程封装,端到端已覆盖 |
| TUI 相关(`Write-BakNRetAt` 等) | 低 | 需要真终端;仓库已用 `-InputScript` 驱动做门禁 |
### 为什么本次**不执行**自动补测
1. 该步骤在流水线里标注为**可选**。
2. 用户本轮选择的处置姿态是「最小侵入、不制造大 diff」;补 4~6 条新用例属于**新增工作**而非修复缺陷,应先取得确认。
3. 上述「值得」的 4 条若要补,需要配套夹具(原子替换需要真归档、`-Only` 需要真清单),属于小规模但真实的工作量。
**建议**:单独一轮补 `Move-BakNRetArchiveIntoPlace` 与 `Resolve-BakNRetRootedPath` 两条(这两条对应的是历史上真出过问题的语义),其余保持现状。
---
## 3.2 性能基线(`/performance-baseline-tester`)
**工具适配说明**:该技能面向 HTTP 服务(k6 / RPS / P95)。BakNRet 是**本地 CLI 工具**,
没有 HTTP 端点,因此把「核心 API」映射为**四条关键路径**:模块导入、清单解析、只读干跑(真实清单)、
7z 压缩吞吐。指标是同一台机器上的可复现绝对耗时,用于**同环境前后对比**,不跨机器比较。
**环境**:宿主 `STRIX-X870A`,PowerShell 7.7.0-preview.5,7-Zip 26.03 (x64)
| 指标 | 采样 | min | **中位数** | max |
| --- | --- | --- | --- | --- |
| 模块导入(`Import-Module` 冷启) | 5 | 628.3 ms | **631.0 ms** | 2052.8 ms |
| 清单解析 ×50 轮 | 5 | 2535.9 ms | **2546.9 ms** | 2691.5 ms |
| **只读干跑(真实 28 条清单)** | 5 | 9839.9 ms | **9869.2 ms** | 12900.8 ms |
| 源树扫描(200 文件 ×3 轮) | 1 | — | **18.2 ms** | — |
**7z 压缩基准**(固定夹具:200 × 32 KB = 6.25 MB **不可压缩随机数据**)
| 压缩级别 | 耗时 | 归档体积 |
| --- | --- | --- |
| `-mx=0` | 46.7 ms | 6,555,514 B |
| `-mx=5` | 224.2 ms | 6,555,913 B |
| `-mx=9` | 225.2 ms | 6,555,913 B |
> 夹具是随机数据,所以体积几乎不随级别变化 —— 这正好说明**该夹具测的是吞吐与 I/O,不是压缩率**。
> 若要看压缩率,应换成真实可压缩语料。
### 基线判定
- **基线文件**:`.scratch/ci-cd/20260928-001722/perf_baseline.json`(首次建立,**无历史基线可比,故无退化可判**)。
- **观察**:只读干跑约 9.9 秒是最大单项。它包含了**对 28 个条目的真实目录扫描与空间预估**,
属于设计内的开销(README 承诺「动手之前先预估空间」)。`max 12.9 s` 的离群值出现在首次运行,
与文件系统缓存冷启动一致。
- **告警**:无退化(首次基线)。
- **建议**:若将来要监控退化,把 `perf_baseline.json` 固定成仓库内基线,
并在同机同宿主下对比;**不要在 VM 与宿主之间直接比数字**(两者 I/O 特性不同)。
---
## 3.3 结论
- ✅ 性能基线已建立并可复现,**无退化**。
- ⏸️ 单元测试增强:已给出逐条优先级,**建议单独一轮补 2 条**,本轮不擅自新增。
+171
View File
@@ -0,0 +1,171 @@
# 阶段 2 回归:把阶段 1 的 11 条黑盒用例按**修正后的前置条件**重跑一遍。
# 修正点:
# * BB-11 原用 -DryRun,而孤儿审计只在真实运行里报告 -> 改成真实运行(原用例的夹具错误,不是产品缺陷)
# * BB-06 的期望不变(显式清单不存在必须非 0),验证修复是否生效
param([string]$OutJson = "$PSScriptRoot\blackbox_regression.json")
$ErrorActionPreference = 'Continue'
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$host7 = (Get-Command pwsh).Source
$host51 = (Get-Command powershell).Source
$zip = (Get-Command 7z -ErrorAction SilentlyContinue).Source
if (-not $zip) { foreach ($p in 'C:\Programs\Scoop\shims\7z.exe') { if (Test-Path $p) { $zip = $p } } }
$sandbox = Join-Path $env:TEMP ('baknret-reg-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
$src = Join-Path $sandbox 'srcdocs'
New-Item -ItemType Directory -Path (Join-Path $src 'nested') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $src 'a.txt') -Value 'alpha' -Encoding utf8
Set-Content -LiteralPath (Join-Path $src 'nested\b.txt') -Value 'beta' -Encoding utf8
Set-Content -LiteralPath (Join-Path $src 'nested\skipme.log') -Value 'noise' -Encoding utf8
$listPath = Join-Path $sandbox 'BackupList.txt'
Set-Content -LiteralPath $listPath -Encoding utf8 -Value @(
"$src :- 'nested\skipme.log'",
'+ ' + (Join-Path $sandbox 'nonexistent')
)
$backupDir = Join-Path $sandbox 'Backups'
$cases = [System.Collections.Generic.List[object]]::new()
function Add-Case {
param([string]$Id, [string]$Title, [string]$Expected, [string]$Actual, [bool]$Pass, [string]$Note = '')
$cases.Add([pscustomobject]@{ Id = $Id; Title = $Title; Expected = $Expected; Actual = $Actual
Status = $(if ($Pass) { 'PASS' } else { 'FAIL' }); Note = $Note })
}
function Invoke-Bak {
param([string]$HostName, [string[]]$Arguments, [string]$Script = 'Backup-Data.ps1')
$exe = if ($HostName -eq '7') { $host7 } else { $host51 }
$all = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $repo $Script)) + $Arguments
$out = & $exe @all 2>&1
return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = @($out) }
}
# ---------------------------------------------------------------- BB-01 真实备份
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $listPath, '-BackupDir', $backupDir)
$archives = @(Get-ChildItem -LiteralPath $backupDir -Filter '*.7z' -ErrorAction SilentlyContinue)
Add-Case -Id 'BB-01' -Title '真实备份:退出码 0 + 产出归档 + manifest' `
-Expected '退出码 0;≥1 个 .7z;manifest.json 存在' `
-Actual ("退出码={0};归档={1}" -f $r.ExitCode, $archives.Count) `
-Pass ($r.ExitCode -eq 0 -and $archives.Count -ge 1 -and (Test-Path (Join-Path $backupDir 'manifest.json')))
# ---------------------------------------------------------------- BB-02 排除生效
$listing = if ($archives.Count -gt 0 -and $zip) { @(& $zip l -ba $archives[0].FullName 2>&1) } else { @() }
$hasSkip = @($listing | Where-Object { $_ -match 'skipme\.log' }).Count -gt 0
$hasKeep = @($listing | Where-Object { $_ -match 'a\.txt|b\.txt' }).Count -gt 0
Add-Case -Id 'BB-02' -Title '排除模式真的把内容挡在归档之外' `
-Expected '归档内无 skipme.log,有 a.txt/b.txt' `
-Actual ("skipme={0};keep={1}" -f $hasSkip, $hasKeep) -Pass ($hasKeep -and -not $hasSkip)
# ---------------------------------------------------------------- BB-03 干跑不写盘
$h1 = (Get-FileHash -LiteralPath (Join-Path $backupDir 'manifest.json') -Algorithm SHA256).Hash
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
$h2 = (Get-FileHash -LiteralPath (Join-Path $backupDir 'manifest.json') -Algorithm SHA256).Hash
Add-Case -Id 'BB-03' -Title '-DryRun 一个字节都不写' `
-Expected '退出码 0;manifest SHA256 前后一致' `
-Actual ("退出码={0};哈希一致={1}" -f $r.ExitCode, ($h1 -eq $h2)) -Pass ($r.ExitCode -eq 0 -and $h1 -eq $h2)
# ---------------------------------------------------------------- BB-04 missing-source
$actions = @((Get-Content (Join-Path $backupDir 'manifest.json') -Raw | ConvertFrom-Json).items.PSObject.Properties.Value.action)
Add-Case -Id 'BB-04' -Title '源不存在记 missing-source,不算失败' `
-Expected '含 missing-source;退出码 0' `
-Actual ("actions={0};退出码={1}" -f ($actions -join ','), $r.ExitCode) `
-Pass ($actions -contains 'missing-source' -and $r.ExitCode -eq 0)
# ---------------------------------------------------------------- BB-05 -Only
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-Only', 'srcdocs', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
$hitMissing = @($r.Output | Where-Object { $_ -match 'nonexistent' }).Count -gt 0
Add-Case -Id 'BB-05' -Title '-Only 只处理匹配的条目' `
-Expected '退出码 0;输出不提未选中的 nonexistent' `
-Actual ("退出码={0};提到 nonexistent={1}" -f $r.ExitCode, $hitMissing) -Pass ($r.ExitCode -eq 0 -and -not $hitMissing)
# ---------------------------------------------------------------- BB-06 ★ 修复验证
$ghost = Join-Path $sandbox 'typo-in-path.txt'
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $ghost, '-BackupDir', $backupDir)
$created = Test-Path -LiteralPath $ghost
Add-Case -Id 'BB-06' -Title '★显式指定的清单不存在 -> 必须报失败(本次修复点)' `
-Expected '退出码 ≠ 0;不创建模板;输出含"指定的清单不存在"' `
-Actual ("退出码={0};误建模板={1};提示={2}" -f $r.ExitCode, $created, (@($r.Output | Where-Object { $_ -match '指定的清单不存在' }).Count -gt 0)) `
-Pass ($r.ExitCode -ne 0 -and -not $created -and (@($r.Output | Where-Object { $_ -match '指定的清单不存在' }).Count -gt 0)) `
-Note '阶段 1 原为 FAIL(退出码 0 且建了模板),本次修复后应转为 PASS'
# ---------------------------------------------------------------- BB-06b 对照:首次运行引导仍在
$realList = Join-Path $repo 'BackupList.txt'
$parked = Join-Path $sandbox 'BackupList.parked'
$guideDir = Join-Path $sandbox 'guide'
New-Item -ItemType Directory -Path $guideDir -Force | Out-Null
$r = $null
try {
Move-Item -LiteralPath $realList -Destination $parked -Force
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupDir', (Join-Path $guideDir 'Backups'))
$regenerated = Test-Path -LiteralPath $realList
}
finally {
if (Test-Path -LiteralPath $parked) { Move-Item -LiteralPath $parked -Destination $realList -Force }
}
Add-Case -Id 'BB-06b' -Title '对照:未显式指定时首次运行仍建模板并退出 0' `
-Expected '退出码 0;模板被创建;随后仓库原文件已还原' `
-Actual ("退出码={0};模板创建={1};仓库还原={2}" -f $r.ExitCode, $regenerated, (Test-Path -LiteralPath $realList)) `
-Pass ($r.ExitCode -eq 0 -and $regenerated -and (Test-Path -LiteralPath $realList))
# ---------------------------------------------------------------- BB-07 垫片转发
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) -Script 'Backup.ps1'
$said = @($r.Output | Where-Object { $_ -match '已改名为' }).Count -gt 0
Add-Case -Id 'BB-07' -Title '旧名字垫片转发且退出码原样传递' `
-Expected '退出码 0;有改名提示' `
-Actual ("退出码={0};提示={1}" -f $r.ExitCode, $said) -Pass ($r.ExitCode -eq 0 -and $said)
# ---------------------------------------------------------------- BB-08 双宿主
$r7 = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
$r51 = Invoke-Bak -HostName '5.1' -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
Add-Case -Id 'BB-08' -Title '5.1 与 7.x 行为一致' `
-Expected '两个宿主退出码都是 0' `
-Actual ("7={0};5.1={1}" -f $r7.ExitCode, $r51.ExitCode) -Pass ($r7.ExitCode -eq 0 -and $r51.ExitCode -eq 0)
# ---------------------------------------------------------------- BB-09 特殊字符
$sp = Join-Path $sandbox 'sp&chars#dir'
New-Item -ItemType Directory -Path $sp -Force | Out-Null
Set-Content -LiteralPath (Join-Path $sp 'x.txt') -Value 'special' -Encoding utf8
$list2 = Join-Path $sandbox 'BackupList2.txt'
Set-Content -LiteralPath $list2 -Encoding utf8 -Value "`"$sp`""
$bd2 = Join-Path $sandbox 'Backups2'
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $list2, '-BackupDir', $bd2)
$n = @(Get-ChildItem -LiteralPath $bd2 -Filter '*.7z' -ErrorAction SilentlyContinue).Count
Add-Case -Id 'BB-09' -Title '含 & 与 # 的路径(整行引号)' `
-Expected '退出码 0 且产出归档' `
-Actual ("退出码={0};归档={1}" -f $r.ExitCode, $n) -Pass ($r.ExitCode -eq 0 -and $n -ge 1)
# ---------------------------------------------------------------- BB-10 无控制台不挂起
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
$pinfo.FileName = $host7
$pinfo.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$repo\Manage-Backup.ps1`""
$pinfo.RedirectStandardOutput = $true; $pinfo.RedirectStandardError = $true
$pinfo.UseShellExecute = $false; $pinfo.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($pinfo)
$finished = $proc.WaitForExit(60000)
if (-not $finished) { try { $proc.Kill() } catch {} }
Add-Case -Id 'BB-10' -Title '无控制台且无 -InputScript 时报错退出,绝不挂起' `
-Expected '60 秒内退出且退出码 2' `
-Actual ("已退出={0};退出码={1}" -f $finished, $proc.ExitCode) -Pass ($finished -and $proc.ExitCode -eq 2)
# ---------------------------------------------------------------- BB-11 ★ 修正夹具:真实运行
$orphanDir = Join-Path $sandbox 'Backups3'
New-Item -ItemType Directory -Path $orphanDir -Force | Out-Null
if ($archives.Count -gt 0) { Copy-Item -LiteralPath $archives[0].FullName -Destination (Join-Path $orphanDir 'GhostSoftware.7z') }
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $listPath, '-BackupDir', $orphanDir)
$named = @($r.Output | Where-Object { $_ -match 'GhostSoftware' }).Count -gt 0
Add-Case -Id 'BB-11' -Title '★孤儿归档审计(改用真实运行)' `
-Expected '输出点名 GhostSoftware.7z' `
-Actual ("退出码={0};点名={1}" -f $r.ExitCode, $named) -Pass $named `
-Note '阶段 1 的 FAIL 系夹具错误:孤儿审计只在真实运行里报告,干跑不报'
$report = [ordered]@{
testedAt = (Get-Date).ToString('s')
total = $cases.Count
passed = @($cases | Where-Object Status -eq 'PASS').Count
failed = @($cases | Where-Object Status -eq 'FAIL').Count
cases = $cases
}
$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutJson -Encoding utf8
$cases | Format-Table Id, Status, Title -AutoSize
Write-Host ("回归合计 {0};通过 {1};失败 {2}" -f $report.total, $report.passed, $report.failed)
Write-Host ("沙盒: " + $sandbox)
+209
View File
@@ -0,0 +1,209 @@
# 黑盒测试:只依据 README 记录的对外契约,在**沙盒副本**里驱动 CLI。
# 不读实现细节;每个用例都断言"文档承诺的行为 vs 实际行为"。
param(
[string]$OutJson = "$PSScriptRoot\blackbox_results.json"
)
$ErrorActionPreference = 'Continue'
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$host7 = (Get-Command pwsh).Source
$host51 = (Get-Command powershell).Source
# ---------------------------------------------------------------- 沙盒(绝不碰真实 Backups/ logs/)
$sandbox = Join-Path $env:TEMP ('baknret-bb-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
$src = Join-Path $sandbox 'srcdocs'
New-Item -ItemType Directory -Path (Join-Path $src 'nested') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $src 'a.txt') -Value 'alpha' -Encoding utf8
Set-Content -LiteralPath (Join-Path $src 'nested\b.txt') -Value 'beta' -Encoding utf8
Set-Content -LiteralPath (Join-Path $src 'nested\skipme.log') -Value 'noise' -Encoding utf8
$listPath = Join-Path $sandbox 'BackupList.txt'
Set-Content -LiteralPath $listPath -Encoding utf8 -Value @(
"$src :- 'nested\skipme.log'",
'+ ' + (Join-Path $sandbox 'nonexistent')
)
$backupDir = Join-Path $sandbox 'Backups'
$cases = [System.Collections.Generic.List[object]]::new()
function Add-Case {
param([string]$Id, [string]$Module, [string]$Title, [string]$Priority,
[string]$Precondition, [string]$Steps, [string]$Expected,
[string]$Actual, [bool]$Pass, [string]$Severity = '')
$cases.Add([pscustomobject]@{
Id = $Id; Module = $Module; Title = $Title; Priority = $Priority
Precondition = $Precondition; Steps = $Steps; Expected = $Expected
Actual = $Actual; Status = $(if ($Pass) { 'PASS' } else { 'FAIL' })
Severity = $(if ($Pass) { '' } else { $Severity })
})
}
function Invoke-Bak {
param([string]$HostName, [string[]]$Arguments, [string]$Script = 'Backup-Data.ps1')
$exe = if ($HostName -eq '7') { $host7 } else { $host51 }
$all = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $repo $Script)) + $Arguments
$out = & $exe @all 2>&1
return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = @($out) }
}
function Get-ManifestHash {
$p = Join-Path $backupDir 'manifest.json'
if (-not (Test-Path -LiteralPath $p)) { return '<absent>' }
return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash
}
# ================================================================ 用例
# BB-01 首次真实备份(核心流)
$before = Get-ManifestHash
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $listPath, '-BackupDir', $backupDir)
$archives = @(Get-ChildItem -LiteralPath $backupDir -Filter '*.7z' -ErrorAction SilentlyContinue)
Add-Case -Id 'BB-01' -Module '备份' -Title '文档承诺:备份成功返回 0,并产出 .7z 归档' -Priority 'P0' `
-Precondition '沙盒清单:1 个目录(含排除)+ 1 个不存在的源' `
-Steps 'Backup-Data.ps1(无 -DryRun)' `
-Expected '退出码 0;Backups\ 下出现 1 个 .7z;manifest.json 存在' `
-Actual ("退出码={0};归档={1} 个({2})" -f $r.ExitCode, $archives.Count, ($archives.Name -join ',')) `
-Pass ($r.ExitCode -eq 0 -and $archives.Count -ge 1 -and (Test-Path (Join-Path $backupDir 'manifest.json'))) `
-Severity '严重'
# BB-02 排除规则真的生效(逐个归档内容核对)
$zip = (Get-Command 7z -ErrorAction SilentlyContinue).Source
if (-not $zip) { foreach ($p in 'C:\Programs\Scoop\shims\7z.exe') { if (Test-Path $p) { $zip = $p } } }
$listing = if ($archives.Count -gt 0 -and $zip) { @(& $zip l -ba $archives[0].FullName 2>&1) } else { @() }
$hasSkip = @($listing | Where-Object { $_ -match 'skipme\.log' }).Count -gt 0
$hasKeep = @($listing | Where-Object { $_ -match 'a\.txt|b\.txt' }).Count -gt 0
Add-Case -Id 'BB-02' -Module '排除' -Title '文档承诺::- 排除模式把内容挡在归档之外' -Priority 'P0' `
-Precondition '清单里对源目录写了 :- ''nested\skipme.log''' `
-Steps '7z l 列出归档内容' `
-Expected '归档里**没有** skipme.log,但有 a.txt 与 b.txt' `
-Actual ("skipme.log 命中={0};a/b.txt 命中={1}" -f $hasSkip, $hasKeep) `
-Pass ($hasKeep -and -not $hasSkip) -Severity '严重'
# BB-03 只读模式不写盘(README 的强承诺)
$h1 = Get-ManifestHash
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
$h2 = Get-ManifestHash
Add-Case -Id 'BB-03' -Module '干跑' -Title '文档承诺:-DryRun 一个字节都不写(含 manifest.json)' -Priority 'P0' `
-Precondition '已有 1 份归档与 manifest.json' `
-Steps '记录 SHA256 → 跑 -DryRun → 再记录 SHA256' `
-Expected '退出码 0;manifest.json 的 SHA256 前后完全一致' `
-Actual ("退出码={0};哈希 {1} → {2}" -f $r.ExitCode, $h1.Substring(0, 12), $h2.Substring(0, 12)) `
-Pass ($r.ExitCode -eq 0 -and $h1 -eq $h2) -Severity '致命'
# BB-04 源不存在只算跳过、不算失败
Add-Case -Id 'BB-04' -Module '异常流' -Title '文档承诺:源路径不存在记 missing-source,不算失败' -Priority 'P1' `
-Precondition '清单第 2 行指向不存在的目录' `
-Steps '跑备份后读 manifest.json 的 action 字段' `
-Expected '该条目 action=missing-source,且整体退出码仍为 0' `
-Actual ("退出码={0};manifest action 分布={1}" -f $r.ExitCode,
(@((Get-Content (Join-Path $backupDir 'manifest.json') -Raw | ConvertFrom-Json).items.PSObject.Properties.Value.action) -join ',')) `
-Pass ($r.ExitCode -eq 0 -and (@((Get-Content (Join-Path $backupDir 'manifest.json') -Raw | ConvertFrom-Json).items.PSObject.Properties.Value.action) -contains 'missing-source')) `
-Severity '一般'
# BB-05 -Only 过滤
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-Only', 'srcdocs', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
$hitMissing = @($r.Output | Where-Object { $_ -match 'nonexistent' }).Count -gt 0
Add-Case -Id 'BB-05' -Module '干跑' -Title '文档承诺:-Only 只处理匹配的条目' -Priority 'P1' `
-Precondition '清单 2 条:srcdocs(目录)、nonexistent(不存在)' `
-Steps 'Backup-Data.ps1 -DryRun -Only ''srcdocs''' `
-Expected '退出码 0;输出里不出现未选中的 nonexistent 条目' `
-Actual ("退出码={0};输出提到 nonexistent={1}" -f $r.ExitCode, $hitMissing) `
-Pass ($r.ExitCode -eq 0 -and -not $hitMissing) -Severity '一般'
# BB-06 非法参数(错误推测)
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', (Join-Path $sandbox 'no-such-list.txt'), '-BackupDir', $backupDir)
Add-Case -Id 'BB-06' -Module '异常流' -Title '边界值:清单文件不存在时的行为' -Priority 'P1' `
-Precondition '传入一个不存在的 -BackupListPath' `
-Steps 'Backup-Data.ps1 -BackupListPath <不存在>' `
-Expected '明确报错(非 0 退出码)或给出可读提示,**不得静默返回 0**' `
-Actual ("退出码={0};末行={1}" -f $r.ExitCode, (@($r.Output) | Select-Object -Last 1)) `
-Pass ($r.ExitCode -ne 0) -Severity '一般'
# BB-07 旧名字垫片仍可用(文档承诺"只留一轮")
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) -Script 'Backup.ps1'
$saidRename = @($r.Output | Where-Object { $_ -match '已改名为' }).Count -gt 0
Add-Case -Id 'BB-07' -Module '兼容' -Title '文档承诺:旧名字 Backup.ps1 是转发垫片,退出码原样传递' -Priority 'P1' `
-Precondition '实现已改名为 Backup-Data.ps1' `
-Steps 'Backup.ps1 -DryRun(旧名字)' `
-Expected '打印改名提示;退出码与直接调用一致(0)' `
-Actual ("退出码={0};有改名提示={1}" -f $r.ExitCode, $saidRename) `
-Pass ($r.ExitCode -eq 0 -and $saidRename) -Severity '一般'
# BB-08 双宿主一致性(5.1 是文档承诺支持的一半)
$r7 = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
$r51 = Invoke-Bak -HostName '5.1' -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir)
Add-Case -Id 'BB-08' -Module '跨端一致性' -Title '文档承诺:Windows PowerShell 5.1 与 7.x 行为一致' -Priority 'P0' `
-Precondition '同一沙盒、同一清单' `
-Steps '两个宿主各跑一次 -DryRun,比较退出码' `
-Expected '两者退出码都是 0(GBK 控制台下的中文输出不崩)' `
-Actual ("7 退出码={0};5.1 退出码={1}" -f $r7.ExitCode, $r51.ExitCode) `
-Pass ($r7.ExitCode -eq 0 -and $r51.ExitCode -eq 0) -Severity '致命'
# BB-09 特殊字符路径(错误推测)
$sp = Join-Path $sandbox 'sp&chars#dir'
New-Item -ItemType Directory -Path $sp -Force | Out-Null
Set-Content -LiteralPath (Join-Path $sp 'x.txt') -Value 'special' -Encoding utf8
$list2 = Join-Path $sandbox 'BackupList2.txt'
Set-Content -LiteralPath $list2 -Encoding utf8 -Value "`"$sp`""
$bd2 = Join-Path $sandbox 'Backups2'
$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $list2, '-BackupDir', $bd2)
$ok9 = $r.ExitCode -eq 0 -and @(Get-ChildItem -LiteralPath $bd2 -Filter '*.7z' -ErrorAction SilentlyContinue).Count -ge 1
Add-Case -Id 'BB-09' -Module '边界值' -Title '特殊字符:含 & 与 # 的路径(整行引号写法)' -Priority 'P2' `
-Precondition '源目录名含 & 与 #;清单行整体加引号' `
-Steps '备份该条目' `
-Expected '退出码 0 且真的产出归档(# 不被当注释吃掉)' `
-Actual ("退出码={0};归档数={1}" -f $r.ExitCode, @(Get-ChildItem -LiteralPath $bd2 -Filter '*.7z' -ErrorAction SilentlyContinue).Count) `
-Pass $ok9 -Severity '一般'
# BB-10 无控制台时的明确失败(README:绝不挂起)
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
$pinfo.FileName = $host7
$pinfo.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$repo\Manage-Backup.ps1`""
$pinfo.RedirectStandardOutput = $true
$pinfo.RedirectStandardError = $true
$pinfo.UseShellExecute = $false
$pinfo.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($pinfo)
$finished = $proc.WaitForExit(60000)
$stdout = if ($finished) { $proc.StandardOutput.ReadToEnd() } else { '' }
$stderr = if ($finished) { $proc.StandardError.ReadToEnd() } else { '' }
if (-not $finished) { try { $proc.Kill() } catch {} }
Add-Case -Id 'BB-10' -Module '无头' -Title '文档承诺:没有控制台且没给 -InputScript 时报错退出,绝不挂起' -Priority 'P0' `
-Precondition 'stdout/stderr 被重定向(等价于计划任务/管道环境)' `
-Steps '不传参数直接运行 Manage-Backup.ps1,等待最多 60 秒' `
-Expected '60 秒内退出,退出码 2,并提示"没有可用的控制台"' `
-Actual ("60 秒内退出={0};退出码={1};输出片段={2}" -f $finished, $proc.ExitCode, (@($stdout, $stderr) -join ' ').Trim().Substring(0, [Math]::Min(90, (@($stdout, $stderr) -join ' ').Trim().Length))) `
-Pass ($finished -and $proc.ExitCode -eq 2) -Severity '致命'
# BB-11 孤儿归档审计
$orphanDir = Join-Path $sandbox 'Backups3'
New-Item -ItemType Directory -Path $orphanDir -Force | Out-Null
Copy-Item -LiteralPath (Join-Path $backupDir 'manifest.json') -Destination (Join-Path $orphanDir 'manifest.json') -ErrorAction SilentlyContinue
$fake = Join-Path $orphanDir 'GhostSoftware.7z'
if ($archives.Count -gt 0) { Copy-Item -LiteralPath $archives[0].FullName -Destination $fake }
$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $orphanDir)
$named = @($r.Output | Where-Object { $_ -match 'GhostSoftware' }).Count -gt 0
Add-Case -Id 'BB-11' -Module '审计' -Title '文档承诺:孤儿归档会被点名' -Priority 'P1' `
-Precondition 'Backups3\ 里放一个清单中不存在的 GhostSoftware.7z' `
-Steps '备份后看输出是否点名' `
-Expected '输出里出现该孤儿归档名' `
-Actual ("退出码={0};点名={1}" -f $r.ExitCode, $named) `
-Pass $named -Severity '一般'
# ---------------------------------------------------------------- 汇总
$report = [ordered]@{
testedAt = (Get-Date).ToString('s')
scope = '文档契约黑盒测试(README 为准)'
sandbox = $sandbox
total = $cases.Count
passed = @($cases | Where-Object Status -eq 'PASS').Count
failed = @($cases | Where-Object Status -eq 'FAIL').Count
fatalOrSevere = @($cases | Where-Object { $_.Status -eq 'FAIL' -and $_.Severity -in '致命', '严重' }).Count
cases = $cases
}
$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutJson -Encoding utf8
$cases | Format-Table Id, Module, Priority, Status, Severity, Title -AutoSize
Write-Host ''
Write-Host ("合计 {0};通过 {1};失败 {2};致命/严重 {3}" -f $report.total, $report.passed, $report.failed, $report.fatalOrSevere)
Write-Host ("结果写入 {0}" -f $OutJson)
Write-Host ("沙盒(保留供排查): {0}" -f $sandbox)
+104
View File
@@ -0,0 +1,104 @@
{
"testedAt": "2026-09-28T12:43:39",
"total": 12,
"passed": 12,
"failed": 0,
"cases": [
{
"Id": "BB-01",
"Title": "真实备份:退出码 0 + 产出归档 + manifest",
"Expected": "退出码 0;≥1 个 .7z;manifest.json 存在",
"Actual": "退出码=0;归档=1",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-02",
"Title": "排除模式真的把内容挡在归档之外",
"Expected": "归档内无 skipme.log,有 a.txt/b.txt",
"Actual": "skipme=False;keep=True",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-03",
"Title": "-DryRun 一个字节都不写",
"Expected": "退出码 0;manifest SHA256 前后一致",
"Actual": "退出码=0;哈希一致=True",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-04",
"Title": "源不存在记 missing-source,不算失败",
"Expected": "含 missing-source;退出码 0",
"Actual": "actions=backed-up,missing-source;退出码=0",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-05",
"Title": "-Only 只处理匹配的条目",
"Expected": "退出码 0;输出不提未选中的 nonexistent",
"Actual": "退出码=0;提到 nonexistent=False",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-06",
"Title": "★显式指定的清单不存在 -> 必须报失败(本次修复点)",
"Expected": "退出码 ≠ 0;不创建模板;输出含\"指定的清单不存在\"",
"Actual": "退出码=1;误建模板=False;提示=True",
"Status": "PASS",
"Note": "阶段 1 原为 FAIL(退出码 0 且建了模板),本次修复后应转为 PASS"
},
{
"Id": "BB-06b",
"Title": "对照:未显式指定时首次运行仍建模板并退出 0",
"Expected": "退出码 0;模板被创建;随后仓库原文件已还原",
"Actual": "退出码=0;模板创建=True;仓库还原=True",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-07",
"Title": "旧名字垫片转发且退出码原样传递",
"Expected": "退出码 0;有改名提示",
"Actual": "退出码=0;提示=True",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-08",
"Title": "5.1 与 7.x 行为一致",
"Expected": "两个宿主退出码都是 0",
"Actual": "7=0;5.1=0",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-09",
"Title": "含 & 与 # 的路径(整行引号)",
"Expected": "退出码 0 且产出归档",
"Actual": "退出码=0;归档=1",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-10",
"Title": "无控制台且无 -InputScript 时报错退出,绝不挂起",
"Expected": "60 秒内退出且退出码 2",
"Actual": "已退出=True;退出码=2",
"Status": "PASS",
"Note": ""
},
{
"Id": "BB-11",
"Title": "★孤儿归档审计(改用真实运行)",
"Expected": "输出点名 GhostSoftware.7z",
"Actual": "退出码=0;点名=True",
"Status": "PASS",
"Note": "阶段 1 的 FAIL 系夹具错误:孤儿审计只在真实运行里报告,干跑不报"
}
]
}
+143
View File
@@ -0,0 +1,143 @@
{
"testedAt": "2026-09-28T12:35:02",
"scope": "文档契约黑盒测试(README 为准)",
"sandbox": "C:\\Users\\Shuery\\AppData\\Local\\Temp\\baknret-bb-45647a8d",
"total": 11,
"passed": 9,
"failed": 2,
"fatalOrSevere": 0,
"cases": [
{
"Id": "BB-01",
"Module": "备份",
"Title": "文档承诺:备份成功返回 0,并产出 .7z 归档",
"Priority": "P0",
"Precondition": "沙盒清单:1 个目录(含排除)+ 1 个不存在的源",
"Steps": "Backup-Data.ps1(无 -DryRun)",
"Expected": "退出码 0;Backups\\ 下出现 1 个 .7z;manifest.json 存在",
"Actual": "退出码=0;归档=1 个(srcdocs_from_C_+Users+Shuery+AppData+Local+Temp+baknret-bb-45647a8d.7z)",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-02",
"Module": "排除",
"Title": "文档承诺::- 排除模式把内容挡在归档之外",
"Priority": "P0",
"Precondition": "清单里对源目录写了 :- 'nested\\skipme.log'",
"Steps": "7z l 列出归档内容",
"Expected": "归档里**没有** skipme.log,但有 a.txt 与 b.txt",
"Actual": "skipme.log 命中=False;a/b.txt 命中=True",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-03",
"Module": "干跑",
"Title": "文档承诺:-DryRun 一个字节都不写(含 manifest.json)",
"Priority": "P0",
"Precondition": "已有 1 份归档与 manifest.json",
"Steps": "记录 SHA256 → 跑 -DryRun → 再记录 SHA256",
"Expected": "退出码 0;manifest.json 的 SHA256 前后完全一致",
"Actual": "退出码=0;哈希 6D726A618FD8 → 6D726A618FD8",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-04",
"Module": "异常流",
"Title": "文档承诺:源路径不存在记 missing-source,不算失败",
"Priority": "P1",
"Precondition": "清单第 2 行指向不存在的目录",
"Steps": "跑备份后读 manifest.json 的 action 字段",
"Expected": "该条目 action=missing-source,且整体退出码仍为 0",
"Actual": "退出码=0;manifest action 分布=backed-up,missing-source",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-05",
"Module": "干跑",
"Title": "文档承诺:-Only 只处理匹配的条目",
"Priority": "P1",
"Precondition": "清单 2 条:srcdocs(目录)、nonexistent(不存在)",
"Steps": "Backup-Data.ps1 -DryRun -Only 'srcdocs'",
"Expected": "退出码 0;输出里不出现未选中的 nonexistent 条目",
"Actual": "退出码=0;输出提到 nonexistent=False",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-06",
"Module": "异常流",
"Title": "边界值:清单文件不存在时的行为",
"Priority": "P1",
"Precondition": "传入一个不存在的 -BackupListPath",
"Steps": "Backup-Data.ps1 -BackupListPath <不存在>",
"Expected": "明确报错(非 0 退出码)或给出可读提示,**不得静默返回 0**",
"Actual": "退出码=0;末行=[2026-09-28 12:34:54] [INFO] 模板 BackupList.txt 已创建,请编辑后重试。",
"Status": "FAIL",
"Severity": "一般"
},
{
"Id": "BB-07",
"Module": "兼容",
"Title": "文档承诺:旧名字 Backup.ps1 是转发垫片,退出码原样传递",
"Priority": "P1",
"Precondition": "实现已改名为 Backup-Data.ps1",
"Steps": "Backup.ps1 -DryRun(旧名字)",
"Expected": "打印改名提示;退出码与直接调用一致(0)",
"Actual": "退出码=0;有改名提示=True",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-08",
"Module": "跨端一致性",
"Title": "文档承诺:Windows PowerShell 5.1 与 7.x 行为一致",
"Priority": "P0",
"Precondition": "同一沙盒、同一清单",
"Steps": "两个宿主各跑一次 -DryRun,比较退出码",
"Expected": "两者退出码都是 0(GBK 控制台下的中文输出不崩)",
"Actual": "7 退出码=0;5.1 退出码=0",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-09",
"Module": "边界值",
"Title": "特殊字符:含 & 与 # 的路径(整行引号写法)",
"Priority": "P2",
"Precondition": "源目录名含 & 与 #;清单行整体加引号",
"Steps": "备份该条目",
"Expected": "退出码 0 且真的产出归档(# 不被当注释吃掉)",
"Actual": "退出码=0;归档数=1",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-10",
"Module": "无头",
"Title": "文档承诺:没有控制台且没给 -InputScript 时报错退出,绝不挂起",
"Priority": "P0",
"Precondition": "stdout/stderr 被重定向(等价于计划任务/管道环境)",
"Steps": "不传参数直接运行 Manage-Backup.ps1,等待最多 60 秒",
"Expected": "60 秒内退出,退出码 2,并提示\"没有可用的控制台\"",
"Actual": "60 秒内退出=True;退出码=2;输出片段=û�п��õĿ���̨��Ҳû�и��� -InputScript���޷����뽻�����档\r\nҪ�ǽ���ִ�У���ָ�����������磺-Action Backup ",
"Status": "PASS",
"Severity": ""
},
{
"Id": "BB-11",
"Module": "审计",
"Title": "文档承诺:孤儿归档会被点名",
"Priority": "P1",
"Precondition": "Backups3\\ 里放一个清单中不存在的 GhostSoftware.7z",
"Steps": "备份后看输出是否点名",
"Expected": "输出里出现该孤儿归档名",
"Actual": "退出码=0;点名=False",
"Status": "FAIL",
"Severity": "一般"
}
]
}
+351
View File
@@ -0,0 +1,351 @@
# BakNRet CI/CD 流水线报告
- **报告时间**:2026-09-28
- **项目**:BakNRet(Windows 备份 / 恢复工具,PowerShell 5.1 + 7.x)
- **分支**:`refactor/ms-conventions`
- **测试环境**:Hyper-V VM `BakNRet-Lab`(Gen2 / 8 vCPU / 12 GB)+ 宿主 `STRIX-X870A`
- **流水线入口**:`/ci-cd-pipeline`(用户指定:测试环境为 Hyper-V 虚拟机)
> [!NOTE]
>
> **后续更新(2026-10-02)**:本报告里的两条许可证相关待办已关闭 —— 补 `LICENSE`
> (**Apache-2.0**,正文逐字采用 ASF 的 LICENSE-2.0.txt,仅填 APPENDIX 版权行)已落地,
> 对应许可证合规的 **L-1** 与依赖安全扫描的 **R-1**。报告正文保留为**流水线运行当时**的记录。
---
## 阶段总览
| 阶段 | 名称 | 状态 | 关键结果 |
| --- | --- | --- | --- |
| 0.1 | 依赖安全扫描 | ✅ 通过 | 零运行时依赖;**0 致命/严重**;2 条一般级卫生风险 |
| 0.2 | 许可证合规 | ⚠️ 通过(有缺口) | 无 copyleft 冲突;**主许可证未声明** |
| 0.3 | 语法检查与修复 | ✅ 通过 | 131/131 双宿主解析零错;修 2 处排版 + 1 处自伤;分析器 84→80 条、0 Error |
| 1 | 黑盒测试 | ✅ 通过 | 11 条用例:9 通过、2 失败(**0 致命/严重**) |
| 2 | 缺陷修复与回归 | ✅ 通过 | 修 1 个真实缺陷;回归 **12/12**;Pester 183→**192** 全绿 |
| 3 | 测试增强与性能 | ✅ 通过 | 性能基线首次建立、**无退化**;单测增强给出优先级、未擅自新增 |
| 4 | 代码规范化 | ✅ 通过 | 分析器格式规则门禁通过(0 Error);仓库自有格式门禁为准 |
| 5 | 文档生成与质量检查 | ✅ 通过 | `PROJECT_REPORT.md` 532 行;markdownlint **0 错**;死链 **0** |
| 6 | 数据库迁移审查 | ⏭️ **不适用** | 全仓无数据库、无 SQL、无迁移脚本 |
| 7 | 生产部署 | ⛔ **停在人工确认门** | 需你确认(见文末) |
| 8 | 收尾 | ✅ 完成 | 本报告 |
---
## 阶段 0:静态分析与修复
### 0.1 依赖安全扫描
| 项目 | 结论 |
| --- | --- |
| 依赖管理文件 | 不存在(无 `package.json` / `requirements.txt` / `go.mod` / `pom.xml` / `Cargo.toml`) |
| 运行时依赖 | **0 个模块**(只需 PowerShell 5.1 或 7.x + 7-Zip) |
| CVE | 无可报(没有任何被本仓库固定版本的第三方库) |
**风险**:R-1 无 `LICENSE`(一般)· R-2 工作区存在口令文件 `baknret.key`(一般,**未被 git 跟踪**,
`.gitignore:18 *.key` 已忽略;全程未读取其内容)· R-3 不检查 7z 版本(建议)·
R-4 口令经命令行传给 7z(建议,上游限制)。
### 0.2 许可证合规
| 组件 | 许可证 | 是否随分发 |
| --- | --- | --- |
| 本项目 | **未声明** | — |
| Pester 5.9.1 | Apache-2.0 | 否(`.tools/`,gitignore) |
| PSScriptAnalyzer 1.25.0 | MIT | 否 |
| Newtonsoft.Json | MIT | 否 |
| 7-Zip 26.03 | LGPL-2.1 + BSD-3 + unRAR 限制 | 否(用户自备) |
**无 copyleft 传染**。7-Zip 的 unRAR 限制只禁止「用其代码还原 RAR 压缩算法」,本项目不触发。
### 0.3 语法检查与自动修复
**修复的真实缺陷**
| 文件:行 | 规则 | 修复 |
| --- | --- | --- |
| `Backup.ps1:42` | `PSUseConsistentWhitespace` | `@($Rest)+ @(...)` → `@($Rest) + @(...)` |
| `Restore.ps1:42` | 同上 | 同上 |
**修复过程中的自伤与恢复(重要教训)**
编辑这两个文件后 **UTF-8 BOM 被抹掉**,导致 5.1 立即报 `The string is missing the terminator: '.`,
而 PowerShell 7 完全正常。用 `UTF8Encoding($true)` 重写后恢复,双宿主 `parse_bad=0`。
这条恰好实证了本仓库 Encode 层门禁的价值,也说明「编辑 PowerShell 源文件必须保留 BOM」是硬约束。
**未修复项**:80 条全部为风格类(0 Error),按用户决定登记为「仓库已声明的偏离」,逐条附依据:
行长 160 是配置里写明的有意偏离(`PSScriptAnalyzerSettings.psd1` + [ADR-0008](../docs/adr/0008-analyzer-deviations.md));
`PSPlaceCloseBrace` 等集中在 `Run-Tests.ps1` 的**测试夹具字符串**内(改了会改变断言语义);
`PSUseSupportsShouldProcess` 已全局排除属已知噪声;空 catch 是有意为之(注释已写明理由)。
**待人工确认的观察项**:`Write-BakNRetRunSummary` 的 `$Mode` 参数声明了 `ValidateSet` 与 `Mandatory`
但函数体内从未读取,且该函数未被仓库内任何代码调用(仅对外导出)。判断为未完工实现,
删除会破坏公共 API 参数契约,故**登记而不改动**。
---
## 阶段 1 → 2:黑盒测试与缺陷修复
### 测试环境确认(技能强制)
| 项目 | 事实 |
| --- | --- |
| 目标 | Hyper-V VM `BakNRet-Lab`(Gen2 / 8 vCPU / 12 GB / Default Switch 内部 NAT) |
| 检查点 | 自动检查点、**clean-baseline**、**sandbox-ready** |
| 数据隔离 | 仓库自述:全部操作在 VM 内进行,宿主机仓库 / `Backups\` / `logs\` 不被写入 |
| 生产暴露 | 无(无生产服务器、无域名、无对外服务) |
**已获用户明确确认**后开始测试。
### 用例与结果
| 编号 | 用例 | 阶段 1 | 阶段 2 回归 |
| --- | --- | --- | --- |
| BB-01 | 真实备份:退出码 0 + 产出归档 + manifest | PASS | PASS |
| BB-02 | 排除模式真的把内容挡在归档之外(逐个归档内容核对) | PASS | PASS |
| BB-03 | `-DryRun` 一个字节都不写(manifest SHA256 前后一致) | PASS | PASS |
| BB-04 | 源不存在记 `missing-source`,不算失败 | PASS | PASS |
| BB-05 | `-Only` 只处理匹配的条目 | PASS | PASS |
| BB-06 | **显式清单不存在 → 必须报失败** | **FAIL** | **PASS(已修)** |
| BB-06b | 对照:未显式指定时首次运行仍建模板 | — | PASS |
| BB-07 | 旧名字垫片转发且退出码原样传递 | PASS | PASS |
| BB-08 | 5.1 与 7.x 行为一致 | PASS | PASS |
| BB-09 | 含 `&` 与 `#` 的路径(整行引号写法) | PASS | PASS |
| BB-10 | 无控制台且无 `-InputScript` 时报错退出,绝不挂起 | PASS | PASS |
| BB-11 | 孤儿归档审计 | FAIL(夹具错误) | PASS |
- **阶段 1**:11 条 → 通过 9、失败 2、**致命/严重 0**
- **阶段 2 回归**:12 条 → **全部通过**
### 缺陷清单
| ID | 严重程度 | 标题 | 根因 | 修复 |
| --- | --- | --- | --- | --- |
| **DEF-01** | 一般 | 显式指定的清单不存在时**静默成功** | 「首次运行引导」与「路径写错」两条语义共用一条代码路径 | 按 `$PSBoundParameters.ContainsKey('BackupListPath')` 分流:显式指定 → ERROR + `exit 1`;未指定 → 保留引导 |
| DEF-02 | 提示 | 两处运算符前后缺空格 | 手写拼接 | 补空格(分析器该规则归零) |
| DEF-03 | 提示 | 孤儿审计在干跑下不报告 | **测试夹具错误**(非产品缺陷) | 修正夹具为真实运行 |
**DEF-01 的影响**:计划任务里 `-BackupListPath` 写错(或相对路径按了别的工作目录解析)时,
脚本会在错误位置凭空建一份模板并 **exit 0**;任务计划程序读到「上次运行结果 = 成功」,
而实际一个条目都没处理。这与本仓库已修过的「27 条被静默跳过、退出码仍是 0」是同一类缺陷,
因此判为需要修复。`Restore-Data.ps1` 有对称问题(生成清单 + exit 0),一并修复。
**DEF-03 的处置值得记录**:BB-11 初判 FAIL,复核后确认是**用例设计错了**(用了 `-DryRun`,
而孤儿审计只在真实运行里报告)。保留该记录,因为它说明「失败用例必须先复核再归因」,
否则会把测试自身的问题记到产品头上。
### 回归证据
```text
BakNRet 验收:层次 [Parse, Unit, Smoke, E2E],宿主 [7, 5.1]
[PASS] Encode any 受管 133 个文件:缺 BOM 0、CRLF 0、制表符 0
[PASS] Parse 7 解析通过 131/131 个文件
[PASS] Unit 7 / Smoke 7 / E2E 7
[PASS] Parse 5.1 解析通过 131/131 个文件
[PASS] Unit 5.1 / Smoke 5.1 / E2E 5.1
验收全部通过:9 项(宿主 7 + 5.1)
```
```text
Tests Passed: 192, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
[+] [回归] 显式指定的清单不存在时报失败(退出码 1),且不在错误位置建模板 1.43s
[+] [回归] 未显式指定清单时的首次运行仍建模板并退出 0(引导不能被误伤) 1.44s
```
新增的 2 条用例是**成对**的:只测一条的话,「把所有缺失都改成 exit 1」这种错误实现也能骗过测试。
---
## 阶段 3:测试增强与性能验证
### 单元测试增强(可选步骤)
| 指标 | 数值 |
| --- | --- |
| 对外函数 | 89(与 `FunctionsToExport` 白名单逐一核对一致) |
| 测试中直接点名 | 70(78.7%) |
| 未直接点名 | 19(其中多条由集成路径间接覆盖) |
**建议补测**(本轮未擅自新增,因用户选择了最小侵入姿态,且该步骤标注为可选):
`Move-BakNRetArchiveIntoPlace`(原子替换,历史上真出过「先删后移」缺陷)、
`Resolve-BakNRetRootedPath`(相对路径按仓库根解析这条计划任务关键约定)。
安全描述符相关的 4 个函数**不建议**补单元测试 —— 它们需要真实提权与真实 NTFS,
仓库已用 `tools/lab/Lab.ps1 acl-test`(含负对照)在真 VM 里覆盖,那是更正确的层次。
### 性能基线(首次建立)
环境:宿主 `STRIX-X870A`,PowerShell 7.7.0-preview.5,7-Zip 26.03 (x64)
| 指标 | 采样 | min | 中位数 | max |
| --- | --- | --- | --- | --- |
| 模块导入 | 5 | 628.3 ms | **631.0 ms** | 2052.8 ms |
| 清单解析 ×50 轮 | 5 | 2535.9 ms | **2546.9 ms** | 2691.5 ms |
| 只读干跑(真实 28 条清单) | 5 | 9839.9 ms | **9869.2 ms** | 12900.8 ms |
| 源树扫描(200 文件 ×3 轮) | 1 | — | **18.2 ms** | — |
7z 吞吐(固定夹具 200 × 32 KB **随机数据**):`-mx=0` 46.7 ms / `-mx=5` 224.2 ms / `-mx=9` 225.2 ms。
夹具为不可压缩数据,故体积不随级别变化 —— 该夹具测的是**吞吐与 I/O,不是压缩率**。
**判定**:首次基线,无历史可比,**无退化**。基线文件:`.scratch/ci-cd/20260928-001722/perf_baseline.json`。
跨机比较无意义,仅同机同宿主下对比。
---
## 阶段 4:代码规范化
- **仓库自有的格式门禁**是 `PSScriptAnalyzerSettings.psd1` 打开的 6 条格式规则
(括号 / 缩进 / 空格 / 对齐 / 大小写)+ 160 字符行长,通过 `tools/Invoke-Analyzer.ps1` 执行。
- 本次:**0 条 Error**,格式类告警从 4 条(whitespace)降到 **0 条**。
- PowerShell 没有官方「Google 风格」格式化器;本仓库的等价物就是上面这套规则集,
故阶段 4 以仓库门禁为准,未引入外部格式化器(避免制造与仓库约定冲突的大 diff)。
- `README.md` / `PROJECT_REPORT.md` 由 **Prettier 3.9.9** 格式化并通过 `--check`。
---
## 阶段 5:文档生成与质量检查
| 产物 | 规模 | 质量门禁 |
| --- | --- | --- |
| `README.md` | 925 行 | Prettier ✅;markdownlint 17 → **10 错(全为 MD051 假阳性)**;外部链接 11/11 → 200;内部相对链接全通;63 个标题锚点全部可解析;3 张 Mermaid 图在真实浏览器中解析通过 |
| `PROJECT_REPORT.md` | 532 行 | Prettier ✅;markdownlint **0 错**;相对链接 18/18 有效;外部链接 3/3 → 200 |
**MD051 假阳性说明**:README 的标题带 emoji(如 `## 🚀 快速开始`),GitHub 生成的锚点是
`#-快速开始`(剥离 emoji 后前导连字符)。`markdownlint-cli2` 的锚点生成器**不做 emoji 剥离**,
因此把这类链接报为无效。已用独立脚本按 GitHub 规则复算全部锚点,**63 个全部可解析**,
故不修改标题(emoji 标题是本次文档改造的要求之一)。
**死链检查**:全部外部链接逐个 `HEAD` 请求验证,返回 200;相对链接逐个 `Test-Path` 验证存在。
---
## 阶段 6:数据库迁移审查 —— 不适用
| 检查 | 结果 |
| --- | --- |
| `*.sql` / `*.db` / `*.sqlite` 文件 | 无 |
| `Invoke-Sqlcmd` / `SqlConnection` / `CREATE TABLE` / `SELECT ... FROM` | 无命中 |
| 迁移脚本目录 | 不存在 |
本项目是本地 CLI 工具,**不涉及任何数据库**,该阶段跳过。
---
## 阶段 7:生产部署 —— 停在人工确认门
> [!CAUTION]
>
> **未执行任何部署动作。** 本项目的「生产部署」= 在真实机器上注册计划任务并让它按计划跑备份,
> 属于对用户个人环境产生持久副作用的操作,必须由你确认。
### 部署前状态
| 项目 | 状态 |
| --- | --- |
| 代码门禁 | ✅ 9/9(双宿主)· Pester 192/192 · 黑盒 12/12 |
| 工作树 | 6 个文件已改(+796 / −202),新增 `PROJECT_REPORT.md`(未提交) |
| 运行前提 | PowerShell 5.1/7.x ✅ · 7-Zip 26.03 ✅ |
| 阻塞项 | 会话审批策略为 `never`,`gsudo` 提权被自动拒绝(退出码 999);注册计划任务需要管理员 |
### 建议的部署步骤(待你确认后执行)
```powershell
# 1. 先确认清单与口令就位(口令应在仓库外)
.\Backup-Data.ps1 -DryRun # 只读,先看计划与空间预估
.\Backup-Data.ps1 -Only 'Edge' # 先只备一项,验证端到端
.\Restore-Data.ps1 -VerifyOnly # 只校验,不解压
# 2. 注册计划任务(需要管理员)
.\tools\Register-BackupTask.ps1 -At '21:30' -DryRun # 先看将要注册什么
.\tools\Register-BackupTask.ps1 -At '21:30' # 确认后注册
# 3. 部署后验证
Get-ScheduledTaskInfo -TaskName 'BakNRet Backup' # 上次运行结果
Start-ScheduledTask -TaskName 'BakNRet Backup' # 手动跑一次
```
### 回滚
```powershell
.\tools\Register-BackupTask.ps1 -Remove
```
**风险**:注册计划任务本身可逆(`-Remove`);但**首次真实备份会写入归档目录并消耗磁盘**
(README 已有空间预估与逐条目守卫,不够时只告警不中断)。
---
## 阶段 8:关键指标与建议
### 关键指标
| 指标 | 数值 |
| --- | --- |
| 验收门禁 | **9/9 PASS**(PS 7.7.0-preview.5 + 5.1.26100.9502 各一遍) |
| 解析 | **131/131** 文件双宿主零错 |
| Pester | **192 通过 / 0 失败 / 0 跳过**(183 → 补 2 条回归用例后 185 → 再补 7 条后 192) |
| 黑盒回归 | **12/12** |
| 静态分析 | **80 条 / 0 Error**(修复前 84) |
| 运行时依赖 | **0** |
| 致命 / 严重缺陷 | **0** |
| 修复的真实缺陷 | **1**(DEF-01)+ 2 处排版 |
| 代码变更 | 6 文件,+796 / −202 |
| 文档产物 | `README.md` 925 行 · `PROJECT_REPORT.md` 1004 行 |
### 后续轮次已执行(用户回复「继续」之后)
| 事项 | 结果 |
| --- | --- |
| 补单元测试(原建议 2 条) | ✅ 实际补 **7 条**断言:原子替换 3 条(含 AST 判定 `File.Replace` 与 `[NullString]::Value`)、路径解析 4 条(含 AST 判定「模块里不得引用 `$PSScriptRoot`」)。Pester 185 → **192**,0 失败 |
| 修正 README 的零依赖套件条数 | ✅ 实测为 **128 项**(原写 111,已改正)。这是上一轮 README 重写留下的**事实错误**,由 PROJECT_REPORT 的核对暴露出来 |
| 复核 PROJECT_REPORT.md 的数字 | ✅ 行数口径标注为「**非空行**」;Pester 计数更新为 192;**修正一处过度断言**(原文称「修复后代码在 VM 内验证可用」,实际只取到修复前的 Pester 快照) |
| 补 LICENSE / 移出 `baknret.key` / 确认 `$Mode` 意图 | ⏸️ 仍需你决定 |
> **指标口径说明(本次踩到的坑)**:工程规模按**非空行**统计是 **14389 行**,按**含空行**统计是 **16805 行**。
> 两者都对,但必须写明口径。本次就因为口径不同(我用含空行、报告用非空行),一度把一份**正确的**数字
> 误判为错误,直到逐目录复算(模块 4369 非空行在 HEAD 与工作树上完全一致)才定位到是口径差异。
> 报告里凡出现行数处已统一标注口径。
### 建议(按优先级)
| 优先级 | 事项 | 理由 |
| --- | --- | --- |
| ~~高~~ ✅ **已完成 2026-10-02** | 补 `LICENSE` | 已补 **Apache-2.0**(逐字采用 ASF 原文,仅填 APPENDIX 版权行) |
| ~~高~~ ✅ **已完成 2026-10-02** | 提交并同步到云端 | 3 个提交已推送到 `origin/main`(快进,非 force) |
| 中 | 把 `baknret.key` 移出仓库目录 | 虽未被跟踪,工作区放口令文件是卫生问题 |
| 中 | 确认 `Write-BakNRetRunSummary` 的 `$Mode` 意图 | 未使用参数 + 未被调用 |
| 低 | 在 VM 内补跑修复后的三套件 | 本次因提权被禁未取得(宿主侧已全绿) |
| 低 | 启动时打印 7-Zip 版本 | 便于排障 |
### 本次流水线的诚实边界
1. **VM 测试未跑全**:会话审批策略改为 `never` 后 `gsudo` 提权被自动拒绝,而 Hyper-V 与
PowerShell Direct 都需要管理员。**已核实的部分**(同步成功、VM 内 Pester 183 通过)如实记录;
零依赖与端到端套件在 VM 内的结果**本次未取得**,不做推断。宿主侧同等套件已全部通过。
2. **静态分析未压到 0**:剩余 80 条与仓库**已声明的偏离**冲突,按你的决定登记而不改,
逐条附依据。严格模式的「零容忍」在这里与仓库自身配置相抵,选择尊重仓库约定。
3. **阶段 7 未执行**:等你在下一轮明确确认。
---
## 附件索引
所有中间产物在 `.scratch/ci-cd/`:
| 文件 | 内容 |
| --- | --- |
| `20260928-001722/dependency_security_report.md` | 依赖安全扫描报告 |
| `20260928-001722/license_check_report.md` | 许可证合规报告 |
| `20260928-001722/stage0_static_analysis.md` | 阶段 0 完整报告(含 BOM 自伤记录) |
| `20260928-001722/stage3_tests_and_perf.md` | 阶段 3 报告(覆盖率分析 + 性能基线) |
| `20260928-001722/analyzer_raw.txt` | 修复前静态分析原始输出(84 条) |
| `20260928-001722/analyzer_after.txt` | 修复后静态分析输出(80 条) |
| `20260928-001722/pester_after_fix.txt` | 修复后 Pester 详细输出(185 通过;本轮补测后 192) |
| `20260928-001722/perf_baseline.json` | 性能基线数据 |
| `20260928-001722/blackbox_results.json` | 阶段 1 黑盒结果(11 条) |
| `20260928-001722/blackbox_regression.json` | 阶段 2 回归结果(12 条) |
| `blackbox-tests.ps1` | 黑盒用例脚本(可重跑) |
| `blackbox-regression.ps1` | 回归脚本(可重跑) |
| `perf-baseline.ps1` | 性能基线脚本(可重跑) |
| `../_verify/mermaid.png` | README 三张 Mermaid 图的浏览器渲染截图 |
| `../_verify/report_mermaid.png` | PROJECT_REPORT 四张图的渲染截图(本次独立复验;与 `20260928-001722/report_mermaid.png` 是**两次独立渲染**,字节不同属正常) |
| `20260928-001722/report_mermaid.png` | 同上四张图,由阶段 5 的报告生成方独立渲染并留证 |
+139
View File
@@ -0,0 +1,139 @@
# 宿主性能基线:对 BakNRet 的关键路径做可复现的计时。
# 设计原则:所有指标都是"比值"或"固定夹具下的绝对耗时",可以在同一台机器上重复对比。
param(
[string]$OutJson = '.scratch\ci-cd\20260928-001722\perf_baseline.json',
[int]$Repeat = 5,
[string]$Pwsh = 'pwsh'
)
$ErrorActionPreference = 'Stop'
$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$modulePath = Join-Path $root 'BakNRet\BakNRet.psd1'
$listPath = Join-Path $root 'BackupList.txt'
$cfgPath = Join-Path $root 'BackupConfig.psd1'
function Get-Median([double[]]$Values) {
$s = $Values | Sort-Object
$n = $s.Count
if ($n -eq 0) { return 0 }
if ($n % 2 -eq 1) { return $s[[int](($n - 1) / 2)] }
return ($s[$n / 2 - 1] + $s[$n / 2]) / 2
}
function Measure-Once([scriptblock]$Body) {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
& $Body | Out-Null
$sw.Stop()
return $sw.Elapsed.TotalMilliseconds
}
$results = [ordered]@{
measuredAt = (Get-Date).ToString('s')
machine = $env:COMPUTERNAME
os = [System.Environment]::OSVersion.VersionString
psi = $PSVersionTable.PSVersion.ToString()
cpu = (Get-CimInstance Win32_Processor | Select-Object -First 1 -ExpandProperty Name)
logicalCpu = [int](Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors
workdir = $root
metrics = @{}
notes = @()
}
# ---------------------------------------------------------------- 夹具(固定内容,避免与真实数据联动)
$fixture = Join-Path $env:TEMP ('baknret-perf-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $fixture -Force | Out-Null
$srcDir = Join-Path $fixture 'src'
New-Item -ItemType Directory -Path $srcDir -Force | Out-Null
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$rnd = [Random]::new(20260928)
$payload = New-Object byte[] 32768
for ($f = 0; $f -lt 200; $f++) {
$sub = Join-Path $srcDir ('d{0:D2}' -f ($f % 10))
if (-not (Test-Path $sub)) { New-Item -ItemType Directory -Path $sub -Force | Out-Null }
$rnd.NextBytes($payload)
[System.IO.File]::WriteAllBytes((Join-Path $sub ("f$f.bin")), $payload)
}
$sw.Stop()
$results.fixture = [ordered]@{ files = 200; bytes = 200 * 32768; createMs = [math]::Round($sw.Elapsed.TotalMilliseconds, 1) }
# ---------------------------------------------------------------- 1. 模块导入
$imports = 1..$Repeat | ForEach-Object {
Measure-Once { & $Pwsh -NoProfile -Command "Import-Module '$modulePath' -Force" }
}
$results.metrics.moduleImportMs = [ordered]@{
min = [math]::Round(($imports | Measure-Object -Minimum).Minimum, 1)
median = [math]::Round((Get-Median $imports), 1)
max = [math]::Round(($imports | Measure-Object -Maximum).Maximum, 1)
samples = $Repeat
}
# ---------------------------------------------------------------- 2. 清单解析
$parseScript = @"
Import-Module '$modulePath' -Force
1..50 | ForEach-Object { Get-Content -LiteralPath '$listPath' | ForEach-Object { ConvertFrom-BackupListLine -Line `$_ } }
"@
$parseFile = Join-Path $fixture 'parse.ps1'
Set-Content -LiteralPath $parseFile -Value $parseScript -Encoding utf8
$parses = 1..$Repeat | ForEach-Object { Measure-Once { & $Pwsh -NoProfile -File $parseFile } }
$results.metrics.backupListParse50xMs = [ordered]@{
min = [math]::Round(($parses | Measure-Object -Minimum).Minimum, 1)
median = [math]::Round((Get-Median $parses), 1)
max = [math]::Round(($parses | Measure-Object -Maximum).Maximum, 1)
samples = $Repeat
}
# ---------------------------------------------------------------- 3. 只读干跑(真实清单 + 空间预估 + manifest)
$dryFile = Join-Path $fixture 'dry.ps1'
Set-Content -LiteralPath $dryFile -Encoding utf8 -Value @"
Set-Location '$root'
& '$root\Backup-Data.ps1' -DryRun -BackupDir '$fixture\Backups' -ConfigPath '$cfgPath' *> `$null
exit `$LASTEXITCODE
"@
$dries = 1..$Repeat | ForEach-Object { Measure-Once { & $Pwsh -NoProfile -File $dryFile } }
$results.metrics.dryRunFullListMs = [ordered]@{
min = [math]::Round(($dries | Measure-Object -Minimum).Minimum, 1)
median = [math]::Round((Get-Median $dries), 1)
max = [math]::Round(($dries | Measure-Object -Maximum).Maximum, 1)
samples = $Repeat
}
# ---------------------------------------------------------------- 4. 7z 压缩吞吐(固定夹具,无压缩)
$sevenZip = (Get-Command 7z -ErrorAction SilentlyContinue).Source
if (-not $sevenZip) {
foreach ($p in 'C:\Programs\Scoop\shims\7z.exe', 'C:\Program Files\7-Zip\7z.exe') { if (Test-Path $p) { $sevenZip = $p; break } }
}
if ($sevenZip) {
$archive = Join-Path $fixture 'bench.7z'
$bench = @()
foreach ($lvl in 0, 5, 9) {
if (Test-Path $archive) { Remove-Item $archive -Force }
$ms = Measure-Once { & $sevenZip a -t7z "-mx=$lvl" -bso0 -bsp0 $archive (Join-Path $srcDir '*') }
$bench += [pscustomobject]@{ level = $lvl; ms = [math]::Round($ms, 1); archiveBytes = (Get-Item $archive).Length }
}
$results.metrics.sevenZipBench = @($bench)
$results.fixture.compressionInputBytes = 200 * 32768
$results.metrics.sevenZipVersion = (& $sevenZip | Select-Object -First 2 | Select-Object -Last 1)
}
else {
$results.notes += '找不到 7z.exe,跳过压缩基准'
}
# ---------------------------------------------------------------- 5. 源树扫描(Get-BakNRetFolderSummary 走真实目录)
$summaryScript = @"
Import-Module '$modulePath' -Force
`$sw = [System.Diagnostics.Stopwatch]::StartNew()
1..3 | ForEach-Object { Get-BakNRetFolderSummary -FolderPath '$srcDir' | Out-Null }
`$sw.Stop()
Write-Output ([math]::Round(`$sw.Elapsed.TotalMilliseconds / 3, 1))
"@
$sumFile = Join-Path $fixture 'summary.ps1'
Set-Content -LiteralPath $sumFile -Value $summaryScript -Encoding utf8
$sumMs = (& $Pwsh -NoProfile -File $sumFile | Select-Object -Last 1)
$results.metrics.folderSummary200Files3xMs = [double]$sumMs
Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue
$results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $root $OutJson) -Encoding utf8
Write-Host ('基线已写入 {0}' -f $OutJson)
$results.metrics | ConvertTo-Json -Depth 6
+15
View File
@@ -134,6 +134,21 @@ if (-not (Test-Path -LiteralPath $BackupDir)) {
} }
if (-not (Test-Path -LiteralPath $BackupListPath)) { if (-not (Test-Path -LiteralPath $BackupListPath)) {
# 清单缺失有两条语义完全不同的路:
#
# ① 首次运行(没传 -BackupListPath,用的是仓库自带的那份)-> 建一份模板,退出 0。
# 这是「开箱即用」的引导,**不是失败**。
# ② 显式传了 -BackupListPath 却不存在 -> 是调用方的错误(路径写错、计划任务里
# 的相对路径按了别的工作目录解析)。此时绝不能建模板就退出 0:计划任务会读到
# 「上次运行结果 = 成功」,而实际上一个条目都没处理 —— 这个仓库刚把
# 「27 条被静默跳过、退出码仍是 0」当作一类缺陷修过,这条是同一类。
if ($PSBoundParameters.ContainsKey('BackupListPath')) {
Write-BakNRetLog ("指定的清单不存在:{0}" -f $BackupListPath) -Level ERROR
Write-BakNRetLog '显式指定 -BackupListPath 时不会自动创建模板:请检查路径是否写错;要生成一份起步模板,就去掉该参数。' -Level ERROR
Stop-BakNRetLog
exit 1
}
$template = "# BackupList.txt`n" + $template = "# BackupList.txt`n" +
"# 语法: [+|-] <软件名 或 绝对路径> [:: <路径>] [:- <排除模式>[,<排除模式>...]] [:+ <追加项>[,<追加项>...]] [:encrypt | :!encrypt] [@ <Key>='<值>'] [# 说明]`n" + "# 语法: [+|-] <软件名 或 绝对路径> [:: <路径>] [:- <排除模式>[,<排除模式>...]] [:+ <追加项>[,<追加项>...]] [:encrypt | :!encrypt] [@ <Key>='<值>'] [# 说明]`n" +
"# 示例: Edge`n" + "# 示例: Edge`n" +
+1 -1
View File
@@ -39,7 +39,7 @@ $target = Join-Path $PSScriptRoot 'Backup-Data.ps1'
Write-Host '注意:Backup.ps1 已改名为 Backup-Data.ps1(这层转发只保留一轮)。' -ForegroundColor Yellow Write-Host '注意:Backup.ps1 已改名为 Backup-Data.ps1(这层转发只保留一轮)。' -ForegroundColor Yellow
$hostExe = (Get-Process -Id $PID).Path $hostExe = (Get-Process -Id $PID).Path
$forwardArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $target) + @($Rest)+ @(if ($PSBoundParameters.ContainsKey('Verbose')) { '-Verbose' })+ @(if ($PSBoundParameters.ContainsKey('Debug')) { '-Debug' }) $forwardArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $target) + @($Rest) + @(if ($PSBoundParameters.ContainsKey('Verbose')) { '-Verbose' }) + @(if ($PSBoundParameters.ContainsKey('Debug')) { '-Debug' })
$startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $hostExe $startInfo.FileName = $hostExe
+12 -1
View File
@@ -19,6 +19,13 @@
CompatiblePSEditions = @('Desktop', 'Core') CompatiblePSEditions = @('Desktop', 'Core')
FunctionsToExport = @( FunctionsToExport = @(
'Invoke-BakNRetCatalogEditor',
'Get-BakNRetCatalogField',
'Set-BakNRetCatalogField',
'Invoke-BakNRetConfigEditor',
'Read-BakNRetLine',
'Get-BakNRetConfigSetting',
'Set-BakNRetConfigSetting',
'Invoke-BakNRetEntryScript', 'Invoke-BakNRetEntryScript',
'Invoke-BakNRetBackupListEditor', 'Invoke-BakNRetBackupListEditor',
'Set-BakNRetBackupListDirection', 'Set-BakNRetBackupListDirection',
@@ -107,7 +114,11 @@
AliasesToExport = @() AliasesToExport = @()
PrivateData = @{ PrivateData = @{
PSData = @{ Tags = @('Backup', 'Restore', '7zip', 'Windows', 'PSEdition_Desktop', 'PSEdition_Core') } PSData = @{
Tags = @('Backup', 'Restore', '7zip', 'Windows', 'PSEdition_Desktop', 'PSEdition_Core')
LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0'
Copyright = 'Copyright 2026 Shuery'
}
} }
} }
+19
View File
@@ -168,7 +168,26 @@
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetEntryScript.ps1') . (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetEntryScript.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetConfigSetting.ps1')
. (Join-Path $PSScriptRoot 'Public\Set-BakNRetConfigSetting.ps1')
. (Join-Path $PSScriptRoot 'Public\Read-BakNRetLine.ps1')
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetConfigEditor.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BakNRetCatalogField.ps1')
. (Join-Path $PSScriptRoot 'Public\Set-BakNRetCatalogField.ps1')
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetCatalogEditor.ps1')
Export-ModuleMember -Function @( Export-ModuleMember -Function @(
'Invoke-BakNRetCatalogEditor',
'Get-BakNRetCatalogField',
'Set-BakNRetCatalogField',
'Invoke-BakNRetConfigEditor',
'Read-BakNRetLine',
'Get-BakNRetConfigSetting',
'Set-BakNRetConfigSetting',
'Invoke-BakNRetEntryScript', 'Invoke-BakNRetEntryScript',
'Invoke-BakNRetBackupListEditor', 'Invoke-BakNRetBackupListEditor',
'Set-BakNRetBackupListDirection', 'Set-BakNRetBackupListDirection',
@@ -0,0 +1,60 @@
function Get-BakNRetCatalogField {
<#
.SYNOPSIS
把名录文本拆成"Slot 级字段"的表:行号、软件名、Slot 名、字段名、原文值、是否可编辑。
.DESCRIPTION
可编辑面按**能不能安全往返**来定(ADR-0013):
* `Encrypt` / `Description`,且值在**一行之内** → 可编辑(真实名录里是 19 + 24 个);
* `Path` / `Exclude` / `Include` → 一律只读:它们常带 `$( )` 动态表达式(Scoop 那三条
的 `$(if ($env:SCOOP_GLOBAL) { ... })`)与**跨行字符串拼接**(Edge 的 Exclude),
"改一行"对它们没有明确含义;
* `Description` 也可能是跨行的(Edge 那条多行说明)→ 那种同样只读。
文件头部的注释模板里有 `SoftWareName = @{ ... }` 这样的**示例**:纯文本扫描会把它当成一个
软件,所以扫描时显式跳过块注释里的内容。注意本文档里**不能**写那对块注释符号的闭合形式:
它会提前结束上面这段注释(实测踩到:后半句变成了代码,而它是合法语法 —— 等于调用一个
同名命令,所以解析层抓不到,只有跑到那一步才炸)。
#>
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text)
$lines = $Text -split "`r?`n"
$inComment = $false
$app = ''
$slot = ''
$rows = @()
for ($index = 0; $index -lt $lines.Count; $index++) {
$line = $lines[$index]
$trimmed = $line.Trim()
# 块注释:整段跳过(否则模板里的示例会被当成真条目)
if ($trimmed.StartsWith('<#')) { $inComment = $true; continue }
if ($trimmed.StartsWith('#>')) { $inComment = $false; continue }
if ($inComment) { continue }
if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue }
if ($line -match '^ ([\w''\-]+)\s*=\s*@\{\s*$') { $app = $Matches[1]; $slot = ''; continue }
if ($line -match '^ ([\w''\-]+)\s*=\s*@\{\s*$') { $slot = $Matches[1]; continue }
if ($line -match '^\s{8}\}') { $slot = ''; continue }
if ($line -match '^\s{4}\}') { $app = ''; continue }
if ($slot -and $line -match '^\s{12}([\w]+)\s*=\s*(.+)$') {
$key = $Matches[1]
$value = $Matches[2]
$singleLine = $value -match "^('.*'|(\`$true|\`$false))\s*$"
$rows += [pscustomobject]@{
Line = $index + 1
App = $app
Slot = $slot
Key = $key
Value = $value
Editable = (($key -eq 'Encrypt' -or $key -eq 'Description') -and $singleLine)
Raw = $line
}
}
}
return @($rows)
}
@@ -0,0 +1,67 @@
function Get-BakNRetConfigSetting {
<#
.SYNOPSIS
把配置文本拆成"一行一个标量设置"的表:行号、点号路径、键、原值、行尾注释。
.DESCRIPTION
为什么只认**单行标量**:这份配置文件里还有三处嵌套哈希、一处数组、一处行内空哈希
(SidMap = @{})与大量多行注释。外科式改写(ADR-0013)只碰"一行就是一个值"的那些 ——
值跨行或本身是集合时,"改一行"这个动作没有明确含义,宁可不在界面上提供(如实显示为只读)。
路径用点号拼(Snapshot.Enabled):同名键在不同嵌套里会出现(Enabled 就有两处),
只用键名会把它们混成一个。
`Raw` 是原始行文本:编辑时若没动这一行,就写回原样,键名与对齐的空格一字节都不变。
#>
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text)
$lines = $Text -split "`r?`n"
$stack = New-Object System.Collections.Generic.List[string]
$rows = @()
for ($index = 0; $index -lt $lines.Count; $index++) {
$line = $lines[$index]
$trimmed = $line.Trim()
if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue }
# 进入一层嵌套哈希
if ($trimmed -match '^([\w'']+)\s*=\s*@\{\s*$') {
$stack.Add($Matches[1])
continue
}
# 离开一层:闭括号可能带后续内容,只关心它是否以 } 开头
if ($trimmed.StartsWith('}')) {
if ($stack.Count -gt 0) { $stack.RemoveAt($stack.Count - 1) }
continue
}
# 单行标量:有 = ,且值不以 @ 开头(集合与嵌套不是标量)
if ($trimmed -match '^([\w'']+)\s*=\s*(.+)$') {
$key = $Matches[1]
$rest = $Matches[2]
if ($rest.StartsWith('@')) { continue }
$value = $rest
$comment = ''
$commentIndex = $rest.IndexOf(' #')
if ($commentIndex -ge 0) {
$value = $rest.Substring(0, $commentIndex).Trim()
$comment = $rest.Substring($commentIndex + 1).Trim()
}
$path = if ($stack.Count -gt 0) { ($stack -join '.') + '.' + $key } else { $key }
$rows += [pscustomobject]@{
Line = $index + 1
Path = $path
Key = $key
Value = $value
Comment = $comment
Raw = $line
}
}
}
return @($rows)
}
@@ -0,0 +1,92 @@
function Invoke-BakNRetCatalogEditor {
<#
.SYNOPSIS
名录编辑器:列出**可编辑**的 Slot 字段 → 选一个 → 输入新值 → 校验后保存(并留时间戳备份)。
.DESCRIPTION
装配:Get-BakNRetCatalogField(读)→ 菜单选字段 → Read-BakNRetLine(输入新值)
→ Set-BakNRetCatalogField(只改那一行)→ Save-BakNRetConfigFile(校验通过才原子替换)。
菜单里**只列可编辑的字段**(单行的 Encrypt / Description):把只读的 Path / Exclude 也列出来
再拒绝,会让每个只读项浪费用户一次回车。可编辑面按"能不能安全往返"定,见 Get-BakNRetCatalogField。
校验用模块自己的 Import-BaknretDataFile:这份名录里有 `$( )` 动态表达式与跨行拼接,普通的数据
文件读取器读不了 —— 而正是它保证"改完之后整份文件还能被程序读回来"(值能读回来才算改成功)。
定位用 (软件名, Slot 名, 字段名) 三元组(菜单键里用 | 拼起来),因为同一软件可以有多个 Slot。
取消(选字段或输入时按 Esc)→ 什么都不写、也不产生备份。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)]$Driver,
[switch]$WhatIf
)
if (-not (Test-Path -LiteralPath $Path)) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = "找不到名录文件:$Path"; BackupPath = $null }
}
$text = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
$fields = @(Get-BakNRetCatalogField -Text $text | Where-Object { $_.Editable })
if ($fields.Count -eq 0) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = '名录里没有可编辑的字段(只有单行的 Encrypt / Description 可改)。'; BackupPath = $null }
}
$items = @(foreach ($field in $fields) {
[pscustomobject]@{
Key = ('{0}|{1}|{2}' -f $field.App, $field.Slot, $field.Key)
Text = ('{0,-20} {1,-12} = {2}' -f $field.App, $field.Key, $field.Value)
}
})
$picked = Invoke-BakNRetMenu -Title ('名录:{0}' -f $Path) -Items $items -Driver $Driver -MultiSelect $false -Hint '↑↓ 选字段 回车 改值 Esc 取消'
if ($picked.Action -ne 'confirm') {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ''; BackupPath = $null }
}
$parts = @([string]$picked.Chosen.Key -split '\|')
if ($parts.Count -ne 3) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ('菜单项的形状不对:{0}' -f $picked.Chosen.Key); BackupPath = $null }
}
$field = $fields | Where-Object { $_.App -eq $parts[0] -and $_.Slot -eq $parts[1] -and $_.Key -eq $parts[2] } | Select-Object -First 1
Write-BakNRetAt -X 0 -Y 0 -Text ("{0}.{1}.{2} 当前是 {3};输入新值后回车(Encrypt 用 `$true / `$false,Description 用带引号的字符串),Esc 取消:" -f $parts[0], $parts[1], $parts[2], $field.Value) -ForegroundColor Cyan
$newValue = Read-BakNRetLine -Driver $Driver
if ($null -eq $newValue) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ''; BackupPath = $null }
}
if ($newValue -eq $field.Value) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = '值没有变化,没有要保存的内容。'; BackupPath = $null }
}
$newText = Set-BakNRetCatalogField -Text $text -App $parts[0] -Slot $parts[1] -Key $parts[2] -Value $newValue
$validate = {
param($candidate)
$probePath = Join-Path ([System.IO.Path]::GetTempPath()) ('bnr-cat-' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.psd1')
try {
[System.IO.File]::WriteAllText($probePath, $candidate, (New-Object System.Text.UTF8Encoding($true)))
$null = Import-BaknretDataFile -Path $probePath
return @()
}
catch {
return @('改完之后读不回来:' + $_.Exception.Message)
}
finally {
Remove-Item -LiteralPath $probePath -Force -ErrorAction SilentlyContinue
}
}
$saved = Save-BakNRetConfigFile -Path $Path -Text $newText -Validate $validate `
-BackupDirectory (Join-Path (Split-Path -Parent $Path) 'logs\config-backups') -WhatIf:$WhatIf
return [pscustomobject]@{
Saved = $saved.Saved
Changed = $true
Error = $saved.Error
BackupPath = $saved.BackupPath
}
}
@@ -0,0 +1,85 @@
function Invoke-BakNRetConfigEditor {
<#
.SYNOPSIS
配置编辑器:列出单行标量设置 → 选一个 → 输入新值 → 校验后保存(并留时间戳备份)。
.DESCRIPTION
装配:Get-BakNRetConfigSetting(读)→ Invoke-BakNRetMenu(选)→ Read-BakNRetLine(输入)
→ Set-BakNRetConfigSetting(只改那一行)→ Save-BakNRetConfigFile(校验通过才原子替换)。
输入行**从空开始**、当前值只作为提示显示:预填当前值看着贴心,实际上等于逼用户先删一遍
(对 `'Backups'` 这种带引号的原文尤其别扭)。
校验用 Import-PowerShellDataFile:这份配置里没有动态表达式,能被它读进来是个**强校验**
—— 比"语法能不能过"更强,它还能抓出"值是合法语法、但结构不对"。它只接受文件路径,所以
先把候选文本写到临时文件再读。
取消(选设置或输入时按 Esc)→ 什么都不写、也不产生备份。
值没变 → 直接返回"没有变化",不做无意义的写盘与备份。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)]$Driver,
[switch]$WhatIf
)
if (-not (Test-Path -LiteralPath $Path)) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = "找不到配置文件:$Path"; BackupPath = $null }
}
$text = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
$rows = @(Get-BakNRetConfigSetting -Text $text)
if ($rows.Count -eq 0) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = '配置里没有可编辑的单行标量设置。'; BackupPath = $null }
}
$items = @(foreach ($row in $rows) {
[pscustomobject]@{ Key = $row.Path; Text = ('{0,-26} = {1}' -f $row.Path, $row.Value) }
})
$picked = Invoke-BakNRetMenu -Title ('配置:{0}' -f $Path) -Items $items -Driver $Driver -MultiSelect $false -Hint '↑↓ 选设置 回车 改值 Esc 取消'
if ($picked.Action -ne 'confirm') {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ''; BackupPath = $null }
}
$setting = $rows | Where-Object { $_.Path -eq [string]$picked.Chosen.Key } | Select-Object -First 1
Write-BakNRetAt -X 0 -Y 0 -Text ("{0} 当前是 {1};输入新值后回车确认,Esc 取消(留空表示空串):" -f $setting.Path, $setting.Value) -ForegroundColor Cyan
$newValue = Read-BakNRetLine -Driver $Driver
if ($null -eq $newValue) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ''; BackupPath = $null }
}
if ($newValue -eq $setting.Value) {
return [pscustomobject]@{ Saved = $false; Changed = $false; Error = '值没有变化,没有要保存的内容。'; BackupPath = $null }
}
$newText = Set-BakNRetConfigSetting -Text $text -Path $setting.Path -Value $newValue
$validate = {
param($candidate)
$probePath = Join-Path ([System.IO.Path]::GetTempPath()) ('bnr-cfg-' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.psd1')
try {
[System.IO.File]::WriteAllText($probePath, $candidate, (New-Object System.Text.UTF8Encoding($true)))
$null = Import-PowerShellDataFile -Path $probePath -ErrorAction Stop
return @()
}
catch {
return @('改完之后读不回来:' + $_.Exception.Message)
}
finally {
Remove-Item -LiteralPath $probePath -Force -ErrorAction SilentlyContinue
}
}
$saved = Save-BakNRetConfigFile -Path $Path -Text $newText -Validate $validate `
-BackupDirectory (Join-Path (Split-Path -Parent $Path) 'logs\config-backups') -WhatIf:$WhatIf
return [pscustomobject]@{
Saved = $saved.Saved
Changed = $true
Error = $saved.Error
BackupPath = $saved.BackupPath
}
}
+38
View File
@@ -0,0 +1,38 @@
function Read-BakNRetLine {
<#
.SYNOPSIS
读一行文本:字符追加、Backspace 删一个、Enter 确认、Esc 取消。
.DESCRIPTION
为什么需要它、而不用 `Read-Host`:TUI 里所有输入都走同一个 `-Driver`,门禁才能用
`-InputScript` 把"输入一个值"这一步也驱动起来 —— `Read-Host` 无法脚本驱动,它会在门禁里
**挂住**,而挂起比变红糟得多。
为什么 Esc 返回 `$null` 而不是空串:空串是"把值改成空"这个**合法意图**,取消是另一个意思,
两者必须能区分 —— 否则调用方会把"用户取消"当成"用户要清空这个值"。
其它键(方向键、空格等)一律忽略:在输入行里按方向键不该有副作用。
#>
param(
[Parameter(Mandatory = $true)]$Driver,
[AllowEmptyString()][string]$Initial = ''
)
$buffer = $Initial
while ($true) {
$key = Read-BakNRetKey -Driver $Driver
if ($key -eq 'Enter') { return $buffer }
if ($key -eq 'Esc') { return $null }
if ($key -eq 'Backspace') {
if ($buffer.Length -gt 0) { $buffer = $buffer.Substring(0, $buffer.Length - 1) }
continue
}
if ($key.StartsWith('Char:')) {
$buffer += $key.Substring(5)
}
}
}
@@ -0,0 +1,42 @@
function Set-BakNRetCatalogField {
<#
.SYNOPSIS
改**一个**可编辑的 Slot 字段(`Encrypt` / `Description`):只动那一行、只动 `=` 之后的部分。
.DESCRIPTION
定位靠 (软件名, Slot 名, 字段名) 三元组,并要求**唯一命中**:名录里同一个软件可以有多个
Slot,只用字段名会把它们混起来;命中 0 个或命中多个都抛错 —— 静默选一个等于替你改了别处。
不可编辑的字段(`Path` / `Exclude` / `Include`,以及跨行的 `Description`)一律拒绝并说明
原因:那类值是动态表达式或跨行拼接,"改一行"没有明确含义(ADR-0013)。
本函数只处理字符串;写盘由调用方走 Save-BakNRetConfigFile(校验通过才原子替换 + 时间戳备份)。
#>
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text,
[Parameter(Mandatory = $true)][string]$App,
[Parameter(Mandatory = $true)][string]$Slot,
[Parameter(Mandatory = $true)][string]$Key,
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value
)
$hits = @(Get-BakNRetCatalogField -Text $Text | Where-Object { $_.App -eq $App -and $_.Slot -eq $Slot -and $_.Key -eq $Key })
if ($hits.Count -eq 0) {
throw ('名录里找不到这个字段:{0}.{1}.{2}' -f $App, $Slot, $Key)
}
if ($hits.Count -gt 1) {
throw ('名录里 {0}.{1}.{2} 命中 {3} 处,不敢猜是哪一个' -f $App, $Slot, $Key, $hits.Count)
}
if (-not $hits[0].Editable) {
throw ('{0}.{1}.{2} 不是可编辑字段(只有单行的 Encrypt / Description 可以改:Path 与 Exclude 常带 $( ) 表达式或跨行拼接,"改一行"对它们没有明确含义)' -f $App, $Slot, $Key)
}
$lines = @($Text -split "`r?`n")
$original = $lines[$hits[0].Line - 1]
$equalsIndex = $original.IndexOf('=')
if ($equalsIndex -lt 0) { throw ('这一行没有 = :{0}' -f $original) }
$lines[$hits[0].Line - 1] = $original.Substring(0, $equalsIndex + 1) + ' ' + $Value
return ($lines -join "`n")
}
@@ -0,0 +1,38 @@
function Set-BakNRetConfigSetting {
<#
.SYNOPSIS
改**一个**标量设置的值:只动那一行、且只动 `=` 之后的部分,其余逐字节保持。
.DESCRIPTION
键名与对齐的空格一律不动(那一列对齐是给人读的,改了它 diff 会变得没法看),行尾注释也原样
保留。写回时 `=` 后固定留一个空格 —— 与文件里既有的写法一致。
找不到路径、或该路径不是"单行标量"(值跨行 / 是集合)时**抛错**:静默不动会制造
"保存成功但其实没改",而用户看到的是一份以为改过、其实没改的配置。
本函数只处理字符串;写盘由调用方走 Save-BakNRetConfigFile(校验通过才原子替换 + 时间戳备份)。
#>
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text,
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value
)
$row = @(Get-BakNRetConfigSetting -Text $Text | Where-Object { $_.Path -eq $Path }) | Select-Object -First 1
if (-not $row) {
throw ('配置里找不到单行标量设置:{0}(它可能不存在,也可能值跨行或本身是集合)' -f $Path)
}
$lines = @($Text -split "`r?`n")
$original = $lines[$row.Line - 1]
$equalsIndex = $original.IndexOf('=')
if ($equalsIndex -lt 0) { throw ('这一行没有 = :{0}' -f $original) }
$head = $original.Substring(0, $equalsIndex + 1) + ' '
$tail = ''
if ($row.Comment) { $tail = ' # ' + $row.Comment.TrimStart('#').Trim() }
$lines[$row.Line - 1] = $head + $Value + $tail
return ($lines -join "`n")
}
+15 -1
View File
@@ -1,10 +1,24 @@
# 变更日志 # 变更日志
面向使用者的变更记录。更早的历史在文末「相对旧版修了什么」一节。 面向使用者的变更记录。更早的历史在文末「相对旧版修了什么」一节。
## 2026-09 强化与重构 ## 2026-09 强化与重构
本次改造的每一处修复都有实测证据,不是"看起来更规范了"。 本次改造的每一处修复都有实测证据,不是"看起来更规范了"。
### 新增:交互界面(TUI)与入口重组
| 变化 | 说明 |
| --- | --- |
| 主入口 `Manage-Backup.ps1` | 不带参数进菜单(备份 / 恢复 / 配置);带 `-Action` 直接做该动作,可配 `-Quiet` 走无头 |
| 配置入口 `Edit-Config.ps1` | 三个编辑器:清单(改方向)、设置(改单行标量)、名录(改 `Encrypt` / `Description`) |
| 改名:`Backup.ps1` → `Backup-Data.ps1`、`Restore.ps1` → `Restore-Data.ps1` | 旧名字留**垫片**转发,退出码与输出原样传递;只留一轮 |
| 零依赖 TUI | 只用 `RawUI.ReadKey` / `[Console]` / `Write-Host`:不装模块、不带 DLL,两个 PowerShell 版本都能跑 |
编辑器改配置是**外科式改写**:只动被编辑的那一行(注释、对齐、`$( )` 表达式、跨行拼接一字节不动),
先校验再原子替换,落盘前留时间戳副本到 `logs\config-backups\`。三项都写成了判据(含"把每个字段设成它
当前的值、文件必须逐字节相同"这条往返断言),并跑在 Windows PowerShell 5.1 与 PowerShell 7 上。
旧命令(`.\Backup.ps1 -DryRun` 等)**照旧可用** —— 垫片会转发并原样带出退出码。
### 修复 ### 修复
+20 -1
View File
@@ -1,4 +1,4 @@
# BakNRet # BakNRet
一个 Windows 备份 / 恢复工具:把机器上指定的软件与目录收进归档,并能把它们放回原位。本文件是本项目的术语表——「要处理什么」「东西放在哪」这些概念在本仓库里只有一个叫法,写作与命名都照这里的词来。 一个 Windows 备份 / 恢复工具:把机器上指定的软件与目录收进归档,并能把它们放回原位。本文件是本项目的术语表——「要处理什么」「东西放在哪」这些概念在本仓库里只有一个叫法,写作与命名都照这里的词来。
@@ -71,3 +71,22 @@ _Avoid_: 无用归档、残留、垃圾文件
**BakNRet**: **BakNRet**:
本工具的名称,任何场合(文档、文件名、标识符)都一律写作 `BakNRet`。 本工具的名称,任何场合(文档、文件名、标识符)都一律写作 `BakNRet`。
_Avoid_: BakNRet、baknret、BakRet、BaknRet 备份工具 _Avoid_: BakNRet、baknret、BakRet、BaknRet 备份工具
## 模块与入口
`BakNRet/` 是 PowerShell **模块**:既能被 `Import-Module` 直接用,也是四个入口脚本背后的实现层。
| 位置 | 是什么 | 规矩 |
| --- | --- | --- |
| `BakNRet/BakNRet.psd1` | 模块清单 | `FunctionsToExport` 是**显式白名单**:没列进去的不会出现在外面 |
| `BakNRet/BakNRet.psm1` | 加载器 | **唯一**声明点源顺序的地方(Public 再 Private);别处不许自己点源模块内部文件 |
| `BakNRet/Public/` | 对外函数 | **一个函数一个文件、文件名 = 函数名**;公共函数只放这里 |
| `BakNRet/Private/` | 内部实现 | 4 个映射/读取辅助函数 + `State.ps1`(模块状态:编码、日志句柄、运行锁) |
根目录的四个入口脚本 —— `Manage-Backup.ps1`(主入口,TUI + `-Action`)、`Backup-Data.ps1`、
`Restore-Data.ps1`、`Edit-Config.ps1` —— 负责"解析参数 → 干活 → `exit` 退出码"。
**编排逻辑目前仍在 `Backup-Data.ps1` / `Restore-Data.ps1` 里**(`Invoke-BackupItem` 等),把它们下沉进模块是
已被记录、尚未实施的下一步:做完之后一份编排就能同时服务 TUI 与无头两条路,TUI 也不必再起子进程
(见 `docs/adr/0012`)。层与层的分工见 `docs/adr/0001`。
+42 -2
View File
@@ -56,8 +56,28 @@ if ($Target -eq 'List') {
exit (Invoke-BakNRetListEditorEntry -Path $ListPath -Script $InputScript) exit (Invoke-BakNRetListEditorEntry -Path $ListPath -Script $InputScript)
} }
if ($Target -eq 'Settings') {
$configPath = Join-Path $PSScriptRoot 'BackupConfig.psd1'
$driver = New-BakNRetInputDriver -Script $InputScript -NonInteractive:$([bool]$InputScript)
$saved = Invoke-BakNRetConfigEditor -Path $configPath -Driver $driver
if ($saved.Saved) { Write-Host ("已保存:{0}" -f $configPath) -ForegroundColor Green; Write-Host ("原文件备份:{0}" -f $saved.BackupPath) -ForegroundColor DarkGray; exit 0 }
if ($saved.Error) { Write-Host $saved.Error -ForegroundColor Yellow; exit 1 }
Write-Host '没有改动。' -ForegroundColor DarkGray
exit 0
}
if ($Target -eq 'Catalog') {
$catalogPath = Join-Path $PSScriptRoot 'SoftwareCatalog.psd1'
$driver = New-BakNRetInputDriver -Script $InputScript -NonInteractive:$([bool]$InputScript)
$saved = Invoke-BakNRetCatalogEditor -Path $catalogPath -Driver $driver
if ($saved.Saved) { Write-Host ("已保存:{0}" -f $catalogPath) -ForegroundColor Green; Write-Host ("原文件备份:{0}" -f $saved.BackupPath) -ForegroundColor DarkGray; exit 0 }
if ($saved.Error) { Write-Host $saved.Error -ForegroundColor Yellow; exit 1 }
Write-Host '没有改动。' -ForegroundColor DarkGray
exit 0
}
if ($Target) { if ($Target) {
Write-Host ("{0} 的编辑器还没做:清单已完成;设置与名录分别是第二轮与第三轮的工作。" -f $Target) -ForegroundColor Yellow Write-Host ("{0} 的编辑器还没做:清单 / 设置 / 名录三个界面都已完成。" -f $Target) -ForegroundColor Yellow
exit 1 exit 1
} }
@@ -82,5 +102,25 @@ if ($picked.Chosen.Key -eq 'List') {
exit (Invoke-BakNRetListEditorEntry -Path $ListPath -Script $InputScript) exit (Invoke-BakNRetListEditorEntry -Path $ListPath -Script $InputScript)
} }
Write-Host ("{0} 的编辑器还没做。" -f $picked.Chosen.Key) -ForegroundColor Yellow if ($picked.Chosen.Key -eq 'Settings') {
$configPath = Join-Path $PSScriptRoot 'BackupConfig.psd1'
$driver2 = New-BakNRetInputDriver -Script $InputScript -NonInteractive:$([bool]$InputScript)
$saved2 = Invoke-BakNRetConfigEditor -Path $configPath -Driver $driver2
if ($saved2.Saved) { Write-Host ("已保存:{0}" -f $configPath) -ForegroundColor Green; exit 0 }
if ($saved2.Error) { Write-Host $saved2.Error -ForegroundColor Yellow; exit 1 }
Write-Host '没有改动。' -ForegroundColor DarkGray
exit 0
}
if ($picked.Chosen.Key -eq 'Catalog') {
$catalogPath = Join-Path $PSScriptRoot 'SoftwareCatalog.psd1'
$driver3 = New-BakNRetInputDriver -Script $InputScript -NonInteractive:$([bool]$InputScript)
$saved3 = Invoke-BakNRetCatalogEditor -Path $catalogPath -Driver $driver3
if ($saved3.Saved) { Write-Host ("已保存:{0}" -f $catalogPath) -ForegroundColor Green; exit 0 }
if ($saved3.Error) { Write-Host $saved3.Error -ForegroundColor Yellow; exit 1 }
Write-Host '没有改动。' -ForegroundColor DarkGray
exit 0
}
Write-Host ("未知的目标:{0}" -f $picked.Chosen.Key) -ForegroundColor Yellow
exit 1 exit 1
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
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.
+1011
View File
File diff suppressed because it is too large. Load diff
+752 -178
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -458,6 +458,16 @@ if (-not (Test-Path -LiteralPath $BackupDir)) {
$manifest = Read-BakNRetManifest -Path $manifestPath $manifest = Read-BakNRetManifest -Path $manifestPath
if (-not (Test-Path -LiteralPath $BackupListPath)) { if (-not (Test-Path -LiteralPath $BackupListPath)) {
# 与 Backup-Data.ps1 同一条约定:显式指定了 -BackupListPath 却不存在,是调用方的错误。
# 恢复的副作用比备份更大(它会真的往磁盘写文件、回放安全描述符),更不能把「路径写错」
# 伪装成「已从备份内容生成清单,退出 0」——那会让调用方以为恢复成功了。
if ($PSBoundParameters.ContainsKey('BackupListPath')) {
Write-BakNRetLog ("指定的清单不存在:{0}" -f $BackupListPath) -Level ERROR
Write-BakNRetLog '显式指定 -BackupListPath 时不会自动生成:请检查路径是否写错;要从备份内容重建清单,就去掉该参数。' -Level ERROR
Stop-BakNRetLog
exit 1
}
Write-BakNRetLog '未找到配置文件,正在从备份内容生成...' -Level INFO Write-BakNRetLog '未找到配置文件,正在从备份内容生成...' -Level INFO
$paths = @() $paths = @()
+1 -1
View File
@@ -39,7 +39,7 @@ $target = Join-Path $PSScriptRoot 'Restore-Data.ps1'
Write-Host '注意:Restore.ps1 已改名为 Restore-Data.ps1(这层转发只保留一轮)。' -ForegroundColor Yellow Write-Host '注意:Restore.ps1 已改名为 Restore-Data.ps1(这层转发只保留一轮)。' -ForegroundColor Yellow
$hostExe = (Get-Process -Id $PID).Path $hostExe = (Get-Process -Id $PID).Path
$forwardArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $target) + @($Rest)+ @(if ($PSBoundParameters.ContainsKey('Verbose')) { '-Verbose' })+ @(if ($PSBoundParameters.ContainsKey('Debug')) { '-Debug' }) $forwardArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $target) + @($Rest) + @(if ($PSBoundParameters.ContainsKey('Verbose')) { '-Verbose' }) + @(if ($PSBoundParameters.ContainsKey('Debug')) { '-Debug' })
$startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $hostExe $startInfo.FileName = $hostExe
+154
View File
@@ -1472,6 +1472,160 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -
} }
$run.ExitCode | Should -Be 1 $run.ExitCode | Should -Be 1
} }
# ---- 回归:显式指定的清单不存在时,绝不能"建一份模板 + 退出 0" ----
#
# 缺陷现象:计划任务里 -BackupListPath 写错(或相对路径按了别的工作目录解析)时,
# 脚本会在那个错误位置凭空造一份模板、打印"已创建,请编辑后重试"、然后 **exit 0**。
# 任务计划程序读到的是"上次运行结果 = 成功",而实际上一个条目都没处理 ——
# 与本仓库已修过的"27 条被静默跳过、退出码仍是 0"是同一类缺陷。
It '[回归] 显式指定的清单不存在时报失败(退出码 1),且不在错误位置建模板' {
$ghostList = Join-Path $script:E2ERoot 'explicitly-missing-list.txt'
if (Test-Path -LiteralPath $ghostList) { Remove-Item -LiteralPath $ghostList -Force }
$run = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $ghostList
BackupDir = $script:E2EBackupDir
QuietTool = $true
}
$run.ExitCode | Should -Be 1
$run.Output | Should -Match '指定的清单不存在'
Test-Path -LiteralPath $ghostList | Should -BeFalse
}
# 对照:没传 -BackupListPath 时的首次运行引导必须原样保留(那是开箱即用,不是失败)。
# 这两条必须成对存在 —— 只测一条的话,"把所有缺失都改成 exit 1" 也能骗过测试。
#
# 怎么造出"默认清单不存在":把仓库根那份 BackupList.txt 临时改名,跑完在 finally 里还原。
# 用 try/finally 而不是"跑完再删",是为了让断言失败时仓库也一定被还原。
It '[回归] 未显式指定清单时的首次运行仍建模板并退出 0(引导不能被误伤)' {
$realList = Join-Path $script:ProjectRoot 'BackupList.txt'
$parkedList = Join-Path $script:E2ERoot 'BackupList.parked'
$guideRoot = Join-Path $script:E2ERoot 'guide'
New-Item -ItemType Directory -Path $guideRoot -Force | Out-Null
$run = $null
try {
Move-Item -LiteralPath $realList -Destination $parkedList -Force
$run = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupDir = Join-Path $guideRoot 'Backups'
ConfigPath = Join-Path $script:ProjectRoot 'BackupConfig.psd1'
}
$run.ExitCode | Should -Be 0
$run.Output | Should -Match '模板 BackupList.txt 已创建'
Test-Path -LiteralPath $realList | Should -BeTrue
(Get-Content -LiteralPath $realList -Raw) | Should -Match '语法'
}
finally {
if (Test-Path -LiteralPath $parkedList) {
Move-Item -LiteralPath $parkedList -Destination $realList -Force
}
}
}
}
# ============================================================================
Describe '归档原子替换与路径解析基准(历史上真出过问题的两处语义)' {
# ============================================================================
# ---- Move-BakNRetArchiveIntoPlace ----
#
# 这条对应 CHANGELOG 里记过的缺陷:5.1 上三参数 File.Move 不存在,于是退化成
# "先删后移" —— Move 一失败,**旧归档已经没了**(而新归档还在 .tmp 里)。
# 断言必须能分辨"替换"与"先删后移":后者的危险恰恰在于"目标短暂不存在"。
Context 'Move-BakNRetArchiveIntoPlace' {
It '目标不存在时:把临时文件移到位,临时文件消失' {
$root = Join-Path $script:Sandbox 'atomic-new'
New-Item -ItemType Directory -Path $root -Force | Out-Null
$temp = Join-Path $root 'new.tmp'
$dest = Join-Path $root 'final.7z'
[System.IO.File]::WriteAllText($temp, 'NEW')
Move-BakNRetArchiveIntoPlace -TempPath $temp -DestinationPath $dest
(Get-Content -LiteralPath $dest -Raw) | Should -Be 'NEW'
Test-Path -LiteralPath $temp | Should -BeFalse
}
It '[回归] 目标已存在时:内容被替换为新内容,且旧内容在替换完成前一直还在' {
$root = Join-Path $script:Sandbox 'atomic-existing'
New-Item -ItemType Directory -Path $root -Force | Out-Null
$temp = Join-Path $root 'next.tmp'
$dest = Join-Path $root 'final.7z'
[System.IO.File]::WriteAllText($dest, 'OLD')
[System.IO.File]::WriteAllText($temp, 'NEW')
Move-BakNRetArchiveIntoPlace -TempPath $temp -DestinationPath $dest
(Get-Content -LiteralPath $dest -Raw) | Should -Be 'NEW'
Test-Path -LiteralPath $temp | Should -BeFalse
# "先删后移" 与 "原子替换" 在成功路径上的结果完全一样,靠结果分不出来。
# 能分辨的是**机制**:这条断言把"替换而非删除"钉住 —— 5.1 上必须走到
# File.Replace(ReplaceFile API),而不是先删掉目标再 Move。
$source = Get-Content -LiteralPath (Join-Path $script:ProjectRoot 'BakNRet\Public\Move-BakNRetArchiveIntoPlace.ps1') -Raw
$source | Should -Match '\[System\.IO\.File\]::Replace'
$source | Should -Not -Match 'Remove-Item'
}
It '[回归] 替换走 File.Replace 时第三个参数必须是 [NullString]::Value(传 $null 会报路径为空)' {
# PowerShell 会把 $null 转成空串,于是 File.Replace 报"路径为空"(两个版本实测都这样)。
$source = Get-Content -LiteralPath (Join-Path $script:ProjectRoot 'BakNRet\Public\Move-BakNRetArchiveIntoPlace.ps1') -Raw
$source | Should -Match '\[NullString\]::Value'
}
}
# ---- Resolve-BakNRetRootedPath ----
#
# 这条对应 README 专门写的一节:「相对路径按仓库根解析,不按当前工作目录」。
# 计划任务的工作目录通常是 C:\Windows\System32,在那里 Test-Path baknret.key 为假 ——
# 如果按工作目录解析,加密条目会以"拿不到口令"失败,而配置看上去毫无问题。
Context 'Resolve-BakNRetRootedPath' {
It '[回归] 相对路径按调用方传进来的 $Root 解析,而不是按当前工作目录' {
Push-Location $env:SystemRoot
try {
$resolved = Resolve-BakNRetRootedPath -Path 'Backups' -Root $script:ProjectRoot
$resolved | Should -Be (Join-Path $script:ProjectRoot 'Backups')
# 反证:如果它按工作目录解析,就会落到 System32 下面
$resolved | Should -Not -Match [regex]::Escape($env:SystemRoot)
}
finally {
Pop-Location
}
}
It '[回归] 模块里不得用 $PSScriptRoot 当根:那会解析到模块目录而不是仓库根' {
# 这是"入口逻辑下沉"里最容易出错的一类:闭包捕获的变量在模块作用域里指向别的东西。
# 函数注释专门解释了为什么 $Root 必须是参数 —— 这条断言把它钉住。
#
# 必须用 AST 判定,不能对源码做文本匹配:这个函数的注释里**正当地**提到了
# $PSScriptRoot(解释为什么不许用),文本匹配会把注释当成违规。
# 第一版就是这么写的,结果被自己的注释绊倒 —— 记在这里。
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
(Join-Path $script:ProjectRoot 'BakNRet\Public\Resolve-BakNRetRootedPath.ps1'), [ref]$null, [ref]$null)
$nodes = @($ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.VariableExpressionAst] }, $true))
$referenced = @($nodes | ForEach-Object { $_.VariablePath.UserPath } | Sort-Object -Unique)
$referenced | Should -Not -Contain 'PSScriptRoot'
$referenced | Should -Contain 'Root' # 对照:确实引用了调用方传进来的根
}
It '绝对路径原样返回' {
$absolute = Join-Path $script:Sandbox 'absolute-dir'
Resolve-BakNRetRootedPath -Path $absolute -Root $script:ProjectRoot | Should -Be $absolute
}
It '$Path 为空时回落到 $Default' {
Resolve-BakNRetRootedPath -Path '' -Default 'logs' -Root $script:ProjectRoot |
Should -Be (Join-Path $script:ProjectRoot 'logs')
}
}
} }
# ============================================================================ # ============================================================================
+162
View File
@@ -1736,6 +1736,168 @@ Test-Case '主入口:-Quiet 不画界面、菜单能渲染、进不去界面
} }
} }
Test-Case '配置标量:只认单行标量;改一个值只动那一行;设成当前值则逐字节不变' {
$configPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupConfig.psd1'
$text = Get-Content -LiteralPath $configPath -Raw -Encoding UTF8
$rows = @(Get-BakNRetConfigSetting -Text $text)
Assert-True ($rows.Count -ge 15) ('真实配置里应当认出足够多的标量,实际 ' + $rows.Count)
Assert-Equal "'Backups'" ($rows | Where-Object { $_.Path -eq 'BackupDir' }).Value '顶层标量(读出来的是含引号的原文 —— 原样写回才逐字节等价)'
Assert-Equal 'Snapshot.Enabled' (($rows | Where-Object { $_.Path -eq 'Snapshot.Enabled' }).Path) '嵌套里的键要用点号路径(Enabled 有两处)'
Assert-Equal '$true' (($rows | Where-Object { $_.Path -eq 'Encryption.EncryptHeaders' }).Value) '布尔值按原文读出($true 本身就是原文)'
# 非标量必须被排除:行内空哈希与数组都不是"一行一个值"
Assert-Equal 0 @($rows | Where-Object { $_.Path -eq 'Security.SidMap' }).Count '行内哈希不算标量'
Assert-Equal 0 @($rows | Where-Object { $_.Path -like 'DefaultExcludes*' }).Count '数组不算标量'
# 往返:把每个标量设成它当前的值,文本必须**逐字节相同**(外科式改写的最强形式)
$roundTripped = $text
foreach ($row in $rows) { $roundTripped = Set-BakNRetConfigSetting -Text $roundTripped -Path $row.Path -Value $row.Value }
Assert-Equal $text $roundTripped '每个标量设成当前值后文本逐字节相同'
# 真改一个:只有那一行不同,注释行数不变,并且改完还能读出新值
$changed = Set-BakNRetConfigSetting -Text $text -Path 'BackupDir' -Value "'Backups2'"
$before = @($text -split "`n")
$after = @($changed -split "`n")
Assert-Equal $before.Count $after.Count '行数不变'
Assert-Equal 2 @(Compare-Object -ReferenceObject $before -DifferenceObject $after).Count '只应当有一行不同'
$commentCount = @($before | Where-Object { $_.TrimStart().StartsWith('#') }).Count
Assert-Equal $commentCount @($after | Where-Object { $_.TrimStart().StartsWith('#') }).Count '注释行数不变'
Assert-Equal "'Backups2'" ((Get-BakNRetConfigSetting -Text $changed | Where-Object { $_.Path -eq 'BackupDir' }).Value) '改完还能读出同一个值'
# 找不到 / 不是标量:必须抛错,不能静默不动
$threw = $false
try { Set-BakNRetConfigSetting -Text $text -Path 'No.Such.Key' -Value '1' | Out-Null } catch { $threw = $true }
Assert-True $threw '找不到的路径必须抛错'
$threw2 = $false
try { Set-BakNRetConfigSetting -Text $text -Path 'Security.SidMap' -Value '@{}' | Out-Null } catch { $threw2 = $true }
Assert-True $threw2 '非标量(行内哈希)也必须抛错'
}
Test-Case '输入行:字符追加、Backspace 删除、Esc 取消返回 $null(与空串区分)' {
# 全部用按键序列驱动 —— 这也是它不用 Read-Host 的原因:Read-Host 在门禁里会挂住。
Assert-Equal 'ab' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Char:a', 'Char:b', 'Enter'))) '逐字符输入'
Assert-Equal 'a' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Char:a', 'Char:b', 'Backspace', 'Enter'))) 'Backspace 删一个'
Assert-Equal '' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Backspace', 'Enter'))) '空缓冲按 Backspace 不抛错'
Assert-True ($null -eq (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Char:a', 'Esc')))) 'Esc 应当返回 $null 表示取消'
Assert-Equal '' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Enter'))) '直接回车给空串(空串是合法意图)'
Assert-Equal 'xy' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Char:y', 'Enter')) -Initial 'x') '初始值之后继续追加'
Assert-Equal 'ab' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Char:a', 'Down', 'Char:b', 'Enter'))) '方向键在输入行里被忽略(无副作用)'
Assert-Equal 'k' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Char:K', 'Enter'))) '字母按小写存(与键名归一化一致)'
}
Test-Case '配置编辑器:脚本驱动改一个值,只动那一行且留下备份;取消不写盘' {
$dir = Join-Path $env:TEMP ("bnr-cfged-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$file = Join-Path $dir 'BackupConfig.psd1'
Copy-Item -LiteralPath (Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupConfig.psd1') -Destination $file
$original = [System.IO.File]::ReadAllText($file)
$backupDir = Join-Path $dir 'logs\config-backups'
try {
# MinFreeSpaceGB 是文件里第 5 个标量设置:下移四次选中它,输入 9
$keys = @('Down', 'Down', 'Down', 'Down', 'Enter', 'Char:9', 'Enter')
$result = Invoke-BakNRetConfigEditor -Path $file -Driver (New-BakNRetInputDriver -Script $keys)
Assert-Equal $true $result.Saved '应当保存'
$before = @($original -split "`n")
$after = @([System.IO.File]::ReadAllText($file) -split "`n")
Assert-Equal 2 @(Compare-Object -ReferenceObject $before -DifferenceObject $after).Count '只应当有一行不同'
Assert-Equal @($before | Where-Object { $_.TrimStart().StartsWith('#') }).Count @($after | Where-Object { $_.TrimStart().StartsWith('#') }).Count '注释行数不变'
Assert-True (([System.IO.File]::ReadAllText($file)) -match 'MinFreeSpaceGB = 9') '新值写进去了'
Assert-True (Test-Path $result.BackupPath) '应当留下时间戳备份'
Assert-Equal $original ([System.IO.File]::ReadAllText($result.BackupPath)) '备份里是原文'
# 取消(选设置时 Esc):不写盘、不产生新备份
$countBefore = @(Get-ChildItem $backupDir -File | Measure-Object).Count
$contentBefore = [System.IO.File]::ReadAllText($file)
$cancelled = Invoke-BakNRetConfigEditor -Path $file -Driver (New-BakNRetInputDriver -Script @('Esc'))
Assert-Equal $false $cancelled.Saved '取消不该保存'
Assert-Equal $contentBefore ([System.IO.File]::ReadAllText($file)) '取消后内容不变'
Assert-Equal $countBefore @(Get-ChildItem $backupDir -File | Measure-Object).Count '取消不该新增备份'
} finally {
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
}
}
Test-Case '名录字段:只有单行的 Encrypt / Description 可编辑;改一个只动那一行;往返逐字节等价' {
$catalogPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'SoftwareCatalog.psd1'
$text = Get-Content -LiteralPath $catalogPath -Raw -Encoding UTF8
$fields = @(Get-BakNRetCatalogField -Text $text)
Assert-True ($fields.Count -ge 60) ('应当认出足够多的 Slot 字段,实际 ' + $fields.Count)
# 头部注释模板里的 SoftWareName 示例必须被排除(块注释要跳过)
Assert-Equal 0 @($fields | Where-Object { $_.App -eq 'SoftWareName' }).Count '模板示例不该被当成软件'
# 可编辑面的规则:只有单行的 Encrypt / Description
$editable = @($fields | Where-Object { $_.Editable })
Assert-True ($editable.Count -ge 40) ('可编辑字段应当有四十多个,实际 ' + $editable.Count)
Assert-Equal 0 @($editable | Where-Object { $_.Key -notin @('Encrypt', 'Description') }).Count '只有 Encrypt / Description 可编辑'
Assert-Equal 0 @($fields | Where-Object { $_.Key -eq 'Path' -and $_.Editable }).Count 'Path 一律只读(常带 $() 表达式)'
Assert-Equal 0 @($fields | Where-Object { $_.Key -eq 'Exclude' -and $_.Editable }).Count 'Exclude 一律只读(Edge 那条是跨行拼接)'
# Edge 的 Description 是跨行的:必须只读
$edgeDescription = @($fields | Where-Object { $_.App -eq 'MicrosoftEdge' -and $_.Key -eq 'Description' })
Assert-Equal 1 $edgeDescription.Count 'Edge 的 Description 应当认出来'
Assert-Equal $false $edgeDescription[0].Editable '跨行的 Description 必须只读'
# 往返:把每个可编辑字段设成它当前的值,文本必须逐字节相同
$roundTripped = $text
foreach ($field in $editable) {
$roundTripped = Set-BakNRetCatalogField -Text $roundTripped -App $field.App -Slot $field.Slot -Key $field.Key -Value $field.Value
}
Assert-Equal $text $roundTripped '每个可编辑字段设成当前值后文本逐字节相同'
# 真改一个(AutoDarkMode 的 Encrypt),只动那一行、注释行数不变、改完能读回新值
$changed = Set-BakNRetCatalogField -Text $text -App 'AutoDarkMode' -Slot 'DefaultData' -Key 'Encrypt' -Value '$false'
$before = @($text -split "`n")
$after = @($changed -split "`n")
Assert-Equal 2 @(Compare-Object -ReferenceObject $before -DifferenceObject $after).Count '只应当有一行不同'
Assert-Equal @($before | Where-Object { $_.TrimStart().StartsWith('#') }).Count @($after | Where-Object { $_.TrimStart().StartsWith('#') }).Count '注释行数不变'
Assert-Equal '$false' ((Get-BakNRetCatalogField -Text $changed | Where-Object { $_.App -eq 'AutoDarkMode' -and $_.Key -eq 'Encrypt' }).Value) '改完能读回新值'
# 拒绝:不可编辑的字段、跨行的 Description、不存在的字段,都必须抛错
foreach ($bad in @(
@{ A = 'AutoDarkMode'; S = 'DefaultData'; K = 'Path' },
@{ A = 'MicrosoftEdge'; S = 'DefaultData'; K = 'Description' },
@{ A = 'NoSuchApp'; S = 'NoSuchSlot'; K = 'Encrypt' }
)) {
$threw = $false
try { Set-BakNRetCatalogField -Text $text -App $bad.A -Slot $bad.S -Key $bad.K -Value 'x' | Out-Null } catch { $threw = $true }
Assert-True $threw ('应当拒绝:' + $bad.A + '.' + $bad.S + '.' + $bad.K)
}
}
Test-Case '名录编辑器:脚本驱动改一个 Encrypt,只动那一行且留下备份;取消不写盘' {
$dir = Join-Path $env:TEMP ("bnr-cated-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$file = Join-Path $dir 'SoftwareCatalog.psd1'
Copy-Item -LiteralPath (Join-Path (Split-Path -Parent $PSScriptRoot) 'SoftwareCatalog.psd1') -Destination $file
$original = [System.IO.File]::ReadAllText($file)
$backupDir = Join-Path $dir 'logs\config-backups'
try {
# 第一个可编辑字段是 AutoDarkMode.DefaultData.Encrypt(当前 $true)→ 输入 $false
$keys = @('Enter', 'Char:$', 'Char:f', 'Char:a', 'Char:l', 'Char:s', 'Char:e', 'Enter')
$result = Invoke-BakNRetCatalogEditor -Path $file -Driver (New-BakNRetInputDriver -Script $keys)
Assert-Equal $true $result.Saved '应当保存'
$before = @($original -split "`n")
$after = @([System.IO.File]::ReadAllText($file) -split "`n")
Assert-Equal 2 @(Compare-Object -ReferenceObject $before -DifferenceObject $after).Count '只应当有一行不同'
Assert-Equal @($before | Where-Object { $_.TrimStart().StartsWith('#') }).Count @($after | Where-Object { $_.TrimStart().StartsWith('#') }).Count '注释行数不变'
$nowValue = ((Get-BakNRetCatalogField -Text ([System.IO.File]::ReadAllText($file)) | Where-Object { $_.App -eq 'AutoDarkMode' -and $_.Slot -eq 'DefaultData' -and $_.Key -eq 'Encrypt' }).Value)
Assert-Equal '$false' $nowValue '改完能读回新值'
Assert-True (Test-Path $result.BackupPath) '应当留下时间戳备份'
Assert-Equal $original ([System.IO.File]::ReadAllText($result.BackupPath)) '备份里是原文'
# 取消(选字段时 Esc):不写盘、不产生新备份
$countBefore = @(Get-ChildItem $backupDir -File | Measure-Object).Count
$contentBefore = [System.IO.File]::ReadAllText($file)
$cancelled = Invoke-BakNRetCatalogEditor -Path $file -Driver (New-BakNRetInputDriver -Script @('Esc'))
Assert-Equal $false $cancelled.Saved '取消不该保存'
Assert-Equal $contentBefore ([System.IO.File]::ReadAllText($file)) '取消后内容不变'
Assert-Equal $countBefore @(Get-ChildItem $backupDir -File | Measure-Object).Count '取消不该新增备份'
} finally {
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================ # ============================================================================
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue