<# .SYNOPSIS 真实归档的恢复演练:把**硬盘上真实的归档**恢复到临时目标,再和活的源目录逐字节对拍。 .DESCRIPTION 和 tests/Run-E2E.ps1 的分工: * Run-E2E.ps1 用自己造的假数据,证明的是"整条链路能跑通"; * 本脚本证明的是"**这一批真实归档**解得开,而且解出来的东西和源一致"。 关键设计:**绝不碰真实目录**。做法是给一份临时名录(SoftwareCatalog), 把归档里的顶层条目名映射到临时目标目录,于是 Restore.ps1 会把归档解到临时目录, 而不是 ~\.ssh、C:\Programs\... 这些真地方。真实归档本身只被读取。 归档内的一层名字怎么定,取决于**这个归档是哪种布局**(Backups\ 里两种都有): * 重构后的新布局:包内顶层是 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 'Common.psm1') -Force if (-not (Test-Path -LiteralPath $restoreScript)) { Write-Error "找不到 Restore.ps1:$restoreScript" exit 1 } $config = Get-BaknretConfig -Path $ConfigPath $catalogPath = Resolve-CatalogPath -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-BackupEntry -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($false)) [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 -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