Files
BakNRet/Restore.ps1
T
Shuery 187549e2af feat: 运行结尾输出分类详细的结果汇总(成功/跳过/失败/安全/孤儿)
原先结尾只有一行计数加一个失败清单。计数回答有几个,而人真正要读的是哪几个、为什么——尤其当失败或跳过发生在你没盯着屏幕的时候(计划任务)。

新的 Write-BakNRetRunSummary 按动作分组逐条列出:成功组带归档名/体积/耗时/sha256 前 12 位、以及有文件没打进归档的警告标记;跳过组带具体原因(源未更新/源不存在/路径无效);失败组带退出码与原因原文,并且再单列一遍。另有安全描述符与孤儿归档两段,空分组不打印,末尾给耗时。

数据来源是 manifest 里本次运行写下的记录(按 finishedAt 落在运行窗口内筛),而不是让调用方另维护一份清单。

搬的过程里翻车一次并被自己的断言拦住:Get-RecField 只用 PSObject.Properties.Name 判字段存在,而那是 PSCustomObject 的形态;本进程新造的记录是 [ordered] 字典,于是字段一律读成 $null,现象是"本次运行没有写下任何条目记录"(记录明明在)。现在两种形态都认。

一个已知没做的:排除规则只在内联逐条打印,没进汇总——我没定位到那个打印点,不愿意凭猜往条目循环里插桩。

断言:零依赖套件新增一条,用真日志文件验收汇总内容,并断言空分组不出现。验收:test.ps1 9/9 全绿(7 与 5.1)。
2026-09-27 14:26:59 +08:00

