Files
BakNRet/tests/Restore-Drill.ps1
T
Shuery 42f02d0eca refactor: 公共面补 BakNRet 前缀,产品名大小写全仓统一
16 个没有前缀的公共函数补上 BakNRet(Write-Log → Write-BakNRetLog、Resolve-BackupEntry →
Resolve-BakNRetBackupEntry、Find-ChildDirectoryByName → Find-BakNRetChildDirectoryByName 等),
另外把全仓的 Baknret 统一成 BakNRet(47 个文件、940 处、65 个定义文件重命名)。

这不是审美问题:静态分析直接拓出一条实据 —— Write-Log 与本机某个已装模块导出的命令
**重名**(PSAvoidOverwritingBuiltInCmdlets),而重名的后果是导入两个模块时有一方的命令被
静默遮蔽。补前缀正是这条规则的解法,改名后它归零。

为什么敢做这个规模:PowerShell 的函数名解析大小写不敏感,所以 Baknret → BakNRet 在功能
上是零风险;真正要验证的是 16 个补前缀的调用点,而 276 个断言几乎覆盖了每个函数。另外
"名字与文件名一致"这条不变式有断言盯着(加载器点源的文件集合 vs 磁盘)。

踩到并记下的坑:Windows 文件系统大小写不敏感,所以**只改大小写**的重命名会被 Move-Item
当成同一个文件而静默跳过 —— 同一批里同时改了名字的那 16 个文件却成功了,于是"看起来能跑"。
最后用"先移到临时名、再移到目标名"的两步走解决,判断与替换全部改用显式大小写敏感的形式
(-creplace / -cmatch)。

顺带把名录指纹缓存从 MD5 换成 SHA256(PSAvoidUsingBrokenHashAlgorithms):它只是缓存键,
没有兼容负担。

验收:test.ps1 9/9 全绿(7 与 5.1)、100 个文件两版解析零错、276 个断言全过、
构建工具仍能合回单文件(3300 行)。
2026-09-27 09:56:36 +08:00

