diff --git a/.scratch/_verify/mermaid.png b/.scratch/_verify/mermaid.png new file mode 100644 index 0000000..c02cd41 Binary files /dev/null and b/.scratch/_verify/mermaid.png differ diff --git a/.scratch/_verify/report_mermaid.png b/.scratch/_verify/report_mermaid.png new file mode 100644 index 0000000..d0b5a58 Binary files /dev/null and b/.scratch/_verify/report_mermaid.png differ diff --git a/.scratch/ci-cd/20260928-001722/analyzer_after.txt b/.scratch/ci-cd/20260928-001722/analyzer_after.txt new file mode 100644 index 0000000..ec6b808 --- /dev/null +++ b/.scratch/ci-cd/20260928-001722/analyzer_after.txt @@ -0,0 +1,14 @@ + +PSScriptAnalyzer 1.25.0(D:\Workspace\Temp\BakNRet\.tools\modules\PSScriptAnalyzer\1.25.0) +分析 131 个文件,配置 D:\Workspace\Temp\BakNRet\PSScriptAnalyzerSettings.psd1 + +共 80 条,按规则汇总: + 54 PSAvoidLongLines + 7 PSPlaceCloseBrace + 4 PSAlignAssignmentStatement + 4 PSReviewUnusedParameter + 4 PSUseConsistentIndentation + 4 PSUseSupportsShouldProcess + 2 PSAvoidUsingEmptyCatchBlock + 1 PSUseDeclaredVarsMoreThanAssignments + diff --git a/.scratch/ci-cd/20260928-001722/analyzer_raw.txt b/.scratch/ci-cd/20260928-001722/analyzer_raw.txt new file mode 100644 index 0000000..a4c98c6 --- /dev/null +++ b/.scratch/ci-cd/20260928-001722/analyzer_raw.txt @@ -0,0 +1,101 @@ + +PSScriptAnalyzer 1.25.0(D:\Workspace\Temp\BakNRet\.tools\modules\PSScriptAnalyzer\1.25.0) +分析 131 个文件,配置 D:\Workspace\Temp\BakNRet\PSScriptAnalyzerSettings.psd1 + +共 84 条,按规则汇总: + 54 PSAvoidLongLines + 7 PSPlaceCloseBrace + 4 PSAlignAssignmentStatement + 4 PSReviewUnusedParameter + 4 PSUseConsistentIndentation + 4 PSUseConsistentWhitespace + 4 PSUseSupportsShouldProcess + 2 PSAvoidUsingEmptyCatchBlock + 1 PSUseDeclaredVarsMoreThanAssignments + +逐条: + Backup-Data.ps1:293 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Backup-Data.ps1:299 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Backup-Data.ps1:602 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Backup-Data.ps1:822 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Backup-Data.ps1:832 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Backup.ps1:42 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Backup.ps1:42 [PSUseConsistentWhitespace] Use space before and after binary and assignment operators. + Backup.ps1:42 [PSUseConsistentWhitespace] Use space before and after binary and assignment operators. + BakNRet.Formats.Tests.ps1:384 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + BakNRet.Security.Tests.ps1:171 [PSReviewUnusedParameter] The parameter 'Root' has been declared but not used. + BakNRet.Security.Tests.ps1:355 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + BakNRet.Security.Tests.ps1:373 [PSUseDeclaredVarsMoreThanAssignments] The variable 'sourcePath' is assigned but never used. + BakNRet.Tests.ps1:633 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + BakNRet.Tests.ps1:937 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + BakNRet.Tests.ps1:980 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + BakNRet.Tests.ps1:1030 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + BakNRet.Tests.ps1:1285 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + BakNRet.Tests.ps1:1359 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Edit-Config.ps1:63 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Edit-Config.ps1:73 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Get-BakNRetSoftwareCatalog.ps1:87 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Invoke-BakNRetBackupListEditor.ps1:24 [PSUseSupportsShouldProcess] WhatIf and/or Confirm manually defined in function Invoke-BakNRetBackupListEditor. Instead, please use SupportsShouldProcess attribute. + Invoke-BakNRetBackupListEditor.ps1:69 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Invoke-BakNRetCatalogEditor.ps1:22 [PSUseSupportsShouldProcess] WhatIf and/or Confirm manually defined in function Invoke-BakNRetCatalogEditor. Instead, please use SupportsShouldProcess attribute. + Invoke-BakNRetCatalogEditor.ps1:55 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Invoke-BakNRetConfigEditor.ps1:23 [PSUseSupportsShouldProcess] WhatIf and/or Confirm manually defined in function Invoke-BakNRetConfigEditor. Instead, please use SupportsShouldProcess attribute. + Invoke-BakNRetMenu.ps1:46 [PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks. + Invoke-BakNRetMenu.ps1:86 [PSAlignAssignmentStatement] Assignment statements are not aligned + Invoke-BakNRetMenu.ps1:87 [PSAlignAssignmentStatement] Assignment statements are not aligned + Invoke-BakNRetMenu.ps1:89 [PSAlignAssignmentStatement] Assignment statements are not aligned + Invoke-BakNRetMenu.ps1:90 [PSAlignAssignmentStatement] Assignment statements are not aligned + Lab-Common.ps1:60 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Lab.ps1:41 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Lab.ps1:208 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Lab.ps1:226 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Lab.ps1:227 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Lab.ps1:389 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + New-BakNRetLab.ps1:111 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + provision.ps1:69 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + provision.ps1:89 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Register-BackupTask.ps1:94 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Restore-Data.ps1:141 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Restore-Data.ps1:753 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Restore-Data.ps1:850 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Restore.ps1:42 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Restore.ps1:42 [PSUseConsistentWhitespace] Use space before and after binary and assignment operators. + Restore.ps1:42 [PSUseConsistentWhitespace] Use space before and after binary and assignment operators. + Run-E2E.ps1:408 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-E2E.ps1:458 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-E2E.ps1:502 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-E2E.ps1:575 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:433 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:731 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:744 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:757 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:790 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:802 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:828 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:870 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:931 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:987 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:1012 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:1057 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:1169 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:1417 [PSPlaceCloseBrace] Close brace does not follow a new line. + Run-Tests.ps1:1450 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:1465 [PSPlaceCloseBrace] Close brace does not follow a new line. + Run-Tests.ps1:1608 [PSReviewUnusedParameter] The parameter 't' has been declared but not used. + Run-Tests.ps1:1615 [PSReviewUnusedParameter] The parameter 't' has been declared but not used. + Run-Tests.ps1:1622 [PSPlaceCloseBrace] Close brace does not follow a new line. + Run-Tests.ps1:1683 [PSPlaceCloseBrace] Close brace does not follow a new line. + Run-Tests.ps1:1734 [PSPlaceCloseBrace] Close brace does not follow a new line. + Run-Tests.ps1:1804 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:1816 [PSPlaceCloseBrace] Close brace does not follow a new line. + Run-Tests.ps1:1858 [PSUseConsistentIndentation] Indentation not consistent + Run-Tests.ps1:1859 [PSUseConsistentIndentation] Indentation not consistent + Run-Tests.ps1:1860 [PSUseConsistentIndentation] Indentation not consistent + Run-Tests.ps1:1861 [PSUseConsistentIndentation] Indentation not consistent + Run-Tests.ps1:1883 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:1884 [PSAvoidLongLines] Line exceeds the configured maximum length of 160 characters + Run-Tests.ps1:1896 [PSPlaceCloseBrace] Close brace does not follow a new line. + Save-BakNRetConfigFile.ps1:25 [PSUseSupportsShouldProcess] WhatIf and/or Confirm manually defined in function Save-BakNRetConfigFile. Instead, please use SupportsShouldProcess attribute. + Write-BakNRetAt.ps1:39 [PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks. + Write-BakNRetRunSummary.ps1:16 [PSReviewUnusedParameter] The parameter 'Mode' has been declared but not used. + diff --git a/.scratch/ci-cd/20260928-001722/dependency_security_report.md b/.scratch/ci-cd/20260928-001722/dependency_security_report.md new file mode 100644 index 0000000..d7647bc --- /dev/null +++ b/.scratch/ci-cd/20260928-001722/dependency_security_report.md @@ -0,0 +1,64 @@ +# 依赖安全扫描报告(dependency-security-scanner) + +- **项目**:BakNRet(Windows 备份 / 恢复工具,PowerShell) +- **扫描时间**:2026-09-28 +- **扫描范围**:仓库根目录及全部子目录(排除 `.git`) +- **工具**:`npm audit` / `pip-audit` / `govulncheck` 等均**不适用**(见下) + +## 1. 项目概况 + +| 项目 | 结论 | +| --- | --- | +| 语言 | PowerShell(`.ps1` / `.psm1` / `.psd1`) | +| 依赖管理文件 | **不存在** —— 全仓无 `package.json`、`package-lock.json`、`requirements.txt`、`go.mod`、`pom.xml`、`Cargo.toml` 等 | +| 运行时依赖 | **0 个模块**。运行备份 / 恢复只需要 PowerShell 5.1 或 7.x + 7-Zip | +| 开发期依赖 | Pester 5.9.1、PSScriptAnalyzer 1.25.0 —— 装在 `.tools/`,已 gitignore,**不随发布产物分发** | +| 外部可执行文件 | `7z.exe`(7-Zip 26.03,经 scoop 安装) | + +> [!NOTE] +> +> 依赖扫描技能面向「有包管理器的项目」。本项目**没有任何包管理器依赖**,因此 +> `npm audit` / `safety` / `govulncheck` 一类工具无对象可扫。下面改为对本项目**真实的供应链面** +> 逐项核对,而不是输出一份空报告。 + +## 2. 供应链面核对 + +| 组件 | 来源 | 版本 | 是否随分发 | 风险 | +| --- | --- | --- | --- | --- | +| PowerShell 引擎 | 操作系统 / Microsoft | 5.1.26100.9502、7.7.0-preview.5 | 否(宿主环境) | 由宿主维护;预览版仅供本机验收使用 | +| 7-Zip | scoop(`C:\Programs\Scoop\apps\7zip\26.03`) | 26.03 | 否(用户自备) | 归档/解压的执行者,**必须由用户保持更新** | +| Pester | 仓库内 `.tools/modules/Pester/5.9.1` | 5.9.1 | 否(gitignore) | 仅测试期使用,不接触用户数据 | +| PSScriptAnalyzer | 仓库内 `.tools/modules/PSScriptAnalyzer/1.25.0` | 1.25.0 | 否(gitignore) | 仅静态分析使用 | +| 其余第三方 | 无 | — | — | — | + +**已知 CVE**:无可报。本项目的依赖面里没有任何「被本项目固定版本」的第三方库 +(Pester 与 PSScriptAnalyzer 是测试工具且不进分发,7-Zip 与 PowerShell 由用户环境提供)。 +若要追查这两个工具的 CVE,应查上游公告,而不是本仓库。 + +## 3. 与安全相关的项目事实(实跑核对) + +| 检查项 | 结果 | +| --- | --- | +| 仓库是否跟踪任何密钥文件 | ✅ 否(`git ls-files` 中无 `*.key` / `*.pfx`) | +| `baknret.key` 是否被 gitignore | ✅ 是(`.gitignore:18: *.key`) | +| 工作区是否存在口令文件 `baknret.key` | ⚠️ **是** —— 见风险 R-2 | +| 口令是否可能落进日志 | ✅ 已处理:打印前把 `-p` 参数换成占位符(`tests/BakNRet.Tests.ps1` 有专门断言) | +| 日志中是否出现明文口令 | ✅ 无 | +| `.tools/` 是否可能进分发产物 | ✅ 否(已 gitignore,且构建脚本只读 `BakNRet/` 与 `tools/`) | + +## 4. 风险清单 + +| ID | 严重程度 | 风险 | 证据 | 建议 | +| --- | --- | --- | --- | --- | +| R-1 | **一般** | 仓库没有 `LICENSE` 文件,许可证状态未声明 | 根目录无 `LICENSE*` | 补一份许可证(见 `license_check_report`) | +| R-2 | **一般** | 工作区存在口令文件 `baknret.key`(未被跟踪,但确实在仓库目录里) | `Test-Path baknret.key` = True | 本项目自己的 CHANGELOG 已把「口令文件的出厂默认值指向仓库内」定为待修问题,`BackupConfig.psd1` 的注释也推荐放到仓库外。建议移到 `%USERPROFILE%\.baknret.key` 并用 `-KeyFile` 指过去。**本报告不读取、不记录其内容** | +| R-3 | **建议** | 7-Zip 版本由用户环境决定,脚本不检查版本 | `Find-BakNRet7zExecutable` 只找路径 | 可选:在启动时打印 `7z` 版本,便于排障 | +| R-4 | **建议** | 口令经命令行传给 7z,本机进程列表可见 | 上游限制,README 已用 CAUTION 声明 | 无技术解法,保持文档披露即可 | + +**致命 / 严重漏洞:0 个。** 流水线可继续。 + +## 5. 结论 + +未发现已知安全漏洞。本项目**零运行时依赖**,是当前最重要的一条供应链优势; +唯一两条「一般」级风险都属于**卫生问题**(缺许可证、口令文件放在仓库目录里), +不影响流水线继续执行。 diff --git a/.scratch/ci-cd/20260928-001722/license_check_report.md b/.scratch/ci-cd/20260928-001722/license_check_report.md new file mode 100644 index 0000000..d732aef --- /dev/null +++ b/.scratch/ci-cd/20260928-001722/license_check_report.md @@ -0,0 +1,60 @@ +# 许可证合规检查报告(license-compliance-checker) + +> [!WARNING] +> +> **免责声明**:本报告由自动化工具基于仓库内的文件生成,仅为工程参考,**不构成法律建议**。 +> 涉及对外分发、商业使用或二次许可时,请咨询法务。 + +- **项目**:BakNRet +- **检查时间**:2026-09-28 +- **主许可证**:**未声明** —— 仓库根目录不存在 `LICENSE` / `LICENSE.md` / `LICENSE.txt` + +## 1. 依赖与组件许可证清单 + +`license-checker` / `pip-licenses` / `go-licenses` 等工具**不适用**(本项目无包管理器依赖)。 +下列清单通过读取 `.tools/modules` 下的模块清单与许可证文件、以及七项外部组件的官方许可条款得出。 + +| 组件 | 版本 | 许可证 | 来源依据 | 与主许可证关系 | +| --- | --- | --- | --- | --- | +| BakNRet(本项目) | 1.0.0(模块清单) | **未声明** | 无 `LICENSE` 文件 | — | +| Pester | 5.9.1 | **Apache-2.0** | `Pester.psd1` 的 `Copyright` + `LicenseUri` | 测试期工具,未声明主许可证时无冲突可判 | +| PSScriptAnalyzer | 1.25.0 | **MIT** | `.tools/modules/PSScriptAnalyzer/1.25.0/LICENSE` | 同上 | +| Newtonsoft.Json(PSScriptAnalyzer 内置依赖) | 随包 | **MIT** | 同目录 `ThirdPartyNotices.txt` | 同上 | +| 7-Zip | 26.03 | **LGPL-2.1-or-later + BSD-3-Clause + unRAR 限制** | 上游许可(用户自备,不随本仓库分发) | 未分发,仅本地调用 | +| PowerShell | 5.1 / 7.7 | **MIT**(宿主环境) | — | 未分发 | +| .NET 运行时 | — | **MIT**(宿主环境) | — | 未分发 | + +## 2. 许可证分布 + +```text +MIT ████████████████████ 3 项(PSScriptAnalyzer、Newtonsoft.Json、PowerShell/.NET) +Apache-2.0 ███████ 1 项(Pester) +LGPL + BSD + unRAR ███████ 1 项(7-Zip) +未声明 ███████ 1 项(本项目自身) <-- 需要处理 +``` + +全部第三方许可证均为**宽松型**(MIT / Apache-2.0 / BSD),无 copyleft 传染风险。 + +## 3. 冲突与注意事项 + +| ID | 级别 | 对象 | 说明 | 建议 | +| --- | --- | --- | --- | --- | +| L-1 | ❌ **需处理** | 本项目自身 | 没有 `LICENSE` 文件 = 默认「保留所有权利」。他人**无权**复制、修改、分发;GitHub 上也会显示为无许可证项目 | 明确选一个:想宽松就 MIT,想带专利授权就 Apache-2.0 | +| L-2 | ⚠️ 需注意 | 7-Zip 的 unRAR 限制 | 7-Zip 许可证禁止用其 unRAR 代码**还原 RAR 压缩算法**。本项目只用 7z 解压 / 压缩,不实现 RAR 压缩 | 无需动作;当前用法不触发该限制 | +| L-3 | ⚠️ 需注意 | Pester 5.9.1 的版权年份 | 模块清单里 `Copyright` 写的是 `(c) 2026 by Pester Team`(上游清单原文) | 无需动作;仅记录,勿在文档中改写上游声明 | +| L-4 | ✅ 兼容 | PSScriptAnalyzer(MIT)+ Newtonsoft.Json(MIT) | MIT 与 MIT/Apache 混合无冲突 | 仅在本仓库内作为测试工具使用,未再分发 | + +## 4. 合规建议 + +1. **先补主许可证**(L-1)。这是本仓库唯一的合规缺口,且成本最低。 +2. `.tools/` 下的模块**不要**提交进版本库(已 gitignore)——它们是第三方代码, + 提交会牵出「再分发」与版权声明保留义务。 +3. 若将来把 BakNRet 公开发布,请在 README / 发布产物里保留 7-Zip 的许可与免责声明引用; + 当前仓库并未捆绑 7-Zip 二进制,所以现在无需附带其许可证全文。 +4. 若决定以 MIT 发布,注意 MIT 要求保留版权与许可声明:README「致谢」一节已引用上游项目, + 可再补一份 `THIRD-PARTY-NOTICES.md` 收纳 Pester / PSScriptAnalyzer 的声明(可选)。 + +--- + +**免责声明(重申)**:以上判断基于文件名、模块清单字段与上游公开条款的自动比对, +存在识别错误的可能;**不构成法律建议**。 diff --git a/.scratch/ci-cd/20260928-001722/perf_baseline.json b/.scratch/ci-cd/20260928-001722/perf_baseline.json new file mode 100644 index 0000000..87aa439 --- /dev/null +++ b/.scratch/ci-cd/20260928-001722/perf_baseline.json @@ -0,0 +1,55 @@ +{ + "measuredAt": "2026-09-28T12:24:11", + "machine": "STRIX-X870A", + "os": "Microsoft Windows NT 10.0.26340.0", + "psi": "7.7.0-preview.5", + "cpu": "AMD Ryzen 7 9800X3D 8-Core Processor ", + "logicalCpu": 16, + "workdir": "D:\\Workspace\\Temp\\BakNRet", + "metrics": { + "dryRunFullListMs": { + "min": 9839.9, + "median": 9869.2, + "max": 12900.8, + "samples": 5 + }, + "sevenZipVersion": "7-Zip 26.03 (x64) : Copyright (c) 1999-2026 Igor Pavlov : 2026-09-03", + "folderSummary200Files3xMs": 18.2, + "backupListParse50xMs": { + "min": 2535.9, + "median": 2546.9, + "max": 2691.5, + "samples": 5 + }, + "sevenZipBench": [ + { + "level": 0, + "ms": 46.7, + "archiveBytes": 6555514 + }, + { + "level": 5, + "ms": 224.2, + "archiveBytes": 6555913 + }, + { + "level": 9, + "ms": 225.2, + "archiveBytes": 6555913 + } + ], + "moduleImportMs": { + "min": 628.3, + "median": 631.0, + "max": 2052.8, + "samples": 5 + } + }, + "notes": [], + "fixture": { + "files": 200, + "bytes": 6553600, + "createMs": 237.6, + "compressionInputBytes": 6553600 + } +} diff --git a/.scratch/ci-cd/20260928-001722/pester_after_fix.txt b/.scratch/ci-cd/20260928-001722/pester_after_fix.txt new file mode 100644 index 0000000..c29b691 --- /dev/null +++ b/.scratch/ci-cd/20260928-001722/pester_after_fix.txt @@ -0,0 +1,333 @@ +Pester 5.9.1 (D:\Workspace\Temp\BakNRet\.tools\modules\Pester\5.9.1) +测试文件:D:\Workspace\Temp\BakNRet\tests +Pester v5.9.1 + +Starting discovery in 3 files. +Discovery found 185 tests in 226ms. +Running tests. + +Running tests from 'D:\Workspace\Temp\BakNRet\tests\BakNRet.Formats.Tests.ps1' +Describing 软件名录:Slot 形状(新契约) +[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' } + [+] 一个软件多个 Slot:Kind=Multi,Slot 按名排序且说明被保留 163ms (131ms|32ms) +[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' } + [+] 每个 Slot 都是一个独立的归档项来源(Kind=slot / Origin=catalog) 82ms (81ms|1ms) +[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' } + [+] Slot 级排除写在 Slot 自己身上(相对本 Slot 的归档根) 32ms (30ms|1ms) +[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' } + [+] 数组里"当前不存在"的 Slot 仍然产出归档项(恢复要靠它还原回原位) 64ms (64ms|1ms) + [+] 文件 Slot:归档项是文件项(归档里就是名为 Slot 的文件) 8ms (7ms|1ms) +[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' } + [+] 旧的裸字符串 / 字符串数组写法被拒绝(ERROR + 跳过) 22ms (22ms|1ms) +[2026-09-28 12:42:05] [ERROR] 名录条目 'legacystr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:05] [ERROR] 名录条目 'legacyarr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:05] [ERROR] 名录条目 legacydirs 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' } + [+] 旧的 @{ Dirs = @(...) } 写法不再展开,留下 Invalid 与原因 24ms (23ms|1ms) + [+] 多 Slot 的名录条目在清单里仍然按软件名命名归档 3ms (3ms|0ms) + +Describing 清单修饰符:新契约格式 + [+] :: 覆盖 Path:单 Slot 条目直接生效 13ms (12ms|2ms) + [+] :: 覆盖遇到多 Slot 条目 -> Blocking(不知道给哪一个,绝不猜) 6ms (6ms|0ms) + [+] :- 排除与 :+ 包含并存,顺序任意 15ms (15ms|1ms) + [+] @ Exclude / @ Include / @ Path 与记号写法等价 7ms (6ms|0ms) + [+] :encrypt / :!encrypt 覆盖名录里的加密默认值 11ms (11ms|0ms) + [+] 遗留写法 @encrypt / @pathname / @root= 仍可解析 18ms (17ms|1ms) + [+] 同一行里重复写同类记号会累积(不静默丢掉前一条规则) 12ms (11ms|1ms) +[2026-09-28 12:42:05] [WARN] 清单行缺少目标,已忽略::- logs\ + [+] 行尾说明与缺少目标的行 6ms (6ms|1ms) + +Describing 集成:Slot 布局的打包与恢复 + [+] 备份退出码 0,归档名就是软件名 9ms (7ms|2ms) + [+] 归档顶层就是各个 Slot 名(目录 Slot + 文件 Slot + Include 项) 42ms (41ms|1ms) + [+] manifest.layouts 记下每个归档项是目录还是文件 8ms (7ms|1ms) + [+] 归档内容:\ 布局,文件 Slot 是名为 Slot 的文件,Slot 排除生效 40ms (39ms|1ms) + [+] 真实恢复:目录 Slot、文件 Slot 与 Include 都落回各自的原位 2.37s (2.37s|1ms) + [+] 文件 Slot 在目标不存在时靠 manifest.layouts 恢复成文件(而不是目录) 3ms (2ms|0ms) + [+] 恢复之后 manifest 记下 lastRestoreAt 4ms (4ms|0ms) + +Describing 集成:旧布局归档的回退恢复 + [+] 归档确实是旧布局:顶层是源目录名而不是 Slot 名 30ms (29ms|1ms) + [+] 归档里缺 Slot 层(真实旧归档)时按旧布局回退,把内容还原回原位 1.75s (1.75s|0ms) + [+] 归档里既没有 Slot 层、也没有旧布局名字时明确失败(不再"成功地什么都没恢复") 1.72s (1.72s|0ms) + +Describing 集成:归档内路径冲突会被拒绝执行 + [+] 退出码 1,且给出"归档内路径冲突"的原因,不生成归档 4ms (3ms|1ms) + [+] manifest 里记下这次是 failed,并带上原因 3ms (3ms|0ms) + +Describing 条目从清单里消失后,旧归档必须被点名为孤儿 + [+] 归档确实还在磁盘上,manifest 里也还留着历史记录 5ms (4ms|1ms) + [+] 第二次运行把 my-app.7z 点名成孤儿,并说明 manifest 里还有历史记录 2ms (2ms|0ms) + +Describing manifest 一致性:archive 字段只在文件真的存在时才写 + [+] 存在的归档保留 archive,不存在的被清空 9ms (8ms|1ms) + [+] 清空 archive 时保留条目本身的历史(source / action 不动) 3ms (3ms|0ms) + [+] 人工删掉归档之后再同步一次,记录会被纠正过来 4ms (3ms|0ms) + +Running tests from 'D:\Workspace\Temp\BakNRet\tests\BakNRet.Security.Tests.ps1' +Describing 排除判定与 7z 的 -x! / -xr! 语义对齐 + [+] 锚定模式只命中它自己那棵子树 8ms (7ms|1ms) + [+] ! 通配按任意层级的组件名匹配(* 不是正则) 2ms (1ms|0ms) + [+] !re: 走正则,且组件名与整条相对路径都算命中 2ms (2ms|0ms) + [+] 没有模式时一律不排除 1ms (1ms|0ms) + [+] 模式里的空格按 7z 的规矩当 ? 处理 1ms (1ms|0ms) + +Describing SID 映射(跨机恢复) + [+] 整 SID 精确替换 5ms (5ms|1ms) + [+] 不会误伤以它为前缀的更长的 SID 1ms (1ms|0ms) + [+] 空映射表时原样返回 1ms (1ms|0ms) + +Describing 安全描述符采集 +[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL + [+] Full:每个对象一条记录,键是归档内相对路径 340ms (340ms|1ms) +[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL + [+] 根记录的 SDDL 保留了 CREATOR OWNER、IO 标志、孤儿 SID 和 protected 位 12ms (12ms|0ms) +[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL +[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL + [+] Smart 比 Full 少,但根永远保留 24ms (23ms|0ms) +[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL + [+] Roots 只存归档项的根,不再往下走 4ms (4ms|0ms) +[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL + [+] sidecar 往返:条数与 SDDL 原样保留 25ms (24ms|0ms) + [+] 旁挂文件不存在时读出 $null(调用方据此打告警,而不是静默当没事) 1ms (1ms|0ms) +[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL + [+] 排除模式在采集时同样生效(采集树 == 归档树) 9ms (9ms|0ms) +[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL + +Describing 安全描述符回放 +[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL + [+] 回放后根对象的安全描述符与源逐字节一致(protected / CO / 孤儿 SID 全在) 40ms (39ms|1ms) + [+] 全部对象的安全指纹与源一致(属主/属组/ACE 集合) 5ms (5ms|0ms) +[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL + [+] 目标不存在或不是普通对象时记 Skipped,不记 Failed 3ms (3ms|0ms) +[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL + [+] 归档根名对不上时一条都不回放(不会把兄弟项的 ACL 倒过来) 1ms (1ms|0ms) +[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL + [+] 属组写不进去时不会连累 DACL:回退到底也要把 ACL 落下去 7ms (7ms|0ms) +[2026-09-28 12:42:21] [WARN] 这些特权不在当前令牌里(需要管理员或 SYSTEM):SeRestorePrivilege、SeBackupPrivilege —— 属主将无法改成别的账户,只能恢复 DACL + [+] 对象的安全描述符读不到时带 e 记账,回放时跳过而不是写坏 2ms (2ms|0ms) + +Describing 与 Backup.ps1 / Restore.ps1 的集成 + [+] 备份会写出 .acl.json,并在 manifest 里记下它 2.34s (2.34s|1ms) + [+] 恢复会把安全描述符回放回去(删源之后仍然逐对象与备份前一致) 2.14s (2.14s|0ms) + [+] -SkipSecurity 时不回放(目标保持新建对象的默认 ACL) 1.77s (1.77s|0ms) + [+] 归档旁边没有 acl.json 时打告警、不算失败(旧归档照样恢复得出来) 1.76s (1.76s|0ms) + +Running tests from 'D:\Workspace\Temp\BakNRet\tests\BakNRet.Tests.ps1' +Describing BackupList.txt 解析 + [+] 注释行与空行返回 $null 2ms (2ms|1ms) + [+] 裸路径:Path 原样、方向 both、不是软件名、没有任何覆盖 6ms (6ms|0ms) + [+] 软件名:IsName 为真 2ms (1ms|0ms) + [+] IsName 判定:含分隔符或 % 就算字面路径 3ms (2ms|0ms) + [+] 标记必须是独立记号:C:\a:-b 仍然只是一个路径 2ms (2ms|0ms) + [+] 行首 + 仅备份、- 仅恢复,方向标记不进入目标 4ms (4ms|0ms) + [+] 只有方向标记、没有目标 -> 忽略该行 2ms (2ms|0ms) + [+] 行首标记贴在目标上也算(+Name / -Path),且标记不进入目标 4ms (4ms|0ms) + [+] 非行首的 + / - 不是方向标记 22ms (22ms|0ms) + [+] :: 现在表示"覆盖 Path",与 :- 彻底分开 8ms (5ms|3ms) + [+] :: 的值可以带空格(一直取到下一个标记之前) 2ms (2ms|0ms) + [+] :- 的逗号 / 分号列表拆成多个模式 7ms (6ms|0ms) + [+] 排除模式的引号只保护空白,不保护逗号 3ms (2ms|0ms) + [+] :+ 的包含项是 : 2ms (2ms|0ms) + [+] :encrypt 与 :!encrypt 控制该条目的加密开关 2ms (2ms|0ms) + [+] @ Key='Value' 覆盖 Path / Exclude / Include / Encrypt 4ms (4ms|0ms) + [+] @ 的键名大小写不敏感,且支持紧跟 @ 的写法 2ms (2ms|0ms) + [+] 历史写法 @encrypt / @!encrypt 仍然被识别成加密覆盖 2ms (2ms|0ms) + [+] 历史写法 @pathname / @root= 进入 Flags 3ms (2ms|0ms) +[2026-09-28 12:42:30] [WARN] 清单里的 @ 字段 'UnknownKey' 不是已知字段(Path / Exclude / Include / Encrypt),已忽略:Foo @ UnknownKey='v' :- logs\ + [+] 未知的 @ 字段进入 UnknownKeys,且不影响其余字段解析 2ms (2ms|0ms) + [+] 行尾的 # 说明会成为 Comment 2ms (2ms|0ms) + [+] 路径里紧贴的 # 不会被当成注释 1ms (1ms|0ms) + [+] 没有说明时 Comment 为空 1ms (1ms|0ms) + [+] 目标可以带空格:第一个标记之前整段都是目标 3ms (2ms|0ms) +[2026-09-28 12:42:30] [WARN] 清单行缺少目标,已忽略::- logs +[2026-09-28 12:42:30] [WARN] 清单行缺少目标,已忽略:@ Exclude='x' + [+] 缺少目标(标记出现在第一个位置)会被忽略并告警 2ms (2ms|0ms) + [+] 记号切分:引号内的空白不切分,引号本身留在记号里 2ms (1ms|0ms) + [+] 记号识别只认完整记号 2ms (2ms|0ms) + [+] 去引号:成对才去,不成对原样返回 2ms (1ms|0ms) + +Describing 归档命名 + [+] 基础命名规则:末级名_from_上级路径用加号连接 2ms (1ms|1ms) + [+] / 与 \ 以及重复分隔符结果一致 2ms (1ms|0ms) + [+] 命名与路径往返 6ms (6ms|0ms) + [+] 归档名里不含非法文件名字符 4ms (4ms|0ms) + [+] %变量% 写法里的 % 会保留在归档名里 1ms (1ms|0ms) + [+] 软件名条目:默认用软件名做归档名 5ms (4ms|0ms) + [+] 字面路径条目:仍用路径命名算法(现有清单无需改写) 2ms (2ms|0ms) + [+] @pathname 用名录里的真实路径命名,而不是软件名 4ms (3ms|0ms) +[2026-09-28 12:42:30] [WARN] 名录里没有 'no-such-thing',按目录名处理 +[2026-09-28 12:42:30] [WARN] 名录里没有 'no-such-thing',按目录名处理 + [+] 名录里没有该名字:归档名退回可读目录名,并给出 Error 5ms (4ms|0ms) + +Describing 7z 排除参数翻译 + [+] 相对模式自动补上归档根目录名 5ms (5ms|1ms) + [+] 模式已带根名前缀时,Split-BakNRetPatternScope 先摘掉前缀,结果不重复 5ms (4ms|0ms) + [+] ! 前缀翻译成递归组件匹配(-xr!) 1ms (1ms|0ms) + [+] 模式里的空格转成 ?(归档项自己的名字按原样保留) 2ms (1ms|0ms) + [+] 生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效) 1ms (1ms|0ms) + [+] 空模式被忽略 2ms (1ms|0ms) + [+] !re: 会把正则展开成精确的 -x! 参数 25ms (25ms|0ms) + [+] 非法正则给出 Error,而不是抛异常 6ms (6ms|0ms) + [+] 正则命中数超过上限时明确报错 3ms (3ms|0ms) + [+] 参数总长超过安全上限时明确报错 1ms (1ms|0ms) + [+] Split-BakNRetPatternScope:指名 Slot 的模式只分给那个 Slot 2ms (1ms|0ms) + [+] Split-BakNRetPatternScope:没点名任何 Slot 的模式广播给每一项 1ms (1ms|0ms) + [+] Split-BakNRetPatternScope:! 与 !re: 模式广播给每一项 1ms (1ms|0ms) + [+] Split-BakNRetPatternScope:每个归档项都有键(没有模式时是空数组) 2ms (2ms|0ms) + [+] Merge-BakNRetExcludeArgument 去重且保持顺序 3ms (2ms|0ms) + [+] 多 Slot 条目:逐项分配 + 翻译 + 去重合成一份参数 3ms (3ms|0ms) + +Describing 归档项与暂存目录 + [+] New-BakNRetArchiveItem 规范化归档内路径并算出 TopName 3ms (3ms|1ms) + [+] Get-BakNRetArchiveTopName 取归档内相对路径的第一段 1ms (1ms|0ms) + [+] 暂存半途失败会自己清干净(不留指向真实数据的 junction) 33ms (32ms|0ms) + [+] 目录项用 junction 挂进暂存目录,文件项用硬链接(名字就是归档内路径) 18ms (17ms|0ms) + [+] Remove-BakNRetArchiveStaging 只删连接点,不顺着走进真实目录 8ms (8ms|0ms) + +Describing 命令行参数拼接 + [+] 无空格参数原样输出 2ms (1ms|1ms) + [+] 含空格参数加引号 3ms (2ms|0ms) + [+] 引号内的结尾反斜杠翻倍(否则会被当成转义引号) 2ms (1ms|0ms) + [+] 内部引号被转义 1ms (1ms|0ms) + [+] 空参数输出一对空引号 2ms (1ms|1ms) + +Describing manifest 与配置 + [+] manifest 读写往返 10ms (9ms|1ms) + [+] manifest 原样保存 layouts(name/kind),全新恢复时靠它判断目录还是文件 7ms (7ms|0ms) +[2026-09-28 12:42:30] [WARN] manifest 解析失败(将重新建立):C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\broken.json —— Conversion from JSON failed with error: Invalid character after parsing property name. Expected ':' but got: i. Path '', line 1, position 7. + [+] manifest 损坏时不抛异常,而是重建空清单 9ms (8ms|0ms) + [+] 配置缺失时返回默认值 4ms (4ms|0ms) + [+] 配置嵌套段落合并且不丢默认键 6ms (6ms|0ms) + [+] 口令:环境变量可读取,取不到时返回 $null(绝不退化成明文) 3ms (3ms|0ms) + +Describing 软件名录 +[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path +[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot +[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' } + [+] 名录解析:条目与 Slot 的字段集合就是新契约 54ms (53ms|1ms) +[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path +[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot +[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' } + [+] 一个软件多个 Slot:Kind=Multi,Slot 按名字排序 33ms (33ms|0ms) +[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path +[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot +[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' } + [+] Kind:Single / Multi / Partial / Unresolved / Invalid 34ms (34ms|0ms) +[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path +[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot +[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' } + [+] 前缀补全:_ 与 - 都会被补全 28ms (27ms|0ms) +[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path +[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot +[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' } + [+] 不会把 Legendary 误配成 LegendarySomething 28ms (27ms|0ms) +[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path +[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot +[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' } + [+] 一个 Slot 命中多个候选目录:报 Error,绝不悄悄挑一棵树 31ms (31ms|0ms) +[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path +[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot +[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' } + [+] 文件 Slot:Path 指向文件时 IsFile 为真,Encrypt 也带上 25ms (25ms|0ms) +[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path +[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot +[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' } + [+] %变量% 会在名录路径里展开 27ms (27ms|0ms) + [+] $( ... ) 子表达式会被求值(含嵌套) 6ms (6ms|0ms) +[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path +[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot +[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' } +[2026-09-28 12:42:30] [ERROR] 名录条目 nopath 有问题:Slot S 缺少 Path +[2026-09-28 12:42:30] [ERROR] 名录条目 dupslot 有问题:Slot D 的 Path 匹配到 2 个目录:C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_1、C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\catalog\dup_2;一个 Slot 只能对应一个目录,请拆成多个 Slot +[2026-09-28 12:42:30] [ERROR] 名录条目 badslot 有问题:Slot S 的写法不对,应写成 @{ Path = '...' } + [+] 读取结果按"路径 + 时间戳 + 长度 + 内容 MD5"缓存 63ms (62ms|0ms) + [+] 名录文件内容变了以后缓存自动失效 17ms (16ms|0ms) + [+] Includes:分文件维护的名录会被合并 12ms (11ms|0ms) +[2026-09-28 12:42:30] [ERROR] 名录条目 'arr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:30] [ERROR] 名录条目 'objarr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:30] [ERROR] 名录条目 'bare' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:30] [ERROR] 名录条目 dirstyle 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' } + [+] 旧的裸字符串 / 字符串数组 / 对象数组写法都会报 ERROR 并被跳过 3ms (3ms|0ms) +[2026-09-28 12:42:30] [ERROR] 名录条目 'arr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:30] [ERROR] 名录条目 'objarr' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:30] [ERROR] 名录条目 'bare' 格式不对:应写成 @{ = @{ Path = '...' } } +[2026-09-28 12:42:30] [ERROR] 名录条目 dirstyle 有问题:Slot Dirs 的写法不对,应写成 @{ Path = '...' } + [+] 旧的 @{ Dirs = @(...) } 写法不再展开:留下 Invalid 条目和原因 4ms (3ms|0ms) + [+] 软件名会被规范化成合法文件名 2ms (1ms|0ms) + +Describing Resolve-BakNRetBackupEntry:条目解析 + [+] resolve 与归档项的字段集合就是新契约(旧字段已删除) 4ms (4ms|1ms) + [+] 软件名条目:isName/CatalogEntry/BaseName/ArchiveFlavor/Source 7ms (6ms|0ms) + [+] 软件名条目:Items 来自 Slot(ArchivePath=Slot 名,Kind=slot,Origin=catalog) 6ms (6ms|0ms) + [+] 字面路径条目:一个 path 项(ArchivePath 是末级名) 3ms (3ms|0ms) + [+] 字面路径条目上的 @ Path= 覆盖目标路径,但归档名仍按原路径 2ms (2ms|0ms) + [+] 名录里的路径不存在时仍给出 Items(恢复要靠它还原回原位) 5ms (5ms|0ms) + [+] :: 覆盖 Path:单 Slot 条目直接生效 5ms (5ms|0ms) + [+] :: / @ Path= 覆盖遇到多 Slot 条目 -> Blocking(不猜是哪一个) 5ms (5ms|0ms) + [+] 条目级 :- 覆盖名录里的排除:HasExcludeOverride 为真 3ms (3ms|0ms) + [+] 名录 Slot 的 Include 会追加成 include 项 5ms (5ms|0ms) + [+] 条目级 :+ / @ Include= 覆盖名录里的 Include 11ms (11ms|0ms) +[2026-09-28 12:42:30] [WARN] encapp:名录里各 Slot 的 Encrypt 不一致,整个归档按加密处理 + [+] 加密:名录里各 Slot 取或;条目级 :encrypt / :!encrypt 覆盖 9ms (9ms|0ms) + [+] 方向标记会传递到 Resolve-BakNRetBackupEntry.Direction 5ms (5ms|0ms) + [+] 归档内路径冲突(include 与 Slot 同名)-> Blocking 5ms (4ms|0ms) + [+] 归档内路径冲突(父子关系)-> Blocking 5ms (5ms|0ms) +[2026-09-28 12:42:30] [WARN] 名录里没有 'no-such-thing',按目录名处理 + [+] 名录里没有该名字:Error 给出,Items 为空 3ms (2ms|0ms) + +Describing 外部命令退出码(旧实现的核心缺陷) +[2026-09-28 12:42:30] [DEBUG] 执行: cmd.exe /c "exit 0" -p<口令已隐藏> + [+] 口令不会进日志:DEBUG 下打印的命令行要遮蔽 -p 参数 31ms (30ms|1ms) + [+] 运行锁:同一份备份目录同时只能有一个持有者 27ms (27ms|0ms) + [+] 运行锁:释放之后可以重新取得 8ms (7ms|0ms) + [+] 空目录的摘要给 0 而不是 $null(否则空间守卫会静默失效) 9ms (9ms|0ms) + [+] 原子替换:成功时新内容到位且不留 .tmp;失败时旧内容完好 14ms (14ms|0ms) + [+] Invoke-ExternalCommand 能拿到真实退出码 13ms (12ms|0ms) + [+] 成功时拿到 0 12ms (12ms|0ms) + +Describing 集成:真实 7z 压缩与排除规则 + [+] 排除规则与空格处理在真实归档上生效 55ms (54ms|1ms) + [+] 对照组:不加排除时被排除的文件确实在归档里(证明上一条不是空归档) 58ms (57ms|0ms) +ERROR: C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\integration\corrupt.7z +C:\Users\Shuery\AppData\Local\Temp\baknret-pester-ffa25c80\integration\corrupt.7z +Open ERROR: Cannot open the file as [7z] archive + + +ERRORS: +Headers Error + [+] 7z t 对完好归档返回 0,对损坏归档返回非 0(归档后校验的依据) 52ms (52ms|0ms) + +Describing 集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目) + [+] 备份退出码为 0(旧实现会把成功的压缩判成失败) 2ms (1ms|1ms) + [+] 归档已生成且 manifest 记录了条目、动作、校验结果与 layouts 5ms (5ms|0ms) + [+] 备份过程写了日志文件 3ms (2ms|0ms) + [+] 字面路径条目沿用 布局;归档里保留应保留内容、不含被排除项 32ms (32ms|0ms) + [+] [回归] manifest.roots 记录的是归档内真实的顶层条目名 32ms (31ms|0ms) + [+] Restore -DryRun 退出码 0,且一个字节都不写(manifest SHA256 不变) 1.57s (1.57s|0ms) + [+] Restore -WhatIf 同样不写盘 1.6s (1.6s|0ms) + [+] 真实恢复:退出码 0,文件逐字节一致,被排除项没有被恢复出来 2.12s (2.12s|0ms) + [+] 真实恢复之后 manifest 才被更新(lastRestoreAt) 2ms (2ms|0ms) + [+] 孤儿归档会被点名报告,但不影响退出码 2.37s (2.37s|0ms) + [+] 带 -Only 时不做孤儿审计(避免把未选中的归档误报成孤儿) 1.74s (1.74s|0ms) + [+] 源路径不存在时记为 missing-source,退出码仍为 0(跳过不算失败) 1.76s (1.76s|0ms) + [+] 归档名重复时直接报失败(退出码 1),不静默互相覆盖 2.37s (2.37s|0ms) + [+] [回归] 显式指定的清单不存在时报失败(退出码 1),且不在错误位置建模板 1.43s (1.43s|0ms) + [+] [回归] 未显式指定清单时的首次运行仍建模板并退出 0(引导不能被误伤) 1.44s (1.44s|0ms) + +Describing 集成:方向标记与孤儿审计 + [+] 行首 - 的条目:备份跳过它,但仍把它算作"有主"(不报成孤儿) 4ms (3ms|1ms) + [+] 行首 + 的条目:恢复跳过它、不写回目标,同时登记归档名 2ms (2ms|0ms) +Tests completed in 50.99s +Tests Passed: 185, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 + +Pester 测试全部通过:185 项(跳过 0 项) diff --git a/.scratch/ci-cd/20260928-001722/report_mermaid.png b/.scratch/ci-cd/20260928-001722/report_mermaid.png new file mode 100644 index 0000000..b7f5e3a Binary files /dev/null and b/.scratch/ci-cd/20260928-001722/report_mermaid.png differ diff --git a/.scratch/ci-cd/20260928-001722/stage0_static_analysis.md b/.scratch/ci-cd/20260928-001722/stage0_static_analysis.md new file mode 100644 index 0000000..3d61611 --- /dev/null +++ b/.scratch/ci-cd/20260928-001722/stage0_static_analysis.md @@ -0,0 +1,135 @@ +# 阶段 0 报告:静态分析与修复 + +- **项目**:BakNRet(PowerShell 5.1 / 7.x,Windows 备份恢复工具) +- **分支**:`refactor/ms-conventions`(HEAD 之前 `d72fe63`) +- **执行时间**:2026-09-28 +- **结论**:✅ **通过**(0 个致命 / 严重问题;2 处真实缺陷已修复并回归;1 处自伤已发现并修复) + +--- + +## 0.1 依赖安全扫描 + +**执行器**:`/dependency-security-scanner` + +| 项目 | 结论 | +| --- | --- | +| 依赖管理文件 | 不存在(无 `package.json` / `requirements.txt` / `go.mod` / `pom.xml` / `Cargo.toml`) | +| 运行时依赖 | **0 个模块** —— 只需 PowerShell 5.1 或 7.x + 7-Zip | +| CVE | 无可报(没有任何被本仓库固定版本的第三方库) | +| 致命 / 严重漏洞 | **0** | + +**风险登记** + +| ID | 级别 | 内容 | 处置 | +| --- | --- | --- | --- | +| R-1 | 一般 | 无 `LICENSE`,许可证未声明 | 移交阶段 0.2;**建议补一份** | +| R-2 | 一般 | 工作区存在口令文件 `baknret.key`(`.gitignore:18 *.key` 已忽略,`git ls-files` 确认**未被跟踪**) | 建议移到仓库外(`%USERPROFILE%\.baknret.key`)+ `-KeyFile`;**本次全程未读取其内容** | +| R-3 | 建议 | 脚本不检查 7-Zip 版本 | 可选增强 | +| R-4 | 建议 | 口令经命令行传给 7z,进程列表短暂可见 | 7z 上游限制,README 已 CAUTION 披露,无技术解 | + +完整性证据:`dependency_security_report.md`。 + +--- + +## 0.2 许可证合规检查 + +**执行器**:`/license-compliance-checker` + +| 组件 | 版本 | 许可证 | 判断 | +| --- | --- | --- | --- | +| 本项目 | 1.0.0 | **未声明**(无 `LICENSE`) | ❌ **L-1 需处理** | +| Pester | 5.9.1 | Apache-2.0 | ✅ 宽松,未分发 | +| PSScriptAnalyzer | 1.25.0 | MIT | ✅ 宽松,未分发 | +| Newtonsoft.Json | 随包 | MIT | ✅ 宽松,未分发 | +| 7-Zip | 26.03 | LGPL-2.1 + BSD-3 + unRAR 限制 | ⚠️ L-2:本项目只做解压/压缩,**不实现 RAR 压缩**,不触发该限制;且未捆绑分发 | +| PowerShell / .NET | 宿主 | MIT | ✅ | + +**冲突**:无 copyleft 传染。**唯一缺口 = 主许可证未声明**(L-1)。 +完整性证据:`license_check_report.md`(含「非法律建议」免责声明)。 + +--- + +## 0.3 语法检查与自动修复(严格模式) + +**执行器**:`/syntax-fixer`(内含 `/syntax-checker` 语义) + +### 检查面 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 编码门禁 | `test.ps1` Encode 层 | ✅ 受管 **133** 个文件:缺 BOM 0、CRLF 0、制表符 0 | +| 解析(PS 7.7.0-preview.5) | `test.ps1` Parse 层 | ✅ **131/131** 解析零错 | +| 解析(5.1.26100.9502) | 同上 | ✅ **131/131** 解析零错 | +| 静态分析 | `tools/Invoke-Analyzer.ps1` | **80 条**,全部 `Warning` 级、**0 条 Error** | + +### 已修复的真实缺陷 + +| # | 文件:行 | 规则 | 修复 | 语义 | +| --- | --- | --- | --- | --- | +| 1 | `Backup.ps1:42` | `PSUseConsistentWhitespace` | `@($Rest)+ @(…)` → `@($Rest) + @(…)`(补 2 处空格) | **无行为改变**,仅排版 | +| 2 | `Restore.ps1:42` | `PSUseConsistentWhitespace` | 同上(补 2 处空格) | **无行为改变**,仅排版 | + +### ⚠️ 修复过程中的自伤与恢复(值得记录) + +修改 `Backup.ps1` / `Restore.ps1` 后,两个文件的 **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 层的价值**,也说明「编辑 PowerShell 源文件必须保留 BOM」是一条硬约束。 + +### 未修复项:按仓库已声明的偏离登记(用户已确认此处置) + +| 规则 | 条数 | 为什么不改 | 依据 | +| --- | --- | --- | --- | +| `PSAvoidLongLines` | 54 | 仓库**有意**把上限设为 160 而非官方 120(120 意味着 270 处改动) | `PSScriptAnalyzerSettings.psd1` 第 22–25 行;[ADR-0008](../../../docs/adr/0008-analyzer-deviations.md) | +| `PSPlaceCloseBrace` | 7 | 全在 `Run-Tests.ps1` **测试夹具字符串**内,缩进/换行是被断言的文本 | 改了会改变断言语义 | +| `PSUseConsistentIndentation` | 4 | 同上 | 同上 | +| `PSAlignAssignmentStatement` | 4 | `Invoke-BakNRetMenu.ps1` 的表格字面量,对齐影响 TUI 列宽 | 属于刻意排版 | +| `PSUseSupportsShouldProcess` | 4 | 该规则**已全局排除**于配置,属已知噪声 | `PSScriptAnalyzerSettings.psd1` 第 44 行 | +| `PSAvoidUsingEmptyCatchBlock` | 2 | `Write-BakNRetAt.ps1:39` 有意空 catch(注释已写明理由:定位失败不影响写文本);加输出会破坏 TUI | 源码注释 | +| `PSReviewUnusedParameter` | 4 | 3 条在测试 helper;1 条**可能真有价值** → 见下 | — | +| `PSUseDeclaredVarsMoreThanAssignments` | 1 | `BakNRet.Security.Tests.ps1:373` 的 `$sourcePath` 已赋值未使用 | 疑似残留,非功能缺陷 | + +### 🔎 待人工确认的观察项(未改动) + +```text +BakNRet/Public/Write-BakNRetRunSummary.ps1:16 + [Parameter(Mandatory = $true)][ValidateSet('backup','restore','verify')][string]$Mode +``` + +- `$Mode` 声明了 `ValidateSet` 与 `Mandatory`,但函数体内**从未读取**(全文仅第 16 行出现)。 +- 该函数也**从未被仓库内任何代码调用**(`grep` 全仓仅命中定义处),只通过 `FunctionsToExport` 对外导出。 +- 判断:像是「按运行类型分组」的未完工实现,**不是**能安全自动删改的东西(删除会破坏公共 API 参数契约)。 +- 处置:登记为观察项,**留待仓库作者决定**。 + +--- + +## 0.4 回归验证 + +| 层次 | PS 7.7.0-preview.5 | 5.1.26100.9502 | +| --- | --- | --- | +| Encode(宿主无关,跑一次) | ✅ 133 文件 0 问题 | — | +| Parse | ✅ 131/131 | ✅ 131/131 | +| Unit(Pester) | ✅ 通过 | ✅ 通过 | +| Smoke(零依赖) | ✅ 通过 | ✅ 通过 | +| E2E | ✅ 通过 | ✅ 通过 | +| **合计** | **9/9 PASS,退出码 0** | | + +修复前后各跑一次,结论一致 → **无回归**。 +分析器:`84 → 80` 条(4 条 `PSUseConsistentWhitespace` 全部归零,其余不变)。 +证据:`analyzer_raw.txt`(修复前)、`analyzer_after.txt`(修复后)。 + +--- + +## 0.5 阶段结论 + +- ✅ 无致命 / 严重问题,**流水线可继续**。 +- 🔧 2 处真实排版缺陷已修复;1 处 BOM 自伤已发现并恢复(这条本身是「Encode 层有效」的实证)。 +- ⚠️ 3 项待办移交下游:补 `LICENSE`(L-1)、迁移 `baknret.key`(R-2)、确认 `$Mode` 意图(观察项)。 +- 严格模式「零容忍」与仓库**已声明偏离**冲突的部分,按用户决定:**登记而不改**,理由逐条留档(上表)。 diff --git a/.scratch/ci-cd/20260928-001722/stage3_tests_and_perf.md b/.scratch/ci-cd/20260928-001722/stage3_tests_and_perf.md new file mode 100644 index 0000000..ff5a302 --- /dev/null +++ b/.scratch/ci-cd/20260928-001722/stage3_tests_and_perf.md @@ -0,0 +1,90 @@ +# 阶段 3 报告:测试增强评估与性能基线 + +- **执行时间**:2026-09-28 +- **结论**:性能基线已建立(首次,无退化可判);单元测试增强**建议但未执行**(理由见下) + +--- + +## 3.1 单元测试增强评估(`/unit-test-generator`,可选步骤) + +### 现状盘点 + +| 指标 | 数值 | +| --- | --- | +| 对外函数总数 | **89**(`BakNRet/Public/*.ps1`,与 `FunctionsToExport` 白名单逐一核对一致) | +| 测试中被**直接点名**的函数 | 70(78.7%) | +| 未被直接点名 | 19(21.3%) | +| Pester 用例 | **185** 条(`BakNRet.Tests.ps1` 127 + `Formats` 33 + `Security` 25),0 失败 0 跳过 | +| 另有 | 零依赖套件 128 项、端到端 36 项、真实归档演练 12 项 | + +### 未被直接点名的 19 个函数——逐条判断 + +> 「未被直接点名」≠「未被覆盖」:其中不少是通过集成路径间接执行到的(如 `Write-BakNRetLog` 被所有用例调用、`ConvertTo-BakNRetWildcardPattern` 由 `Test-BakNRetPathExcluded` 间接驱动)。 + +| 函数 | 是否值得补测试 | 理由 | +| --- | --- | --- | +| `Move-BakNRetArchiveIntoPlace` | **值得(最高优先)** | 「临时文件 → 校验 → 原子替换」是本仓库的核心安全承诺之一,且 CHANGELOG 记录过它在 5.1 上退化成「先删后移」的缺陷。目前只有端到端间接覆盖 | +| `Resolve-BakNRetRootedPath` | 值得 | 「相对路径按仓库根解析,不按工作目录」是计划任务场景的关键约定(README 专门写了这一节) | +| `Test-BakNRetItemSelected` | 值得 | `-Only` / `-Skip` 的通配匹配语义,边界(大小写、通配符)值得钉住 | +| `ConvertFrom-BakNRetPatternList` | 值得 | `,` 与 `;` 双分隔符是历史缺陷的修复点(CHANGELOG:解析器用 `;` 而清单里写 `,`) | +| `Get-Optimized7zArgument` | 一般 | 参数拼接优化,间接覆盖已足够 | +| `Save-BakNRetItemRecord` | 一般 | 与 `Write-BakNRetManifest` 组合使用,集成覆盖 | +| `Find-BakNRet7zExecutable` / `Resolve-BakNRetCompressionTool` | 一般 | 依赖机器状态,单元测试价值低(真实冒烟更合适) | +| `Get-BakNRetFreeSpaceGB` / `Test-BakNRetAdministrator` | 低 | 环境查询,测试易受机器状态影响 | +| `Enable-BakNRetPrivilege` / `Set-BakNRetObjectSecurity` / `Get-BakNRetAceSignatureList` / `Test-BakNRetSecurityRecordNeeded` | 低(**已由 VM 演练覆盖**) | 这几条需要真实提权与真实 NTFS,单元测试造不出可信夹具;仓库已经用 `tools/lab/Lab.ps1 acl-test`(含负对照)在真 VM 里覆盖,这是更正确的层次 | +| `Write-BakNRetBackupEntryPlan` | 低 | 纯输出,间接覆盖 | +| `Invoke-BakNRetEntryScript` | 低 | 子进程封装,端到端已覆盖 | +| TUI 相关(`Write-BakNRetAt` 等) | 低 | 需要真终端;仓库已用 `-InputScript` 驱动做门禁 | + +### 为什么本次**不执行**自动补测 + +1. 该步骤在流水线里标注为**可选**。 +2. 用户本轮选择的处置姿态是「最小侵入、不制造大 diff」;补 4~6 条新用例属于**新增工作**而非修复缺陷,应先取得确认。 +3. 上述「值得」的 4 条若要补,需要配套夹具(原子替换需要真归档、`-Only` 需要真清单),属于小规模但真实的工作量。 + +**建议**:单独一轮补 `Move-BakNRetArchiveIntoPlace` 与 `Resolve-BakNRetRootedPath` 两条(这两条对应的是历史上真出过问题的语义),其余保持现状。 + +--- + +## 3.2 性能基线(`/performance-baseline-tester`) + +**工具适配说明**:该技能面向 HTTP 服务(k6 / RPS / P95)。BakNRet 是**本地 CLI 工具**, +没有 HTTP 端点,因此把「核心 API」映射为**四条关键路径**:模块导入、清单解析、只读干跑(真实清单)、 +7z 压缩吞吐。指标是同一台机器上的可复现绝对耗时,用于**同环境前后对比**,不跨机器比较。 + +**环境**:宿主 `STRIX-X870A`,PowerShell 7.7.0-preview.5,7-Zip 26.03 (x64) + +| 指标 | 采样 | min | **中位数** | max | +| --- | --- | --- | --- | --- | +| 模块导入(`Import-Module` 冷启) | 5 | 628.3 ms | **631.0 ms** | 2052.8 ms | +| 清单解析 ×50 轮 | 5 | 2535.9 ms | **2546.9 ms** | 2691.5 ms | +| **只读干跑(真实 28 条清单)** | 5 | 9839.9 ms | **9869.2 ms** | 12900.8 ms | +| 源树扫描(200 文件 ×3 轮) | 1 | — | **18.2 ms** | — | + +**7z 压缩基准**(固定夹具:200 × 32 KB = 6.25 MB **不可压缩随机数据**) + +| 压缩级别 | 耗时 | 归档体积 | +| --- | --- | --- | +| `-mx=0` | 46.7 ms | 6,555,514 B | +| `-mx=5` | 224.2 ms | 6,555,913 B | +| `-mx=9` | 225.2 ms | 6,555,913 B | + +> 夹具是随机数据,所以体积几乎不随级别变化 —— 这正好说明**该夹具测的是吞吐与 I/O,不是压缩率**。 +> 若要看压缩率,应换成真实可压缩语料。 + +### 基线判定 + +- **基线文件**:`.scratch/ci-cd/20260928-001722/perf_baseline.json`(首次建立,**无历史基线可比,故无退化可判**)。 +- **观察**:只读干跑约 9.9 秒是最大单项。它包含了**对 28 个条目的真实目录扫描与空间预估**, + 属于设计内的开销(README 承诺「动手之前先预估空间」)。`max 12.9 s` 的离群值出现在首次运行, + 与文件系统缓存冷启动一致。 +- **告警**:无退化(首次基线)。 +- **建议**:若将来要监控退化,把 `perf_baseline.json` 固定成仓库内基线, + 并在同机同宿主下对比;**不要在 VM 与宿主之间直接比数字**(两者 I/O 特性不同)。 + +--- + +## 3.3 结论 + +- ✅ 性能基线已建立并可复现,**无退化**。 +- ⏸️ 单元测试增强:已给出逐条优先级,**建议单独一轮补 2 条**,本轮不擅自新增。 diff --git a/.scratch/ci-cd/blackbox-regression.ps1 b/.scratch/ci-cd/blackbox-regression.ps1 new file mode 100644 index 0000000..82149a9 --- /dev/null +++ b/.scratch/ci-cd/blackbox-regression.ps1 @@ -0,0 +1,171 @@ +# 阶段 2 回归:把阶段 1 的 11 条黑盒用例按**修正后的前置条件**重跑一遍。 +# 修正点: +# * BB-11 原用 -DryRun,而孤儿审计只在真实运行里报告 -> 改成真实运行(原用例的夹具错误,不是产品缺陷) +# * BB-06 的期望不变(显式清单不存在必须非 0),验证修复是否生效 +param([string]$OutJson = "$PSScriptRoot\blackbox_regression.json") + +$ErrorActionPreference = 'Continue' +$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$host7 = (Get-Command pwsh).Source +$host51 = (Get-Command powershell).Source +$zip = (Get-Command 7z -ErrorAction SilentlyContinue).Source +if (-not $zip) { foreach ($p in 'C:\Programs\Scoop\shims\7z.exe') { if (Test-Path $p) { $zip = $p } } } + +$sandbox = Join-Path $env:TEMP ('baknret-reg-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +$src = Join-Path $sandbox 'srcdocs' +New-Item -ItemType Directory -Path (Join-Path $src 'nested') -Force | Out-Null +Set-Content -LiteralPath (Join-Path $src 'a.txt') -Value 'alpha' -Encoding utf8 +Set-Content -LiteralPath (Join-Path $src 'nested\b.txt') -Value 'beta' -Encoding utf8 +Set-Content -LiteralPath (Join-Path $src 'nested\skipme.log') -Value 'noise' -Encoding utf8 + +$listPath = Join-Path $sandbox 'BackupList.txt' +Set-Content -LiteralPath $listPath -Encoding utf8 -Value @( + "$src :- 'nested\skipme.log'", + '+ ' + (Join-Path $sandbox 'nonexistent') +) +$backupDir = Join-Path $sandbox 'Backups' + +$cases = [System.Collections.Generic.List[object]]::new() +function Add-Case { + param([string]$Id, [string]$Title, [string]$Expected, [string]$Actual, [bool]$Pass, [string]$Note = '') + $cases.Add([pscustomobject]@{ Id = $Id; Title = $Title; Expected = $Expected; Actual = $Actual + Status = $(if ($Pass) { 'PASS' } else { 'FAIL' }); Note = $Note }) +} + +function Invoke-Bak { + param([string]$HostName, [string[]]$Arguments, [string]$Script = 'Backup-Data.ps1') + $exe = if ($HostName -eq '7') { $host7 } else { $host51 } + $all = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $repo $Script)) + $Arguments + $out = & $exe @all 2>&1 + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = @($out) } +} + +# ---------------------------------------------------------------- BB-01 真实备份 +$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $listPath, '-BackupDir', $backupDir) +$archives = @(Get-ChildItem -LiteralPath $backupDir -Filter '*.7z' -ErrorAction SilentlyContinue) +Add-Case -Id 'BB-01' -Title '真实备份:退出码 0 + 产出归档 + manifest' ` + -Expected '退出码 0;≥1 个 .7z;manifest.json 存在' ` + -Actual ("退出码={0};归档={1}" -f $r.ExitCode, $archives.Count) ` + -Pass ($r.ExitCode -eq 0 -and $archives.Count -ge 1 -and (Test-Path (Join-Path $backupDir 'manifest.json'))) + +# ---------------------------------------------------------------- BB-02 排除生效 +$listing = if ($archives.Count -gt 0 -and $zip) { @(& $zip l -ba $archives[0].FullName 2>&1) } else { @() } +$hasSkip = @($listing | Where-Object { $_ -match 'skipme\.log' }).Count -gt 0 +$hasKeep = @($listing | Where-Object { $_ -match 'a\.txt|b\.txt' }).Count -gt 0 +Add-Case -Id 'BB-02' -Title '排除模式真的把内容挡在归档之外' ` + -Expected '归档内无 skipme.log,有 a.txt/b.txt' ` + -Actual ("skipme={0};keep={1}" -f $hasSkip, $hasKeep) -Pass ($hasKeep -and -not $hasSkip) + +# ---------------------------------------------------------------- BB-03 干跑不写盘 +$h1 = (Get-FileHash -LiteralPath (Join-Path $backupDir 'manifest.json') -Algorithm SHA256).Hash +$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) +$h2 = (Get-FileHash -LiteralPath (Join-Path $backupDir 'manifest.json') -Algorithm SHA256).Hash +Add-Case -Id 'BB-03' -Title '-DryRun 一个字节都不写' ` + -Expected '退出码 0;manifest SHA256 前后一致' ` + -Actual ("退出码={0};哈希一致={1}" -f $r.ExitCode, ($h1 -eq $h2)) -Pass ($r.ExitCode -eq 0 -and $h1 -eq $h2) + +# ---------------------------------------------------------------- BB-04 missing-source +$actions = @((Get-Content (Join-Path $backupDir 'manifest.json') -Raw | ConvertFrom-Json).items.PSObject.Properties.Value.action) +Add-Case -Id 'BB-04' -Title '源不存在记 missing-source,不算失败' ` + -Expected '含 missing-source;退出码 0' ` + -Actual ("actions={0};退出码={1}" -f ($actions -join ','), $r.ExitCode) ` + -Pass ($actions -contains 'missing-source' -and $r.ExitCode -eq 0) + +# ---------------------------------------------------------------- BB-05 -Only +$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-Only', 'srcdocs', '-BackupListPath', $listPath, '-BackupDir', $backupDir) +$hitMissing = @($r.Output | Where-Object { $_ -match 'nonexistent' }).Count -gt 0 +Add-Case -Id 'BB-05' -Title '-Only 只处理匹配的条目' ` + -Expected '退出码 0;输出不提未选中的 nonexistent' ` + -Actual ("退出码={0};提到 nonexistent={1}" -f $r.ExitCode, $hitMissing) -Pass ($r.ExitCode -eq 0 -and -not $hitMissing) + +# ---------------------------------------------------------------- BB-06 ★ 修复验证 +$ghost = Join-Path $sandbox 'typo-in-path.txt' +$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $ghost, '-BackupDir', $backupDir) +$created = Test-Path -LiteralPath $ghost +Add-Case -Id 'BB-06' -Title '★显式指定的清单不存在 -> 必须报失败(本次修复点)' ` + -Expected '退出码 ≠ 0;不创建模板;输出含"指定的清单不存在"' ` + -Actual ("退出码={0};误建模板={1};提示={2}" -f $r.ExitCode, $created, (@($r.Output | Where-Object { $_ -match '指定的清单不存在' }).Count -gt 0)) ` + -Pass ($r.ExitCode -ne 0 -and -not $created -and (@($r.Output | Where-Object { $_ -match '指定的清单不存在' }).Count -gt 0)) ` + -Note '阶段 1 原为 FAIL(退出码 0 且建了模板),本次修复后应转为 PASS' + +# ---------------------------------------------------------------- BB-06b 对照:首次运行引导仍在 +$realList = Join-Path $repo 'BackupList.txt' +$parked = Join-Path $sandbox 'BackupList.parked' +$guideDir = Join-Path $sandbox 'guide' +New-Item -ItemType Directory -Path $guideDir -Force | Out-Null +$r = $null +try { + Move-Item -LiteralPath $realList -Destination $parked -Force + $r = Invoke-Bak -HostName 7 -Arguments @('-BackupDir', (Join-Path $guideDir 'Backups')) + $regenerated = Test-Path -LiteralPath $realList +} +finally { + if (Test-Path -LiteralPath $parked) { Move-Item -LiteralPath $parked -Destination $realList -Force } +} +Add-Case -Id 'BB-06b' -Title '对照:未显式指定时首次运行仍建模板并退出 0' ` + -Expected '退出码 0;模板被创建;随后仓库原文件已还原' ` + -Actual ("退出码={0};模板创建={1};仓库还原={2}" -f $r.ExitCode, $regenerated, (Test-Path -LiteralPath $realList)) ` + -Pass ($r.ExitCode -eq 0 -and $regenerated -and (Test-Path -LiteralPath $realList)) + +# ---------------------------------------------------------------- BB-07 垫片转发 +$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) -Script 'Backup.ps1' +$said = @($r.Output | Where-Object { $_ -match '已改名为' }).Count -gt 0 +Add-Case -Id 'BB-07' -Title '旧名字垫片转发且退出码原样传递' ` + -Expected '退出码 0;有改名提示' ` + -Actual ("退出码={0};提示={1}" -f $r.ExitCode, $said) -Pass ($r.ExitCode -eq 0 -and $said) + +# ---------------------------------------------------------------- BB-08 双宿主 +$r7 = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) +$r51 = Invoke-Bak -HostName '5.1' -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) +Add-Case -Id 'BB-08' -Title '5.1 与 7.x 行为一致' ` + -Expected '两个宿主退出码都是 0' ` + -Actual ("7={0};5.1={1}" -f $r7.ExitCode, $r51.ExitCode) -Pass ($r7.ExitCode -eq 0 -and $r51.ExitCode -eq 0) + +# ---------------------------------------------------------------- BB-09 特殊字符 +$sp = Join-Path $sandbox 'sp&chars#dir' +New-Item -ItemType Directory -Path $sp -Force | Out-Null +Set-Content -LiteralPath (Join-Path $sp 'x.txt') -Value 'special' -Encoding utf8 +$list2 = Join-Path $sandbox 'BackupList2.txt' +Set-Content -LiteralPath $list2 -Encoding utf8 -Value "`"$sp`"" +$bd2 = Join-Path $sandbox 'Backups2' +$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $list2, '-BackupDir', $bd2) +$n = @(Get-ChildItem -LiteralPath $bd2 -Filter '*.7z' -ErrorAction SilentlyContinue).Count +Add-Case -Id 'BB-09' -Title '含 & 与 # 的路径(整行引号)' ` + -Expected '退出码 0 且产出归档' ` + -Actual ("退出码={0};归档={1}" -f $r.ExitCode, $n) -Pass ($r.ExitCode -eq 0 -and $n -ge 1) + +# ---------------------------------------------------------------- BB-10 无控制台不挂起 +$pinfo = New-Object System.Diagnostics.ProcessStartInfo +$pinfo.FileName = $host7 +$pinfo.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$repo\Manage-Backup.ps1`"" +$pinfo.RedirectStandardOutput = $true; $pinfo.RedirectStandardError = $true +$pinfo.UseShellExecute = $false; $pinfo.CreateNoWindow = $true +$proc = [System.Diagnostics.Process]::Start($pinfo) +$finished = $proc.WaitForExit(60000) +if (-not $finished) { try { $proc.Kill() } catch {} } +Add-Case -Id 'BB-10' -Title '无控制台且无 -InputScript 时报错退出,绝不挂起' ` + -Expected '60 秒内退出且退出码 2' ` + -Actual ("已退出={0};退出码={1}" -f $finished, $proc.ExitCode) -Pass ($finished -and $proc.ExitCode -eq 2) + +# ---------------------------------------------------------------- BB-11 ★ 修正夹具:真实运行 +$orphanDir = Join-Path $sandbox 'Backups3' +New-Item -ItemType Directory -Path $orphanDir -Force | Out-Null +if ($archives.Count -gt 0) { Copy-Item -LiteralPath $archives[0].FullName -Destination (Join-Path $orphanDir 'GhostSoftware.7z') } +$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $listPath, '-BackupDir', $orphanDir) +$named = @($r.Output | Where-Object { $_ -match 'GhostSoftware' }).Count -gt 0 +Add-Case -Id 'BB-11' -Title '★孤儿归档审计(改用真实运行)' ` + -Expected '输出点名 GhostSoftware.7z' ` + -Actual ("退出码={0};点名={1}" -f $r.ExitCode, $named) -Pass $named ` + -Note '阶段 1 的 FAIL 系夹具错误:孤儿审计只在真实运行里报告,干跑不报' + +$report = [ordered]@{ + testedAt = (Get-Date).ToString('s') + total = $cases.Count + passed = @($cases | Where-Object Status -eq 'PASS').Count + failed = @($cases | Where-Object Status -eq 'FAIL').Count + cases = $cases +} +$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutJson -Encoding utf8 +$cases | Format-Table Id, Status, Title -AutoSize +Write-Host ("回归合计 {0};通过 {1};失败 {2}" -f $report.total, $report.passed, $report.failed) +Write-Host ("沙盒: " + $sandbox) diff --git a/.scratch/ci-cd/blackbox-tests.ps1 b/.scratch/ci-cd/blackbox-tests.ps1 new file mode 100644 index 0000000..d0657c9 --- /dev/null +++ b/.scratch/ci-cd/blackbox-tests.ps1 @@ -0,0 +1,209 @@ +# 黑盒测试:只依据 README 记录的对外契约,在**沙盒副本**里驱动 CLI。 +# 不读实现细节;每个用例都断言"文档承诺的行为 vs 实际行为"。 +param( + [string]$OutJson = "$PSScriptRoot\blackbox_results.json" +) + +$ErrorActionPreference = 'Continue' +$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$host7 = (Get-Command pwsh).Source +$host51 = (Get-Command powershell).Source + +# ---------------------------------------------------------------- 沙盒(绝不碰真实 Backups/ logs/) +$sandbox = Join-Path $env:TEMP ('baknret-bb-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +$src = Join-Path $sandbox 'srcdocs' +New-Item -ItemType Directory -Path (Join-Path $src 'nested') -Force | Out-Null +Set-Content -LiteralPath (Join-Path $src 'a.txt') -Value 'alpha' -Encoding utf8 +Set-Content -LiteralPath (Join-Path $src 'nested\b.txt') -Value 'beta' -Encoding utf8 +Set-Content -LiteralPath (Join-Path $src 'nested\skipme.log') -Value 'noise' -Encoding utf8 + +$listPath = Join-Path $sandbox 'BackupList.txt' +Set-Content -LiteralPath $listPath -Encoding utf8 -Value @( + "$src :- 'nested\skipme.log'", + '+ ' + (Join-Path $sandbox 'nonexistent') +) +$backupDir = Join-Path $sandbox 'Backups' + +$cases = [System.Collections.Generic.List[object]]::new() +function Add-Case { + param([string]$Id, [string]$Module, [string]$Title, [string]$Priority, + [string]$Precondition, [string]$Steps, [string]$Expected, + [string]$Actual, [bool]$Pass, [string]$Severity = '') + + $cases.Add([pscustomobject]@{ + Id = $Id; Module = $Module; Title = $Title; Priority = $Priority + Precondition = $Precondition; Steps = $Steps; Expected = $Expected + Actual = $Actual; Status = $(if ($Pass) { 'PASS' } else { 'FAIL' }) + Severity = $(if ($Pass) { '' } else { $Severity }) + }) +} + +function Invoke-Bak { + param([string]$HostName, [string[]]$Arguments, [string]$Script = 'Backup-Data.ps1') + $exe = if ($HostName -eq '7') { $host7 } else { $host51 } + $all = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $repo $Script)) + $Arguments + $out = & $exe @all 2>&1 + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = @($out) } +} + +function Get-ManifestHash { + $p = Join-Path $backupDir 'manifest.json' + if (-not (Test-Path -LiteralPath $p)) { return '' } + return (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash +} + +# ================================================================ 用例 + +# BB-01 首次真实备份(核心流) +$before = Get-ManifestHash +$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $listPath, '-BackupDir', $backupDir) +$archives = @(Get-ChildItem -LiteralPath $backupDir -Filter '*.7z' -ErrorAction SilentlyContinue) +Add-Case -Id 'BB-01' -Module '备份' -Title '文档承诺:备份成功返回 0,并产出 .7z 归档' -Priority 'P0' ` + -Precondition '沙盒清单:1 个目录(含排除)+ 1 个不存在的源' ` + -Steps 'Backup-Data.ps1(无 -DryRun)' ` + -Expected '退出码 0;Backups\ 下出现 1 个 .7z;manifest.json 存在' ` + -Actual ("退出码={0};归档={1} 个({2})" -f $r.ExitCode, $archives.Count, ($archives.Name -join ',')) ` + -Pass ($r.ExitCode -eq 0 -and $archives.Count -ge 1 -and (Test-Path (Join-Path $backupDir 'manifest.json'))) ` + -Severity '严重' + +# BB-02 排除规则真的生效(逐个归档内容核对) +$zip = (Get-Command 7z -ErrorAction SilentlyContinue).Source +if (-not $zip) { foreach ($p in 'C:\Programs\Scoop\shims\7z.exe') { if (Test-Path $p) { $zip = $p } } } +$listing = if ($archives.Count -gt 0 -and $zip) { @(& $zip l -ba $archives[0].FullName 2>&1) } else { @() } +$hasSkip = @($listing | Where-Object { $_ -match 'skipme\.log' }).Count -gt 0 +$hasKeep = @($listing | Where-Object { $_ -match 'a\.txt|b\.txt' }).Count -gt 0 +Add-Case -Id 'BB-02' -Module '排除' -Title '文档承诺::- 排除模式把内容挡在归档之外' -Priority 'P0' ` + -Precondition '清单里对源目录写了 :- ''nested\skipme.log''' ` + -Steps '7z l 列出归档内容' ` + -Expected '归档里**没有** skipme.log,但有 a.txt 与 b.txt' ` + -Actual ("skipme.log 命中={0};a/b.txt 命中={1}" -f $hasSkip, $hasKeep) ` + -Pass ($hasKeep -and -not $hasSkip) -Severity '严重' + +# BB-03 只读模式不写盘(README 的强承诺) +$h1 = Get-ManifestHash +$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) +$h2 = Get-ManifestHash +Add-Case -Id 'BB-03' -Module '干跑' -Title '文档承诺:-DryRun 一个字节都不写(含 manifest.json)' -Priority 'P0' ` + -Precondition '已有 1 份归档与 manifest.json' ` + -Steps '记录 SHA256 → 跑 -DryRun → 再记录 SHA256' ` + -Expected '退出码 0;manifest.json 的 SHA256 前后完全一致' ` + -Actual ("退出码={0};哈希 {1} → {2}" -f $r.ExitCode, $h1.Substring(0, 12), $h2.Substring(0, 12)) ` + -Pass ($r.ExitCode -eq 0 -and $h1 -eq $h2) -Severity '致命' + +# BB-04 源不存在只算跳过、不算失败 +Add-Case -Id 'BB-04' -Module '异常流' -Title '文档承诺:源路径不存在记 missing-source,不算失败' -Priority 'P1' ` + -Precondition '清单第 2 行指向不存在的目录' ` + -Steps '跑备份后读 manifest.json 的 action 字段' ` + -Expected '该条目 action=missing-source,且整体退出码仍为 0' ` + -Actual ("退出码={0};manifest action 分布={1}" -f $r.ExitCode, + (@((Get-Content (Join-Path $backupDir 'manifest.json') -Raw | ConvertFrom-Json).items.PSObject.Properties.Value.action) -join ',')) ` + -Pass ($r.ExitCode -eq 0 -and (@((Get-Content (Join-Path $backupDir 'manifest.json') -Raw | ConvertFrom-Json).items.PSObject.Properties.Value.action) -contains 'missing-source')) ` + -Severity '一般' + +# BB-05 -Only 过滤 +$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-Only', 'srcdocs', '-BackupListPath', $listPath, '-BackupDir', $backupDir) +$hitMissing = @($r.Output | Where-Object { $_ -match 'nonexistent' }).Count -gt 0 +Add-Case -Id 'BB-05' -Module '干跑' -Title '文档承诺:-Only 只处理匹配的条目' -Priority 'P1' ` + -Precondition '清单 2 条:srcdocs(目录)、nonexistent(不存在)' ` + -Steps 'Backup-Data.ps1 -DryRun -Only ''srcdocs''' ` + -Expected '退出码 0;输出里不出现未选中的 nonexistent 条目' ` + -Actual ("退出码={0};输出提到 nonexistent={1}" -f $r.ExitCode, $hitMissing) ` + -Pass ($r.ExitCode -eq 0 -and -not $hitMissing) -Severity '一般' + +# BB-06 非法参数(错误推测) +$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', (Join-Path $sandbox 'no-such-list.txt'), '-BackupDir', $backupDir) +Add-Case -Id 'BB-06' -Module '异常流' -Title '边界值:清单文件不存在时的行为' -Priority 'P1' ` + -Precondition '传入一个不存在的 -BackupListPath' ` + -Steps 'Backup-Data.ps1 -BackupListPath <不存在>' ` + -Expected '明确报错(非 0 退出码)或给出可读提示,**不得静默返回 0**' ` + -Actual ("退出码={0};末行={1}" -f $r.ExitCode, (@($r.Output) | Select-Object -Last 1)) ` + -Pass ($r.ExitCode -ne 0) -Severity '一般' + +# BB-07 旧名字垫片仍可用(文档承诺"只留一轮") +$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) -Script 'Backup.ps1' +$saidRename = @($r.Output | Where-Object { $_ -match '已改名为' }).Count -gt 0 +Add-Case -Id 'BB-07' -Module '兼容' -Title '文档承诺:旧名字 Backup.ps1 是转发垫片,退出码原样传递' -Priority 'P1' ` + -Precondition '实现已改名为 Backup-Data.ps1' ` + -Steps 'Backup.ps1 -DryRun(旧名字)' ` + -Expected '打印改名提示;退出码与直接调用一致(0)' ` + -Actual ("退出码={0};有改名提示={1}" -f $r.ExitCode, $saidRename) ` + -Pass ($r.ExitCode -eq 0 -and $saidRename) -Severity '一般' + +# BB-08 双宿主一致性(5.1 是文档承诺支持的一半) +$r7 = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) +$r51 = Invoke-Bak -HostName '5.1' -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $backupDir) +Add-Case -Id 'BB-08' -Module '跨端一致性' -Title '文档承诺:Windows PowerShell 5.1 与 7.x 行为一致' -Priority 'P0' ` + -Precondition '同一沙盒、同一清单' ` + -Steps '两个宿主各跑一次 -DryRun,比较退出码' ` + -Expected '两者退出码都是 0(GBK 控制台下的中文输出不崩)' ` + -Actual ("7 退出码={0};5.1 退出码={1}" -f $r7.ExitCode, $r51.ExitCode) ` + -Pass ($r7.ExitCode -eq 0 -and $r51.ExitCode -eq 0) -Severity '致命' + +# BB-09 特殊字符路径(错误推测) +$sp = Join-Path $sandbox 'sp&chars#dir' +New-Item -ItemType Directory -Path $sp -Force | Out-Null +Set-Content -LiteralPath (Join-Path $sp 'x.txt') -Value 'special' -Encoding utf8 +$list2 = Join-Path $sandbox 'BackupList2.txt' +Set-Content -LiteralPath $list2 -Encoding utf8 -Value "`"$sp`"" +$bd2 = Join-Path $sandbox 'Backups2' +$r = Invoke-Bak -HostName 7 -Arguments @('-BackupListPath', $list2, '-BackupDir', $bd2) +$ok9 = $r.ExitCode -eq 0 -and @(Get-ChildItem -LiteralPath $bd2 -Filter '*.7z' -ErrorAction SilentlyContinue).Count -ge 1 +Add-Case -Id 'BB-09' -Module '边界值' -Title '特殊字符:含 & 与 # 的路径(整行引号写法)' -Priority 'P2' ` + -Precondition '源目录名含 & 与 #;清单行整体加引号' ` + -Steps '备份该条目' ` + -Expected '退出码 0 且真的产出归档(# 不被当注释吃掉)' ` + -Actual ("退出码={0};归档数={1}" -f $r.ExitCode, @(Get-ChildItem -LiteralPath $bd2 -Filter '*.7z' -ErrorAction SilentlyContinue).Count) ` + -Pass $ok9 -Severity '一般' + +# BB-10 无控制台时的明确失败(README:绝不挂起) +$pinfo = New-Object System.Diagnostics.ProcessStartInfo +$pinfo.FileName = $host7 +$pinfo.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$repo\Manage-Backup.ps1`"" +$pinfo.RedirectStandardOutput = $true +$pinfo.RedirectStandardError = $true +$pinfo.UseShellExecute = $false +$pinfo.CreateNoWindow = $true +$proc = [System.Diagnostics.Process]::Start($pinfo) +$finished = $proc.WaitForExit(60000) +$stdout = if ($finished) { $proc.StandardOutput.ReadToEnd() } else { '' } +$stderr = if ($finished) { $proc.StandardError.ReadToEnd() } else { '' } +if (-not $finished) { try { $proc.Kill() } catch {} } +Add-Case -Id 'BB-10' -Module '无头' -Title '文档承诺:没有控制台且没给 -InputScript 时报错退出,绝不挂起' -Priority 'P0' ` + -Precondition 'stdout/stderr 被重定向(等价于计划任务/管道环境)' ` + -Steps '不传参数直接运行 Manage-Backup.ps1,等待最多 60 秒' ` + -Expected '60 秒内退出,退出码 2,并提示"没有可用的控制台"' ` + -Actual ("60 秒内退出={0};退出码={1};输出片段={2}" -f $finished, $proc.ExitCode, (@($stdout, $stderr) -join ' ').Trim().Substring(0, [Math]::Min(90, (@($stdout, $stderr) -join ' ').Trim().Length))) ` + -Pass ($finished -and $proc.ExitCode -eq 2) -Severity '致命' + +# BB-11 孤儿归档审计 +$orphanDir = Join-Path $sandbox 'Backups3' +New-Item -ItemType Directory -Path $orphanDir -Force | Out-Null +Copy-Item -LiteralPath (Join-Path $backupDir 'manifest.json') -Destination (Join-Path $orphanDir 'manifest.json') -ErrorAction SilentlyContinue +$fake = Join-Path $orphanDir 'GhostSoftware.7z' +if ($archives.Count -gt 0) { Copy-Item -LiteralPath $archives[0].FullName -Destination $fake } +$r = Invoke-Bak -HostName 7 -Arguments @('-DryRun', '-BackupListPath', $listPath, '-BackupDir', $orphanDir) +$named = @($r.Output | Where-Object { $_ -match 'GhostSoftware' }).Count -gt 0 +Add-Case -Id 'BB-11' -Module '审计' -Title '文档承诺:孤儿归档会被点名' -Priority 'P1' ` + -Precondition 'Backups3\ 里放一个清单中不存在的 GhostSoftware.7z' ` + -Steps '备份后看输出是否点名' ` + -Expected '输出里出现该孤儿归档名' ` + -Actual ("退出码={0};点名={1}" -f $r.ExitCode, $named) ` + -Pass $named -Severity '一般' + +# ---------------------------------------------------------------- 汇总 +$report = [ordered]@{ + testedAt = (Get-Date).ToString('s') + scope = '文档契约黑盒测试(README 为准)' + sandbox = $sandbox + total = $cases.Count + passed = @($cases | Where-Object Status -eq 'PASS').Count + failed = @($cases | Where-Object Status -eq 'FAIL').Count + fatalOrSevere = @($cases | Where-Object { $_.Status -eq 'FAIL' -and $_.Severity -in '致命', '严重' }).Count + cases = $cases +} +$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutJson -Encoding utf8 +$cases | Format-Table Id, Module, Priority, Status, Severity, Title -AutoSize +Write-Host '' +Write-Host ("合计 {0};通过 {1};失败 {2};致命/严重 {3}" -f $report.total, $report.passed, $report.failed, $report.fatalOrSevere) +Write-Host ("结果写入 {0}" -f $OutJson) +Write-Host ("沙盒(保留供排查): {0}" -f $sandbox) diff --git a/.scratch/ci-cd/blackbox_regression.json b/.scratch/ci-cd/blackbox_regression.json new file mode 100644 index 0000000..537b155 --- /dev/null +++ b/.scratch/ci-cd/blackbox_regression.json @@ -0,0 +1,104 @@ +{ + "testedAt": "2026-09-28T12:43:39", + "total": 12, + "passed": 12, + "failed": 0, + "cases": [ + { + "Id": "BB-01", + "Title": "真实备份:退出码 0 + 产出归档 + manifest", + "Expected": "退出码 0;≥1 个 .7z;manifest.json 存在", + "Actual": "退出码=0;归档=1", + "Status": "PASS", + "Note": "" + }, + { + "Id": "BB-02", + "Title": "排除模式真的把内容挡在归档之外", + "Expected": "归档内无 skipme.log,有 a.txt/b.txt", + "Actual": "skipme=False;keep=True", + "Status": "PASS", + "Note": "" + }, + { + "Id": "BB-03", + "Title": "-DryRun 一个字节都不写", + "Expected": "退出码 0;manifest SHA256 前后一致", + "Actual": "退出码=0;哈希一致=True", + "Status": "PASS", + "Note": "" + }, + { + "Id": "BB-04", + "Title": "源不存在记 missing-source,不算失败", + "Expected": "含 missing-source;退出码 0", + "Actual": "actions=backed-up,missing-source;退出码=0", + "Status": "PASS", + "Note": "" + }, + { + "Id": "BB-05", + "Title": "-Only 只处理匹配的条目", + "Expected": "退出码 0;输出不提未选中的 nonexistent", + "Actual": "退出码=0;提到 nonexistent=False", + "Status": "PASS", + "Note": "" + }, + { + "Id": "BB-06", + "Title": "★显式指定的清单不存在 -> 必须报失败(本次修复点)", + "Expected": "退出码 ≠ 0;不创建模板;输出含\"指定的清单不存在\"", + "Actual": "退出码=1;误建模板=False;提示=True", + "Status": "PASS", + "Note": "阶段 1 原为 FAIL(退出码 0 且建了模板),本次修复后应转为 PASS" + }, + { + "Id": "BB-06b", + "Title": "对照:未显式指定时首次运行仍建模板并退出 0", + "Expected": "退出码 0;模板被创建;随后仓库原文件已还原", + "Actual": "退出码=0;模板创建=True;仓库还原=True", + "Status": "PASS", + "Note": "" + }, + { + "Id": "BB-07", + "Title": "旧名字垫片转发且退出码原样传递", + "Expected": "退出码 0;有改名提示", + "Actual": "退出码=0;提示=True", + "Status": "PASS", + "Note": "" + }, + { + "Id": "BB-08", + "Title": "5.1 与 7.x 行为一致", + "Expected": "两个宿主退出码都是 0", + "Actual": "7=0;5.1=0", + "Status": "PASS", + "Note": "" + }, + { + "Id": "BB-09", + "Title": "含 & 与 # 的路径(整行引号)", + "Expected": "退出码 0 且产出归档", + "Actual": "退出码=0;归档=1", + "Status": "PASS", + "Note": "" + }, + { + "Id": "BB-10", + "Title": "无控制台且无 -InputScript 时报错退出,绝不挂起", + "Expected": "60 秒内退出且退出码 2", + "Actual": "已退出=True;退出码=2", + "Status": "PASS", + "Note": "" + }, + { + "Id": "BB-11", + "Title": "★孤儿归档审计(改用真实运行)", + "Expected": "输出点名 GhostSoftware.7z", + "Actual": "退出码=0;点名=True", + "Status": "PASS", + "Note": "阶段 1 的 FAIL 系夹具错误:孤儿审计只在真实运行里报告,干跑不报" + } + ] +} diff --git a/.scratch/ci-cd/blackbox_results.json b/.scratch/ci-cd/blackbox_results.json new file mode 100644 index 0000000..e9a40d4 --- /dev/null +++ b/.scratch/ci-cd/blackbox_results.json @@ -0,0 +1,143 @@ +{ + "testedAt": "2026-09-28T12:35:02", + "scope": "文档契约黑盒测试(README 为准)", + "sandbox": "C:\\Users\\Shuery\\AppData\\Local\\Temp\\baknret-bb-45647a8d", + "total": 11, + "passed": 9, + "failed": 2, + "fatalOrSevere": 0, + "cases": [ + { + "Id": "BB-01", + "Module": "备份", + "Title": "文档承诺:备份成功返回 0,并产出 .7z 归档", + "Priority": "P0", + "Precondition": "沙盒清单:1 个目录(含排除)+ 1 个不存在的源", + "Steps": "Backup-Data.ps1(无 -DryRun)", + "Expected": "退出码 0;Backups\\ 下出现 1 个 .7z;manifest.json 存在", + "Actual": "退出码=0;归档=1 个(srcdocs_from_C_+Users+Shuery+AppData+Local+Temp+baknret-bb-45647a8d.7z)", + "Status": "PASS", + "Severity": "" + }, + { + "Id": "BB-02", + "Module": "排除", + "Title": "文档承诺::- 排除模式把内容挡在归档之外", + "Priority": "P0", + "Precondition": "清单里对源目录写了 :- 'nested\\skipme.log'", + "Steps": "7z l 列出归档内容", + "Expected": "归档里**没有** skipme.log,但有 a.txt 与 b.txt", + "Actual": "skipme.log 命中=False;a/b.txt 命中=True", + "Status": "PASS", + "Severity": "" + }, + { + "Id": "BB-03", + "Module": "干跑", + "Title": "文档承诺:-DryRun 一个字节都不写(含 manifest.json)", + "Priority": "P0", + "Precondition": "已有 1 份归档与 manifest.json", + "Steps": "记录 SHA256 → 跑 -DryRun → 再记录 SHA256", + "Expected": "退出码 0;manifest.json 的 SHA256 前后完全一致", + "Actual": "退出码=0;哈希 6D726A618FD8 → 6D726A618FD8", + "Status": "PASS", + "Severity": "" + }, + { + "Id": "BB-04", + "Module": "异常流", + "Title": "文档承诺:源路径不存在记 missing-source,不算失败", + "Priority": "P1", + "Precondition": "清单第 2 行指向不存在的目录", + "Steps": "跑备份后读 manifest.json 的 action 字段", + "Expected": "该条目 action=missing-source,且整体退出码仍为 0", + "Actual": "退出码=0;manifest action 分布=backed-up,missing-source", + "Status": "PASS", + "Severity": "" + }, + { + "Id": "BB-05", + "Module": "干跑", + "Title": "文档承诺:-Only 只处理匹配的条目", + "Priority": "P1", + "Precondition": "清单 2 条:srcdocs(目录)、nonexistent(不存在)", + "Steps": "Backup-Data.ps1 -DryRun -Only 'srcdocs'", + "Expected": "退出码 0;输出里不出现未选中的 nonexistent 条目", + "Actual": "退出码=0;输出提到 nonexistent=False", + "Status": "PASS", + "Severity": "" + }, + { + "Id": "BB-06", + "Module": "异常流", + "Title": "边界值:清单文件不存在时的行为", + "Priority": "P1", + "Precondition": "传入一个不存在的 -BackupListPath", + "Steps": "Backup-Data.ps1 -BackupListPath <不存在>", + "Expected": "明确报错(非 0 退出码)或给出可读提示,**不得静默返回 0**", + "Actual": "退出码=0;末行=[2026-09-28 12:34:54] [INFO] 模板 BackupList.txt 已创建,请编辑后重试。", + "Status": "FAIL", + "Severity": "一般" + }, + { + "Id": "BB-07", + "Module": "兼容", + "Title": "文档承诺:旧名字 Backup.ps1 是转发垫片,退出码原样传递", + "Priority": "P1", + "Precondition": "实现已改名为 Backup-Data.ps1", + "Steps": "Backup.ps1 -DryRun(旧名字)", + "Expected": "打印改名提示;退出码与直接调用一致(0)", + "Actual": "退出码=0;有改名提示=True", + "Status": "PASS", + "Severity": "" + }, + { + "Id": "BB-08", + "Module": "跨端一致性", + "Title": "文档承诺:Windows PowerShell 5.1 与 7.x 行为一致", + "Priority": "P0", + "Precondition": "同一沙盒、同一清单", + "Steps": "两个宿主各跑一次 -DryRun,比较退出码", + "Expected": "两者退出码都是 0(GBK 控制台下的中文输出不崩)", + "Actual": "7 退出码=0;5.1 退出码=0", + "Status": "PASS", + "Severity": "" + }, + { + "Id": "BB-09", + "Module": "边界值", + "Title": "特殊字符:含 & 与 # 的路径(整行引号写法)", + "Priority": "P2", + "Precondition": "源目录名含 & 与 #;清单行整体加引号", + "Steps": "备份该条目", + "Expected": "退出码 0 且真的产出归档(# 不被当注释吃掉)", + "Actual": "退出码=0;归档数=1", + "Status": "PASS", + "Severity": "" + }, + { + "Id": "BB-10", + "Module": "无头", + "Title": "文档承诺:没有控制台且没给 -InputScript 时报错退出,绝不挂起", + "Priority": "P0", + "Precondition": "stdout/stderr 被重定向(等价于计划任务/管道环境)", + "Steps": "不传参数直接运行 Manage-Backup.ps1,等待最多 60 秒", + "Expected": "60 秒内退出,退出码 2,并提示\"没有可用的控制台\"", + "Actual": "60 秒内退出=True;退出码=2;输出片段=û�п��õĿ���̨��Ҳû�и��� -InputScript���޷����뽻�����档\r\nҪ�ǽ���ִ�У���ָ�����������磺-Action Backup ", + "Status": "PASS", + "Severity": "" + }, + { + "Id": "BB-11", + "Module": "审计", + "Title": "文档承诺:孤儿归档会被点名", + "Priority": "P1", + "Precondition": "Backups3\\ 里放一个清单中不存在的 GhostSoftware.7z", + "Steps": "备份后看输出是否点名", + "Expected": "输出里出现该孤儿归档名", + "Actual": "退出码=0;点名=False", + "Status": "FAIL", + "Severity": "一般" + } + ] +} diff --git a/.scratch/ci-cd/cicd_report_20260928.md b/.scratch/ci-cd/cicd_report_20260928.md new file mode 100644 index 0000000..da6d220 --- /dev/null +++ b/.scratch/ci-cd/cicd_report_20260928.md @@ -0,0 +1,345 @@ +# BakNRet CI/CD 流水线报告 + +- **报告时间**:2026-09-28 +- **项目**:BakNRet(Windows 备份 / 恢复工具,PowerShell 5.1 + 7.x) +- **分支**:`refactor/ms-conventions` +- **测试环境**:Hyper-V VM `BakNRet-Lab`(Gen2 / 8 vCPU / 12 GB)+ 宿主 `STRIX-X870A` +- **流水线入口**:`/ci-cd-pipeline`(用户指定:测试环境为 Hyper-V 虚拟机) + +--- + +## 阶段总览 + +| 阶段 | 名称 | 状态 | 关键结果 | +| --- | --- | --- | --- | +| 0.1 | 依赖安全扫描 | ✅ 通过 | 零运行时依赖;**0 致命/严重**;2 条一般级卫生风险 | +| 0.2 | 许可证合规 | ⚠️ 通过(有缺口) | 无 copyleft 冲突;**主许可证未声明** | +| 0.3 | 语法检查与修复 | ✅ 通过 | 131/131 双宿主解析零错;修 2 处排版 + 1 处自伤;分析器 84→80 条、0 Error | +| 1 | 黑盒测试 | ✅ 通过 | 11 条用例:9 通过、2 失败(**0 致命/严重**) | +| 2 | 缺陷修复与回归 | ✅ 通过 | 修 1 个真实缺陷;回归 **12/12**;Pester 183→**192** 全绿 | +| 3 | 测试增强与性能 | ✅ 通过 | 性能基线首次建立、**无退化**;单测增强给出优先级、未擅自新增 | +| 4 | 代码规范化 | ✅ 通过 | 分析器格式规则门禁通过(0 Error);仓库自有格式门禁为准 | +| 5 | 文档生成与质量检查 | ✅ 通过 | `PROJECT_REPORT.md` 532 行;markdownlint **0 错**;死链 **0** | +| 6 | 数据库迁移审查 | ⏭️ **不适用** | 全仓无数据库、无 SQL、无迁移脚本 | +| 7 | 生产部署 | ⛔ **停在人工确认门** | 需你确认(见文末) | +| 8 | 收尾 | ✅ 完成 | 本报告 | + +--- + +## 阶段 0:静态分析与修复 + +### 0.1 依赖安全扫描 + +| 项目 | 结论 | +| --- | --- | +| 依赖管理文件 | 不存在(无 `package.json` / `requirements.txt` / `go.mod` / `pom.xml` / `Cargo.toml`) | +| 运行时依赖 | **0 个模块**(只需 PowerShell 5.1 或 7.x + 7-Zip) | +| CVE | 无可报(没有任何被本仓库固定版本的第三方库) | + +**风险**:R-1 无 `LICENSE`(一般)· R-2 工作区存在口令文件 `baknret.key`(一般,**未被 git 跟踪**, +`.gitignore:18 *.key` 已忽略;全程未读取其内容)· R-3 不检查 7z 版本(建议)· +R-4 口令经命令行传给 7z(建议,上游限制)。 + +### 0.2 许可证合规 + +| 组件 | 许可证 | 是否随分发 | +| --- | --- | --- | +| 本项目 | **未声明** | — | +| Pester 5.9.1 | Apache-2.0 | 否(`.tools/`,gitignore) | +| PSScriptAnalyzer 1.25.0 | MIT | 否 | +| Newtonsoft.Json | MIT | 否 | +| 7-Zip 26.03 | LGPL-2.1 + BSD-3 + unRAR 限制 | 否(用户自备) | + +**无 copyleft 传染**。7-Zip 的 unRAR 限制只禁止「用其代码还原 RAR 压缩算法」,本项目不触发。 + +### 0.3 语法检查与自动修复 + +**修复的真实缺陷** + +| 文件:行 | 规则 | 修复 | +| --- | --- | --- | +| `Backup.ps1:42` | `PSUseConsistentWhitespace` | `@($Rest)+ @(...)` → `@($Rest) + @(...)` | +| `Restore.ps1:42` | 同上 | 同上 | + +**修复过程中的自伤与恢复(重要教训)** + +编辑这两个文件后 **UTF-8 BOM 被抹掉**,导致 5.1 立即报 `The string is missing the terminator: '.`, +而 PowerShell 7 完全正常。用 `UTF8Encoding($true)` 重写后恢复,双宿主 `parse_bad=0`。 +这条恰好实证了本仓库 Encode 层门禁的价值,也说明「编辑 PowerShell 源文件必须保留 BOM」是硬约束。 + +**未修复项**:80 条全部为风格类(0 Error),按用户决定登记为「仓库已声明的偏离」,逐条附依据: +行长 160 是配置里写明的有意偏离(`PSScriptAnalyzerSettings.psd1` + [ADR-0008](../docs/adr/0008-analyzer-deviations.md)); +`PSPlaceCloseBrace` 等集中在 `Run-Tests.ps1` 的**测试夹具字符串**内(改了会改变断言语义); +`PSUseSupportsShouldProcess` 已全局排除属已知噪声;空 catch 是有意为之(注释已写明理由)。 + +**待人工确认的观察项**:`Write-BakNRetRunSummary` 的 `$Mode` 参数声明了 `ValidateSet` 与 `Mandatory` +但函数体内从未读取,且该函数未被仓库内任何代码调用(仅对外导出)。判断为未完工实现, +删除会破坏公共 API 参数契约,故**登记而不改动**。 + +--- + +## 阶段 1 → 2:黑盒测试与缺陷修复 + +### 测试环境确认(技能强制) + +| 项目 | 事实 | +| --- | --- | +| 目标 | Hyper-V VM `BakNRet-Lab`(Gen2 / 8 vCPU / 12 GB / Default Switch 内部 NAT) | +| 检查点 | 自动检查点、**clean-baseline**、**sandbox-ready** | +| 数据隔离 | 仓库自述:全部操作在 VM 内进行,宿主机仓库 / `Backups\` / `logs\` 不被写入 | +| 生产暴露 | 无(无生产服务器、无域名、无对外服务) | + +**已获用户明确确认**后开始测试。 + +### 用例与结果 + +| 编号 | 用例 | 阶段 1 | 阶段 2 回归 | +| --- | --- | --- | --- | +| BB-01 | 真实备份:退出码 0 + 产出归档 + manifest | PASS | PASS | +| BB-02 | 排除模式真的把内容挡在归档之外(逐个归档内容核对) | PASS | PASS | +| BB-03 | `-DryRun` 一个字节都不写(manifest SHA256 前后一致) | PASS | PASS | +| BB-04 | 源不存在记 `missing-source`,不算失败 | PASS | PASS | +| BB-05 | `-Only` 只处理匹配的条目 | PASS | PASS | +| BB-06 | **显式清单不存在 → 必须报失败** | **FAIL** | **PASS(已修)** | +| BB-06b | 对照:未显式指定时首次运行仍建模板 | — | PASS | +| BB-07 | 旧名字垫片转发且退出码原样传递 | PASS | PASS | +| BB-08 | 5.1 与 7.x 行为一致 | PASS | PASS | +| BB-09 | 含 `&` 与 `#` 的路径(整行引号写法) | PASS | PASS | +| BB-10 | 无控制台且无 `-InputScript` 时报错退出,绝不挂起 | PASS | PASS | +| BB-11 | 孤儿归档审计 | FAIL(夹具错误) | PASS | + +- **阶段 1**:11 条 → 通过 9、失败 2、**致命/严重 0** +- **阶段 2 回归**:12 条 → **全部通过** + +### 缺陷清单 + +| ID | 严重程度 | 标题 | 根因 | 修复 | +| --- | --- | --- | --- | --- | +| **DEF-01** | 一般 | 显式指定的清单不存在时**静默成功** | 「首次运行引导」与「路径写错」两条语义共用一条代码路径 | 按 `$PSBoundParameters.ContainsKey('BackupListPath')` 分流:显式指定 → ERROR + `exit 1`;未指定 → 保留引导 | +| DEF-02 | 提示 | 两处运算符前后缺空格 | 手写拼接 | 补空格(分析器该规则归零) | +| DEF-03 | 提示 | 孤儿审计在干跑下不报告 | **测试夹具错误**(非产品缺陷) | 修正夹具为真实运行 | + +**DEF-01 的影响**:计划任务里 `-BackupListPath` 写错(或相对路径按了别的工作目录解析)时, +脚本会在错误位置凭空建一份模板并 **exit 0**;任务计划程序读到「上次运行结果 = 成功」, +而实际一个条目都没处理。这与本仓库已修过的「27 条被静默跳过、退出码仍是 0」是同一类缺陷, +因此判为需要修复。`Restore-Data.ps1` 有对称问题(生成清单 + exit 0),一并修复。 + +**DEF-03 的处置值得记录**:BB-11 初判 FAIL,复核后确认是**用例设计错了**(用了 `-DryRun`, +而孤儿审计只在真实运行里报告)。保留该记录,因为它说明「失败用例必须先复核再归因」, +否则会把测试自身的问题记到产品头上。 + +### 回归证据 + +```text +BakNRet 验收:层次 [Parse, Unit, Smoke, E2E],宿主 [7, 5.1] + [PASS] Encode any 受管 133 个文件:缺 BOM 0、CRLF 0、制表符 0 + [PASS] Parse 7 解析通过 131/131 个文件 + [PASS] Unit 7 / Smoke 7 / E2E 7 + [PASS] Parse 5.1 解析通过 131/131 个文件 + [PASS] Unit 5.1 / Smoke 5.1 / E2E 5.1 +验收全部通过:9 项(宿主 7 + 5.1) +``` + +```text +Tests Passed: 192, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 + [+] [回归] 显式指定的清单不存在时报失败(退出码 1),且不在错误位置建模板 1.43s + [+] [回归] 未显式指定清单时的首次运行仍建模板并退出 0(引导不能被误伤) 1.44s +``` + +新增的 2 条用例是**成对**的:只测一条的话,「把所有缺失都改成 exit 1」这种错误实现也能骗过测试。 + +--- + +## 阶段 3:测试增强与性能验证 + +### 单元测试增强(可选步骤) + +| 指标 | 数值 | +| --- | --- | +| 对外函数 | 89(与 `FunctionsToExport` 白名单逐一核对一致) | +| 测试中直接点名 | 70(78.7%) | +| 未直接点名 | 19(其中多条由集成路径间接覆盖) | + +**建议补测**(本轮未擅自新增,因用户选择了最小侵入姿态,且该步骤标注为可选): +`Move-BakNRetArchiveIntoPlace`(原子替换,历史上真出过「先删后移」缺陷)、 +`Resolve-BakNRetRootedPath`(相对路径按仓库根解析这条计划任务关键约定)。 +安全描述符相关的 4 个函数**不建议**补单元测试 —— 它们需要真实提权与真实 NTFS, +仓库已用 `tools/lab/Lab.ps1 acl-test`(含负对照)在真 VM 里覆盖,那是更正确的层次。 + +### 性能基线(首次建立) + +环境:宿主 `STRIX-X870A`,PowerShell 7.7.0-preview.5,7-Zip 26.03 (x64) + +| 指标 | 采样 | min | 中位数 | max | +| --- | --- | --- | --- | --- | +| 模块导入 | 5 | 628.3 ms | **631.0 ms** | 2052.8 ms | +| 清单解析 ×50 轮 | 5 | 2535.9 ms | **2546.9 ms** | 2691.5 ms | +| 只读干跑(真实 28 条清单) | 5 | 9839.9 ms | **9869.2 ms** | 12900.8 ms | +| 源树扫描(200 文件 ×3 轮) | 1 | — | **18.2 ms** | — | + +7z 吞吐(固定夹具 200 × 32 KB **随机数据**):`-mx=0` 46.7 ms / `-mx=5` 224.2 ms / `-mx=9` 225.2 ms。 +夹具为不可压缩数据,故体积不随级别变化 —— 该夹具测的是**吞吐与 I/O,不是压缩率**。 + +**判定**:首次基线,无历史可比,**无退化**。基线文件:`.scratch/ci-cd/20260928-001722/perf_baseline.json`。 +跨机比较无意义,仅同机同宿主下对比。 + +--- + +## 阶段 4:代码规范化 + +- **仓库自有的格式门禁**是 `PSScriptAnalyzerSettings.psd1` 打开的 6 条格式规则 + (括号 / 缩进 / 空格 / 对齐 / 大小写)+ 160 字符行长,通过 `tools/Invoke-Analyzer.ps1` 执行。 +- 本次:**0 条 Error**,格式类告警从 4 条(whitespace)降到 **0 条**。 +- PowerShell 没有官方「Google 风格」格式化器;本仓库的等价物就是上面这套规则集, + 故阶段 4 以仓库门禁为准,未引入外部格式化器(避免制造与仓库约定冲突的大 diff)。 +- `README.md` / `PROJECT_REPORT.md` 由 **Prettier 3.9.9** 格式化并通过 `--check`。 + +--- + +## 阶段 5:文档生成与质量检查 + +| 产物 | 规模 | 质量门禁 | +| --- | --- | --- | +| `README.md` | 925 行 | Prettier ✅;markdownlint 17 → **10 错(全为 MD051 假阳性)**;外部链接 11/11 → 200;内部相对链接全通;63 个标题锚点全部可解析;3 张 Mermaid 图在真实浏览器中解析通过 | +| `PROJECT_REPORT.md` | 532 行 | Prettier ✅;markdownlint **0 错**;相对链接 18/18 有效;外部链接 3/3 → 200 | + +**MD051 假阳性说明**:README 的标题带 emoji(如 `## 🚀 快速开始`),GitHub 生成的锚点是 +`#-快速开始`(剥离 emoji 后前导连字符)。`markdownlint-cli2` 的锚点生成器**不做 emoji 剥离**, +因此把这类链接报为无效。已用独立脚本按 GitHub 规则复算全部锚点,**63 个全部可解析**, +故不修改标题(emoji 标题是本次文档改造的要求之一)。 + +**死链检查**:全部外部链接逐个 `HEAD` 请求验证,返回 200;相对链接逐个 `Test-Path` 验证存在。 + +--- + +## 阶段 6:数据库迁移审查 —— 不适用 + +| 检查 | 结果 | +| --- | --- | +| `*.sql` / `*.db` / `*.sqlite` 文件 | 无 | +| `Invoke-Sqlcmd` / `SqlConnection` / `CREATE TABLE` / `SELECT ... FROM` | 无命中 | +| 迁移脚本目录 | 不存在 | + +本项目是本地 CLI 工具,**不涉及任何数据库**,该阶段跳过。 + +--- + +## 阶段 7:生产部署 —— 停在人工确认门 + +> [!CAUTION] +> +> **未执行任何部署动作。** 本项目的「生产部署」= 在真实机器上注册计划任务并让它按计划跑备份, +> 属于对用户个人环境产生持久副作用的操作,必须由你确认。 + +### 部署前状态 + +| 项目 | 状态 | +| --- | --- | +| 代码门禁 | ✅ 9/9(双宿主)· Pester 192/192 · 黑盒 12/12 | +| 工作树 | 6 个文件已改(+796 / −202),新增 `PROJECT_REPORT.md`(未提交) | +| 运行前提 | PowerShell 5.1/7.x ✅ · 7-Zip 26.03 ✅ | +| 阻塞项 | 会话审批策略为 `never`,`gsudo` 提权被自动拒绝(退出码 999);注册计划任务需要管理员 | + +### 建议的部署步骤(待你确认后执行) + +```powershell +# 1. 先确认清单与口令就位(口令应在仓库外) +.\Backup-Data.ps1 -DryRun # 只读,先看计划与空间预估 +.\Backup-Data.ps1 -Only 'Edge' # 先只备一项,验证端到端 +.\Restore-Data.ps1 -VerifyOnly # 只校验,不解压 + +# 2. 注册计划任务(需要管理员) +.\tools\Register-BackupTask.ps1 -At '21:30' -DryRun # 先看将要注册什么 +.\tools\Register-BackupTask.ps1 -At '21:30' # 确认后注册 + +# 3. 部署后验证 +Get-ScheduledTaskInfo -TaskName 'BakNRet Backup' # 上次运行结果 +Start-ScheduledTask -TaskName 'BakNRet Backup' # 手动跑一次 +``` + +### 回滚 + +```powershell +.\tools\Register-BackupTask.ps1 -Remove +``` + +**风险**:注册计划任务本身可逆(`-Remove`);但**首次真实备份会写入归档目录并消耗磁盘** +(README 已有空间预估与逐条目守卫,不够时只告警不中断)。 + +--- + +## 阶段 8:关键指标与建议 + +### 关键指标 + +| 指标 | 数值 | +| --- | --- | +| 验收门禁 | **9/9 PASS**(PS 7.7.0-preview.5 + 5.1.26100.9502 各一遍) | +| 解析 | **131/131** 文件双宿主零错 | +| Pester | **192 通过 / 0 失败 / 0 跳过**(183 → 补 2 条回归用例后 185 → 再补 7 条后 192) | +| 黑盒回归 | **12/12** | +| 静态分析 | **80 条 / 0 Error**(修复前 84) | +| 运行时依赖 | **0** | +| 致命 / 严重缺陷 | **0** | +| 修复的真实缺陷 | **1**(DEF-01)+ 2 处排版 | +| 代码变更 | 6 文件,+796 / −202 | +| 文档产物 | `README.md` 925 行 · `PROJECT_REPORT.md` 1004 行 | + +### 后续轮次已执行(用户回复「继续」之后) + +| 事项 | 结果 | +| --- | --- | +| 补单元测试(原建议 2 条) | ✅ 实际补 **7 条**断言:原子替换 3 条(含 AST 判定 `File.Replace` 与 `[NullString]::Value`)、路径解析 4 条(含 AST 判定「模块里不得引用 `$PSScriptRoot`」)。Pester 185 → **192**,0 失败 | +| 修正 README 的零依赖套件条数 | ✅ 实测为 **128 项**(原写 111,已改正)。这是上一轮 README 重写留下的**事实错误**,由 PROJECT_REPORT 的核对暴露出来 | +| 复核 PROJECT_REPORT.md 的数字 | ✅ 行数口径标注为「**非空行**」;Pester 计数更新为 192;**修正一处过度断言**(原文称「修复后代码在 VM 内验证可用」,实际只取到修复前的 Pester 快照) | +| 补 LICENSE / 移出 `baknret.key` / 确认 `$Mode` 意图 | ⏸️ 仍需你决定 | + +> **指标口径说明(本次踩到的坑)**:工程规模按**非空行**统计是 **14389 行**,按**含空行**统计是 **16805 行**。 +> 两者都对,但必须写明口径。本次就因为口径不同(我用含空行、报告用非空行),一度把一份**正确的**数字 +> 误判为错误,直到逐目录复算(模块 4369 非空行在 HEAD 与工作树上完全一致)才定位到是口径差异。 +> 报告里凡出现行数处已统一标注口径。 + +### 建议(按优先级) + +| 优先级 | 事项 | 理由 | +| --- | --- | --- | +| **高** | 补 `LICENSE` | 当前等于「保留所有权利」,他人无权分发 | +| **高** | 提交前复核本次改动并决定是否 commit | 6 个文件已改,尚未提交 | +| 中 | 把 `baknret.key` 移出仓库目录 | 虽未被跟踪,工作区放口令文件是卫生问题 | +| 中 | 确认 `Write-BakNRetRunSummary` 的 `$Mode` 意图 | 未使用参数 + 未被调用 | +| 低 | 在 VM 内补跑修复后的三套件 | 本次因提权被禁未取得(宿主侧已全绿) | +| 低 | 启动时打印 7-Zip 版本 | 便于排障 | + +### 本次流水线的诚实边界 + +1. **VM 测试未跑全**:会话审批策略改为 `never` 后 `gsudo` 提权被自动拒绝,而 Hyper-V 与 + PowerShell Direct 都需要管理员。**已核实的部分**(同步成功、VM 内 Pester 183 通过)如实记录; + 零依赖与端到端套件在 VM 内的结果**本次未取得**,不做推断。宿主侧同等套件已全部通过。 +2. **静态分析未压到 0**:剩余 80 条与仓库**已声明的偏离**冲突,按你的决定登记而不改, + 逐条附依据。严格模式的「零容忍」在这里与仓库自身配置相抵,选择尊重仓库约定。 +3. **阶段 7 未执行**:等你在下一轮明确确认。 + +--- + +## 附件索引 + +所有中间产物在 `.scratch/ci-cd/`: + +| 文件 | 内容 | +| --- | --- | +| `20260928-001722/dependency_security_report.md` | 依赖安全扫描报告 | +| `20260928-001722/license_check_report.md` | 许可证合规报告 | +| `20260928-001722/stage0_static_analysis.md` | 阶段 0 完整报告(含 BOM 自伤记录) | +| `20260928-001722/stage3_tests_and_perf.md` | 阶段 3 报告(覆盖率分析 + 性能基线) | +| `20260928-001722/analyzer_raw.txt` | 修复前静态分析原始输出(84 条) | +| `20260928-001722/analyzer_after.txt` | 修复后静态分析输出(80 条) | +| `20260928-001722/pester_after_fix.txt` | 修复后 Pester 详细输出(185 通过;本轮补测后 192) | +| `20260928-001722/perf_baseline.json` | 性能基线数据 | +| `20260928-001722/blackbox_results.json` | 阶段 1 黑盒结果(11 条) | +| `20260928-001722/blackbox_regression.json` | 阶段 2 回归结果(12 条) | +| `blackbox-tests.ps1` | 黑盒用例脚本(可重跑) | +| `blackbox-regression.ps1` | 回归脚本(可重跑) | +| `perf-baseline.ps1` | 性能基线脚本(可重跑) | +| `../_verify/mermaid.png` | README 三张 Mermaid 图的浏览器渲染截图 | +| `../_verify/report_mermaid.png` | PROJECT_REPORT 四张图的渲染截图(本次独立复验;与 `20260928-001722/report_mermaid.png` 是**两次独立渲染**,字节不同属正常) | +| `20260928-001722/report_mermaid.png` | 同上四张图,由阶段 5 的报告生成方独立渲染并留证 | diff --git a/.scratch/ci-cd/perf-baseline.ps1 b/.scratch/ci-cd/perf-baseline.ps1 new file mode 100644 index 0000000..8d549c4 --- /dev/null +++ b/.scratch/ci-cd/perf-baseline.ps1 @@ -0,0 +1,139 @@ +# 宿主性能基线:对 BakNRet 的关键路径做可复现的计时。 +# 设计原则:所有指标都是"比值"或"固定夹具下的绝对耗时",可以在同一台机器上重复对比。 +param( + [string]$OutJson = '.scratch\ci-cd\20260928-001722\perf_baseline.json', + [int]$Repeat = 5, + [string]$Pwsh = 'pwsh' +) + +$ErrorActionPreference = 'Stop' +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$modulePath = Join-Path $root 'BakNRet\BakNRet.psd1' +$listPath = Join-Path $root 'BackupList.txt' +$cfgPath = Join-Path $root 'BackupConfig.psd1' + +function Get-Median([double[]]$Values) { + $s = $Values | Sort-Object + $n = $s.Count + if ($n -eq 0) { return 0 } + if ($n % 2 -eq 1) { return $s[[int](($n - 1) / 2)] } + return ($s[$n / 2 - 1] + $s[$n / 2]) / 2 +} + +function Measure-Once([scriptblock]$Body) { + $sw = [System.Diagnostics.Stopwatch]::StartNew() + & $Body | Out-Null + $sw.Stop() + return $sw.Elapsed.TotalMilliseconds +} + +$results = [ordered]@{ + measuredAt = (Get-Date).ToString('s') + machine = $env:COMPUTERNAME + os = [System.Environment]::OSVersion.VersionString + psi = $PSVersionTable.PSVersion.ToString() + cpu = (Get-CimInstance Win32_Processor | Select-Object -First 1 -ExpandProperty Name) + logicalCpu = [int](Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors + workdir = $root + metrics = @{} + notes = @() +} + +# ---------------------------------------------------------------- 夹具(固定内容,避免与真实数据联动) +$fixture = Join-Path $env:TEMP ('baknret-perf-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +New-Item -ItemType Directory -Path $fixture -Force | Out-Null +$srcDir = Join-Path $fixture 'src' +New-Item -ItemType Directory -Path $srcDir -Force | Out-Null + +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$rnd = [Random]::new(20260928) +$payload = New-Object byte[] 32768 +for ($f = 0; $f -lt 200; $f++) { + $sub = Join-Path $srcDir ('d{0:D2}' -f ($f % 10)) + if (-not (Test-Path $sub)) { New-Item -ItemType Directory -Path $sub -Force | Out-Null } + $rnd.NextBytes($payload) + [System.IO.File]::WriteAllBytes((Join-Path $sub ("f$f.bin")), $payload) +} +$sw.Stop() +$results.fixture = [ordered]@{ files = 200; bytes = 200 * 32768; createMs = [math]::Round($sw.Elapsed.TotalMilliseconds, 1) } + +# ---------------------------------------------------------------- 1. 模块导入 +$imports = 1..$Repeat | ForEach-Object { + Measure-Once { & $Pwsh -NoProfile -Command "Import-Module '$modulePath' -Force" } +} +$results.metrics.moduleImportMs = [ordered]@{ + min = [math]::Round(($imports | Measure-Object -Minimum).Minimum, 1) + median = [math]::Round((Get-Median $imports), 1) + max = [math]::Round(($imports | Measure-Object -Maximum).Maximum, 1) + samples = $Repeat +} + +# ---------------------------------------------------------------- 2. 清单解析 +$parseScript = @" +Import-Module '$modulePath' -Force +1..50 | ForEach-Object { Get-Content -LiteralPath '$listPath' | ForEach-Object { ConvertFrom-BackupListLine -Line `$_ } } +"@ +$parseFile = Join-Path $fixture 'parse.ps1' +Set-Content -LiteralPath $parseFile -Value $parseScript -Encoding utf8 +$parses = 1..$Repeat | ForEach-Object { Measure-Once { & $Pwsh -NoProfile -File $parseFile } } +$results.metrics.backupListParse50xMs = [ordered]@{ + min = [math]::Round(($parses | Measure-Object -Minimum).Minimum, 1) + median = [math]::Round((Get-Median $parses), 1) + max = [math]::Round(($parses | Measure-Object -Maximum).Maximum, 1) + samples = $Repeat +} + +# ---------------------------------------------------------------- 3. 只读干跑(真实清单 + 空间预估 + manifest) +$dryFile = Join-Path $fixture 'dry.ps1' +Set-Content -LiteralPath $dryFile -Encoding utf8 -Value @" +Set-Location '$root' +& '$root\Backup-Data.ps1' -DryRun -BackupDir '$fixture\Backups' -ConfigPath '$cfgPath' *> `$null +exit `$LASTEXITCODE +"@ +$dries = 1..$Repeat | ForEach-Object { Measure-Once { & $Pwsh -NoProfile -File $dryFile } } +$results.metrics.dryRunFullListMs = [ordered]@{ + min = [math]::Round(($dries | Measure-Object -Minimum).Minimum, 1) + median = [math]::Round((Get-Median $dries), 1) + max = [math]::Round(($dries | Measure-Object -Maximum).Maximum, 1) + samples = $Repeat +} + +# ---------------------------------------------------------------- 4. 7z 压缩吞吐(固定夹具,无压缩) +$sevenZip = (Get-Command 7z -ErrorAction SilentlyContinue).Source +if (-not $sevenZip) { + foreach ($p in 'C:\Programs\Scoop\shims\7z.exe', 'C:\Program Files\7-Zip\7z.exe') { if (Test-Path $p) { $sevenZip = $p; break } } +} +if ($sevenZip) { + $archive = Join-Path $fixture 'bench.7z' + $bench = @() + foreach ($lvl in 0, 5, 9) { + if (Test-Path $archive) { Remove-Item $archive -Force } + $ms = Measure-Once { & $sevenZip a -t7z "-mx=$lvl" -bso0 -bsp0 $archive (Join-Path $srcDir '*') } + $bench += [pscustomobject]@{ level = $lvl; ms = [math]::Round($ms, 1); archiveBytes = (Get-Item $archive).Length } + } + $results.metrics.sevenZipBench = @($bench) + $results.fixture.compressionInputBytes = 200 * 32768 + $results.metrics.sevenZipVersion = (& $sevenZip | Select-Object -First 2 | Select-Object -Last 1) +} +else { + $results.notes += '找不到 7z.exe,跳过压缩基准' +} + +# ---------------------------------------------------------------- 5. 源树扫描(Get-BakNRetFolderSummary 走真实目录) +$summaryScript = @" +Import-Module '$modulePath' -Force +`$sw = [System.Diagnostics.Stopwatch]::StartNew() +1..3 | ForEach-Object { Get-BakNRetFolderSummary -FolderPath '$srcDir' | Out-Null } +`$sw.Stop() +Write-Output ([math]::Round(`$sw.Elapsed.TotalMilliseconds / 3, 1)) +"@ +$sumFile = Join-Path $fixture 'summary.ps1' +Set-Content -LiteralPath $sumFile -Value $summaryScript -Encoding utf8 +$sumMs = (& $Pwsh -NoProfile -File $sumFile | Select-Object -Last 1) +$results.metrics.folderSummary200Files3xMs = [double]$sumMs + +Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue + +$results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $root $OutJson) -Encoding utf8 +Write-Host ('基线已写入 {0}' -f $OutJson) +$results.metrics | ConvertTo-Json -Depth 6 diff --git a/PROJECT_REPORT.md b/PROJECT_REPORT.md new file mode 100644 index 0000000..23bdc5d --- /dev/null +++ b/PROJECT_REPORT.md @@ -0,0 +1,1011 @@ +# 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` |