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。
1012 lines
76 KiB
Markdown
1012 lines
76 KiB
Markdown
# 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` 占位符 —— 不恢复属主,原程序(服务账户 / 专用用户)恢复后就没有权限。
|
||
1. **归档内用“Slot”分层**,让同一个软件里两个都叫 `persist` 的目录不再互相覆盖,恢复时也能精确地
|
||
“只解出这一棵子树”。
|
||
1. **只读模式一个字节都不写**(`-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章-绪论)
|
||
- [第2章 开发技术](#第2章-开发技术)
|
||
- [第3章 需求分析](#第3章-需求分析)
|
||
- [第4章 详细设计](#第4章-详细设计)
|
||
- [第5章 编码与实现](#第5章-编码与实现)
|
||
- [第6章 系统测试](#第6章-系统测试)
|
||
- [第7章 总结与展望](#第7章-总结与展望)
|
||
- [参考文献](#参考文献)
|
||
- [致谢](#致谢)
|
||
- [附录 A:文档质量检查摘要](#附录-a文档质量检查摘要)
|
||
|
||
## 第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 整体架构
|
||
|
||
```mermaid
|
||
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
|
||
```
|
||
|
||
```mermaid
|
||
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 备份数据流
|
||
|
||
```mermaid
|
||
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 恢复数据流
|
||
|
||
```mermaid
|
||
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 上踩到的一个真实坑,修复方式成了全仓约定:
|
||
|
||
```powershell
|
||
# 默认值不能写在 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 退出码取法(旧实现的核心缺陷)
|
||
|
||
```powershell
|
||
# 不要用 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 归档的原子替换
|
||
|
||
```powershell
|
||
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 运行锁:独占文件句柄
|
||
|
||
```powershell
|
||
$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 挂进去、文件项用硬链接(不可用时退回复制),打包完立刻拆掉。
|
||
|
||
```text
|
||
Scoop.7z
|
||
├── DefaultConfig\ <- Slot 名,恢复时回到 %UserProfile%\.config\scoop
|
||
├── GlobalPersist\ <- Slot 名,恢复时回到 C:\ProgramData\scoop\persist
|
||
└── UserPersist\ <- Slot 名,恢复时回到 %UserProfile%\scoop\persist
|
||
```
|
||
|
||
恢复时把目标父目录建成“指向真实目标的 junction”,让 7z 直接写穿它落地(零拷贝),解完立刻拆掉:
|
||
|
||
```powershell
|
||
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 安全描述符:采集与三级回退回放
|
||
|
||
采集的键用**归档内相对路径**而不是宿主机路径,理由写得很清楚:
|
||
|
||
```powershell
|
||
# 键用归档内路径而不是宿主机路径:目标机器上 %UserProfile% 会变、名录的前缀补全
|
||
# (legendary -> legendary_2.0.4)也会变,只有归档内相对路径在两端是同一个坐标系。
|
||
```
|
||
|
||
(`BakNRet/Public/Get-BakNRetSecurityRecords.ps1`)
|
||
|
||
回放顺序**必须按深度自顶向下**(父目录先写,子对象的继承才会收敛到原样),并且写失败有三级回退:
|
||
|
||
```powershell
|
||
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 一个被修掉的语义两义性
|
||
|
||
代码里有一处“同一个现象、两种相反的语义”的经典处理:
|
||
|
||
```powershell
|
||
# 清单缺失有两条语义完全不同的路:
|
||
#
|
||
# ① 首次运行(没传 -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:中文列宽必须自己算
|
||
|
||
```powershell
|
||
# 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 前缀补全:刻意只搜一层
|
||
|
||
```powershell
|
||
$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!<完整归档内路径>`:
|
||
|
||
```powershell
|
||
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`):
|
||
|
||
```text
|
||
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`):
|
||
|
||
```powershell
|
||
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 被编辑操作抹掉**,导致:
|
||
|
||
```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 层 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` + 退出码 +
|
||
逐条打印把这类问题挤出去,并且每条修复都配一条“能红能绿”的回归断言。
|
||
1. **宁可明确失败,不可静默做错。** 前缀补全只搜一层、建不出连接点就报错、命令行过长就报错、
|
||
加密取不到口令就失败 —— 全都拒绝“退化成另一种布局/明文”。
|
||
1. **注释记录的是实测,不是愿望。** 代码里大量注释形如“实测:带 `[CmdletBinding()]` -> 空串”
|
||
“5.1 的 `LengthInBufferCells` 返回 2”“`File.Replace` 第三参数必须传 `[NullString]::Value`”,
|
||
把“为什么不能那样写”钉在代码旁边。
|
||
1. **测试的测试也要测。** 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 行)。
|
||
1. `CONTEXT.md` —— 术语表与模块/入口分工(92 行)。
|
||
1. `CHANGELOG.md` —— 面向使用者的变更记录(83 行)。
|
||
1. `docs/adr/0001-module-source-layout.md` —— 模块源码拆成 `BakNRet/{Public,Private}`,另提供单文件构建。
|
||
1. `docs/adr/0002-security-descriptor-sidecar.md` —— 安全描述符不进归档,改为旁挂 `<归档名>.acl.json`。
|
||
1. `docs/adr/0003-no-7z-update-mode.md` —— 不使用 7z 的更新模式(`u`),每次都从零打包。
|
||
1. `docs/adr/0004-slot-layout-and-staging.md` —— Slot 决定归档内的顶层目录名,靠暂存目录 + junction 实现。
|
||
1. `docs/adr/0005-dual-powershell-support.md` —— 同时支持 5.1 与 7.x,源文件一律 UTF-8 with BOM。
|
||
1. `docs/adr/0006-testing-strategy.md` —— 测试双轨:Pester 是单元面,零依赖套件是冒烟与 5.1 入口。
|
||
1. `docs/adr/0007-run-lock-via-file-handle.md` —— 运行锁用独占文件句柄,而不是命名互斥体。
|
||
1. `docs/adr/0008-analyzer-deviations.md` —— 静态分析的三条有意排除,以及 160 字符的行长。
|
||
1. `docs/adr/0009-prefix-completion-is-single-level.md` —— 前缀补全只搜一层,且不提供深度开关。
|
||
1. `docs/adr/0010-zero-dependency-tui.md` —— TUI 用零依赖自研,不引入任何 TUI 库。
|
||
1. `docs/adr/0011-progress-hook-exception.md` —— 进度用注入的钩子,是对“模块不持有运行状态”的有意例外。
|
||
1. `docs/adr/0012-entry-rename-and-shims.md` —— 入口改名:新名字 + 只留一轮的薄垫片。
|
||
1. `docs/adr/0013-tui-writes-config-surgically.md` —— TUI 写配置:外科式改写,校验通过才原子替换。
|
||
1. `docs/software-catalog.md`、`docs/backup-list-syntax.md`、`docs/archive-layout.md`、`docs/security-descriptor.md` —— 四篇主题文档。
|
||
1. `PSScriptAnalyzerSettings.psd1` —— 静态分析配置(含三条有意排除与行长理由)。
|
||
1. `tools/lab/README.md` —— 隔离测试环境的搭建、用法与“踩过的坑”。
|
||
1. 第三方参考:[7-Zip](https://www.7-zip.org/)、[Pester](https://github.com/pester/Pester)、[PSScriptAnalyzer](https://github.com/PowerShell/PSScriptAnalyzer)。
|
||
|
||
流水线证据(相对仓库根):
|
||
|
||
1. `.scratch/ci-cd/20260928-001722/dependency_security_report.md` —— 依赖与供应链安全扫描。
|
||
1. `.scratch/ci-cd/20260928-001722/license_check_report.md` —— 许可证合规检查。
|
||
1. `.scratch/ci-cd/20260928-001722/stage0_static_analysis.md` —— 阶段 0 汇总(静态分析与修复)。
|
||
1. `.scratch/ci-cd/20260928-001722/analyzer_raw.txt`、`analyzer_after.txt` —— 修复前后的静态分析输出。
|
||
1. `.scratch/ci-cd/20260928-001722/pester_after_fix.txt` —— 修复后的 Pester 详细输出。
|
||
1. `.scratch/ci-cd/20260928-001722/perf_baseline.json` —— 性能基线。
|
||
1. `.scratch/ci-cd/blackbox_results.json`、`.scratch/ci-cd/blackbox_regression.json` —— 黑盒首轮与回归结果。
|
||
|
||
## 致谢
|
||
|
||
- **[7-Zip](https://www.7-zip.org/)**(Igor Pavlov)—— 归档、校验与解压的实际执行者。本项目没有捆绑它的
|
||
二进制,仅调用用户自备的 `7z.exe`。
|
||
- **[Pester](https://github.com/pester/Pester)** —— 单元与集成测试框架;本项目的 192 项用例运行其上。
|
||
- **[PSScriptAnalyzer](https://github.com/PowerShell/PSScriptAnalyzer)** —— 静态分析与格式规则门禁;
|
||
本项目用显式配置打开了 6 条默认 Disabled 的格式规则。
|
||
- **PowerShell 团队** —— 双宿主兼容(5.1 与 7.x)的宿主环境。
|
||
- 本项目的**测试体系**:把“看起来能跑”变成“能红能绿”的那些断言,是这份报告里所有结论的前提。
|
||
|
||
## 附录 A:文档质量检查摘要
|
||
|
||
### A.1 Prettier
|
||
|
||
```text
|
||
命令:npx --yes prettier@3 --write PROJECT_REPORT.md
|
||
结果:PROJECT_REPORT.md 104ms(格式化完成;最终 1004 行 / 789 非空行 / 76434 字节)
|
||
说明:格式化未把 GitHub 提示块(> [!NOTE])折叠成单行,无需 prettier-ignore 包裹。
|
||
```
|
||
|
||
### A.2 markdownlint
|
||
|
||
```text
|
||
命令: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`)
|
||
解析并渲染,逐张判定:
|
||
|
||
```text
|
||
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` |
|