Files
BakNRet/Restore.ps1
T
Shuery 045d51ac9c 引入软件名录:清单写软件名,归档名也用软件名
新功能
- 新增 SoftwareCatalog.psd1 —— "软件名 -> 目录"映射表,BackupList.txt 里
  直接写软件名即可,归档名也就是软件名(FooClolor.7z),
  不再是 FooClolor_from_C_+Programs.7z 这种由路径拼出来的名字。
- 三种写法可混用:软件名、字面路径(现有清单无需改写)、软件名 @pathname。
- 名录支持前缀补全(legendary -> legendary_2.0.4,只认 <名>_* / <名>-*)、
  Variants(同名目录在多处)、Includes(分文件维护)。
- tools/Rename-Archives.ps1:存量归档重命名,默认试运行,逐份大小校验并重建 manifest。
- 归档名重复直接报错,不再静默互相覆盖。

两套测试全绿:单元 42 项、端到端 23 项(新增名录命名/解析/迁移用例)。

过程中修掉的缺陷
- Resolve-BackupEntry 里 @pathname 与 Unresolved 分支顺序错误,
  @pathname 会被静默吃掉(改名后仍用软件名)。
- 源目录被删除时解析器丢掉 Sources,导致恢复端把软件名当路径、
  报 "Cannot bind argument to parameter 'Path' because it is an empty string"。
  恢复的语义恰恰是"源不存在就要还原回去",现在 Sources 照旧给出。
- 源存在性检查曾被漏掉,Get-Item 对不存在路径抛异常会中断整轮备份;
  且不能用 Join-Path 探测——目标盘符不存在时它会直接抛异常。
- 计划任务脚本外的 Caller 需要 -DryRun 才能验,已实跑确认。
2026-09-21 20:55:19 +08:00

