<# .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, [string]$RelativePath) $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 } # 归档布局与历史保持一致:顶层就是**源目录名**(软件名只用于归档文件名)。 # 一个条目可能打包了好几个目录(软件名录里的数组写法 / `:+` 追加), # 所以**不能整包往每个目标里倒** —— 那会把兄弟目录也复制到不相干的父目录下。 # 这里只解出该目标自己那棵子树($RelativePath),其余不动。 $sevenZip = Get-7zExecutable if ($sevenZip) { Write-Log '使用 7z 解压' -Level DEBUG $argument = @('x', '-bsp2', '-y', "-o$destParent") if ($password) { $argument += "-p$password" } $argument += $ArchiveFile.FullName if ($RelativePath) { $argument += $RelativePath } $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\") if ($RelativePath) { $argument += $RelativePath } $exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" } } '.zip' { Write-Log '使用内置 ZIP 解压' -Level DEBUG if ($RelativePath) { Write-Log "内置 ZIP 不支持只解子树,将整包解压($RelativePath)" -Level WARN } 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 $argument = @('-xf', $ArchiveFile.FullName, '-C', $destParent) if ($RelativePath) { $argument += $RelativePath } $exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList $argument 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 = @() # 只有真的恢复成功了才允许写回 manifest。 # -WhatIf / -DryRun / -VerifyOnly 以及"全部跳过"的运行必须一个字节都不写: # 之前这里无条件写回,实际上只是把 updatedAt 改了,却直接违背了 # "试运行不会写入任何文件" 的承诺(已用 manifest 的 SHA256 复现)。 $manifestDirty = $false 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 RelativePath = @($source.RelativePaths)[0] Description = $source.Description Origin = $source.Origin } } } else { $expanded = [Environment]::ExpandEnvironmentVariables($displayPath) $targets += [pscustomobject]@{ DestPath = $expanded RelativePath = (Split-Path -Path $expanded -Leaf) Description = $null Origin = 'path' } } # 防御:解析不出目的地时明确失败,别把空字符串喂给 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 }) # 说清楚"这条会把哪些目录还原到哪儿、为什么" Write-Log ("恢复计划:{0}(归档 {1})" -f $displayPath, $archiveFile.Name) foreach ($target in $plannedTargets) { $targetExists = Test-Path -LiteralPath $target.DestPath Write-Log (" 目标:{0}" -f $target.DestPath) Write-Log (" 归档内子树:{0};{1}" -f $target.RelativePath, $(if ($targetExists) { '已存在,将覆盖同名文件' } else { '不存在,将新建' })) if ($target.Description) { Write-Log (" 介绍:{0}" -f $target.Description) } } 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 -RelativePath $target.RelativePath)) { $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 } $manifestDirty = $true } } else { $stats.failed++ $failures += $displayPath } } catch { Write-Log "恢复失败: $displayPath,$_" -Level ERROR $stats.failed++ $failures += $displayPath } } # ============================================================================ # 收尾:报告孤儿归档 # ============================================================================ if (-not $VerifyOnly -and $Only.Count -eq 0 -and $Skip.Count -eq 0) { $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 } } } elseif (-not $VerifyOnly) { # 带 -Only/-Skip 时只有被选中的条目会被处理,其余归档都不在 $referencedArchives 里, # 按上面的算法报出来全是假孤儿(还会吓唬人说"注意别误删"),所以整段跳过。 Write-Log '本次只恢复了部分条目,跳过孤儿归档审计(避免把未选中的归档误报成孤儿)' -Level DEBUG } try { if ($manifestDirty) { Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null Write-Log 'manifest 已更新(记下本次恢复时间)' -Level DEBUG } else { Write-Log 'manifest 无需更新:本次没有实际恢复任何条目' -Level DEBUG } } 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