你的决定:口令文件继续放在仓库根,靠 .gitignore 的 *.key 兜住"不被提交"。我把出厂默认值改回 baknret.key,并把配置注释从"必须放在仓库之外"改成如实说明这是一次取舍:省事 vs 「不提交」依赖一个规则文件(git add -f、或整目录复制到别处再初始化仓库时,口令会跟着走)。 顺手修掉一个潜在陷阱:口令文件写相对路径时,原先的 Test-Path 是按**当前工作目录**找的。计划任务的工作目录通常是 C:\Windows\System32,在那里 Test-Path baknret.key 为假,加密条目就会以"拿不到口令"失败 —— 而配置看上去毫无问题。现在相对路径按仓库根解析(复用上一轮抽出来的 Resolve-BakNRetRootedPath)。 证据:把工作目录切到 C:\Windows\System32 再跑真实清单只读冒烟,仍然 4/4 通过(那份真实配置里有 5 个加密条目、口令文件就是仓库根的 baknret.key)。 验收:test.ps1 9/9 全绿(7 与 5.1)。
864 lines
39 KiB
PowerShell
864 lines
39 KiB
PowerShell
<#
|
||
.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'
|
||
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
|
||
}
|
||
|
||
if ($failures.Count -gt 0) {
|
||
Write-BakNRetLog '失败条目:' -Level ERROR
|
||
foreach ($failure in $failures) { Write-BakNRetLog " - $failure" -Level ERROR }
|
||
}
|
||
|
||
$summaryText = "恢复完成 - 成功:$($stats.restored),跳过:$($stats.skipped),失败:$($stats.failed)"
|
||
if ($securityApplied -gt 0) { $summaryText += ",安全描述符:$securityApplied 个对象" }
|
||
if ($VerifyOnly) { $summaryText = "校验完成 - 通过:$($stats.verified),失败:$($stats.failed)" }
|
||
if ($stats.planned -gt 0) { $summaryText += ",试运行计划:$($stats.planned)" }
|
||
Write-BakNRetLog $summaryText -Level INFO
|
||
|
||
$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
|