Files
BakNRet/PROJECT_REPORT.md
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

76 KiB
Raw Permalink Blame History

BakNRet 项目报告

本报告由 CI/CD 流水线的第 5 阶段(文档生成与质量检查)产出,100% 基于仓库实际内容与本次流水线的实测证据。 仓库路径:D:\Workspace\Temp\BakNRet 分支:refactor/ms-conventions 基线提交:d72fe63 报告日期:2026-09-28

摘要

BakNRet 是一个面向 Windows 的备份与恢复工具:它把一份纯文本清单(BackupList.txt)里列出的软件与目录, 用 7-Zip 打包进 Backups\ 目录,并能在需要时把它们原样放回原位。与常见的“打包脚本”不同,BakNRet 把恢复后的可用性当作第一目标,因此在三个方向上做了普通备份脚本通常不做的事:

  1. 随归档一起保存 NTFS 安全描述符(属主 / 属组 / DACL 旁挂成 <归档名>.acl.json)。7-Zip 的 .7z 格式在官方文档里就写明装不下 NTFS 安全信息(-sni 仅支持 WIM),而 C:\ProgramData 下的目录依赖 CREATOR OWNER 占位符 —— 不恢复属主,原程序(服务账户 / 专用用户)恢复后就没有权限。
  2. 归档内用“Slot”分层,让同一个软件里两个都叫 persist 的目录不再互相覆盖,恢复时也能精确地 “只解出这一棵子树”。
  3. 只读模式一个字节都不写(-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 最值得记录的不是它做什么,而是它把哪些“看起来能跑”的做法改成了可验证的契约:

  1. 静默失败是头号敌人。 “27 条被静默跳过、退出码仍是 0”“排除模式对所有条目都失效” “建模板并退出 0” —— 这三个不同层面的缺陷属于同一类。项目现在用 manifest.json + 退出码 + 逐条打印把这类问题挤出去,并且每条修复都配一条“能红能绿”的回归断言。
  2. 宁可明确失败,不可静默做错。 前缀补全只搜一层、建不出连接点就报错、命令行过长就报错、 加密取不到口令就失败 —— 全都拒绝“退化成另一种布局/明文”。
  3. 注释记录的是实测,不是愿望。 代码里大量注释形如“实测:带 [CmdletBinding()] -> 空串” “5.1 的 LengthInBufferCells 返回 2”“File.Replace 第三参数必须传 [NullString]::Value”, 把“为什么不能那样写”钉在代码旁边。
  4. 测试的测试也要测。 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 只保留一轮,等所有调用方改用新名字后即可删除。

参考文献

以下均为本仓库内的文件(相对仓库根)。

  1. README.md —— 面向使用者的总说明(925 行)。
  2. CONTEXT.md —— 术语表与模块/入口分工(92 行)。
  3. CHANGELOG.md —— 面向使用者的变更记录(83 行)。
  4. docs/adr/0001-module-source-layout.md —— 模块源码拆成 BakNRet/{Public,Private},另提供单文件构建。
  5. docs/adr/0002-security-descriptor-sidecar.md —— 安全描述符不进归档,改为旁挂 <归档名>.acl.json。
  6. docs/adr/0003-no-7z-update-mode.md —— 不使用 7z 的更新模式(u),每次都从零打包。
  7. docs/adr/0004-slot-layout-and-staging.md —— Slot 决定归档内的顶层目录名,靠暂存目录 + junction 实现。
  8. docs/adr/0005-dual-powershell-support.md —— 同时支持 5.1 与 7.x,源文件一律 UTF-8 with BOM。
  9. docs/adr/0006-testing-strategy.md —— 测试双轨:Pester 是单元面,零依赖套件是冒烟与 5.1 入口。
  10. docs/adr/0007-run-lock-via-file-handle.md —— 运行锁用独占文件句柄,而不是命名互斥体。
  11. docs/adr/0008-analyzer-deviations.md —— 静态分析的三条有意排除,以及 160 字符的行长。
  12. docs/adr/0009-prefix-completion-is-single-level.md —— 前缀补全只搜一层,且不提供深度开关。
  13. docs/adr/0010-zero-dependency-tui.md —— TUI 用零依赖自研,不引入任何 TUI 库。
  14. docs/adr/0011-progress-hook-exception.md —— 进度用注入的钩子,是对“模块不持有运行状态”的有意例外。
  15. docs/adr/0012-entry-rename-and-shims.md —— 入口改名:新名字 + 只留一轮的薄垫片。
  16. docs/adr/0013-tui-writes-config-surgically.md —— TUI 写配置:外科式改写,校验通过才原子替换。
  17. docs/software-catalog.md、docs/backup-list-syntax.md、docs/archive-layout.md、docs/security-descriptor.md —— 四篇主题文档。
  18. PSScriptAnalyzerSettings.psd1 —— 静态分析配置(含三条有意排除与行长理由)。
  19. tools/lab/README.md —— 隔离测试环境的搭建、用法与“踩过的坑”。
  20. 第三方参考:7-Zip、Pester、PSScriptAnalyzer。

流水线证据(相对仓库根):

  1. .scratch/ci-cd/20260928-001722/dependency_security_report.md —— 依赖与供应链安全扫描。
  2. .scratch/ci-cd/20260928-001722/license_check_report.md —— 许可证合规检查。
  3. .scratch/ci-cd/20260928-001722/stage0_static_analysis.md —— 阶段 0 汇总(静态分析与修复)。
  4. .scratch/ci-cd/20260928-001722/analyzer_raw.txt、analyzer_after.txt —— 修复前后的静态分析输出。
  5. .scratch/ci-cd/20260928-001722/pester_after_fix.txt —— 修复后的 Pester 详细输出。
  6. .scratch/ci-cd/20260928-001722/perf_baseline.json —— 性能基线。
  7. .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/&#42; 全路径引用,该路径确实存在;裸名只是本文档叙述时的省略
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