479 lines
19 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
按 BackupList.txt 执行恢复。
.DESCRIPTION
与旧版相比的核心变化:
1. 归档查找以 manifest.json 为准(按归档基础名索引),拿不到才退回
"从文件名反推路径"。旧版只靠文件名反推,且用 -Filter "$baseName.*" 通配匹配,
一旦解析出偏差,归档就变成谁都找不到的孤儿。
2. 退出码可靠:三条解压分支(7z / RAR / tar)统一走 Invoke-ExternalCommand。
旧版 tar 分支写成 `$LASTEXITCODE -ne 0 -and $proc.ExitCode -ne 0`,
而 $LASTEXITCODE 是上一条原生命令的残留值,跟 Start-Process 无关,
恰为 0 时会把解压失败吞掉并报成功。
3. 支持 -WhatIf / -DryRun:恢复是会覆盖 E:\CodeSpace、Edge User Data 这种
真实目录的破坏性操作,必须能先看清单再决定。
4. 支持 -Only / -Skip 只恢复指定条目,-VerifyOnly 只校验不写盘。
5. 结尾按失败数 exit。
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param(
[Parameter()]
[string]$BackupListPath = (Join-Path $PSScriptRoot 'BackupList.txt'),
[Parameter()]
[string]$BackupDir,
[Parameter()]
[string]$ConfigPath = (Join-Path $PSScriptRoot 'BackupConfig.psd1'),
[Parameter()]
[string]$KeyFile,
[Parameter()]
[string[]]$Only = @(),
[Parameter()]
[string[]]$Skip = @(),
# 忽略"目标比归档新"的保护,强制解压
[Parameter()]
[switch]$Force,
# 只打印计划,不解压(等价于 -WhatIf)
[Parameter()]
[switch]$DryRun,
# 只对归档做 7z t 校验,不解压
[Parameter()]
[switch]$VerifyOnly
)
$ErrorActionPreference = 'Stop'
if ($DryRun) { $WhatIfPreference = $true }
# ============================================================================
# 载入依赖
# ============================================================================
$modulePath = Join-Path $PSScriptRoot 'Common.psm1'
if (-not (Test-Path -LiteralPath $modulePath)) {
Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。"
exit 1
}
Import-Module $modulePath -Force
if ($PSBoundParameters.ContainsKey('Verbose')) { Set-BaknretDebug }
$script:Config = Get-BaknretConfig -Path $ConfigPath
$SupportedFormats = @('.7z', '.rar', '.zip', '.tar')
function Resolve-ConfigPath {
param([string]$Path, [string]$Default)
$value = if ($Path) { $Path } else { $Default }
if (-not [System.IO.Path]::IsPathRooted($value)) {
$value = Join-Path $PSScriptRoot $value
}
return $value
}
if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir }
$logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir
$catalogPath = Resolve-CatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot
$manifestPath = Join-Path $BackupDir 'manifest.json'
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'restore'
Write-Log "日志文件:$logPath"
Write-Log "备份目录:$BackupDir"
Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
if ($WhatIfPreference) { Write-Log '试运行模式(-WhatIf / -DryRun):不会写入任何文件' -Level WARN }
if (-not (Test-Administrator)) {
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
}
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
$password = Get-BaknretPassword -PasswordFile $passwordFile
# ============================================================================
# 归档查找
# ============================================================================
function Find-ArchiveByBaseName {
<#
.SYNOPSIS
按归档基础名精确定位归档文件。
.DESCRIPTION
旧版用 Get-ChildItem -Filter "$baseName.*",-Filter 会做通配符解释,
路径里含 `[` `]` 时会失配;这里改为精确比较 BaseName。
#>
param([string]$BaseName)
$candidate = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.BaseName -eq $BaseName -and $_.Extension.ToLower() -in $SupportedFormats } |
Select-Object -First 1
return $candidate
}
function Get-ArchiveForEntry {
param($Entry, $Manifest)
if ($Manifest -and $Manifest.items.Contains($Entry.baseName)) {
$record = $Manifest.items[$Entry.baseName]
$archiveName = $null
if ($record.PSObject.Properties.Name -contains 'archive') { $archiveName = $record.archive }
if ($archiveName) {
$path = Join-Path $BackupDir $archiveName
if (Test-Path -LiteralPath $path) {
return [pscustomobject]@{ File = (Get-Item -LiteralPath $path); Source = 'manifest'; Record = $record }
}
Write-Log "manifest 记录的归档不存在,回退按文件名查找:$archiveName" -Level WARN
}
}
$fallback = Find-ArchiveByBaseName -BaseName $Entry.baseName
if ($fallback) {
$record = $null
if ($Manifest -and $Manifest.items.Contains($Entry.baseName)) { $record = $Manifest.items[$Entry.baseName] }
return [pscustomobject]@{ File = $fallback; Source = 'filename'; Record = $record }
}
return $null
}
function Get-7zExecutable {
<# .SYNOPSIS 定位 7z.exe(PATH 优先,其次是常见安装位置)。 #>
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $sevenZip) {
$candidates = @(
(Join-Path $env:ProgramFiles '7-Zip\7z.exe'),
(Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe')
)
$sevenZip = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
}
return $sevenZip
}
function Invoke-Extraction {
param([object]$ArchiveFile, [string]$DestinationPath)
$extension = $ArchiveFile.Extension.ToLower()
$destParent = Split-Path -Path $DestinationPath -Parent
if (-not (Test-Path -LiteralPath $destParent)) {
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
}
# 归档布局与历史保持一致:根目录就是源目录名(软件名条目也一样,
# 软件名只用于归档文件名),因此直接整包解压到目标的父目录即可。
$sevenZip = Get-7zExecutable
if ($sevenZip) {
Write-Log '使用 7z 解压' -Level DEBUG
$argument = @('x', '-bsp2', '-y', "-o$destParent")
if ($password) { $argument += "-p$password" }
$argument += $ArchiveFile.FullName
$exitCode = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument
if ($exitCode -ne 0) { throw "7z 解压失败(退出码:$exitCode)" }
return $true
}
switch ($extension) {
'.rar' {
$rarExe = Get-Command rar, unrar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $rarExe) { throw '未找到 RAR 工具' }
Write-Log '使用 RAR 解压' -Level DEBUG
$argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$destParent\")
$exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument
if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" }
}
'.zip' {
Write-Log '使用内置 ZIP 解压' -Level DEBUG
Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $destParent -Force
}
'.tar' {
$tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $tarExe) { throw '未找到 TAR 工具' }
Write-Log '使用 TAR 解压' -Level DEBUG
$exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList @('-xf', $ArchiveFile.FullName, '-C', $destParent)
if ($exitCode -ne 0) { throw "TAR 解压失败(退出码:$exitCode)" }
}
default { throw "不支持的文件格式:$extension" }
}
return $true
}
# ============================================================================
# 准备
# ============================================================================
if (-not (Test-Path -LiteralPath $BackupDir)) {
Write-Log "备份目录不存在: $BackupDir" -Level ERROR
Stop-BaknretLog
exit 1
}
$manifest = Read-BaknretManifest -Path $manifestPath
if (-not (Test-Path -LiteralPath $BackupListPath)) {
Write-Log '未找到配置文件,正在从备份内容生成...' -Level INFO
$paths = @()
if ($manifest.items.Count -gt 0) {
foreach ($key in $manifest.items.Keys) {
$record = $manifest.items[$key]
if ($record.PSObject.Properties.Name -contains 'source' -and $record.source) {
$paths += $record.source
}
}
}
if ($paths.Count -eq 0) {
$backupFiles = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -match '_from_' }
foreach ($file in $backupFiles) {
$original = Convert-BackupFileNameToPath -FileName $file.Name
if ($original) { $paths += $original }
}
}
$paths = @($paths | Sort-Object -Unique)
if ($paths.Count -eq 0) {
Write-Log '无法从备份内容还原出任何路径。' -Level ERROR
Stop-BaknretLog
exit 1
}
$content = "# BackupList.txt(自动生成,排除规则需要手工补回)`n" + (($paths -join [Environment]::NewLine) + [Environment]::NewLine)
[System.IO.File]::WriteAllText($BackupListPath, $content, [System.Text.UTF8Encoding]::new($false))
Write-Log "已生成配置,包含 $($paths.Count) 个项目,请检查后重新运行" -Level INFO
Stop-BaknretLog
exit 0
}
function Test-EntrySelected {
param([string]$DisplayPath, [string]$BaseName)
if ($Only.Count -gt 0) {
$matched = $false
foreach ($pattern in $Only) {
if ($DisplayPath -like $pattern -or $BaseName -like $pattern) { $matched = $true; break }
}
if (-not $matched) { return $false }
}
foreach ($pattern in $Skip) {
if ($DisplayPath -like $pattern -or $BaseName -like $pattern) { return $false }
}
return $true
}
# ============================================================================
# 主流程
# ============================================================================
$lines = Get-Content -LiteralPath $BackupListPath -ErrorAction Stop
$stats = @{ restored = 0; skipped = 0; failed = 0; verified = 0; planned = 0 }
$failures = @()
$referencedArchives = @()
Write-Log '开始执行恢复' -Level INFO
foreach ($line in $lines) {
$item = ConvertFrom-BackupListLine -Line $line
if (-not $item) { continue }
$displayPath = $item.Path
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
$baseName = $resolved.BaseName
if (-not $baseName) { $stats.skipped++; continue }
if (-not (Test-EntrySelected -DisplayPath $displayPath -BaseName $baseName)) { continue }
$found = Get-ArchiveForEntry -Entry ([pscustomobject]@{ baseName = $baseName }) -Manifest $manifest
if (-not $found) {
Write-Log "跳过: $displayPath,未找到归档 $baseName" -Level WARN
$stats.skipped++
continue
}
# 恢复目的地。清单条目可能带多个源(同名目录分散在多处),每个源各恢复各的。
$targets = @()
if ($resolved.Sources.Count -gt 0) {
foreach ($source in $resolved.Sources) {
$targets += [pscustomobject]@{ DestPath = $source.SourcePath }
}
} else {
$targets += [pscustomobject]@{ DestPath = [Environment]::ExpandEnvironmentVariables($displayPath) }
}
# 防御:解析不出目的地时明确失败,别把空字符串喂给 Split-Path/Test-Path
# (那种报错是 "Cannot bind argument to parameter 'Path' because it is an empty string")
$targets = @($targets | Where-Object { $_.DestPath -and -not [string]::IsNullOrWhiteSpace($_.DestPath) })
if ($targets.Count -eq 0) {
$reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何源路径)"
Write-Log "失败: $displayPath,$reason" -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
$destPath = $targets[0].DestPath
$archiveFile = $found.File
$referencedArchives += $archiveFile.BaseName
# 加密归档在取不到口令时必须直接失败:7z 在没有 -p 时会在控制台等输入,
# 在计划任务里会静默挂起,比报错更糟。
$isEncrypted = $false
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'encrypted')) {
$isEncrypted = [bool]$found.Record.encrypted
}
if ($isEncrypted -and -not $password) {
Write-Log "失败: $displayPath,归档已加密但取不到口令(设置 BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件)" -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
if ($VerifyOnly) {
if ($archiveFile.Extension.ToLower() -ne '.7z') {
Write-Log "跳过校验(非 7z): $($archiveFile.Name)" -Level DEBUG
continue
}
$verifyTool = Get-7zExecutable
if (-not $verifyTool) {
Write-Log '未找到 7z,无法校验' -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
$verifyArgument = @('t', '-bso0', '-bsp0')
if ($password) { $verifyArgument += "-p$password" }
$verifyArgument += $archiveFile.FullName
$verifyCode = Invoke-ExternalCommand -FilePath $verifyTool -ArgumentList $verifyArgument
if ($verifyCode -eq 0) {
Write-Log "校验通过: $($archiveFile.Name)" -Level INFO
$stats.verified++
} else {
Write-Log "校验失败: $($archiveFile.Name)(退出码 $verifyCode)" -Level ERROR
$stats.failed++
$failures += $displayPath
}
continue
}
Write-Log "准备恢复: $displayPath <- $($archiveFile.Name)(来源:$($found.Source))" -Level INFO
if ((Test-Path -LiteralPath $destPath) -and -not $Force) {
try {
$destSummary = Get-FolderSummary -FolderPath $destPath
$archiveTime = $archiveFile.LastWriteTime
if ($destSummary.LatestModifiedTime -and $destSummary.LatestModifiedTime -gt $archiveTime) {
Write-Log "跳过: $displayPath,目标目录比归档新(用 -Force 覆盖)" -Level WARN
$stats.skipped++
continue
}
} catch {
Write-Log "目标目录摘要读取失败,继续恢复:$_" -Level DEBUG
}
}
$plannedTargets = @($targets | Where-Object { $_.DestPath })
foreach ($target in $plannedTargets) {
if (Test-Path -LiteralPath $target.DestPath) { continue }
# Split-Path -Parent 对根路径(如 "E:\")返回空串,此时无父目录可建
$targetParent = Split-Path -Path $target.DestPath -Parent
if ($targetParent) {
Write-Log "提示: 目标不存在,将新建 $targetParent" -Level DEBUG
} else {
Write-Log "提示: 目标不存在,且没有可创建的父目录:$($target.DestPath)" -Level DEBUG
}
}
$shouldRun = $true
foreach ($target in $plannedTargets) {
if (-not $PSCmdlet.ShouldProcess($target.DestPath, "从 $($archiveFile.Name) 解压")) { $shouldRun = $false }
}
if (-not $shouldRun) {
foreach ($target in $plannedTargets) {
Write-Log "[试运行] 将解压 $($archiveFile.Name) -> $($target.DestPath)" -Level INFO
}
$stats.planned++
continue
}
$restoreFailed = $false
try {
foreach ($target in $plannedTargets) {
if (-not (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $target.DestPath)) {
$restoreFailed = $true
break
}
}
if (-not $restoreFailed) {
$stats.restored++
Write-Log "恢复成功: $baseName" -Level INFO
if ($manifest.items.Contains($baseName)) {
$record = $manifest.items[$baseName]
if ($record -is [System.Collections.IDictionary]) {
$record['lastRestoreAt'] = (Get-Date).ToString('o')
} else {
$record | Add-Member -NotePropertyName lastRestoreAt -NotePropertyValue ((Get-Date).ToString('o')) -Force
}
}
} else {
$stats.failed++
$failures += $displayPath
}
} catch {
Write-Log "恢复失败: $displayPath,$_" -Level ERROR
$stats.failed++
$failures += $displayPath
}
}
# ============================================================================
# 收尾:报告孤儿归档
# ============================================================================
if (-not $VerifyOnly) {
$orphans = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -notin $referencedArchives })
if ($orphans.Count -gt 0) {
Write-Log '以下归档没有任何清单条目指向(恢复不到,注意别误删):' -Level WARN
foreach ($orphan in $orphans) {
Write-Log (" - {0}({1} MB,{2})" -f $orphan.Name, [math]::Round($orphan.Length / 1MB, 2), $orphan.LastWriteTime) -Level WARN
}
}
}
try {
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null
} catch {
Write-Log "manifest 写回失败(不影响本次恢复):$_" -Level WARN
}
if ($failures.Count -gt 0) {
Write-Log '失败条目:' -Level ERROR
foreach ($failure in $failures) { Write-Log " - $failure" -Level ERROR }
}
$summaryText = "恢复完成 - 成功:$($stats.restored),跳过:$($stats.skipped),失败:$($stats.failed)"
if ($VerifyOnly) { $summaryText = "校验完成 - 通过:$($stats.verified),失败:$($stats.failed)" }
if ($stats.planned -gt 0) { $summaryText += ",试运行计划:$($stats.planned)" }
Write-Log $summaryText -Level INFO
$logPath = Get-BaknretLogPath
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO }
Stop-BaknretLog
if ($stats.failed -gt 0) { exit 1 }
exit 0