Compare commits

..
10 Commits
Author SHA1 Message Date
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
38 changed files with 4595 additions and 184 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,60 @@
# 许可证合规检查报告(license-compliance-checker)
> [!WARNING]
>
> **免责声明**:本报告由自动化工具基于仓库内的文件生成,仅为工程参考,**不构成法律建议**。
> 涉及对外分发、商业使用或二次许可时,请咨询法务。
- **项目**:BakNRet
- **检查时间**:2026-09-28
- **主许可证**:**未声明** —— 仓库根目录不存在 `LICENSE` / `LICENSE.md` / `LICENSE.txt`
## 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": "一般"
}
]
}
+345
View File
@@ -0,0 +1,345 @@
# 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 虚拟机)
---
## 阶段总览
| 阶段 | 名称 | 状态 | 关键结果 |
| --- | --- | --- | --- |
| 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 与工作树上完全一致)才定位到是口径差异。
> 报告里凡出现行数处已统一标注口径。
### 建议(按优先级)
| 优先级 | 事项 | 理由 |
| --- | --- | --- |
| **高** | 补 `LICENSE` | 当前等于「保留所有权利」,他人无权分发 |
| **高** | 提交前复核本次改动并决定是否 commit | 6 个文件已改,尚未提交 |
| 中 | 把 `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
+7
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',
+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
+1011
View File
File diff suppressed because it is too large. Load diff
+725 -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