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

1012 lines
76 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/&#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`)
解析并渲染,逐张判定:
```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` |