Files
Shuery 45bbd66815 docs(report): 新增 PROJECT_REPORT.md 与 CI/CD 流水线记录
PROJECT_REPORT.md:论文式项目报告(摘要 / 目录 / 7 章 / 参考文献 / 致谢 / 附录 A),
4 张 Mermaid 图同样经真实浏览器渲染验证。所有数字都与证据文件逐个核对过。

.scratch/ci-cd/:本次流水线的全部证据与可重跑脚本
  * 阶段报告:依赖安全扫描 / 许可证合规 / 阶段 0 静态分析 / 阶段 3 测试与性能
  * 证据:分析器原始输出(修复前 84 条、修复后 80 条)、Pester 详细输出、
    性能基线 JSON、黑盒用例结果(阶段 1 的 11 例与阶段 2 回归的 12 例)
  * 可重跑:blackbox-tests.ps1 / blackbox-regression.ps1 / perf-baseline.ps1

两条边界必须写在明处,不能含糊:

  * **VM 内验证只取到修复前的快照**(Pester 183 项全绿)。随后会话审批策略改为 never,
    gsudo 提权被自动拒绝(退出码 999),而 Hyper-V 与 PowerShell Direct 都需要管理员,
    于是修复后的三套件在 VM 内**没有跑成**。报告里明确标注了范围,没有把"宿主跑绿了"
    说成"VM 也跑绿了"。
  * **性能基线是首次建立**,无历史可比,故无退化可判;指标只在同机同宿主下对比,
    跨机比数字没有意义。

另外记一笔:静态分析的口径是"仓库自己的门禁"。剩余 80 条全是风格类(0 Error),且逐条
有依据 —— 行长 160 是配置里写明的有意偏离,PSPlaceCloseBrace 等集中在测试夹具字符串内
(改了会改变断言语义)。严格模式的"零容忍"在这里与仓库自身约定相抵,选择尊重仓库约定
并在报告里登记,而不是制造一个横跨 12 个文件的纯排版大 diff。
2026-10-02 01:17:40 +08:00

210 lines
13 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 黑盒测试:只依据 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 '<absent>' }
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)