859 lines
38 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。
6. 清单行首 `+`(仅备份)的条目会跳过;`-`(仅恢复)的条目照常恢复。
7. 归档内的一层目录由 SoftwareCatalog 的 Slot 决定(`<Slot>\<内容>`),
恢复时只解出该 Slot 那棵子树,并通过"目标父目录下的 junction"直接落地
(零拷贝;建不出连接点时退回先解到临时目录再合并)。
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param(
[Parameter()]
[string]$BackupListPath,
[Parameter()]
[string]$BackupDir,
[Parameter()]
[string]$ConfigPath,
[Parameter()]
[string]$KeyFile,
[Parameter()]
[string[]]$Only = @(),
[Parameter()]
[string[]]$Skip = @(),
# 忽略"目标比归档新"的保护,强制解压
[Parameter()]
[switch]$Force,
# 只打印计划,不解压(等价于 -WhatIf)
[Parameter()]
[switch]$DryRun,
# 只对归档做 7z t 校验,不解压
[Parameter()]
[switch]$VerifyOnly,
# 不恢复安全描述符(属主 / ACL):默认会按 <归档名>.acl.json 回放
[Parameter()]
[switch]$SkipSecurity
)
$ErrorActionPreference = 'Stop'
# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上,
# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带
# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值
# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。
if (-not $BackupListPath) { $BackupListPath = Join-Path $PSScriptRoot 'BackupList.txt' }
if (-not $ConfigPath) { $ConfigPath = Join-Path $PSScriptRoot 'BackupConfig.psd1' }
if ($DryRun) { $WhatIfPreference = $true }
# ============================================================================
# 载入依赖
# ============================================================================
$modulePath = Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1'
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')
if (-not $BackupDir) { $BackupDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.BackupDir -Root $PSScriptRoot }
$logDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.LogDir -Root $PSScriptRoot
$catalogPath = Resolve-BakNRetCatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot
$manifestPath = Join-Path $BackupDir 'manifest.json'
$logPath = Start-BakNRetLog -Directory $logDir -Prefix 'restore'
$runStartedAt = Get-Date
Write-BakNRetLog "日志文件:$logPath"
Write-BakNRetLog "备份目录:$BackupDir"
Write-BakNRetLog ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
if ($WhatIfPreference) { Write-BakNRetLog '试运行模式(-WhatIf / -DryRun):不会写入任何文件' -Level WARN }
if (-not (Test-BakNRetAdministrator)) {
Write-BakNRetLog '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
}
# 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。
# 三种只读模式不取锁:它们一个字节都不写,没必要被正在跑的备份挡在外面。
$runLock = $null
if (-not $WhatIfPreference -and -not $VerifyOnly) {
$runLock = Enter-BakNRetRunLock -Directory $BackupDir
if (-not $runLock) {
Write-BakNRetLog ("另一次运行正在进行中(锁文件:{0},里面写明了持有者)。本次不执行。" -f (Get-BakNRetRunLockPath -Directory $BackupDir)) -Level ERROR
Stop-BakNRetLog
exit 1
}
Write-BakNRetLog ("已取得运行锁:{0}" -f (Get-BakNRetRunLockPath -Directory $BackupDir)) -Level DEBUG
}
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
if ($passwordFile) {
# 相对路径按**仓库根**解析,而不是按当前工作目录:计划任务的工作目录是 C:\Windows\System32,
# 在那里 Test-Path 找不到 baknret.key,加密条目就会以"拿不到口令"失败,而配置看上去没问题。
$passwordFile = Resolve-BakNRetRootedPath -Path $passwordFile -Default $passwordFile -Root $PSScriptRoot
}
$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-BakNRetLog "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 Invoke-ExtractionRaw {
<#
.SYNOPSIS
把归档里某个子树解到指定目录,不关心"落地"问题。
.DESCRIPTION
归档布局:软件名条目是 `<Slot>\...`(Slot 就是归档内的一层目录),
手写路径条目是 `<源目录名>\...`。这里只负责把指定的那条路径解出来。
#>
param(
[Parameter(Mandatory = $true)][object]$ArchiveFile,
[Parameter(Mandatory = $true)][string]$Destination,
[string]$RelativePath,
[string]$Password
)
$extension = $ArchiveFile.Extension.ToLower()
if (-not (Test-Path -LiteralPath $Destination)) {
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
}
$sevenZip = Find-BakNRet7zExecutable
if ($sevenZip) {
Write-BakNRetLog '使用 7z 解压' -Level DEBUG
$argument = @('x', '-bsp2', '-y', "-o$Destination")
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-BakNRetLog '使用 RAR 解压' -Level DEBUG
$argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$Destination\")
if ($RelativePath) { $argument += $RelativePath }
$exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument
if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" }
}
'.zip' {
Write-BakNRetLog '使用内置 ZIP 解压' -Level DEBUG
if ($RelativePath) {
Write-BakNRetLog "内置 ZIP 不支持只解子树,将整包解压($RelativePath)" -Level WARN
}
Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $Destination -Force
}
'.tar' {
$tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $tarExe) { throw '未找到 TAR 工具' }
Write-BakNRetLog '使用 TAR 解压' -Level DEBUG
$argument = @('-xf', $ArchiveFile.FullName, '-C', $Destination)
if ($RelativePath) { $argument += $RelativePath }
$exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList $argument
if ($exitCode -ne 0) { throw "TAR 解压失败(退出码:$exitCode)" }
}
default { throw "不支持的文件格式:$extension" }
}
return $true
}
function Invoke-ExtractionByLayout {
<#
.SYNOPSIS
按**当前归档布局**(软件名条目 = `<Slot>\<内容>`)解出一个归档项并落到目标位置。
.DESCRIPTION
$Item:ArchivePath(归档内相对路径)、RealPath(宿主机目标)、IsFile。
落地方式(关键:不整包往目标里倒,只解出这一项自己那棵子树):
* 目录项 -> 在目标的父目录下建一个**指向目标目录的 junction**,
让 7z 直接写穿连接点落地(零拷贝,不需要"先解到临时目录再整体搬一遍"),
解完立刻拆掉连接点。建不出连接点(父目录里已有同名实体等)时,
退回"解到临时目录再逐项合并",只慢不错。
* 文件项 -> 解到临时目录后把文件搬到目标位置(保留 Path 里的原始文件名)。
目标目录只覆盖同名文件,不删除多余文件(镜像同步不是这里的语义)。
#>
param(
[Parameter(Mandatory = $true)][object]$ArchiveFile,
[Parameter(Mandatory = $true)][object]$Item,
[string]$Password
)
$archivePath = [string]$Item.ArchivePath
$destPath = [string]$Item.RealPath
if ([string]::IsNullOrWhiteSpace($archivePath)) { throw "归档项缺少归档内路径($destPath)" }
if ([string]::IsNullOrWhiteSpace($destPath)) { throw "归档项缺少目标路径($archivePath)" }
$destParent = Split-Path -Path $destPath -Parent
if (-not $destParent) { throw "无法确定目标父目录:$destPath" }
if ($Item.IsFile) {
$temp = Join-Path $env:TEMP ('bnr-file-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $temp -Force | Out-Null
try {
if (-not (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $temp -RelativePath $archivePath -Password $Password)) {
return $false
}
$produced = Join-Path $temp $archivePath
if (-not (Test-Path -LiteralPath $produced -PathType Leaf)) {
throw "归档里的 $archivePath 不是一个文件"
}
if (-not (Test-Path -LiteralPath $destParent)) {
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
}
Move-Item -LiteralPath $produced -Destination $destPath -Force
}
finally {
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
}
return $true
}
# 目录项:先把目标目录准备好(不存在就建),再决定用连接点还是合并兜底
if (-not (Test-Path -LiteralPath $destPath)) {
New-Item -ItemType Directory -Path $destPath -Force | Out-Null
}
$anchorName = Get-BakNRetArchiveTopName -ArchivePath $archivePath
$anchorPath = if ($anchorName) { Join-Path $destParent $anchorName } else { $null }
$junctionCreated = $false
if ($anchorPath -and -not (Test-Path -LiteralPath $anchorPath)) {
try {
New-BakNRetJunction -Path $anchorPath -Target $destPath | Out-Null
$junctionCreated = $true
Write-BakNRetLog ("落地:{0} -> {1}(经连接点 {2})" -f $archivePath, $destPath, $anchorPath) -Level DEBUG
}
catch {
Write-BakNRetLog "无法建连接点($($_.Exception.Message)),改为先解到临时目录再合并" -Level WARN
}
}
if ($junctionCreated) {
try {
return (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $destParent -RelativePath $archivePath -Password $Password)
}
finally {
Remove-BakNRetJunction -Path $anchorPath
}
}
Write-BakNRetLog ("落地:{0} -> {1}(先解到临时目录再合并)" -f $archivePath, $destPath) -Level WARN
$temp = Join-Path $env:TEMP ('bnr-merge-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $temp -Force | Out-Null
try {
if (-not (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $temp -RelativePath $archivePath -Password $Password)) {
return $false
}
$source = Join-Path $temp $archivePath
if (-not (Test-Path -LiteralPath $source)) { throw "归档里没有 $archivePath" }
# 逐个顶层子项复制(而不是 `Copy-Item '<源>\*'`):空目录时通配符匹配不到任何东西,
# Copy-Item 会直接报 "Cannot find path"。合并语义:覆盖同名文件,不删多余文件。
foreach ($child in @(Get-ChildItem -LiteralPath $source -Force -ErrorAction SilentlyContinue)) {
Copy-Item -LiteralPath $child.FullName -Destination $destPath -Recurse -Force
}
}
finally {
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
}
return $true
}
function Test-BakNRetArchivePath {
<#
.SYNOPSIS
归档里有没有这条路径。
.DESCRIPTION
必须问,不能靠退出码猜:7z 在"归档里没有这个名字"时**同样返回 0**
(打印一句 "No files to process" 就结束),所以只解压、然后看退出码,
会把"什么都没解出来"当成成功 —— 那正是最危险的静默失败。
7z 的列表输出没法用管道读(受限环境会拒绝创建管道),所以用
`Start-Process -RedirectStandardOutput <文件>` 把它重定向到文件再读
(Start-Process 的重定向是直接给子进程一个文件句柄,不经过管道);
用 -sccUTF-8 保证非 ASCII 路径不会因为控制台代码页而丢字。
列表为空 = 这条路径不在归档里。
注意这里刻意**不用** ExitCode:本机的 PowerShell 预览版上
`Start-Process -PassThru` 的 ExitCode 恒为 $null(见 README「设计取舍」),
而 7z 在"路径不存在"时退出码同样是 0,所以退出码本来也不可用。
#>
param(
[Parameter(Mandatory = $true)][object]$ArchiveFile,
[Parameter(Mandatory = $true)][string]$RelativePath,
[string]$Password
)
$sevenZip = Find-BakNRet7zExecutable
if (-not $sevenZip) { return $true } # 没有 7z 时不预判,交给解压分支自己去失败
$item = ([string]$RelativePath).Trim([char[]]@('\', '/'))
if ([string]::IsNullOrWhiteSpace($item)) { return $false }
$outFile = Join-Path $env:TEMP ('bnr-list-' + [guid]::NewGuid().ToString('N') + '.txt')
$errFile = "$outFile.err"
try {
$argument = @('l', '-ba', '-sccUTF-8')
if ($Password) { $argument += "-p$Password" }
$argument += $ArchiveFile.FullName
$argument += $item
$null = Start-Process -FilePath $sevenZip `
-ArgumentList (ConvertTo-BakNRetNativeArgumentString -ArgumentList $argument) `
-RedirectStandardOutput $outFile -RedirectStandardError $errFile `
-NoNewWindow -Wait -PassThru
$lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue)
}
catch {
Write-BakNRetLog "无法列出归档内容(跳过预判):$($_.Exception.Message)" -Level DEBUG
return $true
}
finally {
Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $errFile -Force -ErrorAction SilentlyContinue
}
# 列表每行的末尾才是路径(前面是时间 / 属性 / 大小),所以按"路径记号"来判定
$escaped = [regex]::Escape($item)
foreach ($line in $lines) {
$text = ([string]$line).Trim()
if (-not $text) { continue }
if ($text -match "(?i)(^|[\s\\/])$escaped($|[\s\\/])") { return $true }
}
return $false
}
function Invoke-Extraction {
<#
.SYNOPSIS
解出一个归档项并落地;包内布局对不上时回退到重构前的旧布局。
.DESCRIPTION
Slot 布局(`<Slot>\<内容>`)是本次重构才开始用的,Backups/ 里还躺着不少
按旧布局(包内直接是 `<源目录名>\...`)生成的归档。所以先问归档"这条路径在不在":
* 在 -> 按当前布局解(junction 零拷贝落地,见 Invoke-ExtractionByLayout);
* 不在,但有旧布局的 `<目标末级名>` -> 打印告警并按旧布局解,
与重构前的恢复语义完全一致;
* 两个都没有 -> 明确失败,而不是"成功地什么都没恢复"。
#>
param(
[Parameter(Mandatory = $true)][object]$ArchiveFile,
[Parameter(Mandatory = $true)][object]$Item,
[string]$Password
)
$archivePath = [string]$Item.ArchivePath
$destPath = [string]$Item.RealPath
$legacyName = Split-Path -Path $destPath -Leaf
if (Test-BakNRetArchivePath -ArchiveFile $ArchiveFile -RelativePath $archivePath -Password $Password) {
return (Invoke-ExtractionByLayout -ArchiveFile $ArchiveFile -Item $Item -Password $Password)
}
if ($legacyName -and ($legacyName -ine $archivePath) -and
(Test-BakNRetArchivePath -ArchiveFile $ArchiveFile -RelativePath $legacyName -Password $Password)) {
Write-BakNRetLog ("归档里没有 '{0}'(可能是重构前的旧归档),按旧布局回退为 '{1}'" -f $archivePath, $legacyName) -Level WARN
$parent = Split-Path -Path $destPath -Parent
if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
return (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $parent -RelativePath $legacyName -Password $Password)
}
throw ("归档 {0} 里既没有 '{1}',也没有旧布局的 '{2}';请确认归档与清单/名录是否匹配" -f `
$ArchiveFile.Name, $archivePath, $legacyName)
}
# ============================================================================
# 准备
# ============================================================================
if (-not (Test-Path -LiteralPath $BackupDir)) {
Write-BakNRetLog "备份目录不存在: $BackupDir" -Level ERROR
Stop-BakNRetLog
exit 1
}
$manifest = Read-BakNRetManifest -Path $manifestPath
if (-not (Test-Path -LiteralPath $BackupListPath)) {
Write-BakNRetLog '未找到配置文件,正在从备份内容生成...' -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-BakNRetBackupFileNameToPath -FileName $file.Name
if ($original) { $paths += $original }
}
}
$paths = @($paths | Sort-Object -Unique)
if ($paths.Count -eq 0) {
Write-BakNRetLog '无法从备份内容还原出任何路径。' -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($true))
Write-BakNRetLog "已生成配置,包含 $($paths.Count) 个项目,请检查后重新运行" -Level INFO
Stop-BakNRetLog
exit 0
}
# ============================================================================
# 主流程
# ============================================================================
$lines = Get-Content -Encoding UTF8 -LiteralPath $BackupListPath -ErrorAction Stop
$stats = @{ restored = 0; skipped = 0; failed = 0; verified = 0; planned = 0 }
$securityApplied = 0 # 本次回放成功的安全描述符对象数
$failures = @()
$referencedArchives = @()
# 只有真的恢复成功了才允许写回 manifest。
# -WhatIf / -DryRun / -VerifyOnly 以及"全部跳过"的运行必须一个字节都不写:
# 之前这里无条件写回,实际上只是把 updatedAt 改了,却直接违背了
# "试运行不会写入任何文件" 的承诺(已用 manifest 的 SHA256 复现)。
$manifestDirty = $false
Write-BakNRetLog '开始执行恢复' -Level INFO
foreach ($line in $lines) {
$item = ConvertFrom-BackupListLine -Line $line
if (-not $item) { continue }
$displayPath = $item.Path
$resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $catalogPath
$baseName = $resolved.BaseName
if (-not $baseName) { $stats.skipped++; continue }
if (-not (Test-BakNRetItemSelected -DisplayPath $displayPath -BaseName $baseName -Only $Only -Skip $Skip)) { continue }
if ($resolved.Direction -eq 'backup') {
# 仅备份的条目照样要登记归档名:审计要能看出"这个归档是有主的",
# 否则它会被误报成孤儿(只是它本来就恢复不到,因为行首写了 +)。
$referencedArchives += $baseName
Write-BakNRetLog "跳过(行首 +,仅备份): $displayPath" -Level DEBUG
continue
}
# 解析阶段就定死的结构性错误(名录条目有问题、归档内路径冲突):
# 恢复一半比明确失败更危险,所以整条失败。
if ($resolved.Blocking) {
Write-BakNRetLog "失败: $displayPath,$($resolved.Blocking)" -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
$found = Get-ArchiveForEntry -Entry ([pscustomobject]@{ baseName = $baseName }) -Manifest $manifest
if (-not $found) {
Write-BakNRetLog "跳过: $displayPath,未找到归档 $baseName" -Level WARN
$stats.skipped++
continue
}
# "是目录还是文件"的判据,按可靠性排序:
# 1. 目标在磁盘上真实存在 -> 直接看它;
# 2. manifest 记的 layouts(备份时记录的归档内条目类型)-> 全新恢复时靠它;
# 3. 名录解析出来的 IsFile(源当前存在时才有值);
# 4. 都没有就按目录处理。
$layouts = @{}
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'layouts')) {
foreach ($layout in @($found.Record.layouts)) {
if (-not $layout) { continue }
$layoutName = [string]$layout.name
if ([string]::IsNullOrWhiteSpace($layoutName)) { continue }
$layouts[$layoutName.ToLower()] = [string]$layout.kind
}
}
# 恢复目的地:一个条目可以挂多个归档项(名录里多个 Slot、`:+` 追加),
# 每一项只还原**它自己那棵子树**,不会把兄弟项也复制过去。
$targets = @()
foreach ($entryItem in @($resolved.Items)) {
$dest = [string]$entryItem.RealPath
if ([string]::IsNullOrWhiteSpace($dest)) { continue }
$isFile = [bool]$entryItem.IsFile
if (Test-Path -LiteralPath $dest -PathType Leaf) {
$isFile = $true
}
elseif (Test-Path -LiteralPath $dest -PathType Container) {
$isFile = $false
}
elseif ($layouts.ContainsKey(([string]$entryItem.ArchivePath).ToLower())) {
$isFile = ($layouts[([string]$entryItem.ArchivePath).ToLower()] -eq 'file')
}
$targets += [pscustomobject]@{
ArchivePath = [string]$entryItem.ArchivePath
RealPath = $dest
DestPath = $dest
IsFile = $isFile
Description = $entryItem.Description
Origin = $entryItem.Origin
}
}
# 兜底:解析不出归档项时按字面路径处理(历史清单里的裸路径)
if ($targets.Count -eq 0 -and -not $resolved.IsName) {
$expanded = [Environment]::ExpandEnvironmentVariables($displayPath)
if (-not [string]::IsNullOrWhiteSpace($expanded)) {
$targets += [pscustomobject]@{
ArchivePath = (Split-Path -Path $expanded -Leaf)
RealPath = $expanded
DestPath = $expanded
IsFile = (Test-Path -LiteralPath $expanded -PathType 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-BakNRetLog "失败: $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-BakNRetLog "失败: $displayPath,归档已加密但取不到口令(设置 BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件)" -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
if ($VerifyOnly) {
if ($archiveFile.Extension.ToLower() -ne '.7z') {
Write-BakNRetLog "跳过校验(非 7z): $($archiveFile.Name)" -Level DEBUG
continue
}
$verifyTool = Find-BakNRet7zExecutable
if (-not $verifyTool) {
Write-BakNRetLog '未找到 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-BakNRetLog "校验通过: $($archiveFile.Name)" -Level INFO
$stats.verified++
}
else {
Write-BakNRetLog "校验失败: $($archiveFile.Name)(退出码 $verifyCode)" -Level ERROR
$stats.failed++
$failures += $displayPath
}
continue
}
Write-BakNRetLog "准备恢复: $displayPath <- $($archiveFile.Name)(来源:$($found.Source))" -Level INFO
if ((Test-Path -LiteralPath $destPath) -and -not $Force) {
try {
$destSummary = Get-BakNRetFolderSummary -FolderPath $destPath
$archiveTime = $archiveFile.LastWriteTime
if ($destSummary.LatestModifiedTime -and $destSummary.LatestModifiedTime -gt $archiveTime) {
Write-BakNRetLog "跳过: $displayPath,目标目录比归档新(用 -Force 覆盖)" -Level WARN
$stats.skipped++
continue
}
}
catch {
Write-BakNRetLog "目标目录摘要读取失败,继续恢复:$_" -Level DEBUG
}
}
$plannedTargets = @($targets | Where-Object { $_.DestPath })
# 说清楚"这条会把哪些目录还原到哪儿、为什么"
Write-BakNRetLog ("恢复计划:{0}(归档 {1})" -f $displayPath, $archiveFile.Name)
foreach ($target in $plannedTargets) {
$targetExists = Test-Path -LiteralPath $target.DestPath
Write-BakNRetLog (" 目标:{0}" -f $target.DestPath)
Write-BakNRetLog (" 归档内路径:{0}({1});{2}" -f $target.ArchivePath,
$(if ($target.IsFile) { '文件' } else { '目录' }),
$(if ($targetExists) { '已存在,将覆盖同名文件' } else { '不存在,将新建' }))
if ($target.Description) { Write-BakNRetLog (" 介绍:{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-BakNRetLog "提示: 目标不存在,将新建 $targetParent" -Level DEBUG
}
else {
Write-BakNRetLog "提示: 目标不存在,且没有可创建的父目录:$($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-BakNRetLog "[试运行] 将解压 $($archiveFile.Name) -> $($target.DestPath)" -Level INFO
}
$stats.planned++
continue
}
$restoreFailed = $false
try {
foreach ($target in $plannedTargets) {
if (-not (Invoke-Extraction -ArchiveFile $archiveFile -Item $target -Password $password)) {
$restoreFailed = $true
break
}
}
if (-not $restoreFailed) {
# ------------------------------------------------------------------
# 安全描述符(属主 / ACL)回放
# ------------------------------------------------------------------
# 解压出来的对象:属主是"跑恢复脚本的进程"、DACL 是"从目标父目录继承 + 进程默认"。
# 对 C:\ProgramData 这类目录是致命的 —— 那里的 (A;OICIIO;GA;;;CO) 靠
# CREATOR OWNER 把全权给"对象的属主",属主一变,原程序就没了权限。
# 必须在解压**之后**、对真实目标路径做(连接点在 Invoke-Extraction 里已经拆掉了)。
if ($SkipSecurity) {
Write-BakNRetLog '按 -SkipSecurity 跳过了安全描述符恢复' -Level DEBUG
}
elseif (([string]$script:Config.Security.Mode) -eq 'Off') {
Write-BakNRetLog '配置里 Security.Mode = Off,跳过安全描述符恢复' -Level DEBUG
}
else {
$sidecarName = $null
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'security') -and $found.Record.security) {
$sidecarName = [string]$found.Record.security.file
}
if (-not $sidecarName) { $sidecarName = "$baseName.acl.json" }
$sidecar = Read-BakNRetSecuritySidecar -Path (Join-Path $BackupDir $sidecarName)
if (-not $sidecar) {
Write-BakNRetLog ("这个归档没有安全描述符旁挂文件({0}):恢复出来的属主/ACL 是新建对象的默认值 —— 原程序若依赖特殊权限(ProgramData 下的 CREATOR OWNER 最典型),会报无读写权限" -f $sidecarName) -Level WARN
}
else {
$sidMap = @{}
if ($script:Config.Security.SidMap) { $sidMap = $script:Config.Security.SidMap }
$secTotal = 0; $secApplied = 0; $secOwnerFailed = 0; $secSkipped = 0; $secFailed = 0
$secMessages = @()
foreach ($target in $plannedTargets) {
$sec = Restore-BakNRetSecurity -Sidecar $sidecar -ArchiveRoot $target.ArchivePath `
-TargetPath $target.DestPath -SidMap $sidMap
$secTotal += $sec.Total
$secApplied += $sec.Applied
$secOwnerFailed += $sec.OwnerFailed
$secSkipped += $sec.Skipped
$secFailed += $sec.Failed
$secMessages += @($sec.Failures)
}
$securityApplied += $secApplied
Write-BakNRetLog ("安全描述符:回放 {0}/{1} 个对象(属主/属组未恢复 {2},跳过 {3},失败 {4})" -f `
$secApplied, $secTotal, $secOwnerFailed, $secSkipped, $secFailed) -Level INFO
foreach ($message in @($secMessages | Select-Object -First 5)) {
Write-BakNRetLog (" ! {0}" -f $message) -Level WARN
}
if ($secFailed -gt 0) {
Write-BakNRetLog ("恢复成功但安全描述符有 {0} 个对象失败,已计入失败条目(退出码 1)" -f $secFailed) -Level ERROR
$failures += $displayPath
}
}
}
$stats.restored++
Write-BakNRetLog "恢复成功: $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-BakNRetLog "恢复失败: $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-BakNRetLog '以下归档没有任何清单条目指向(恢复不到,注意别误删):' -Level WARN
foreach ($orphan in $orphans) {
Write-BakNRetLog (" - {0}({1} MB,{2})" -f $orphan.Name, [math]::Round($orphan.Length / 1MB, 2), $orphan.LastWriteTime) -Level WARN
}
}
}
elseif (-not $VerifyOnly) {
# 带 -Only/-Skip 时只有被选中的条目会被处理,其余归档都不在 $referencedArchives 里,
# 按上面的算法报出来全是假孤儿(还会吓唬人说"注意别误删"),所以整段跳过。
Write-BakNRetLog '本次只恢复了部分条目,跳过孤儿归档审计(避免把未选中的归档误报成孤儿)' -Level DEBUG
}
try {
if ($manifestDirty) {
# 顺手维持"manifest 写了 archive,磁盘上就真有那个文件"这条不变式
$null = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $BackupDir
Write-BakNRetManifest -Path $manifestPath -Manifest $manifest | Out-Null
Write-BakNRetLog 'manifest 已更新(记下本次恢复时间)' -Level DEBUG
}
else {
Write-BakNRetLog 'manifest 无需更新:本次没有实际恢复任何条目' -Level DEBUG
}
}
catch {
Write-BakNRetLog "manifest 写回失败(不影响本次恢复):$_" -Level WARN
}
$summaryMode = if ($VerifyOnly) { 'verify' } else { 'restore' }
$counterText = @{ 成功 = $stats.restored; 跳过 = $stats.skipped; 失败 = $stats.failed; 校验通过 = $stats.verified }
if ($stats.planned -gt 0) { $counterText['试运行计划'] = $stats.planned }
Write-BakNRetRunSummary -Mode $summaryMode -Manifest $manifest -StartedAt $runStartedAt -Failures $failures -Counters $counterText -SecurityApplied $securityApplied
$logPath = Get-BakNRetLogPath
if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO }
Exit-BakNRetRunLock -Lock $runLock
Stop-BakNRetLog
if ($stats.failed -gt 0) { exit 1 }
exit 0