569 lines
24 KiB
PowerShell
Raw 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.
<#
.SYNOPSIS
真实归档的恢复演练:把**硬盘上真实的归档**恢复到临时目标,再和活的源目录逐字节对拍。
.DESCRIPTION
和 tests/Run-E2E.ps1 的分工:
* Run-E2E.ps1 用自己造的假数据,证明的是"整条链路能跑通";
* 本脚本证明的是"**这一批真实归档**解得开,而且解出来的东西和源一致"。
关键设计:**绝不碰真实目录**。做法是给一份临时名录(SoftwareCatalog),
把归档里的顶层条目名映射到临时目标目录,于是 Restore.ps1 会把归档解到临时目录,
而不是 ~\.ssh、C:\Programs\... 这些真地方。真实归档本身只被读取。
归档内的一层名字怎么定,取决于**这个归档是哪种布局**(Backups\ 里两种都有):
* 重构后的新布局:包内顶层是 Slot 名(`<Slot>\<内容>`,文件 Slot 就是名为
`<Slot>` 的文件)——manifest 记录的 layouts 里有这个名字;
* 重构前的旧布局:包内顶层是源路径的末级名(`<末级名>\...`)——manifest 没有 layouts。
本脚本按 manifest 判断,把临时名录的 Slot 名设成归档里**真实存在的那一层名字**,
因此新旧布局都能被 Restore.ps1 正常解出来,而不是依赖"解不出来再回退"。
对拍规则(关键:先把"源变了"和"归档坏了"分开):
* 内容不一致时看活源文件的修改时间:晚于归档时间 ⇒ 源在备份之后被改过,
只提示、不算失败;不晚于归档时间却内容不同 ⇒ 归档或解压有问题,算失败;
* 归档里有、活源里没有的文件:如果它所在的活源目录(或最近的还在的祖辈)
的修改时间晚于归档时间 ⇒ 是备份之后从源里删掉的,只提示、不算失败;
否则 ⇒ 归档里混进了源里没有的东西,算失败;
* 活源里在备份之后新增的文件只提示;
* 一个条目一个文件都对不上 —— 失败(多半是空归档,必须点名)。
真实机器上的归档常常是几周前的,所以"必须和今天逐字节一致"不是合理判据;
上面对"源变了"的区分让这个演练在活的机器上也能天天跑。
.EXAMPLE
# 用真实归档(默认读 BackupConfig.psd1 里的 BackupDir)做演练
pwsh -File .\tests\Restore-Drill.ps1
.EXAMPLE
# 只演练指定条目(写 BackupList.txt 里那样的行:软件名或绝对路径),并保留临时目录
pwsh -File .\tests\Restore-Drill.ps1 -Entries 'OpenSSH','C:\Programs\MiFlash' -KeepWorkRoot
#>
[CmdletBinding()]
param(
# 归档所在目录;默认取 BackupConfig.psd1 里的 BackupDir
[string]$BackupDir,
# 要演练的条目,写法与 BackupList.txt 的一行相同(软件名或绝对路径)。
# 默认是一组"小、静态、无排除规则"的条目;不存在的源 / 归档会被干净地跳过。
[string[]]$Entries = @(
'OpenSSH', 'Legendary', 'OpenCode', 'PowerShell', 'WindowsPowerShell',
'WindowsTerminal', 'TranslucentTB', 'Kazumi', 'PiliPlus',
'C:\Programs\MiFlash', 'C:\Programs\MiFlash_Unlock',
'D:\UserData\Documents\Aria'
),
[string]$ConfigPath,
[string]$WorkRoot,
# 活源在备份之后变过的文件只告警、不算失败
[switch]$AllowChanged,
[switch]$KeepWorkRoot
)
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上,
# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带
# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值
# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。
if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'BackupConfig.psd1' }
$restoreScript = Join-Path $projectRoot 'Restore.ps1'
Import-Module (Join-Path $projectRoot 'BakNRet\BakNRet.psd1') -Force
if (-not (Test-Path -LiteralPath $restoreScript)) {
Write-Error "找不到 Restore.ps1:$restoreScript"
exit 1
}
$config = Get-BakNRetConfig -Path $ConfigPath
$catalogPath = Resolve-BakNRetCatalogPath -Configured $config.SoftwareCatalog -Root $projectRoot
if (-not $BackupDir) {
$BackupDir = $config.BackupDir
if (-not [System.IO.Path]::IsPathRooted($BackupDir)) { $BackupDir = Join-Path $projectRoot $BackupDir }
}
if (-not (Test-Path -LiteralPath $BackupDir)) {
Write-Error "归档目录不存在:$BackupDir"
exit 1
}
if (-not $WorkRoot) {
$WorkRoot = Join-Path $env:TEMP ('baknret-drill-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
}
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
$manifest = Read-BakNRetManifest -Path (Join-Path $BackupDir 'manifest.json')
$archiveFiles = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') })
# Restore.ps1 恢复成功后会**写回 manifest.json**(记 lastRestoreAt)。真实 Backups\ 只能读,
# 所以给子进程一个临时 BackupDir:里面放一份 manifest 副本 + 指向真实归档的符号链接
# (建不出符号链接就退化成复制)。这样归档还是那批真货,但写只会写进临时目录。
$scratchBackupRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('bnr-drill-backups-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $scratchBackupRoot -Force | Out-Null
$realManifestPath = Join-Path $BackupDir 'manifest.json'
if (Test-Path -LiteralPath $realManifestPath) {
Copy-Item -LiteralPath $realManifestPath -Destination (Join-Path $scratchBackupRoot 'manifest.json') -Force
}
Write-Host ''
Write-Host '== 真实归档恢复演练:归档 -> 临时目标 -> 与活源逐字节对拍 ==' -ForegroundColor Cyan
Write-Host " 归档目录:$BackupDir(只读;恢复写盘只写临时目录)"
Write-Host " 软件名录:$catalogPath"
Write-Host " 工作目录:$WorkRoot"
Write-Host ''
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
function Test-RemovedFromLiveAfterBackup {
<#
.SYNOPSIS
归档里有、活源里没有的文件,是不是"备份之后从源里删掉了"。
.DESCRIPTION
从活源根往下走,停在第一个不存在的层级,看最近的那个还在的祖辈的修改时间:
晚于归档时间 ⇒ 这个文件是在备份之后被删的(源变了,不是归档坏了);
不晚于归档时间 ⇒ 它本该还在,归档里却有别人没有的东西,算失败。
#>
param(
[Parameter(Mandatory = $true)][string]$LiveRoot,
[Parameter(Mandatory = $true)][string]$Relative,
[Parameter(Mandatory = $true)][datetime]$ArchiveTime
)
$probe = $LiveRoot
foreach ($segment in @($Relative -split '[\\/]' | Where-Object { $_ })) {
$next = Join-Path $probe $segment
if (-not (Test-Path -LiteralPath $next)) { break }
$probe = $next
}
$item = Get-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
return ($item.LastWriteTime -gt $ArchiveTime)
}
function Compare-RestoredTree {
<#
.SYNOPSIS
把恢复出来的树和活源逐字节对拍。
.DESCRIPTION
对拍结果分成两类,因为它们的含义完全不同:
* Stale(活源在归档之后被改过):**只提示**。这是源变了,不是归档坏了 ——
真实机器上的归档往往是几周前的,硬按"必须和今天一致"判失败毫无意义。
* Changed(活源时间不晚于归档,内容却不一样):**失败**。这说明归档本身
或者解压环节有问题,是真正要查的。
#>
param(
[Parameter(Mandatory = $true)][string]$RestoredPath,
[Parameter(Mandatory = $true)][string]$LivePath,
[Parameter(Mandatory = $true)][datetime]$ArchiveTime
)
$report = [pscustomobject]@{
Restored = 0
Matched = 0
Stale = @()
Removed = @()
Changed = @()
Extra = @()
Missing = @()
}
if (-not (Test-Path -LiteralPath $RestoredPath)) { throw "恢复目标不存在:$RestoredPath" }
$liveItem = Get-Item -LiteralPath $LivePath -Force -ErrorAction Stop
$restoredItem = Get-Item -LiteralPath $RestoredPath -Force -ErrorAction Stop
# 源本身是个文件(例如 translucenttb 的 settings.json):直接比这一个
if (-not $liveItem.PSIsContainer) {
if ($restoredItem.PSIsContainer) { throw "源是文件,恢复出来的却是目录:$RestoredPath" }
$report.Restored = 1
if ((Get-FileHash -LiteralPath $restoredItem.FullName -Algorithm SHA256).Hash -eq
(Get-FileHash -LiteralPath $liveItem.FullName -Algorithm SHA256).Hash) {
$report.Matched = 1
}
elseif ($liveItem.LastWriteTime -gt $ArchiveTime) {
$report.Stale = @($restoredItem.Name)
}
else {
$report.Changed = @($restoredItem.Name)
}
return $report
}
$restoredRoot = $restoredItem.FullName
$liveRoot = $liveItem.FullName
$restoredFiles = @(Get-ChildItem -LiteralPath $restoredRoot -Recurse -Force -File -ErrorAction SilentlyContinue)
$report.Restored = $restoredFiles.Count
foreach ($file in $restoredFiles) {
$relative = $file.FullName.Substring($restoredRoot.Length).TrimStart('\')
$liveFile = Join-Path $liveRoot $relative
if (-not (Test-Path -LiteralPath $liveFile)) {
if (Test-RemovedFromLiveAfterBackup -LiveRoot $liveRoot -Relative $relative -ArchiveTime $ArchiveTime) {
$report.Removed += $relative
}
else {
$report.Extra += $relative
}
continue
}
if ((Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash -eq
(Get-FileHash -LiteralPath $liveFile -Algorithm SHA256).Hash) {
$report.Matched++
continue
}
# 内容不一样:先看是不是"源在归档之后动过"
if ((Get-Item -LiteralPath $liveFile -Force).LastWriteTime -gt $ArchiveTime) {
$report.Stale += $relative
}
else {
$report.Changed += $relative
}
}
foreach ($file in @(Get-ChildItem -LiteralPath $liveRoot -Recurse -Force -File -ErrorAction SilentlyContinue)) {
$relative = $file.FullName.Substring($liveRoot.Length).TrimStart('\')
if (-not (Test-Path -LiteralPath (Join-Path $restoredRoot $relative))) {
$report.Missing += $relative
}
}
return $report
}
function Get-ArchiveRelativeName {
<#
.SYNOPSIS
决定一个归档项在**这个归档里**实际叫什么名字。
.DESCRIPTION
manifest 里有 layouts(重构后写的归档)时,项名就是 Slot 名;
没有 layouts(重构前的归档)时,包内那一层是源路径的末级名。
名字对不上就解不出东西,所以这里必须按归档的真实布局来选。
#>
param($Item, $LayoutKinds)
if ($LayoutKinds.Count -gt 0) {
if ($LayoutKinds.ContainsKey([string]$Item.ArchivePath)) { return [string]$Item.ArchivePath }
return $null
}
return (Split-Path -Path ([string]$Item.RealPath) -Leaf)
}
function Invoke-ScratchRestore {
<#
.SYNOPSIS
用子进程跑 Restore.ps1,返回退出码与它自己的日志文件。
.DESCRIPTION
绝不能 `$lines = & pwsh @args 2>&1`:那会给子进程建管道,本机沙箱直接拒绝
(Access to the path '\\.\pipe\LOCAL\dotnet_...' is denied)。
Invoke-ExternalCommand 继承 stdio、不建管道,退出码可靠,所以这里用它启动子进程;
子进程的输出不用管道拿,而是读它自己写下的 restore-*.log。
#>
param(
[Parameter(Mandatory = $true)][string]$ScratchList,
[Parameter(Mandatory = $true)][string]$ScratchConfig,
[Parameter(Mandatory = $true)][string]$ScratchLogDir,
[Parameter(Mandatory = $true)][string]$ScratchBackupDir
)
$pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source)
if (-not $pwshExe) { $pwshExe = 'pwsh' }
New-Item -ItemType Directory -Path $ScratchLogDir -Force | Out-Null
$before = @(Get-ChildItem -LiteralPath $ScratchLogDir -File -Filter 'restore-*.log' -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty FullName)
$code = Invoke-ExternalCommand -FilePath $pwshExe -ArgumentList @(
'-NoProfile', '-NonInteractive', '-File', $restoreScript,
'-BackupListPath', $ScratchList,
'-ConfigPath', $ScratchConfig,
'-BackupDir', $ScratchBackupDir,
'-Force'
)
$log = Get-ChildItem -LiteralPath $ScratchLogDir -File -Filter 'restore-*.log' -ErrorAction SilentlyContinue |
Where-Object { $before -notcontains $_.FullName } |
Sort-Object LastWriteTime | Select-Object -Last 1
return [pscustomobject]@{ Code = $code; Log = $log }
}
# ---------------------------------------------------------------------------
# 演练
# ---------------------------------------------------------------------------
$rows = @()
$failures = @()
$checked = 0
$entryIndex = 0
foreach ($name in $Entries) {
$entryIndex++
$entry = ConvertFrom-BackupListLine -Line $name
if (-not $entry) { continue }
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth
if (-not $resolved.BaseName) {
$failures += "$name :解析不出归档名"
$rows += [pscustomobject]@{ Entry = $name; Status = 'FAIL'; Detail = '解析不出归档名' }
continue
}
$items = @($resolved.Items)
if ($items.Count -eq 0) {
$reason = if ($resolved.Error) { $resolved.Error } else { '解析不出归档项' }
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = $reason }
continue
}
# 找到归档:manifest 记录优先,其次按归档基础名 / 源路径末级名精确匹配文件。
# Backups\ 里既有按软件名命名的归档,也有按路径算法命名的旧归档。
$legacyLeaves = @($items | ForEach-Object { Split-Path -Path ([string]$_.RealPath) -Leaf } | Where-Object { $_ })
$archiveFile = $null
$record = $null
if ($manifest.items.Contains($resolved.BaseName)) { $record = $manifest.items[$resolved.BaseName] }
if ($record -and ($record.PSObject.Properties.Name -contains 'archive') -and $record.archive) {
$candidate = Join-Path $BackupDir ([string]$record.archive)
if (Test-Path -LiteralPath $candidate) { $archiveFile = Get-Item -LiteralPath $candidate }
}
if (-not $archiveFile) {
$matched = @()
foreach ($file in $archiveFiles) {
if ($file.BaseName -ieq $resolved.BaseName) { $matched += $file; continue }
foreach ($leaf in $legacyLeaves) {
if ($file.BaseName -ieq $leaf) { $matched += $file; break }
}
}
if ($matched.Count -gt 1) {
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "多个归档都可能是它:$(($matched | ForEach-Object { $_.Name }) -join '、')" }
continue
}
if ($matched.Count -eq 1) { $archiveFile = $matched[0] }
}
if (-not $archiveFile) {
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "归档不存在(基础名 $($resolved.BaseName))" }
continue
}
$archiveTime = $archiveFile.LastWriteTime
# 让子进程的 BackupDir 里也"有"这个归档:优先符号链接(零拷贝),不行才复制
$scratchArchive = Join-Path $scratchBackupRoot $archiveFile.Name
if (-not (Test-Path -LiteralPath $scratchArchive)) {
try {
New-Item -ItemType SymbolicLink -Path $scratchArchive -Target $archiveFile.FullName -ErrorAction Stop | Out-Null
}
catch {
Copy-Item -LiteralPath $archiveFile.FullName -Destination $scratchArchive -Force
}
}
# 归档里那一层的真名:manifest.layouts 决定(新布局 = Slot 名,旧布局 = 末级名)
if (-not $record -and $manifest.items.Contains($archiveFile.BaseName)) { $record = $manifest.items[$archiveFile.BaseName] }
$layoutKinds = @{}
if ($record -and ($record.PSObject.Properties.Name -contains 'layouts') -and $record.layouts) {
foreach ($layout in @($record.layouts)) {
if (-not $layout) { continue }
$layoutName = [string]$layout.name
if (-not [string]::IsNullOrWhiteSpace($layoutName)) { $layoutKinds[$layoutName] = [string]$layout.kind }
}
}
$entryRoot = Join-Path (Join-Path $WorkRoot 'restore') ("e$entryIndex")
New-Item -ItemType Directory -Path $entryRoot -Force | Out-Null
$pairs = @()
$slotLines = @()
$skipReason = $null
for ($index = 0; $index -lt $items.Count; $index++) {
$item = $items[$index]
$livePath = [string]$item.RealPath
if ([string]::IsNullOrWhiteSpace($livePath)) { continue }
$liveItem = Get-Item -LiteralPath $livePath -Force -ErrorAction SilentlyContinue
if (-not $liveItem) { continue } # 源没了,跳过(归档里也不该有它)
$archiveName = Get-ArchiveRelativeName -Item $item -LayoutKinds $layoutKinds
if ([string]::IsNullOrWhiteSpace($archiveName)) {
$skipReason = "manifest.layouts 里没有归档项 '$($item.ArchivePath)'(名录改过?)"
break
}
if (@($pairs | Where-Object { $_.Name -ieq $archiveName }).Count -gt 0) {
$skipReason = "多个源都映射到归档内的同一个名字 '$archiveName',无法判定谁是谁(旧归档常见)"
break
}
$target = Join-Path $entryRoot ([string]$index)
if ($liveItem.PSIsContainer) {
New-Item -ItemType Directory -Path $target -Force | Out-Null
}
else {
[System.IO.File]::WriteAllText($target, '')
}
$pairs += [pscustomobject]@{ Name = $archiveName; Restored = $target; Live = $livePath }
$slotLines += " '$archiveName' = @{ Path = '$target' }"
}
if ($skipReason) {
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = $skipReason }
continue
}
if ($pairs.Count -eq 0) {
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '所有源目录当前都不存在,无法对拍' }
continue
}
# 临时名录:键 = 归档基础名(这样 Restore 能通过 manifest / 文件名找到归档),
# 每个 Slot 的 Path 指向一个临时目标 —— Restore 就解到这里,碰不到真实目录。
$catalogKey = $archiveFile.BaseName
$scratchCatalog = Join-Path $WorkRoot ("catalog-e$entryIndex.psd1")
$scratchList = Join-Path $WorkRoot ("list-e$entryIndex.txt")
$scratchConfig = Join-Path $WorkRoot ("config-e$entryIndex.psd1")
$scratchLogDir = Join-Path $WorkRoot ("logs\e$entryIndex")
[System.IO.File]::WriteAllText($scratchCatalog,
"@{`n '$catalogKey' = @{`n$($slotLines -join "`n")`n }`n}`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($scratchList, "$catalogKey`n", [System.Text.UTF8Encoding]::new($true))
[System.IO.File]::WriteAllText($scratchConfig, @"
@{
BackupDir = '$scratchBackupRoot'
LogDir = '$scratchLogDir'
SoftwareCatalog = '$scratchCatalog'
CatalogMaxDepth = $($config.CatalogMaxDepth)
VerifyArchive = `$true
}
"@, [System.Text.UTF8Encoding]::new($false))
Write-Host ("-- 演练 {0}(归档 {1},{2} 个源)" -f $name, $archiveFile.Name, $pairs.Count) -ForegroundColor Gray
$restore = Invoke-ScratchRestore -ScratchList $scratchList -ScratchConfig $scratchConfig -ScratchLogDir $scratchLogDir -ScratchBackupDir $scratchBackupRoot
if ($restore.Code -ne 0) {
if ($restore.Log) {
foreach ($line in @(Get-Content -Encoding UTF8 -LiteralPath $restore.Log.FullName -ErrorAction SilentlyContinue | Select-Object -Last 12)) {
Write-Host (" | {0}" -f $line) -ForegroundColor DarkGray
}
}
$rows += [pscustomobject]@{ Entry = $name; Status = 'FAIL'; Detail = "Restore.ps1 退出码 $($restore.Code)" }
$failures += "$name :Restore.ps1 退出码 $($restore.Code)"
continue
}
$checked++
$matched = 0
$restoredCount = 0
$extra = @()
$stale = @()
$removed = @()
$changed = @()
$notArchived = @()
foreach ($pair in $pairs) {
$one = Compare-RestoredTree -RestoredPath $pair.Restored -LivePath $pair.Live -ArchiveTime $archiveTime
$matched += $one.Matched
$restoredCount += $one.Restored
$extra += $one.Extra
$stale += $one.Stale
$removed += $one.Removed
$changed += $one.Changed
$notArchived += $one.Missing
}
$detail = "对拍 $matched/$restoredCount"
$status = 'PASS'
if ($extra.Count -gt 0) {
$status = 'FAIL'
$detail += ";归档里有源里没有的 $($extra.Count) 个文件"
$failures += "$name :恢复出源里没有的文件(首例 $($extra[0]))"
}
if ($stale.Count -gt 0) {
# 活源在归档之后被改过:源变了,不是归档坏了,只提示
$detail += ";源在备份后变过 $($stale.Count) 个(不算失败)"
}
if ($removed.Count -gt 0) {
# 归档里有、活源里没了,且源目录在归档之后动过:也是"源变了",只提示
$detail += ";备份后从源里删掉 $($removed.Count) 个(不算失败)"
}
if ($changed.Count -gt 0) {
if ($AllowChanged) {
$detail += ";与活源不一致 $($changed.Count) 个(-AllowChanged,已容忍)"
}
else {
$status = 'FAIL'
$detail += ";与活源不一致 $($changed.Count) 个(首例 $($changed[0]))"
$failures += "$name :与活源不一致(首例 $($changed[0]))"
}
}
if (($matched + $stale.Count) -eq 0) {
$status = 'FAIL'
$detail += ";没有任何文件能对上(多半是空归档)"
$failures += "$name :恢复出 0 个可对拍的文件"
}
if ($notArchived.Count -gt 0) {
# 排除规则命中的文件、以及备份之后新增的文件都会落在这里,只是提示
$detail += ";活源另有 $($notArchived.Count) 个文件不在归档里(排除规则/备份后新增)"
}
$rows += [pscustomobject]@{ Entry = $name; Status = $status; Detail = $detail }
}
# ---------------------------------------------------------------------------
# 报告
# ---------------------------------------------------------------------------
# 临时 BackupDir 用完即删:删符号链接只会删链接本身,真实的归档不受影响
Remove-Item -LiteralPath $scratchBackupRoot -Recurse -Force -ErrorAction SilentlyContinue
Write-Host ''
Write-Host '演练结果:' -ForegroundColor Cyan
$rows | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
if ($failures.Count -gt 0) {
Write-Host '失败明细:' -ForegroundColor Red
foreach ($failure in $failures) { Write-Host " - $failure" -ForegroundColor Red }
}
$passed = @($rows | Where-Object { $_.Status -eq 'PASS' }).Count
$skipped = @($rows | Where-Object { $_.Status -eq 'SKIP' }).Count
$failed = @($rows | Where-Object { $_.Status -eq 'FAIL' }).Count
Write-Host ("恢复演练:通过 {0},跳过 {1},失败 {2}(真正对拍的条目 {3})" -f $passed, $skipped, $failed, $checked) -ForegroundColor $(if ($failed -gt 0) { 'Red' } else { 'Green' })
if ($KeepWorkRoot) {
Write-Host "临时工作目录保留在:$WorkRoot" -ForegroundColor Yellow
}
else {
Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction SilentlyContinue
}
if ($failed -gt 0) { exit 1 }
exit 0