# 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 结构;归档内 `\<内容>` | | 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
TUI 菜单 / -Action"] BD["Backup-Data.ps1
备份动作"] RD["Restore-Data.ps1
恢复动作"] EC["Edit-Config.ps1
配置编辑器"] SH["Backup.ps1 / Restore.ps1
旧名字垫片,只留一轮"] MB --> BD MB --> RD MB --> EC SH --> BD SH --> RD ``` ```mermaid flowchart TB BD["Backup-Data.ps1 / Restore-Data.ps1"] BD --> M1["日志与运行锁"] BD --> M2["外部命令封装
取真实退出码"] BD --> M3["清单解析
方向 / 排除 / 追加 / 覆盖"] BD --> M5["归档命名与暂存目录
junction 与硬链接"] BD --> M6["manifest 与原子替换"] BD --> M7["安全描述符
采集与回放"] M1 --> M5 M2 --> M3 M3 --> M4["软件名录与 Slot 解析"] M4 --> M5 M5 --> M6 M6 --> M7 EC2["Edit-Config.ps1"] --> M8["TUI 控件
菜单 / 按键序列 / 列宽"] M8 -. 驱动 .-> EC2 ``` **分层意图**:入口层只做“解析参数 → 调用模块 → `exit` 退出码”,模块层不持有运行状态(唯一的例外是 ADR-0011 记录的“进度钩子注入点”)。这样同一份能力既能服务 TUI,也能服务无头调用。 ### 4.2 备份数据流 ```mermaid flowchart TD subgraph repo["仓库(唯一真相)"] BL["BackupList.txt
要处理什么"] SC["SoftwareCatalog.psd1
软件名到 Slot 组"] BC["BackupConfig.psd1
目录 / 校验 / 加密"] end BL --> P["解析清单
方向 / 排除 / 追加 / 覆盖"] SC --> P BC --> P P --> PLAN["打印计划 + 空间预估
只读,-DryRun 到此为止"] PLAN --> STAGE["建暂存目录
目录走 junction,文件走硬链接"] STAGE --> SZ["7z 打包
每次都从零,不用更新模式"] SZ --> TMP["写临时归档 .tmp"] TMP --> V{"7z t 校验通过?"} V -- 否 --> DISC["丢弃临时文件
旧归档原封不动"] V -- 是 --> SWAP["原子替换
File.Move 或 File.Replace"] SWAP --> DONE["归档 + manifest.json
+ 同名 .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
7z 直接写穿它,零拷贝"] J1 --> J2["解出这一棵子树
缺 Slot 层时退回旧布局"] J2 --> J3["拆掉 junction"] K -- 文件 --> F1["解到临时目录
再搬到 Path 指定的位置"] J3 --> ACL["按 acl.json 自顶向下回放
属主 / 属组 / DACL"] F1 --> ACL ACL --> R["打印逐项结果
按失败数 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`,跟随重定向): | 链接 | 状态码 | | ------------------------------------------------ | ------ | | | 200 | | | 200 | | | 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` |