Files
BakNRet/Restore.ps1
T
Shuery 43fa4e52dd 清单格式:两种写法(软件名 / 手写目录)都支持 :+ 追加与 :- 排除;名录改用对象数组并逐条介绍目录
需求
- 支持两种条目写法:1) 直接写软件名录里的软件名;2) 用户手写目录。
- 两种写法都必须支持追加(:+)与排除(:-)。
- 软件名录要改进:scoop 合并成"一个软件 + 一个目录数组"。
- 运行时要把"分别是哪些目录、每个目录是干什么的、排除/追加的理由"讲清楚。

实现
- 名录(SoftwareCatalog.psd1 / Get-SoftwareCatalog)
  * 一个软件挂多个目录时写成**对象数组**:@{ Path = '...'; Description = '...' };
    也接受纯字符串数组与旧的 @{ Dirs = ... } / @{ Variants = ... }。
  * 目录说明(Description)一路带到运行日志里。
  * "声明了但当前不存在"的目录不再被丢掉:备份跳过,恢复仍然知道它该回到哪个位置。
  * scoop 合并成一个数组条目(%UserProfile%\scoop\persist + %UserProfile%\.config\scoop);
    ScoopApps-persist 保持独立条目 —— 它和前者末级名同为 persist,并进同一个归档会在包里撞名。
- 解析与解析结果(ConvertFrom-BackupListLine / Resolve-BackupEntry)
  * `:+` 以前只对"软件名且能解析出目录"的写法生效,**手写目录的 :+ 会被整段丢掉**;
    现在统一生效,且 :+ 后面写软件名会按名录展开。
  * 行尾 `# 说明` 解析成 Comment,运行时打印。
- 归档与恢复
  * 同一条目里两个同名目录:打包前明确报错(退出码 1),不再静默混成一棵树。
    (7z 命令行没有"入库改名"的能力,归档内顶层名只能是文件系统上的那个名字。)
  * 恢复时每个源只解出**它自己那棵子树**,不会再往别的父目录里复制兄弟目录。
- 可解释性
  * 新增 Write-BackupEntryPlan:打包前打印条目的目录(含来源与介绍)以及排除/追加的出处;
    Restore.ps1 同样打印"哪棵子树还原到哪、会新建还是覆盖"。
- 孤儿归档审计修正:判据只看当前清单,不再把 manifest 的历史记录当成"已知"。
  否则"条目被合并/改名后留下的旧归档"会被历史记录遮住,永远不会报警。

验证
- Pester 84 项、零依赖单元 49 项、端到端 23 项,全部通过。
- 真实机器:合并后的 scoop.7z 233 MB / 29478 项 / 7z t 通过,manifest.roots=[persist|scoop];
  恢复演练 26982/26982 逐字节一致(.ssh、legendary、Aria 同批通过)。
- 迁移提醒:scoop-config.7z 与 scoop-persist.7z 已无清单条目指向,会出现在孤儿审计里;
  确认 scoop.7z 无误后可以自行删除。
2026-09-22 08:18:16 +08:00

526 lines
21 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, [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