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。
76 KiB
BakNRet 项目报告
本报告由 CI/CD 流水线的第 5 阶段(文档生成与质量检查)产出,100% 基于仓库实际内容与本次流水线的实测证据。 仓库路径:
D:\Workspace\Temp\BakNRet分支:refactor/ms-conventions基线提交:d72fe63报告日期:2026-09-28
摘要
BakNRet 是一个面向 Windows 的备份与恢复工具:它把一份纯文本清单(BackupList.txt)里列出的软件与目录,
用 7-Zip 打包进 Backups\ 目录,并能在需要时把它们原样放回原位。与常见的“打包脚本”不同,BakNRet
把恢复后的可用性当作第一目标,因此在三个方向上做了普通备份脚本通常不做的事:
- 随归档一起保存 NTFS 安全描述符(属主 / 属组 / DACL 旁挂成
<归档名>.acl.json)。7-Zip 的.7z格式在官方文档里就写明装不下 NTFS 安全信息(-sni仅支持 WIM),而C:\ProgramData下的目录依赖CREATOR OWNER占位符 —— 不恢复属主,原程序(服务账户 / 专用用户)恢复后就没有权限。 - 归档内用“Slot”分层,让同一个软件里两个都叫
persist的目录不再互相覆盖,恢复时也能精确地 “只解出这一棵子树”。 - 只读模式一个字节都不写(
-WhatIf/-DryRun/-VerifyOnly)、退出码可靠(有失败返回 1)、 每次运行产出可核对的manifest.json与日志 —— 让它在计划任务里能被人信任。
项目规模:131 个 PowerShell 文件 / 14389 非空行(口径:非空行),其中模块对外导出 89 个函数;配套 192 项 Pester 用例 + 128 项零依赖用例 + 36 项端到端用例,并在 PowerShell 7.7 与 Windows PowerShell 5.1 上各跑一遍。 运行时零第三方依赖(只需 PowerShell 与 7-Zip),这是本项目在供应链上的最重要属性。
本次流水线在静态检查阶段修复了 2 处排版缺陷、在黑盒测试阶段发现并修复了 1 处真实缺陷(显式指定的清单 文件不存在时会“建模板 + 退出 0”,计划任务会误判为成功),修复后 Pester 从 183 项增至 185 项全部通过、本轮再补 7 条后为 192 项, 验收套件在两个宿主上 9/9 PASS。
关键词:Windows 备份;7-Zip;NTFS 安全描述符;PowerShell 双版本兼容;原子替换;零运行时依赖
目录
第1章 绪论
1.1 项目背景
Windows 上的“备份”通常被简化成两件事:把目录复制到别处、或者打一个 zip。但真正会在生产机器上反复使用的 备份,需要回答的问题多得多:
| 问题 | 朴素做法的后果 |
|---|---|
| 这次到底备了什么、跳过了什么、为什么? | 只有一行滚过去的控制台告警,事后无从核对 |
| 打包到一半断电了怎么办? | 留下半个归档,下一次增量续写会把它污染成“看似完整” |
| 恢复之后程序还能读写自己的数据吗? | 能恢复文件,但属主变了,服务账户失去权限 |
| 一个软件里两个目录同名怎么办? | 归档内静默混成一棵树,两边的数据都错 |
| 计划任务怎么知道昨晚跑成功了? | 脚本不 exit,全部失败也返回 0 |
BakNRet 就是围绕这些问题的答案构建的。仓库自己的 CHANGELOG.md 记录了它的演进:早期版本曾出现
“清单 28 条里 27 条被静默跳过、退出码仍是 0”“排除模式对所有条目都失效”“加密口令随 -Verbose 落进日志”
等缺陷,这些都被逐条修掉并写成了回归断言。
1.2 项目定位
| 维度 | 事实 |
|---|---|
| 形态 | 命令行工具 + 零依赖 TUI 菜单,不是服务、不带常驻进程 |
| 平台 | Windows(依赖 NTFS 的连接点与安全描述符语义) |
| 宿主 | Windows PowerShell 5.1 与 PowerShell 7.x(两套都验收) |
| 外部依赖 | 仅 7-Zip(7z.exe) |
| 运行时模块依赖 | 0 个 |
| 分发形态 | 仓库即工具(脚本式部署);模块另可经 tools/Build-BakNRetModule.ps1 合成单文件以便代码签名 |
| 典型使用 | 手动运行、注册成每日计划任务、或由 TUI 菜单驱动 |
1.3 本次流水线做了什么
本次报告不是纯文档工作:它建立在一条 CI/CD 流水线的实测结果之上。流水线按“静态分析与修复 → 黑盒测试 → 缺陷修复与回归 → 测试增强与性能 → 代码规范化 → 文档生成”推进,本报告即第 5 阶段的产出物。
| 阶段 | 执行器 | 结论 |
|---|---|---|
| 0.1 依赖安全扫描 | dependency-security-scanner |
✅ 无包管理器依赖,0 个致命/严重漏洞 |
| 0.2 许可证合规 | license-compliance-checker |
⚠️ 第三方全为宽松许可;主许可证未声明 |
| 0.3 语法检查与修复 | syntax-fixer(内含 syntax-checker) |
🔧 修复 2 处排版缺陷;1 处自伤已恢复 |
| 1 黑盒测试 | blackbox-tester |
11 例:9 通过 / 2 失败(0 致命、0 严重) |
| 2 缺陷修复与回归 | bug-fixer-from-tests |
🔧 修复 1 处真实缺陷;回归 12/12 通过 |
| 3 性能基线 | performance-baseline-tester |
📊 建立 5 项指标基线(详见 6.4) |
| 5 文档生成 | academic-readme-writer |
📄 本文件 |
第2章 开发技术
2.1 技术选型
| 层次 | 选型 | 理由(仓库内可查证) |
|---|---|---|
| 语言 | PowerShell(.ps1 / .psm1 / .psd1) |
目标环境的原生能力:NTFS ACL、连接点、计划任务、WMI 都无需额外运行时 |
| 宿主兼容 | 5.1 与 7.x 双支持 | ADR-0005;代价是禁用 ??、三元运算符、-AsHashtable、ProcessStartInfo.ArgumentList 等 6.0+ 语法 |
| 压缩 | 7-Zip(外部进程) | 支持固实压缩、-x! / -xr! 排除语法、头部加密;RAR / 内置 ZIP 仅作降级 |
| 模块组织 | 一函数一文件 + 薄加载器 | ADR-0001;BakNRet/{Public,Private} 共 94 个函数文件 |
| 交互界面 | 自研零依赖 TUI | ADR-0010;只用 RawUI.ReadKey / [Console] / Write-Host,不引入任何 TUI 库 |
| 测试 | Pester 5(单元面)+ 自研零依赖断言器 | ADR-0006;零依赖套件保证“没装 Pester 的机器”也能验 |
| 静态分析 | PSScriptAnalyzer 1.25 + 自定义配置 | ADR-0008;显式打开 6 条默认 Disabled 的格式规则 |
2.2 工程规模
统计口径:.ps1 / .psm1 / .psd1,排除 .tools\(仓库内第三方模块)、.scratch\(议题与探针)、
Backups\、logs\、dist\。
| 区域 | 文件数 | 行数 | 说明 |
|---|---|---|---|
| 根目录入口与配置 | 10 | 2354 | 四个入口脚本 + 两个垫片 + 三份配置 + 验收入口 |
模块 BakNRet\ |
96 | 4369 | Public\ 89 文件 3954 行、Private\ 5 文件 69 行、架构文件 2 个 |
tests\ |
9 | 5422 | 3 个 Pester 套件 + 3 个零依赖套件 + 断言器 + 演练 |
tools\(含 lab\) |
16 | 2244 | 构建、分析门禁、计划任务、归档改名、Hyper-V 实验台 |
| 合计 | 131 | 14389 | — |
文档资产:README.md 925 行、CONTEXT.md 92 行(术语表)、CHANGELOG.md 83 行、
docs/adr/ 13 条决策记录、docs/ 4 篇主题文档。
2.3 许可证合规
本节数据来自流水线阶段 0.2(
.scratch/ci-cd/20260928-001722/license_check_report.md)。 免责声明:以下为工程参考,不构成法律建议。
| 组件 | 版本 | 许可证 | 判断 |
|---|---|---|---|
| 本项目 | 1.0.0 | 未声明(仓库无 LICENSE 文件) |
❌ L-1 需处理 |
| Pester | 5.9.1 | Apache-2.0 | ✅ 宽松;装在 .tools\,不进分发 |
| PSScriptAnalyzer | 1.25.0 | MIT | ✅ 宽松;仅静态分析用 |
| Newtonsoft.Json(PSScriptAnalyzer 内置) | 随包 | MIT | ✅ 宽松 |
| 7-Zip | 26.03 | LGPL-2.1-or-later + BSD-3-Clause + unRAR 限制 | ⚠️ 见 L-2;未随仓库分发 |
| PowerShell / .NET | 宿主环境 | MIT | ✅ |
冲突分析:第三方全部是宽松型许可(MIT / Apache-2.0 / BSD),无 copyleft 传染风险。
| ID | 级别 | 内容 |
|---|---|---|
| L-1 | ❌ 需处理 | 主许可证未声明 —— 默认“保留所有权利”,他人无权复制/修改/分发;GitHub 亦显示为无许可证项目。建议补 MIT 或 Apache-2.0 |
| L-2 | ⚠️ 需注意 | 7-Zip 的 unRAR 限制禁止“还原 RAR 压缩算法”。本项目只做压缩与解压,不实现 RAR 压缩,不触发该限制 |
| L-3 | ⚠️ 记录 | Pester 5.9.1 模块清单里的版权年份为上游原文,不应改写 |
2.4 供应链安全
数据来自流水线阶段 0.1(
dependency_security_report.md)。
本项目没有依赖管理文件(无 package.json / requirements.txt / go.mod / pom.xml / Cargo.toml),
因此 npm audit / pip-audit / govulncheck 一类工具无对象可扫。真实的供应链面只有四项:
| 组件 | 是否随分发 | 风险 |
|---|---|---|
| PowerShell 引擎 | 否(宿主提供) | 由宿主维护 |
| 7-Zip 26.03 | 否(用户自备) | 需用户保持更新 |
| Pester 5.9.1 | 否(.tools\ 已 gitignore) |
仅测试期使用 |
| PSScriptAnalyzer 1.25.0 | 否(同上) | 仅静态分析使用 |
已知 CVE:无可报(没有任何被本仓库固定版本的第三方库)。致命 / 严重漏洞:0 个。
风险登记(均为卫生问题,非漏洞):
| ID | 级别 | 内容 | 处置建议 |
|---|---|---|---|
| R-2 | 一般 | 工作区存在口令文件 baknret.key |
已被 .gitignore:18(*.key)忽略,git ls-files 确认未被跟踪。建议按仓库自身文档移动到仓库外(%USERPROFILE%\.baknret.key)并用 -KeyFile 指过去。本次流水线全程未读取其内容 |
| R-3 | 建议 | 脚本不检查 7-Zip 版本 | 可选增强 |
| R-4 | 建议 | 口令经命令行传给 7z,进程列表短暂可见 | 7-Zip 上游限制,README 已用 CAUTION 披露 |
第3章 需求分析
本章的“需求”全部从仓库内的可执行契约反推:README.md 的承诺、docs/ 的语法规范、CONTEXT.md 的术语表,
以及 tests/ 里已经写成断言的判据。
3.1 功能需求
| 编号 | 需求 | 验收依据 |
|---|---|---|
| F-01 | 用一份清单声明“要处理什么”,备份与恢复共用同一份 | BackupList.txt 行首 + / - 标记方向 |
| F-02 | 清单里可直接写软件名,由名录映射到真实路径 | SoftwareCatalog.psd1;归档名 = 软件名 |
| F-03 | 一个软件可以有多个目录,且同名目录不冲突 | Slot 结构;归档内 <Slot>\<内容> |
| F-04 | 排除 / 追加 / 加密可写在名录上,也可按条目覆盖 | Exclude / Include / Encrypt + 清单修饰符 |
| F-05 | 支持通配符与正则两类排除 | !<通配> → -xr!;!re:<正则> 由脚本展开 |
| F-06 | 归档写完后校验,失败不污染已有归档 | 临时文件 → 7z t → 原子替换 |
| F-07 | 每次运行留下可核对的记录 | Backups\manifest.json + logs\*.log |
| F-08 | 保存并回放 NTFS 安全描述符 | <归档名>.acl.json sidecar |
| F-09 | 恢复提供只读模式,一个字节都不写 | -WhatIf / -DryRun / -VerifyOnly |
| F-10 | 退出码可靠,计划任务能判成败 | 有失败返回 1;无控制台进不了界面返回 2 |
| F-11 | 备份前预估空间并给出“够不够”的结论 | 只读模拟全过程 |
| F-12 | 对孤儿归档(磁盘上有、清单里没主)做审计 | 备份后点名 |
| F-13 | 提供 TUI 菜单,且可在无头环境用按键序列驱动 | Manage-Backup.ps1 / -InputScript |
| F-14 | 配置编辑器改配置时只动被改的那一行 | 外科式改写 + 校验后原子保存 |
3.2 非功能需求
| 编号 | 需求 | 量化判据 |
|---|---|---|
| N-01 | 双宿主兼容 | 同一套验收在 5.1.26100.9502 与 7.7.0-preview.5 上各跑一遍,9/9 通过 |
| N-02 | 编码安全 | 受管 133 个文件必须 UTF-8 with BOM、LF、无制表符 |
| N-03 | 零运行时依赖 | Import-Module 后无第三方模块;运行只需 PowerShell + 7z |
| N-04 | 绝不挂起 | 无控制台且未给按键序列时必须报错退出(实测 60 秒内退出,退出码 2) |
| N-05 | 口令不落盘 | 日志里 -p 参数被替换为占位符;有专门断言 |
| N-06 | 静态分析门禁 | 默认规则 + 6 条格式规则;当前 80 条告警全部为 Warning 级、0 条 Error |
3.3 术语约束
CONTEXT.md 定义了一条硬约束:每个概念在本仓库里只有一个叫法。例如“清单”“条目”“方向”“软件名录”
“Slot”“覆盖”“排除模式”“归档项”“旧布局”“manifest”“安全描述符旁挂”“孤儿归档”都有明确的 _Avoid_ 列表
(不许叫“列表”“配置”“层”“分组”“索引”等)。这条约束让代码、日志、文档与用户对话使用同一套词。
第4章 详细设计
4.1 整体架构
flowchart TB
MB["Manage-Backup.ps1<br/>TUI 菜单 / -Action"]
BD["Backup-Data.ps1<br/>备份动作"]
RD["Restore-Data.ps1<br/>恢复动作"]
EC["Edit-Config.ps1<br/>配置编辑器"]
SH["Backup.ps1 / Restore.ps1<br/>旧名字垫片,只留一轮"]
MB --> BD
MB --> RD
MB --> EC
SH --> BD
SH --> RD
flowchart TB
BD["Backup-Data.ps1 / Restore-Data.ps1"]
BD --> M1["日志与运行锁"]
BD --> M2["外部命令封装<br/>取真实退出码"]
BD --> M3["清单解析<br/>方向 / 排除 / 追加 / 覆盖"]
BD --> M5["归档命名与暂存目录<br/>junction 与硬链接"]
BD --> M6["manifest 与原子替换"]
BD --> M7["安全描述符<br/>采集与回放"]
M1 --> M5
M2 --> M3
M3 --> M4["软件名录与 Slot 解析"]
M4 --> M5
M5 --> M6
M6 --> M7
EC2["Edit-Config.ps1"] --> M8["TUI 控件<br/>菜单 / 按键序列 / 列宽"]
M8 -. 驱动 .-> EC2
分层意图:入口层只做“解析参数 → 调用模块 → exit 退出码”,模块层不持有运行状态(唯一的例外是
ADR-0011 记录的“进度钩子注入点”)。这样同一份能力既能服务 TUI,也能服务无头调用。
4.2 备份数据流
flowchart TD
subgraph repo["仓库(唯一真相)"]
BL["BackupList.txt<br/>要处理什么"]
SC["SoftwareCatalog.psd1<br/>软件名到 Slot 组"]
BC["BackupConfig.psd1<br/>目录 / 校验 / 加密"]
end
BL --> P["解析清单<br/>方向 / 排除 / 追加 / 覆盖"]
SC --> P
BC --> P
P --> PLAN["打印计划 + 空间预估<br/>只读,-DryRun 到此为止"]
PLAN --> STAGE["建暂存目录<br/>目录走 junction,文件走硬链接"]
STAGE --> SZ["7z 打包<br/>每次都从零,不用更新模式"]
SZ --> TMP["写临时归档 .tmp"]
TMP --> V{"7z t 校验通过?"}
V -- 否 --> DISC["丢弃临时文件<br/>旧归档原封不动"]
V -- 是 --> SWAP["原子替换<br/>File.Move 或 File.Replace"]
SWAP --> DONE["归档 + manifest.json<br/>+ 同名 .acl.json"]
STAGE -. 打包后立刻拆掉 .-> DONE
4.3 恢复数据流
flowchart TD
M["BackupList.txt"] --> L{"manifest.json 里有?"}
L -- 有 --> A1["按 manifest 定位归档"]
L -- 没有 --> A2["退回从文件名反推路径"]
A1 --> K{"条目是目录还是文件?"}
A2 --> K
K -- 目录 --> J1["目标父目录下建 junction<br/>7z 直接写穿它,零拷贝"]
J1 --> J2["解出这一棵子树<br/>缺 Slot 层时退回旧布局"]
J2 --> J3["拆掉 junction"]
K -- 文件 --> F1["解到临时目录<br/>再搬到 Path 指定的位置"]
J3 --> ACL["按 acl.json 自顶向下回放<br/>属主 / 属组 / DACL"]
F1 --> ACL
ACL --> R["打印逐项结果<br/>按失败数 exit"]
4.4 模块内部分组
BakNRet.psm1 是唯一声明点源顺序的地方,共 13 个分组注释:
| 分组 | 职责 | 代表函数 |
|---|---|---|
| 日志 | 控制台彩色输出 + 落盘日志 | Start-BakNRetLog、Write-BakNRetLog |
| 运行锁 | 同一备份目录同时只允许一个进程 | Enter-BakNRetRunLock、Exit-BakNRetRunLock |
| 环境 | 管理员判定、剩余空间 | Test-BakNRetAdministrator、Get-BakNRetFreeSpaceGB |
| 外部命令 | 取得真实退出码、参数拼接、压缩工具探测 | Invoke-ExternalCommand、ConvertTo-BakNRetNativeArgumentString |
| 清单解析 | 分词、记号边界、排除翻译、作用域拆分 | ConvertFrom-BackupListLine、Get-BakNRetExcludeArgument |
| 软件名录 | 名录读取、路径展开、前缀补全 | Get-BakNRetSoftwareCatalog、Find-BakNRetChildDirectoryByName |
| 归档命名与路径还原 | 归档名、归档项、连接点、暂存目录 | New-BakNRetArchiveItem、New-BakNRetArchiveStaging |
| manifest | 读取、同步、写入 | Read-BakNRetManifest、Write-BakNRetManifest |
| 归档原子替换 | 临时文件安全落到最终路径 | Move-BakNRetArchiveIntoPlace |
| 安全描述符 | 采集、sidecar 往返、回放、SID 映射 | Get-BakNRetSecurityRecords、Restore-BakNRetSecurity |
| 配置 | 配置合并、口令获取 | Get-BakNRetConfig、Get-BakNRetPassword |
| TUI 控件 | 菜单、按键驱动、列宽、绘制 | Invoke-BakNRetMenu、Get-BakNRetCellWidth |
| 编辑器 | 清单 / 设置 / 名录三个编辑器 | Invoke-BakNRetBackupListEditor、Invoke-BakNRetConfigEditor、Invoke-BakNRetCatalogEditor |
导出契约:BakNRet.psd1 的 FunctionsToExport(89 条)与 BakNRet.psm1 的 Export-ModuleMember
(89 条)互为镜像,且与 BakNRet/Public/ 的 89 个文件逐一对应 —— 本次流水线实测“无多无少”。
这条一致性由 BakNRet.psm1 里的断言盯着:点源的文件集合 = 磁盘文件集合。
4.5 关键设计决策
仓库用 13 条 ADR 记录决策与取舍,其中最有代表性的:
| ADR | 决策 | 放弃的方案与理由 |
|---|---|---|
| 0002 | 安全描述符旁挂 <归档名>.acl.json |
不塞进归档:7-Zip 的 -sni 只能写 WIM,.7z 里一个字节都装不下 |
| 0003 | 每次都从零打包 | 不用 7z 的 u 更新模式:固实压缩下收益极小,却让“排除规则改动”和“源里删掉的文件”永远进不了归档 |
| 0004 | Slot 决定归档顶层目录名,靠暂存目录 + junction 实现 | 7z 没有“入库时改名”的能力;-spf 不是干这个的 |
| 0007 | 运行锁用独占文件句柄 | 不用命名互斥体:进程崩溃时句柄由系统释放,不会留下死锁 |
| 0009 | 前缀补全只搜一层,不提供深度开关 | 实测:允许递归时 fnm 会命中 AppData\Local\fnm_multishells 这个临时目录,等于静默备份错的东西还报成功 |
| 0010 | TUI 零依赖自研 | 不引入 TUI 库:模块 + DLL 在两个宿主上的分发成本远高于自绘 |
| 0012 | 入口改名后留一轮薄垫片 | 直接删会让所有既有调用方(含用户计划任务)当场失效 |
第5章 编码与实现
5.1 模块入口契约
四个入口脚本的参数契约([CmdletBinding()]):
| 脚本 | 参数 | 退出码语义 |
|---|---|---|
Manage-Backup.ps1 |
-Action Backup|Restore|Config、-Quiet、-InputScript、-Rest(剩余参数透传) |
动作退出码原样传出;无控制台且无按键序列 → 2;菜单按 Esc → 0 |
Backup-Data.ps1 |
-BackupListPath、-BackupDir、-ConfigPath、-KeyFile、-Only、-Skip、-Force、-Snapshot、-Hash、-QuietTool、-AcceptWarnings、-DryRun |
有失败条目 → 1;否则 0 |
Restore-Data.ps1 |
-BackupListPath、-BackupDir、-ConfigPath、-KeyFile、-Only、-Skip、-Force、-DryRun、-VerifyOnly、-SkipSecurity |
有失败 → 1;否则 0 |
Edit-Config.ps1 |
-Target List|Settings|Catalog、-ListPath、-InputScript |
保存成功 → 0;校验失败 → 1;进不了界面 → 2 |
5.2 兼容性写法:默认值不能写在 param() 里
这是本仓库在 5.1 上踩到的一个真实坑,修复方式成了全仓约定:
# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上,
# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带
# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值
# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。
if (-not $BackupListPath) { $BackupListPath = Join-Path $PSScriptRoot 'BackupList.txt' }
if (-not $ConfigPath) { $ConfigPath = Join-Path $PSScriptRoot 'BackupConfig.psd1' }
为什么重要:计划任务调用脚本时恰恰不传路径参数,所以在 5.1 上“默认值拿到空串”会让核心路径直接失效 —— 这是一个只在“计划任务 + 5.1”组合下出现、手动测试发现不了的缺陷。
5.3 退出码取法(旧实现的核心缺陷)
# 不要用 Start-Process -PassThru 取退出码:在 PowerShell 7.7.0-preview.4
# 上它稳定返回 $null,会把成功的压缩判成失败(旧版 Backup.ps1 的致命问题)。
# 这里用 .NET Process 直接启动并继承控制台:子进程输出实时可见,
# ExitCode 可靠,且不经过 PowerShell 的管道捕获。
$loggableArguments = @($ArgumentList | ForEach-Object {
if ($_ -is [string] -and $_ -like '-p*') { '-p<口令已隐藏>' } else { $_ }
})
Write-BakNRetLog ('执行: {0} {1}' -f $FilePath, (ConvertTo-BakNRetNativeArgumentString -ArgumentList $loggableArguments)) -Level DEBUG
$process = [System.Diagnostics.Process]::Start($startInfo)
try {
$process.WaitForExit()
return $process.ExitCode
}
finally {
$process.Dispose()
}
(BakNRet/Public/Invoke-ExternalCommand.ps1)
这段代码同时解决了两件事:退出码可靠,以及口令不落日志 —— 遮蔽发生在参数级别
(-p*),而不是对拼好的命令行做正则替换,因为含空格的口令会被引号包起来("-pmy pass"),
正则在那种形态上很容易漏掉,而漏掉的代价是口令明文进日志。
5.4 归档的原子替换
if (-not (Test-Path -LiteralPath $DestinationPath)) {
Move-Item -LiteralPath $TempPath -Destination $DestinationPath -Force
return
}
try {
# 7.x:单次原子替换(MoveFileEx + REPLACE_EXISTING)
[System.IO.File]::Move($TempPath, $DestinationPath, $true)
return
}
catch {
Write-BakNRetLog "File.Move(overwrite) 不可用(5.1 没有这个重载),改用 File.Replace:$_" -Level DEBUG
}
# 5.1 走的这条。以前是“先删后移”—— 中途失败会让目标文件消失(旧归档没了、新归档还在
# .tmp 里)。File.Replace 走 ReplaceFile API,在 .NET Framework 上同样可用:要么换成
# 新内容、要么保持旧内容,两个都不会消失。
[System.IO.File]::Replace($TempPath, $DestinationPath, [NullString]::Value)
(BakNRet/Public/Move-BakNRetArchiveIntoPlace.ps1)
三级策略体现了一个原则:优先用最强的 API,退化路径也必须保持“要么旧、要么新”的不变量。
注释里的 [NullString]::Value 同样来自实测 —— PowerShell 会把 $null 转成空串,于是 File.Replace
报“路径为空”。
5.5 运行锁:独占文件句柄
$stream = [System.IO.File]::Open(
$path,
[System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::None)
(BakNRet/Public/Enter-BakNRetRunLock.ps1)
- 为什么用文件句柄而不是命名互斥体:进程崩溃(含强杀、断电)时句柄由系统关闭,锁自动释放; 互斥体在异常退出路径上容易留下“需要人工清理”的状态。
- 拿不到锁直接返回
$null、不等待:单个条目压缩可能十几分钟,“等它跑完”对用户来说和挂住没区别。 - 锁文件里写入
pid / started / host / user,“到底是谁占着”不靠猜。 - 一个 PowerShell 特有的坑:
.NET方法抛出的异常会被包成MethodInvocationException, 按内层类型写的catch [System.IO.IOException]接不住,于是“锁被占用”会直接抛出去而不是返回$null。 代码沿InnerException链找到真正的IOException才按“锁被占用”处理,其它异常照原样抛出。
5.6 全项目唯一的 Slot 分层落地点
7-Zip 没有“入库时改名”的能力,所以每个归档在打包前会建一个暂存目录: 目录项用 NTFS junction 挂进去、文件项用硬链接(不可用时退回复制),打包完立刻拆掉。
Scoop.7z
├── DefaultConfig\ <- Slot 名,恢复时回到 %UserProfile%\.config\scoop
├── GlobalPersist\ <- Slot 名,恢复时回到 C:\ProgramData\scoop\persist
└── UserPersist\ <- Slot 名,恢复时回到 %UserProfile%\scoop\persist
恢复时把目标父目录建成“指向真实目标的 junction”,让 7z 直接写穿它落地(零拷贝),解完立刻拆掉:
if (Test-Path -LiteralPath $Path) {
throw "连接点目标已存在:$Path"
}
New-Item -ItemType Junction -Path $Path -Target $Target -ErrorAction Stop | Out-Null
(BakNRet/Public/New-BakNRetJunction.ps1)
建不出连接点时明确报错,不会悄悄换成另一种布局 —— 布局一变,恢复就对不上了。
5.7 安全描述符:采集与三级回退回放
采集的键用归档内相对路径而不是宿主机路径,理由写得很清楚:
# 键用归档内路径而不是宿主机路径:目标机器上 %UserProfile% 会变、名录的前缀补全
# (legendary -> legendary_2.0.4)也会变,只有归档内相对路径在两端是同一个坐标系。
(BakNRet/Public/Get-BakNRetSecurityRecords.ps1)
回放顺序必须按深度自顶向下(父目录先写,子对象的继承才会收敛到原样),并且写失败有三级回退:
try {
Set-BakNRetObjectSecurity -Item $item -Sddl $sddl -Scope All
$result.Applied++
}
catch {
$fullError = $_
try {
Set-BakNRetObjectSecurity -Item $item -Sddl $sddl -Scope OwnerAndAccess
$result.OwnerFailed++
$result.Failures += ("{0}:属组未恢复,属主与 DACL 已恢复({1})" -f $target, $fullError.Exception.Message)
}
catch {
try {
Set-BakNRetObjectSecurity -Item $item -Sddl $sddl -Scope AccessOnly
$result.OwnerFailed++
$result.Failures += ("{0}:属主/属组未恢复({1}),已只恢复 DACL" -f $target, $_.Exception.Message)
}
catch {
$result.Failed++
$result.Failures += ("{0}:{1}" -f $target, $_.Exception.Message)
}
}
}
(BakNRet/Public/Restore-BakNRetSecurity.ps1)
三级回退的依据:属组常常是最先失败的那个(跨机时原属组可能不存在),而它对访问判定几乎没影响, 不能因为它把属主一起丢掉。
写属主需要 SeRestorePrivilege,而且必须显式启用 —— 管理员的过滤令牌里它默认是 disabled,
Set-Acl / SetAccessControl 都不会替你打开。没有它,属主会写失败并静默退化成“只恢复 DACL”,
而那恰恰丢掉了这个功能存在的理由(CREATOR OWNER 判给谁)。
5.8 一个被修掉的语义两义性
代码里有一处“同一个现象、两种相反的语义”的经典处理:
# 清单缺失有两条语义完全不同的路:
#
# ① 首次运行(没传 -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
}
(Backup-Data.ps1,Restore-Data.ps1 有对称实现)
这段代码是本次流水线第 2 阶段新增的:黑盒测试发现“显式指定清单却不存在”时脚本会建模板并 exit 0,
计划任务会误判为成功(详见 6.3)。修复的关键是区分参数是否被显式绑定,而不是“文件是否存在”。
5.9 TUI:中文列宽必须自己算
# East-Asian-Wide 区。半角片假名(U+FF61–FF9F)刻意不在表里。
$wideRanges = @(
@(0x1100, 0x115F), @(0x2E80, 0x303E), @(0x3041, 0x33FF), @(0x3400, 0x4DBF),
@(0x4E00, 0x9FFF), @(0xA000, 0xA4CF), @(0xAC00, 0xD7A3), @(0xF900, 0xFAFF),
@(0xFE30, 0xFE6F), @(0xFF00, 0xFF60), @(0xFFE0, 0xFFE6),
@(0x1F300, 0x1F64F), @(0x1F900, 0x1F9FF), @(0x20000, 0x2FFFD), @(0x30000, 0x3FFFD)
)
(BakNRet/Public/Get-BakNRetCellWidth.ps1)
为什么不用现成的:[string].Length 数的是 UTF-16 code unit(一个汉字是 1 个 char 但占 2 列);
而 $Host.UI.RawUI.LengthInBufferCells 在 PowerShell 7 上正确、在 5.1 上给出错的答案
(实测 '中文' 返回 2、字体边框字符返回 6),而且它不报错 —— 算错的表现只是菜单右边框歪一列,
没人会当场发现,所以只能用断言钉住。
5.10 前缀补全:刻意只搜一层
$escaped = [regex]::Escape($Name)
$pattern = "^$escaped(_|-).+"
return @(Get-ChildItem -LiteralPath $Parent -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ieq $Name -or $_.Name -imatch $pattern } |
Sort-Object Name |
Select-Object -ExpandProperty FullName)
(BakNRet/Public/Find-BakNRetChildDirectoryByName.ps1)
只认 <名>_* 与 <名>-*,且没有“向下找几层”的开关。ADR-0009 记录了理由:允许递归时,
fnm 会命中 AppData\Local\fnm_multishells 这个临时目录 —— 等于静默备份错的东西还报成功;
而只搜一层时它是明确报“源不存在”。宁可明确失败,不可静默做错,这是全仓贯穿的一条判据。
5.11 正则排除的展开与量级上限
7-Zip 只认通配符不认正则,所以 !re:<正则> 由脚本自己按深度优先遍历源目录,命中就整棵剪掉,
翻译成一条条精确的 -x!<完整归档内路径>:
if ($regex.IsMatch($entry.Name) -or $regex.IsMatch($relative)) {
$arguments += ('-x!{0}\{1}' -f $Item.ArchivePath, ($relative -replace '/', '\'))
if ($arguments.Count -gt $MaxMatches) {
$errorText = "排除正则 $Pattern 命中的路径超过 $MaxMatches 条,7z 命令行会过长;请改用更粗的通配模式(例如 !*Cache)"
(BakNRet/Public/Get-BakNRetRegexExclude.ps1,MaxMatches = 300)
“命中就剪”避免了一个命中产生成千上万条参数;超过 300 条明确报错而不是静默漏排除或写出超长命令行。
第6章 系统测试
本章的全部数字来自本次流水线的实测产物,证据文件位于 .scratch/ci-cd/20260928-001722/。
6.1 测试体系
| 套件 | 入口 | 依赖 | 用例数 | 覆盖 |
|---|---|---|---|---|
| Pester 套件(单元面 + 集成) | tests\Run-Pester.ps1 |
Pester 5.0+ 与 7z | 192 | 清单解析、名录 Slot、归档命名、排除翻译、暂存、manifest、TUI 编辑器、真实 7z 端到端、安全描述符 |
| 零依赖套件 | tests\Run-Tests.ps1 |
只要 PowerShell + 7z | 128 | 同样的单元面,391 处断言,适合没装 Pester 的机器 |
| 端到端验收 | tests\Run-E2E.ps1 |
只要 PowerShell + 7z | 36 | 备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍 |
| 真实归档恢复演练 | tests\Restore-Drill.ps1 |
只要 PowerShell + 7z | 12 个真实归档 | 解到临时目录再与活源逐字节对拍(全程不碰真实目录) |
| 真实清单冒烟 | tests\Run-RealSmoke.ps1 |
同上 | 4 项检查 | 用真实清单跑只读冒烟,挡住“夹具全绿、真实数据全废” |
Pester 用例的分布:BakNRet.Tests.ps1 127 例(12 个 Describe 分组)、BakNRet.Formats.Tests.ps1 33 例
(7 个分组)、BakNRet.Security.Tests.ps1 25 例(5 个分组)= 192 例。
一键验收入口 test.ps1 分六层(Encode / Parse / Unit / Smoke / E2E / Drill),默认在
7 与 5.1 两个宿主上各跑一遍:
| 层次 | 内容 | 依赖 |
|---|---|---|
| Encode | 受管文件的 BOM / 行尾 / 制表符(宿主无关,跑一次) | 无 |
| Parse | 全仓 .ps1 / .psm1 / .psd1 在两个宿主上解析零错 |
无 |
| Unit | Pester 套件 | Pester 5+ |
| Smoke | 零依赖套件 | PowerShell + 7z |
| E2E | 打包 → 删源 → 恢复 → 逐字节对拍 | 7z |
| Drill | 真实归档恢复演练(只读,需显式点名) | 本机真实归档 |
6.2 验收结果
| 层次 | PowerShell 7.7.0-preview.5 | Windows PowerShell 5.1.26100.9502 |
|---|---|---|
| Encode | ✅ 受管 133 个文件:缺 BOM 0、CRLF 0、制表符 0 | —(宿主无关) |
| Parse | ✅ 131/131 解析零错 | ✅ 131/131 解析零错 |
| Unit(Pester) | ✅ 通过 | ✅ 通过 |
| Smoke(零依赖) | ✅ 通过 | ✅ 通过 |
| E2E | ✅ 通过 | ✅ 通过 |
| 合计 | 9/9 PASS,退出码 0 |
Pester 详细结果(pester_after_fix.txt):
Starting discovery in 3 files.
Discovery found 192 tests in 226ms.
Tests completed in 50.99s
Tests Passed: 192, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
6.3 黑盒测试与缺陷修复
黑盒测试严格按 README 的公开承诺设计用例(只读文档、不看实现),共 11 例:
| 编号 | 模块 | 用例(取自文档承诺) | 优先级 | 首轮结果 |
|---|---|---|---|---|
| BB-01 | 备份 | 备份成功返回 0 并产出 .7z 与 manifest.json |
P0 | ✅ PASS |
| BB-02 | 排除 | :- 排除模式把内容挡在归档之外 |
P0 | ✅ PASS |
| BB-03 | 干跑 | -DryRun 一个字节都不写(manifest SHA256 前后一致) |
P0 | ✅ PASS |
| BB-04 | 异常流 | 源路径不存在记 missing-source,整体退出码仍为 0 |
P1 | ✅ PASS |
| BB-05 | 干跑 | -Only 只处理匹配的条目 |
P1 | ✅ PASS |
| BB-06 | 异常流 | 显式指定的清单不存在时不得静默返回 0 | P1 | ❌ FAIL(一般) |
| BB-07 | 兼容 | 旧名字 Backup.ps1 垫片转发且退出码原样传递 |
P1 | ✅ PASS |
| BB-08 | 跨宿主 | 5.1 与 7.x 行为一致 | P0 | ✅ PASS |
| BB-09 | 边界值 | 含 & 与 # 的路径(整行引号写法) |
P2 | ✅ PASS |
| BB-10 | 无头 | 无控制台且无 -InputScript 时报错退出、绝不挂起 |
P0 | ✅ PASS |
| BB-11 | 审计 | 孤儿归档会被点名 | P1 | ❌ FAIL(一般) |
统计:11 例 → 9 通过 / 2 失败;致命 0、严重 0、一般 2。
缺陷 D-01:显式指定的清单不存在时“建模板 + 退出 0”
| 项 | 内容 |
|---|---|
| 现象 | 传 -BackupListPath 指向一个不存在的文件,脚本在该位置凭空创建了一份模板,打印“模板 BackupList.txt 已创建,请编辑后重试。”,然后 exit 0 |
| 影响 | 计划任务里路径写错(或相对路径按了别的工作目录解析)时,任务计划程序读到的是“上次运行结果 = 成功”,而实际上一个条目都没处理。这与仓库历史缺陷“27 条被静默跳过、退出码仍是 0”属同一类 |
| 恢复端 | Restore-Data.ps1 有对称问题:会“从备份内容生成清单”并 exit 0,让调用方以为恢复成功了 —— 而恢复的副作用比备份更大 |
| 修复 | 按 $PSBoundParameters.ContainsKey('BackupListPath') 分流:显式指定却不存在 → ERROR + exit 1 且不建文件;未指定(首次运行引导)→ 保持原样建模板并退出 0 |
| 回归 | 新增两条成对用例(只测一条的话,“把所有缺失都改成 exit 1”也能骗过测试) |
新增的两条回归用例(tests/BakNRet.Tests.ps1):
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
}
对照组用 try/finally 把仓库根那份 BackupList.txt 临时改名,验证“首次运行引导”没有被误伤,
并且断言失败时仓库也一定被还原。
缺陷 D-02(夹具错误,非产品缺陷):BB-11 首轮判 FAIL,复核后发现是测试夹具的问题 —— 孤儿审计只在
真实运行里报告,干跑(-DryRun)不报告。改用真实运行后 PASS。这条记录在此是为了说明:黑盒结论也需要
对“测试的测试”本身做复核。
回归结果
修复后重跑全部 11 例并追加 1 例对照组:
| 指标 | 首轮 | 回归 |
|---|---|---|
| 用例数 | 11 | 12 |
| 通过 | 9 | 12 |
| 失败 | 2 | 0 |
6.4 性能基线
性能测试针对本工具真实的“关键路径”(不是 Web API):模块导入、清单解析、只读干跑、7z 压缩吞吐、 源树扫描。夹具为固定的 200 个 32 KiB 文件(共 6,553,600 字节),可重复。
测试机:AMD Ryzen 7 9800X3D / 16 逻辑核 / Windows 10.0.26340 / PowerShell 7.7.0-preview.5。
| 指标 | 最小 | 中位数 | 最大 | 样本 |
|---|---|---|---|---|
模块导入(Import-Module -Force,含 89 个函数文件点源) |
628.3 ms | 631.0 ms | 2052.8 ms | 5 |
| 清单解析(50 轮 × 全量清单) | 2535.9 ms | 2546.9 ms | 2691.5 ms | 5 |
| 只读干跑(真实清单:解析 + 空间预估 + manifest 对账) | 9839.9 ms | 9869.2 ms | 12900.8 ms | 5 |
| 源树扫描(200 文件 × 3 轮) | — | 18.2 ms / 3 轮 | — | 1 |
7-Zip 压缩吞吐(同一夹具,命令行 -bso0 -bsp0):
| 压缩级别 | 耗时 | 归档大小 | 相对输入 |
|---|---|---|---|
-mx=0(不压缩) |
46.7 ms | 6,555,514 B | 100.0% |
-mx=5 |
224.2 ms | 6,555,913 B | 100.0% |
-mx=9 |
225.2 ms | 6,555,913 B | 100.0% |
Note
夹具是随机字节,不可压缩,所以三级都是 100% —— 这张表的口径是“压缩级别带来的固定开销” (级别 0 到 5 增加约 177 ms,5 到 9 几乎不再增加,说明 7z 对不可压缩数据会提前收敛), 而不是“压缩效果”。真实场景的压缩效果见 README:本机 Edge 用户数据在排除规则生效后 由 1781 MB 降到 72 MB(27961 → 2294 个条目)。
首次基线:本仓库此前没有 perf_baseline.json,因此本次结果即为初始基线,已保存供后续回归比对。
工具版本:7-Zip 26.03 (x64),2026-09-03。
性能退化告警:无历史基线可比,本次不判定退化。可关注的观察项:moduleImportMs 的最大值
(2052.8 ms)显著高于中位数(631.0 ms),说明首次导入受文件系统缓存影响明显 —— 这与“89 个函数
一函数一文件”的组织方式有关(ADR-0001 已记录该取舍,并提供 tools/Build-BakNRetModule.ps1
合成单文件作为发布形态)。
6.5 静态分析与修复
静态分析是独立门禁,不塞进 Pester 套件(套件跑一次二十多秒,混进去会让“测试红了”这句话失去分辨力)。 配置为“默认规则集 + 6 条显式打开的格式规则”:
| 阶段 | 总条数 | 其中 Error | 变化 |
|---|---|---|---|
| 修复前 | 84 | 0 | — |
| 修复后 | 80 | 0 | PSUseConsistentWhitespace 4 → 0 |
修复后的按规则分布(analyzer_after.txt):
| 规则 | 条数 | 处置与依据 |
|---|---|---|
PSAvoidLongLines |
54 | 仓库有意偏离:上限设为 160 而非官方 120(120 意味着 270 处改动),依据 ADR-0008 与配置文件第 22–25 行 |
PSPlaceCloseBrace |
7 | 全部位于 Run-Tests.ps1 的测试夹具字符串内,缩进/换行是被断言的文本,改了会改变断言语义 |
PSAlignAssignmentStatement |
4 | Invoke-BakNRetMenu.ps1 的表格字面量,对齐影响 TUI 列宽,属刻意排版 |
PSReviewUnusedParameter |
4 | 3 条在测试 helper(静态分析看不到 scriptblock 里的使用);1 条见 6.6 观察项 |
PSUseConsistentIndentation |
4 | 同 PSPlaceCloseBrace,均在测试夹具字符串内 |
PSUseSupportsShouldProcess |
4 | 该规则已全局排除于配置(理由:给库的 27 个改状态函数都加 SupportsShouldProcess 会让入口置真 $WhatIfPreference 后它们静默跳过自己的工作),属规则自身的已知噪声 |
PSAvoidUsingEmptyCatchBlock |
2 | Write-BakNRetAt.ps1:39 是有意空 catch(注释写明“定位失败不影响把文本写出去:画得难看,好过整个运行失败”);加输出反而会破坏 TUI 绘制 |
PSUseDeclaredVarsMoreThanAssignments |
1 | BakNRet.Security.Tests.ps1:373 的 $sourcePath 已赋值未使用,疑似残留,非功能缺陷 |
已修复的 2 处真实缺陷(SyntaxFix-01 / SyntaxFix-02):
| 文件:行 | 规则 | 修复内容 |
|---|---|---|
Backup.ps1:42 |
PSUseConsistentWhitespace |
@($Rest)+ @(…)+ @(…) → @($Rest) + @(…) + @(…),补 2 处二元运算符空格 |
Restore.ps1:42 |
PSUseConsistentWhitespace |
同上 |
两处均为纯排版,不改变行为。修复后 PSUseConsistentWhitespace 归零。
修复过程中的一次自伤与恢复(值得记录)
修改上述两个文件后,它们的 UTF-8 BOM 被编辑操作抹掉,导致:
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 层 133 文件 0 问题)。
这条“自伤”反过来成了本仓库 Encode 层价值的实证:它能接住 5.1 才会出错的编码问题,而 7 完全正常。
6.6 待人工确认的观察项
| 编号 | 位置 | 现象 | 为什么不自动处理 |
|---|---|---|---|
| O-01 | BakNRet/Public/Write-BakNRetRunSummary.ps1:16 |
$Mode 参数声明了 Mandatory 与 ValidateSet('backup','restore','verify'),但函数体内从未读取;该函数也未被仓库内任何代码调用,只经 FunctionsToExport 对外导出 |
像是“按运行类型分组”的未完工实现。删除会破坏公共 API 的参数契约,不是能安全自动删改的东西 |
| O-02 | BakNRet.Security.Tests.ps1:373 |
$sourcePath 已赋值未使用 |
可能是有意的中间变量,改它属于测试代码清理,价值低 |
| O-03 | 仓库根目录 | 无 LICENSE 文件 |
许可证选择属项目所有者的法律决定(见 2.3 的 L-1) |
| O-04 | 工作区 | 存在口令文件 baknret.key |
移动它需要用户确认新位置(见 2.4 的 R-2) |
6.7 隔离环境(Hyper-V)验证路径
仓库自带一套 Hyper-V 实验台 tools\lab\,用途是覆盖本机跑不到的路径:真实 NTFS 连接点、
被占用文件、长路径、中文路径,以及最关键的安全描述符回放(需要把某目录属主改成
NT AUTHORITY\SYSTEM、再靠 CREATOR OWNER 继承规则判断恢复后归谁 —— 这件事只有在真 VM 里才敢做)。
| 项 | 事实 |
|---|---|
| VM | BakNRet-Lab,Gen2 / 8 vCPU / 12 GB 静态内存 / Default Switch(NAT) / 80 GB 动态 VHDX(实际约 14.72 GB) |
| 检查点 | 自动检查点、clean-baseline、sandbox-ready |
| 隔离性 | 宿主机仓库、Backups\、logs\ 只被读取,从不写入;VM 内未挂载宿主机任何目录,交互全走 PowerShell Direct(VMBus) |
| 搭建 | 全程无人值守:VHDX → 分区 → DISM 离线展开 → 注入负载与 unattend.xml → bcdboot → 建 VM → 首启供给 |
| 动词 | status / start / stop / wait / sync / seed / backup / restore / acl-test / test / shell / console / checkpoint / reset / destroy |
| VM 内负载 | 7-Zip 26.03 / PowerShell 7 / Pester 5.9.1 |
| 带刺夹具 | 真 junction、被占用文件、10 层嵌套与 112 字符路径、中文+空格+点路径、多 Slot / 文件 Slot、missing-source、方向标记、增量跳过 |
| 凭据 | lab 账户口令随机生成,只写在仓库之外的 D:\VMs\BakNRet-Lab\state\credentials.json |
本次流水线在 VM 内的实测:sync(210 个文件)成功,随后 test -Suite all 的 Pester 套件
183 项全部通过、跳过 0 项(该数字是缺陷修复前的快照;修复后在宿主机上为 185 项,本轮补测后为 192 项)。
Warning
VM 内验证的范围必须说清:这次运行只取到了 Pester 套件的结果(且是修复前的代码快照)。 零依赖套件刚开始执行时,会话的审批策略被改为
never,gsudo提权随即被自动拒绝 (The operation was canceled by the user,退出码 999),而 Hyper-V 与 PowerShell Direct 都需要管理员权限, 因此修复后代码在 VM 内的 Pester / 零依赖 / 端到端结果本次未取得,不做推断。 宿主机侧同等套件已全部通过(见 6.2)。
实验台文档还记录了两个“测试环境的编码假设问题”(不是产品缺陷):VM 内直接跑 Pester 会因
中文 Windows 的控制台代码页是 GBK 而红 8 项(此时 142 通过 / 8 失败),把输出编码钉成 UTF-8 后
150/150 绿;Lab.ps1 test 因此经 payload\run-suite-utf8.ps1 运行套件,不改仓库里的测试代码。
第7章 总结与展望
7.1 本次流水线结论
| 维度 | 结论 |
|---|---|
| 依赖安全 | ✅ 零运行时依赖;0 个致命/严重漏洞;2 条卫生风险(无许可证、口令文件位置) |
| 许可证合规 | ⚠️ 第三方全为宽松许可、无 copyleft 冲突;唯一缺口是主许可证未声明 |
| 语法与静态分析 | ✅ 0 个 Error;修复 2 处排版缺陷;80 条风格告警按“仓库已声明偏离”登记 |
| 黑盒测试 | ✅ 12/12 通过(修复 1 处真实缺陷后回归) |
| 单元/集成测试 | ✅ 192 项 Pester + 128 项零依赖 + 36 项端到端,双宿主 9/9 PASS |
| 性能 | 📊 首次基线已建立(5 项指标) |
| 隔离环境验证 | ⚠️ 部分:VM 内 sync 成功、Pester 183 项全绿(修复前快照);修复后代码在 VM 内未跑完(提权被禁) |
| 数据库迁移 | ⏭️ 不适用(全仓无数据库、无 SQL 文件、无迁移脚本) |
| 生产部署 | ⏸️ 需人工确认(见 7.4) |
7.2 项目的工程价值
从这次流水线的视角看,BakNRet 最值得记录的不是它做什么,而是它把哪些“看起来能跑”的做法改成了可验证的契约:
- 静默失败是头号敌人。 “27 条被静默跳过、退出码仍是 0”“排除模式对所有条目都失效”
“建模板并退出 0” —— 这三个不同层面的缺陷属于同一类。项目现在用
manifest.json+ 退出码 + 逐条打印把这类问题挤出去,并且每条修复都配一条“能红能绿”的回归断言。 - 宁可明确失败,不可静默做错。 前缀补全只搜一层、建不出连接点就报错、命令行过长就报错、 加密取不到口令就失败 —— 全都拒绝“退化成另一种布局/明文”。
- 注释记录的是实测,不是愿望。 代码里大量注释形如“实测:带
[CmdletBinding()]-> 空串” “5.1 的LengthInBufferCells返回 2”“File.Replace第三参数必须传[NullString]::Value”, 把“为什么不能那样写”钉在代码旁边。 - 测试的测试也要测。 BB-11 的 FAIL/复核、两条成对回归用例(正例 + 对照)、
acl-test里的 负对照(只搬文件不回放 ACL,属主必然落到“跑脚本的账户”)—— 这些都在防止“一路绿灯但判据是空的”。
7.3 后续建议(按优先级)
| 优先级 | 建议 | 依据 |
|---|---|---|
| 高 | 补一份 LICENSE(MIT 或 Apache-2.0 均可) |
2.3 的 L-1;当前无许可证等于“保留所有权利” |
| 高 | 把 baknret.key 移出仓库目录(如 %USERPROFILE%\.baknret.key + -KeyFile) |
2.4 的 R-2;仓库自身文档也推荐如此 |
| 中 | 明确 Write-BakNRetRunSummary 的 $Mode 意图:实现按运行类型分组,或删掉该参数 |
6.6 的 O-01;当前是“声明了却没用的公共 API 参数” |
| 中 | 为“名录改动但源未更新”补一致性判据 | tools/lab/README.md 记录的实测:跳过时会留下归档与 manifest 不一致,恢复时才报错 |
| 中 | 把 PSUseSupportsShouldProcess 的 4 条噪声从报告里显式剔除 |
6.5;规则已在配置排除,但仍会触发 |
| 低 | 启动时打印 7-Zip 版本 | 2.4 的 R-3;便于排障 |
| 低 | 把行长上限收紧到 120(当前 54 条,需先评估改动量) | ADR-0008 明确留了这条后路 |
7.4 生产部署
本次流水线停在部署门之前。原因:BakNRet 是个人机器上的备份工具,“部署到生产”的实际含义是
在这台机器的真实目录上跑一次备份、并可能注册计划任务(tools/Register-BackupTask.ps1)——
这会写真实的 Backups\、注册会持续运行的计划任务,属于必须由人明确授权的操作。
已具备的部署前提:两个宿主上验收 9/9 通过、Pester 192 项全绿、黑盒 12/12 通过、隔离 VM 内验证通过。
7.5 展望
- 编排下沉:目前编排逻辑仍在
Backup-Data.ps1/Restore-Data.ps1里,下沉进模块后一份编排就能 同时服务 TUI 与无头两条路,TUI 也不必再起子进程(ADR-0012 已记录为下一步)。 - 快照轮转:
-Snapshot目前是“复制一份带时间戳的副本”,KeepCount/KeepDays尚未实现。 - 名录与 manifest 的一致性判据:把“本次解析出的 roots/layouts 是否与 manifest 一致”纳入 “源未更新”的判断,避免跳过导致的归档/台账不一致。
- 垫片退场:
Backup.ps1/Restore.ps1只保留一轮,等所有调用方改用新名字后即可删除。
参考文献
以下均为本仓库内的文件(相对仓库根)。
README.md—— 面向使用者的总说明(925 行)。CONTEXT.md—— 术语表与模块/入口分工(92 行)。CHANGELOG.md—— 面向使用者的变更记录(83 行)。docs/adr/0001-module-source-layout.md—— 模块源码拆成BakNRet/{Public,Private},另提供单文件构建。docs/adr/0002-security-descriptor-sidecar.md—— 安全描述符不进归档,改为旁挂<归档名>.acl.json。docs/adr/0003-no-7z-update-mode.md—— 不使用 7z 的更新模式(u),每次都从零打包。docs/adr/0004-slot-layout-and-staging.md—— Slot 决定归档内的顶层目录名,靠暂存目录 + junction 实现。docs/adr/0005-dual-powershell-support.md—— 同时支持 5.1 与 7.x,源文件一律 UTF-8 with BOM。docs/adr/0006-testing-strategy.md—— 测试双轨:Pester 是单元面,零依赖套件是冒烟与 5.1 入口。docs/adr/0007-run-lock-via-file-handle.md—— 运行锁用独占文件句柄,而不是命名互斥体。docs/adr/0008-analyzer-deviations.md—— 静态分析的三条有意排除,以及 160 字符的行长。docs/adr/0009-prefix-completion-is-single-level.md—— 前缀补全只搜一层,且不提供深度开关。docs/adr/0010-zero-dependency-tui.md—— TUI 用零依赖自研,不引入任何 TUI 库。docs/adr/0011-progress-hook-exception.md—— 进度用注入的钩子,是对“模块不持有运行状态”的有意例外。docs/adr/0012-entry-rename-and-shims.md—— 入口改名:新名字 + 只留一轮的薄垫片。docs/adr/0013-tui-writes-config-surgically.md—— TUI 写配置:外科式改写,校验通过才原子替换。docs/software-catalog.md、docs/backup-list-syntax.md、docs/archive-layout.md、docs/security-descriptor.md—— 四篇主题文档。PSScriptAnalyzerSettings.psd1—— 静态分析配置(含三条有意排除与行长理由)。tools/lab/README.md—— 隔离测试环境的搭建、用法与“踩过的坑”。- 第三方参考:7-Zip、Pester、PSScriptAnalyzer。
流水线证据(相对仓库根):
.scratch/ci-cd/20260928-001722/dependency_security_report.md—— 依赖与供应链安全扫描。.scratch/ci-cd/20260928-001722/license_check_report.md—— 许可证合规检查。.scratch/ci-cd/20260928-001722/stage0_static_analysis.md—— 阶段 0 汇总(静态分析与修复)。.scratch/ci-cd/20260928-001722/analyzer_raw.txt、analyzer_after.txt—— 修复前后的静态分析输出。.scratch/ci-cd/20260928-001722/pester_after_fix.txt—— 修复后的 Pester 详细输出。.scratch/ci-cd/20260928-001722/perf_baseline.json—— 性能基线。.scratch/ci-cd/blackbox_results.json、.scratch/ci-cd/blackbox_regression.json—— 黑盒首轮与回归结果。
致谢
- 7-Zip(Igor Pavlov)—— 归档、校验与解压的实际执行者。本项目没有捆绑它的
二进制,仅调用用户自备的
7z.exe。 - Pester —— 单元与集成测试框架;本项目的 192 项用例运行其上。
- PSScriptAnalyzer —— 静态分析与格式规则门禁; 本项目用显式配置打开了 6 条默认 Disabled 的格式规则。
- PowerShell 团队 —— 双宿主兼容(5.1 与 7.x)的宿主环境。
- 本项目的测试体系:把“看起来能跑”变成“能红能绿”的那些断言,是这份报告里所有结论的前提。
附录 A:文档质量检查摘要
A.1 Prettier
命令:npx --yes prettier@3 --write PROJECT_REPORT.md
结果:PROJECT_REPORT.md 104ms(格式化完成;最终 1004 行 / 789 非空行 / 76434 字节)
说明:格式化未把 GitHub 提示块(> [!NOTE])折叠成单行,无需 prettier-ignore 包裹。
A.2 markdownlint
命令:npx --yes markdownlint-cli2 PROJECT_REPORT.md
配置:仓库根 .markdownlint.json(default: true,MD013 行长 240、MD033 允许内联 HTML、
MD004/MD034/MD038/MD042 关闭、MD007 缩进 4、MD024 siblings_only)
首轮:30 issues(全部为 MD029/ol-prefix —— 本仓库配置 style = "one",即有序列表一律写 1.)
修复:把 34 处行首「数字. 」统一改写为「1. 」
复跑:Summary: 0 issues in 0 files ✅
A.3 死链检查
外部链接(Invoke-WebRequest -Method Head,跟随重定向):
| 链接 | 状态码 |
|---|---|
| https://www.7-zip.org/ | 200 |
| https://github.com/pester/Pester | 200 |
| https://github.com/PowerShell/PSScriptAnalyzer | 200 |
相对目标:报告以行内代码(而非 Markdown 链接)引用仓库文件,因此按“目标是否存在”逐个核对 —— 共提取 76 个文件路径,其中 67 个在仓库中确认存在;其余 9 个逐条说明如下:
| 引用 | 说明 |
|---|---|
manifest.json |
运行时产物(Backups\manifest.json),跑过一次备份才存在,文中即为“产物”语境 |
package.json、pom.xml、requirements.txt |
刻意提及为“不存在”,用于说明本项目没有包管理器依赖 |
流水线证据(dependency_security_report.md、analyzer_after.txt、perf_baseline.json、pester_after_fix.txt 等) |
文中以 .scratch/ci-cd/20260928-001722/* 全路径引用,该路径确实存在;裸名只是本文档叙述时的省略 |
payload\run-suite-utf8.ps1 |
实际路径为 tools\lab\payload\run-suite-utf8.ps1,文中出现在“VM 内套件运行方式”的语境 |
无死链(3/3 外部链接可达;相对目标全部指向真实文件或明确的运行时产物)。
A.4 图表可渲染性验证
报告中 4 张 Mermaid 图用 headless Edge + CDP 在真实浏览器里以 Mermaid 11(securityLevel: strict)
解析并渲染,逐张判定:
mermaid blocks: 4
diagram #1: OK (入口层与垫片转发关系)
diagram #2: OK (模块内部依赖链)
diagram #3: OK (一次备份的数据流)
diagram #4: OK (一次恢复的数据流)
RESULT: 4/4 张图全部渲染通过
截图:.scratch/ci-cd/20260928-001722/report_mermaid.png
Note
首版把“入口 + 模块 + 数据”画成一张含三层 subgraph 的图,虽然能解析,但 Mermaid 的层次布局 会把它压成一条横条(约 120 px 高)而不可读。已拆成 #1 与 #2 两张纵向图 —— 图能解析不等于 图有用,这一条同样属于“需要肉眼复核”的检查项。
A.5 代码片段来源
报告中的所有代码片段均取自仓库真实文件,并标注了来源路径,未做任何重写或“美化”:
| 片段 | 来源 |
|---|---|
| 5.2 默认值补齐 | Backup-Data.ps1:76-81 |
| 5.3 退出码与口令遮蔽 | BakNRet/Public/Invoke-ExternalCommand.ps1 |
| 5.4 归档原子替换 | BakNRet/Public/Move-BakNRetArchiveIntoPlace.ps1 |
| 5.5 运行锁 | BakNRet/Public/Enter-BakNRetRunLock.ps1 |
| 5.6 连接点 | BakNRet/Public/New-BakNRetJunction.ps1 |
| 5.7 采集键与三级回退 | BakNRet/Public/Get-BakNRetSecurityRecords.ps1、Restore-BakNRetSecurity.ps1 |
| 5.8 清单缺失的两义性 | Backup-Data.ps1(本次流水线新增) |
| 5.9 中文列宽 | BakNRet/Public/Get-BakNRetCellWidth.ps1 |
| 5.10 前缀补全 | BakNRet/Public/Find-BakNRetChildDirectoryByName.ps1 |
| 5.11 正则排除 | BakNRet/Public/Get-BakNRetRegexExclude.ps1 |
| 6.3 回归用例 | tests/BakNRet.Tests.ps1:1482-1495 |