diff --git a/Backup-Data.ps1 b/Backup-Data.ps1 new file mode 100644 index 0000000..0c88c53 --- /dev/null +++ b/Backup-Data.ps1 @@ -0,0 +1,840 @@ +<# +.SYNOPSIS + 按 BackupList.txt 执行备份。 + +.DESCRIPTION + 与旧版相比的核心变化: + + 1. 退出码可靠 —— 不再用 Start-Process -PassThru(在 PowerShell 7.7.0-preview.4 上 + ExitCode 恒为 $null,会把成功的压缩判成失败),改用 Invoke-ExternalCommand。 + 2. 先写临时归档 → 校验 → 原子替换。中断或断电只会留下 .tmp 文件, + 不会污染正式归档;也不会再出现"半个归档被下次增量续写"的情况。 + 3. 不再使用 7z 的 u(更新)模式。7z 默认是固实压缩,u 本来就要重压大部分数据, + 收益极小,却让排除规则和删除操作永远无法生效(旧归档里会一直留着已删文件)。 + 现在每次都从零打包,于是"排除规则改动"和"源里删掉的文件"都能真正反映到归档。 + 4. 每个条目写进 manifest.json:源、归档、时间、退出码、校验结果、失败原因。 + 跳过和失败从此有据可查,而不是只剩一行滚过去的控制台告警。 + 5. 结尾按失败数 exit,并写日志文件,计划任务能正确判断成败。 + 6. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。 + + 与 SoftwareCatalog.psd1 的 Slot 结构配套: + * 一个软件 = 一个归档,归档内是 `\<该 Path 的内容>`; + * 打包前用暂存目录 + junction / 硬链接把 Slot 名变成包里真实的目录名 + (7z 没有"入库时改名"的能力),打包后立刻拆掉暂存目录; + * 清单行首 `+` = 仅备份、`-` = 仅恢复。 +#> + +[CmdletBinding()] +param( + [Parameter()] + [string]$BackupListPath, + + [Parameter()] + [string]$BackupDir, + + [Parameter()] + [string]$ConfigPath, + + [Parameter()] + [string]$KeyFile, + + # 只处理匹配这些通配符的条目(匹配原始路径或归档基础名) + [Parameter()] + [string[]]$Only = @(), + + # 跳过匹配这些通配符的条目 + [Parameter()] + [string[]]$Skip = @(), + + # 忽略"源未更新"判断,强制重新打包 + [Parameter()] + [switch]$Force, + + # 成功后在 snapshots 目录留一份带时间戳的副本 + [Parameter()] + [switch]$Snapshot, + + # 额外计算归档的 SHA256 写入 manifest(大归档会更慢) + [Parameter()] + [switch]$Hash, + + # 抑制压缩工具的实时输出(日志与 manifest 不受影响) + [Parameter()] + [switch]$QuietTool, + + # 允许用"有警告"的不完整归档覆盖已有的完整归档(默认拒绝) + [Parameter()] + [switch]$AcceptWarnings, + + # 只打印将要做什么,不实际写入 + [Parameter()] + [switch]$DryRun +) + +$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' } + +# ============================================================================ +# 载入依赖 +# ============================================================================ + +$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 + + +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 +$snapshotDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.SnapshotDir -Root $PSScriptRoot +$catalogPath = Resolve-BakNRetCatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot +$manifestPath = Join-Path $BackupDir 'manifest.json' + +$logPath = Start-BakNRetLog -Directory $logDir -Prefix 'backup' +$runStartedAt = Get-Date +Write-BakNRetLog "日志文件:$logPath" +Write-BakNRetLog "备份目录:$BackupDir" +Write-BakNRetLog ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' })) + +if (-not (Test-BakNRetAdministrator)) { + Write-BakNRetLog '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN +} +# 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。 +# -DryRun 不取锁:它一个字节都不写,没必要被正在跑的备份挡在外面。 +$runLock = $null +if (-not $DryRun) { + $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 +} + +# ============================================================================ +# 准备 +# ============================================================================ + +if (-not (Test-Path -LiteralPath $BackupDir)) { + New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null + Write-BakNRetLog "创建备份目录: $BackupDir" -Level DEBUG +} + +if (-not (Test-Path -LiteralPath $BackupListPath)) { + $template = "# BackupList.txt`n" + + "# 语法: [+|-] <软件名 或 绝对路径> [:: <路径>] [:- <排除模式>[,<排除模式>...]] [:+ <追加项>[,<追加项>...]] [:encrypt | :!encrypt] [@ ='<值>'] [# 说明]`n" + + "# 示例: Edge`n" + + "# %UserProfile%\.ssh :encrypt`n" + + "# 完整语法见 README 与 BackupList.txt 自身的注释。`n" + [System.IO.File]::WriteAllText($BackupListPath, $template, [System.Text.UTF8Encoding]::new($true)) + Write-BakNRetLog '模板 BackupList.txt 已创建,请编辑后重试。' -Level INFO + Stop-BakNRetLog + exit 0 +} + +$tool = Resolve-BakNRetCompressionTool +if (-not $tool) { + Write-BakNRetLog '没有找到可用的压缩工具。' -Level ERROR + Stop-BakNRetLog + exit 1 +} + +$toolVersion = try { + $info = (Get-Item -LiteralPath $tool.Command -ErrorAction Stop).VersionInfo + if ($info.ProductVersion) { $info.ProductVersion } elseif ($info.FileVersion) { $info.FileVersion } else { $null } +} +catch { $null } +Write-BakNRetLog ("压缩工具:{0}{1}" -f $tool.Name, $(if ($toolVersion) { "($toolVersion)" } else { '' })) + +$manifest = Read-BakNRetManifest -Path $manifestPath +$manifest.compressor = [pscustomobject]@{ name = $tool.Name; command = $tool.Command; extension = $tool.Extension; version = $toolVersion } + +$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 +$encryptAll = [bool]$script:Config.Encryption.Enabled +$showToolOutput = (-not $QuietTool) -and ($script:Config.ToolOutput -ne 'quiet') +$toolQuietArgument = if ($showToolOutput) { @() } else { @('-bso0', '-bsp0') } + +$lines = Get-Content -Encoding UTF8 -LiteralPath $BackupListPath +$seenBaseNames = @{} +$processed = 0; $skipped = 0; $failed = 0; $planned = 0 +$securityErrorCount = 0 # 有条目"安全描述符里有读不到的对象" +$securityFailed = 0 # 有条目"安全描述符完全没存下来" +$failures = @() +$freeSpaceGB = Get-BakNRetFreeSpaceGB -Path $BackupDir +if ($freeSpaceGB -ge 0) { + Write-BakNRetLog ("备份目录所在卷剩余空间:{0} GB" -f $freeSpaceGB) + if ($freeSpaceGB -lt $script:Config.MinFreeSpaceGB) { + Write-BakNRetLog ("剩余空间低于阈值 {0} GB,大条目可能失败" -f $script:Config.MinFreeSpaceGB) -Level WARN + } +} + + + + +# 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason } +# +# 归档内容由调用方决定:它已经用 New-BakNRetArchiveStaging 把每个归档项按"归档内的名字" +# 挂进了 $StagingRoot(目录走 junction、文件走硬链接/复制),所以这里只做三件事: +# 1. 以暂存目录为工作目录调用压缩工具,把项名加进去; +# 2. 用 7z t 校验,并核对多 Slot 条目的每个顶层名字都真的在包里; +# 3. 有警告时按保护策略决定是否原子替换。 +function Invoke-BackupItem { + param( + [Parameter(Mandatory = $true)][array]$SourceItems, + [Parameter(Mandatory = $true)][string]$StagingRoot, + [Parameter(Mandatory = $true)][string]$FinalPath, + [string[]]$ExcludePatterns = @(), + [switch]$UseEncryption, + [switch]$ProtectPrevious, + [switch]$AcceptWarnings + ) + + $tempPath = "$FinalPath.tmp$($tool.Extension)" + if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue } + + $warnings = $false + $lastExitCode = 0 + $itemNames = @($SourceItems | ForEach-Object { [string]$_.ArchivePath }) + $realPaths = @($SourceItems | ForEach-Object { [string]$_.RealPath }) + + try { + if ($SourceItems.Count -eq 0) { + return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '没有可打包的源目录' } + } + + if ($tool.Name -eq '7z') { + $optimized = Get-Optimized7zArgument -SourcePath $realPaths -Level $script:Config.CompressionLevel + $argument = @($optimized.Argument) + $toolQuietArgument + @($ExcludePatterns) + + if ($UseEncryption) { + if (-not $password) { + return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(见 README「加密」)' } + } + $argument += "-p$password" + if ($script:Config.Encryption.EncryptHeaders) { $argument += '-mhe=on' } + } + + $argument += $tempPath + $argument += $itemNames + + $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot + $lastExitCode = $exitCode + # 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败 + if ($exitCode -ne 0 -and $exitCode -ne 1) { + return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" } + } + if ($exitCode -eq 1) { $warnings = $true } + } + elseif ($tool.Name -eq 'RAR') { + $argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + @($ExcludePatterns) + if ($UseEncryption) { + if (-not $password) { + return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令' } + } + $argument += "-p$password" + } + $argument += $tempPath + $argument += $itemNames + + $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot + $lastExitCode = $exitCode + if ($exitCode -ne 0) { + return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" } + } + } + else { + if ($UseEncryption) { + return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉加密' } + } + # Compress-Archive 没有排除开关,也没有加密;这是降级路径,只保证内容完整。 + # 暂存目录里的名字就是归档内的名字,所以布局与 7z 分支一致。 + $fullPaths = @($SourceItems | ForEach-Object { Join-Path $StagingRoot $_.ArchivePath }) + Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force + } + + if (-not (Test-Path -LiteralPath $tempPath)) { + return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '压缩结束但没有生成临时归档' } + } + + # 校验:确认归档可读且内容 CRC 正确 + if ($script:Config.VerifyArchive -and $tool.Name -eq '7z') { + $verifyArgument = @('t', '-bso0', '-bsp0') + if ($UseEncryption -and $password) { $verifyArgument += "-p$password" } + $verifyArgument += $tempPath + + $verifyCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $verifyArgument -WorkingDirectory $StagingRoot + if ($verifyCode -ne 0) { + Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue + return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" } + } + Write-BakNRetLog '归档校验通过(7z t)' -Level DEBUG + + # 多个 Slot / 追加项时确认每一个顶层名字都真的进了归档:7z 的"警告"可能只体现在某一部分上 + if ($SourceItems.Count -gt 1) { + $listed = @(Get-BakNRetArchiveTopLevelNames -ArchivePath $tempPath -SevenZip $tool.Command -Password $(if ($UseEncryption) { $password } else { $null })) + if ($listed.Count -gt 0) { + $expected = @($SourceItems | ForEach-Object { [string]$_.TopName } | Select-Object -Unique) + $absent = @($expected | Where-Object { $_ -notin $listed }) + if ($absent.Count -gt 0) { + Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue + return [pscustomobject]@{ Ok = $false; ExitCode = $lastExitCode; Warnings = $true; Reason = ("归档缺少这些顶层条目:{0}(归档内实际有:{1})" -f ($absent -join '、'), ($listed -join '、')) } + } + } + } + } + + # 关键保护:压缩工具报了警告(通常是有文件被占用读不到)时, + # 新归档是**不完整**的。用不完整归档覆盖已有的完整归档 = 静默丢数据。 + # 实测:Edge 运行时备份,118 个文件读不到,其中包含 Login Data(密码)、 + # Cookies、History、Web Data —— 恰恰是最不可再生的那部分。 + if ($warnings -and $ProtectPrevious -and -not $AcceptWarnings) { + Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue + return [pscustomobject]@{ + Ok = $false + ExitCode = $lastExitCode + Warnings = $true + Reason = '压缩工具报告有文件被占用而读不到,新归档不完整。为避免覆盖现有的完整归档已保留旧归档;请关闭占用该目录的程序后重跑,或确认可以接受后用 -AcceptWarnings 强制覆盖' + } + } + + Move-BakNRetArchiveIntoPlace -TempPath $tempPath -DestinationPath $FinalPath + return [pscustomobject]@{ Ok = $true; ExitCode = 0; Warnings = $warnings; Reason = $null } + } + catch { + if (Test-Path -LiteralPath $tempPath) { + Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue + } + return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "$_" } + } +} + +# ============================================================================ +# 备份前空间预估(只读,不写任何东西) +# ============================================================================ +# 只做一件事:动手之前告诉用户"这次大概要写多少、盘够不够"。 +# 不做更复杂的占用控制 —— 真正拦住某个条目的是主循环里的逐条目守卫。 +# +# 模型(按清单顺序模拟一遍): +# * 每个要重打的条目会先写一份**临时**归档,这时旧归档还在,所以那一刻占用的 +# 是"当前累计净增量 + 本次预估"; +# * 原子替换之后,本次净增量 = 预估 - 现有归档大小(换成更小的归档会把空间还回来)。 +# 于是:峰值新增 = max_i( 第 i 项之前的累计净增量 + 第 i 项的预估大小 )。 +$spacePlan = @() +$spaceSkipped = 0 +$spaceNoSource = 0 + +foreach ($planLine in $lines) { + $planItem = ConvertFrom-BackupListLine -Line $planLine + if (-not $planItem) { continue } + + $planDisplayPath = $planItem.Path + $planResolved = Resolve-BakNRetBackupEntry -Entry $planItem -CatalogPath $catalogPath + if (-not $planResolved.BaseName) { continue } + if (-not (Test-BakNRetItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName -Only $Only -Skip $Skip)) { continue } + if ($planResolved.Direction -eq 'restore') { continue } + if ($planResolved.Blocking) { continue } + + $planItems = @($planResolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath }) + if ($planItems.Count -eq 0) { $spaceNoSource++; continue } + + $planSourceBytes = [int64]0 + $planSourceFiles = 0 + $planLatest = $null + foreach ($planSource in $planItems) { + $planSummary = Get-BakNRetFolderSummary -FolderPath $planSource.RealPath + $planSourceBytes += [int64]$planSummary.TotalSize + $planSourceFiles += [int]$planSummary.FileCount + if ($planSummary.LatestModifiedTime -and (-not $planLatest -or $planSummary.LatestModifiedTime -gt $planLatest)) { + $planLatest = $planSummary.LatestModifiedTime + } + } + + $planArchiveName = $planResolved.BaseName + $tool.Extension + $planArchivePath = Join-Path $BackupDir $planArchiveName + $planExistingItem = if (Test-Path -LiteralPath $planArchivePath) { Get-Item -LiteralPath $planArchivePath } else { $null } + $planExistingBytes = if ($planExistingItem) { [int64]$planExistingItem.Length } else { [int64]0 } + + # 与主循环同一套判断:源没更新就不会重打 + if (-not $Force -and $planExistingItem -and $planLatest -and $planLatest -le $planExistingItem.LastWriteTime) { + $spaceSkipped++ + continue + } + + $planEstimate = if ($planExistingBytes -gt 0) { + [int64][math]::Min([double]$planSourceBytes, [double]$planExistingBytes * 1.3) + } + else { + # 没有历史归档可比时按"完全不压缩"的悲观值估,宁可报多不报少 + $planSourceBytes + } + + $spacePlan += [pscustomobject]@{ + Name = $planResolved.BaseName + Source = $planDisplayPath + Files = $planSourceFiles + SourceBytes = $planSourceBytes + Existing = $planExistingBytes + Estimate = $planEstimate + } +} + +$freeNowGB = Get-BakNRetFreeSpaceGB -Path $BackupDir + +if ($spacePlan.Count -eq 0) { + Write-BakNRetLog '备份前空间预估:本次没有需要重打的条目(源未更新或源不存在),不会写入新归档' -Level INFO +} +else { + $spacePeak = [double]0 + $spaceCumulative = [double]0 + foreach ($plan in $spacePlan) { + $spacePeak = [math]::Max($spacePeak, $spaceCumulative + $plan.Estimate) + $spaceCumulative += ($plan.Estimate - $plan.Existing) + } + $peakGB = $spacePeak / 1GB + $netGB = $spaceCumulative / 1GB + $estimateGB = ((($spacePlan | Measure-Object -Property Estimate -Sum).Sum)) / 1GB + $existingGB = ((($spacePlan | Measure-Object -Property Existing -Sum).Sum)) / 1GB + + Write-BakNRetLog '==== 备份前空间预估(只读)====' -Level INFO + Write-BakNRetLog (" 目标卷可用空间:{0} GB" -f $freeNowGB) + Write-BakNRetLog (" 本次要重打 {0} 个条目(另有 {1} 个源未更新会跳过、{2} 个源不存在)" -f $spacePlan.Count, $spaceSkipped, $spaceNoSource) + Write-BakNRetLog (" 新归档合计约 {0} GB;其中会替换掉的旧归档 {1} GB" -f [math]::Round($estimateGB, 2), [math]::Round($existingGB, 2)) + + foreach ($plan in ($spacePlan | Sort-Object Estimate -Descending | Select-Object -First 15)) { + Write-BakNRetLog (" - {0,-22} 源 {1,8:N1} MB / {2,6} 文件 现有 {3,7:N1} MB 预估 {4,7:N1} MB" -f ` + $plan.Name, ($plan.SourceBytes / 1MB), $plan.Files, ($plan.Existing / 1MB), ($plan.Estimate / 1MB)) + } + if ($spacePlan.Count -gt 15) { + Write-BakNRetLog (" …… 另有 {0} 个条目未逐条列出" -f ($spacePlan.Count - 15)) + } + + Write-BakNRetLog (" 预计峰值新增占用:{0} GB(全程净增量 {1} GB)" -f [math]::Round($peakGB, 2), [math]::Round($netGB, 2)) + + if ($freeNowGB -lt 0) { + Write-BakNRetLog ' 结论:读不到目标卷可用空间,请自行确认是否够用' -Level WARN + } + elseif ($peakGB -le $freeNowGB) { + Write-BakNRetLog (" 结论:空间足够(预计用 {0} GB / 可用 {1} GB)" -f [math]::Round($peakGB, 2), $freeNowGB) -Level INFO + } + else { + Write-BakNRetLog (" 结论:空间可能不够!预计需要 {0} GB,可用只有 {1} GB,差 {2} GB" -f ` + [math]::Round($peakGB, 2), $freeNowGB, [math]::Round($peakGB - $freeNowGB, 2)) -Level WARN + Write-BakNRetLog ' 仍会继续执行:真正放不下的条目会被逐条目守卫跳过。建议先腾空间,或用 -Only / -Skip 分批备份。' -Level WARN + } + 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 + + if (-not $resolved.BaseName) { + $record = New-BakNRetItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $displayPath -Phase 'parse' + Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason '无法生成归档名' | Out-Null + $failed++; $failures += $displayPath + continue + } + + $baseName = $resolved.BaseName + $sourcePath = [Environment]::ExpandEnvironmentVariables($displayPath) + + if (-not (Test-BakNRetItemSelected -DisplayPath $displayPath -BaseName $baseName -Only $Only -Skip $Skip)) { + Write-BakNRetLog "跳过(未选中): $displayPath" -Level DEBUG + continue + } + + # 归档名登记必须在方向过滤**之前**:`-`(仅恢复)的条目不会产生归档, + # 但它对应的归档是有主的,不能被下游的孤儿审计当成没人要的孤儿。 + # 备份列表里写重了会生成两个同名归档、互相覆盖 —— 直接报错,不猜。 + if ($seenBaseNames.ContainsKey($baseName)) { + $reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖" + Write-BakNRetLog "失败: $displayPath,$reason" -Level ERROR + $record = New-BakNRetItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup' + Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $reason | Out-Null + $failed++; $failures += $displayPath + continue + } + $seenBaseNames[$baseName] = $displayPath + + if ($resolved.Direction -eq 'restore') { + Write-BakNRetLog "跳过(行首 -,仅恢复): $displayPath" -Level INFO + continue + } + + $record = New-BakNRetItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup' + $record.archive = $baseName + $tool.Extension + if ($resolved.CatalogEntry) { $record.catalog = $resolved.CatalogEntry.Path } + $finalPath = Join-Path $BackupDir $record.archive + + # root= 是历史标记:包内的一层目录现在由名录里的 Slot 决定,这里只告警不静默忽略。 + if (@($item.Flags | Where-Object { $_ -like 'root=*' }).Count -gt 0) { + Write-BakNRetLog "警告: $displayPath 使用了 root= 标记。归档内的一层目录现在由 Slot 决定,该标记已废弃,本次忽略" -Level WARN + } + + # 归档内路径冲突:明确失败,绝不把两块内容静默搅进同一棵树 + if ($resolved.Blocking) { + Write-BakNRetLog "失败: $displayPath,$($resolved.Blocking)" -Level ERROR + Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $resolved.Blocking | Out-Null + $failed++; $failures += $displayPath + continue + } + + # 动手之前先把"这条会打包哪些目录、归档里叫什么、排除了什么、为什么"讲清楚 + $planListExcludes = @() + $planCatalogExcludes = @() + if ($resolved.HasExcludeOverride) { + $planListExcludes = @($resolved.ExcludePatterns) + } + else { + $planCatalogExcludes = @($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique) + } + Write-BakNRetBackupEntryPlan -Resolved $resolved -DisplayPath $displayPath ` + -ListExcludes $planListExcludes -CatalogExcludes $planCatalogExcludes ` + -ConfigExcludes @($script:Config.DefaultExcludes) -Comment $item.Comment + + # Items 为空 = 解析不出任何归档项(名录里没这个软件名、或路径拆不出末级名)。 + # 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值, + # 但 Items 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的存在性检查统一处理。 + if ($resolved.Items.Count -eq 0) { + $reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' } + Write-BakNRetLog "跳过: $displayPath,$reason" -Level WARN + Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'missing-source' -Reason $reason | Out-Null + $skipped++ + continue + } + + # 源存在性检查必须在 Get-BakNRetFolderSummary / Get-Item 之前: + # 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。 + $missingItems = @($resolved.Items | Where-Object { -not (Test-Path -LiteralPath $_.RealPath) }) + + if ($missingItems.Count -ge $resolved.Items.Count) { + $missingText = @($missingItems | ForEach-Object { $_.RealPath }) -join ';' + Write-BakNRetLog "跳过: $displayPath,源路径不存在" -Level WARN + Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + $missingText) | Out-Null + $skipped++ + continue + } + + if ($missingItems.Count -gt 0) { + Write-BakNRetLog ("警告: {0} 有 {1} 个归档项的源路径不存在,本次只打包存在的部分:{2}" -f ` + $displayPath, $missingItems.Count, (@($missingItems | ForEach-Object { $_.RealPath }) -join ';')) -Level WARN + } + + # 归档里只放真实存在的源 + $liveItems = @($resolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath }) + + # 归档内的顶层条目名 = 真实存在的归档项在包内的第一层名字(Slot 名 / 源目录名)。 + # 这里记录可核对的事实,备份成功后还会用 Get-BakNRetArchiveTopLevelNames 与归档内容对账。 + $record.roots = @($liveItems | ForEach-Object { $_.TopName } | Select-Object -Unique) + + # 恢复端要知道每个项在归档里是目录还是文件:记在 manifest 里, + # 这样目标机器上目标还不存在(全新恢复)时也判断得出来。 + $record.layouts = @($liveItems | ForEach-Object { + [ordered]@{ + name = $_.ArchivePath + kind = $(if ($_.IsFile) { 'file' } else { 'dir' }) + } + }) + + $primarySource = $liveItems[0].RealPath + if ([string]::IsNullOrWhiteSpace($primarySource)) { + Write-BakNRetLog "跳过: $displayPath,无法确定主源路径" -Level WARN + Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'invalid-path' -Reason '归档项没有可用路径' | Out-Null + $skipped++ + continue + } + + $summary = Get-BakNRetFolderSummary -FolderPath $primarySource + # 从第二个归档项开始累加。刻意不用 `$liveItems[1..($liveItems.Count-1)]`: + # 只有一项时 `1..0` 会退化成 `1,0`,把同一份源数两遍(旧实现的隐蔽 bug)。 + for ($index = 1; $index -lt $liveItems.Count; $index++) { + $extra = Get-BakNRetFolderSummary -FolderPath $liveItems[$index].RealPath + $summary.FileCount += $extra.FileCount + $summary.TotalSize += $extra.TotalSize + if ($extra.LatestModifiedTime -and $extra.LatestModifiedTime -gt $summary.LatestModifiedTime) { + $summary.LatestModifiedTime = $extra.LatestModifiedTime + } + } + $record.sourceFiles = $summary.FileCount + $record.sourceBytes = $summary.TotalSize + + $archiveExists = Test-Path -LiteralPath $finalPath + $archiveItem = if ($archiveExists) { Get-Item -LiteralPath $finalPath } else { $null } + + Write-BakNRetLog ("开始备份: {0} -> {1}({2} 个文件,{3} MB)" -f $displayPath, $record.archive, $summary.FileCount, [math]::Round(($summary.TotalSize / 1MB), 2)) + + # 空目录时 Get-BakNRetFolderSummary 拿不到任何条目,回退到源自身的修改时间 + # (源路径上面已经确认存在,这里的 Get-Item 不会再抛异常) + $sourceLatest = $summary.LatestModifiedTime + if (-not $sourceLatest) { + $sourceLatest = (Get-Item -LiteralPath $primarySource -Force).LastWriteTime + } + + if (-not $Force -and $archiveItem -and $sourceLatest -and $sourceLatest -le $archiveItem.LastWriteTime) { + Write-BakNRetLog "跳过: $displayPath,源目录未更新" -Level INFO + $record.archiveBytes = $archiveItem.Length + Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'skip-unchanged' -Reason ('源最新修改时间 {0} 不晚于归档时间 {1}' -f $sourceLatest, $archiveItem.LastWriteTime) | Out-Null + $skipped++ + continue + } + + # 空间守卫:临时归档与正式归档会同时存在,因此按"新归档预估大小"要求剩余空间 + $estimatedGB = $summary.TotalSize / 1GB + if ($archiveItem) { + $archiveGB = $archiveItem.Length / 1GB + $estimatedGB = [math]::Min($estimatedGB, $archiveGB * 1.3) + } + $freeSpaceGB = Get-BakNRetFreeSpaceGB -Path $BackupDir + if ($freeSpaceGB -ge 0 -and $estimatedGB -gt 0 -and $freeSpaceGB -lt $estimatedGB) { + $reason = ('剩余空间 {0} GB 不足以写入预估 {1} GB 的新归档' -f $freeSpaceGB, [math]::Round($estimatedGB, 2)) + Write-BakNRetLog "失败: $displayPath,$reason" -Level ERROR + Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $reason | Out-Null + $failed++; $failures += $displayPath + continue + } + + if ($DryRun) { + Write-BakNRetLog ("[试运行] 将打包 {0} -> {1}" -f $sourcePath, $finalPath) -Level INFO + $record.reason = '试运行,未执行压缩' + Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'planned' -Reason '试运行,未执行压缩' | Out-Null + $planned++ + continue + } + + $useEncryption = $encryptAll -or [bool]$resolved.Encrypt + $record.encrypted = [bool]$useEncryption + $startedAt = Get-Date + $record.attemptedAt = $startedAt.ToString('o') + + # 排除参数:条目级 `:-` / `@ Exclude` 覆盖优先,否则用名录里各 Slot 自己的 Exclude; + # 再叠上 BackupConfig.psd1 的 DefaultExcludes。 + # 模式先用 `<顶层名>\` 前缀分配到对应归档项上(`Scoop :- GlobalPersist\steam`), + # 再逐项翻译成 7z 的 -x! / -xr!,最后去重合并成一次调用的参数。 + $patternSource = if ($resolved.HasExcludeOverride) { + @($resolved.ExcludePatterns) + } + else { + @($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique) + } + $allPatterns = @($script:Config.DefaultExcludes) + $patternSource + $scopeMap = Split-BakNRetPatternScope -Items $liveItems -Patterns $allPatterns + + $excludeLists = @() + $excludeError = $null + for ($index = 0; $index -lt $liveItems.Count; $index++) { + $expanded = Get-BakNRetExcludeArgument -Item $liveItems[$index] -Patterns @($scopeMap[$index]) + if ($expanded.Error) { $excludeError = $expanded.Error } + $excludeLists += , @($expanded.Arguments) + } + $effectiveExcludes = @(Merge-BakNRetExcludeArgument -ArgumentLists $excludeLists) + + if ($excludeError) { + Write-BakNRetLog "失败: $displayPath,$excludeError" -Level ERROR + Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $excludeError | Out-Null + $failed++; $failures += $displayPath + continue + } + + # 只有在"现有归档是完整的"时才值得保护它。没有 manifest 记录 + # (本次重构之前留下的归档)时按完整处理——宁可保守。 + $protectPrevious = [bool]$archiveExists + if ($archiveExists -and $manifest.items.Contains($baseName)) { + $previousRecord = $manifest.items[$baseName] + if (($previousRecord.PSObject.Properties.Name -contains 'warnings') -and $previousRecord.warnings) { + $protectPrevious = $false + } + } + + # 归档内的一层目录名由 Slot / 追加项的归档内路径决定,所以先把它们以正确的名字 + # 挂进暂存目录(junction / 硬链接),再让压缩工具以暂存目录为工作目录打包。 + $stagingRoot = $null + try { + $stagingRoot = New-BakNRetArchiveStaging -Items $liveItems + $result = Invoke-BackupItem -SourceItems $liveItems -StagingRoot $stagingRoot ` + -FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption ` + -ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings + } + catch { + $result = [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "准备归档内容失败:$_" } + } + finally { + Remove-BakNRetArchiveStaging -Root $stagingRoot + } + + $record.exitCode = $result.ExitCode + $record.attemptWarnings = [bool]$result.Warnings + $record.verified = [bool]$result.Ok + $record.durationSec = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 1) + + if (-not $result.Ok) { + Write-BakNRetLog "备份失败: $displayPath,$($result.Reason)" -Level ERROR + Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $result.Reason | Out-Null + $failed++; $failures += $displayPath + continue + } + + $written = Get-Item -LiteralPath $finalPath + $record.archiveBytes = $written.Length + if ($result.Warnings) { + Write-BakNRetLog "备份成功(压缩工具报告了警告,可能有文件被占用而没打进归档): $displayPath" -Level WARN + Write-BakNRetLog ' 该归档在 manifest 里标记为 warnings=true;如果以后现有归档是完整的,会拒绝被它覆盖' -Level WARN + } + else { + Write-BakNRetLog "备份成功: $baseName" -Level INFO + } + + # ------------------------------------------------------------------ + # 安全描述符(属主 / ACL)写进旁挂文件 <归档名>.acl.json + # ------------------------------------------------------------------ + # 归档格式装不下它(7z 的 -sni 官方说明是"只能写进 WIM 归档"),所以放在归档旁边, + # 和归档一样先写 .tmp 再原子替换。属主必须一起存:C:\ProgramData 的 ACL 里有 + # (A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL + # 而 CREATOR OWNER 是访问检查时才替换的占位符 —— 替换成"被检查对象的属主"。 + # 只回放 ACE 文本、不恢复属主,等于把"谁创建的东西谁有全权"里的"谁"换成跑脚本的账户。 + $securityMode = [string]$script:Config.Security.Mode + $securityFatal = $false + if ($securityMode -and ($securityMode -ne 'Off')) { + $sidecarName = "$baseName.acl.json" + $sidecarPath = Join-Path $BackupDir $sidecarName + try { + $capture = Get-BakNRetSecurityRecords -Items $liveItems -ScopeMap $scopeMap -Mode $securityMode ` + -IncludeSacl:([bool]$script:Config.Security.IncludeSacl) + Save-BakNRetSecuritySidecar -Path $sidecarPath -Records $capture.Records -Mode $securityMode ` + -IncludeSacl:([bool]$script:Config.Security.IncludeSacl) ` + -Errors $capture.Errors -Scanned $capture.Scanned | Out-Null + + $record.security = [ordered]@{ + file = $sidecarName + mode = $securityMode + objects = $capture.Kept + scanned = $capture.Scanned + errors = $capture.Errors + capturedAt = (Get-Date).ToString('o') + } + Write-BakNRetLog ("安全描述符:{0} 个对象写进 {1}(扫描 {2} 个,读不到 {3} 个)" -f ` + $capture.Kept, $sidecarName, $capture.Scanned, $capture.Errors) -Level INFO + + if ($capture.Errors -gt 0) { + $securityErrorCount++ + $unreadable = @($capture.Records | Where-Object { $_.e } | Select-Object -First 3 -ExpandProperty p) + Write-BakNRetLog (" {0} 个对象的安全描述符读不到(恢复后它们的属主/ACL 会是新建对象的默认值),例如:{1}" -f ` + $capture.Errors, ($unreadable -join '、')) -Level WARN + } + } + catch { + $securityFailed++ + Write-BakNRetLog "安全描述符采集/写盘失败:$displayPath —— $_" -Level WARN + $record.security = [ordered]@{ file = $sidecarName; error = "$_" } + if ([bool]$script:Config.Security.FailOnError) { $securityFatal = $true } + } + + if ($securityFatal) { + Write-BakNRetLog "失败: $displayPath,归档已替换,但安全描述符没能存下来(Security.FailOnError = \$true)" -Level ERROR + Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason '归档已替换,但安全描述符没能存下来' | Out-Null + $failed++; $failures += $displayPath + continue + } + } + + if ($Hash -or $script:Config.ComputeHash) { + $record.sha256 = (Get-FileHash -LiteralPath $finalPath -Algorithm SHA256).Hash + Write-BakNRetLog "SHA256: $($record.sha256)" -Level DEBUG + } + + if ($Snapshot -or $script:Config.Snapshot.Enabled) { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $target = Join-Path (Join-Path $snapshotDir $stamp) $record.archive + $targetDir = Split-Path -Parent $target + if (-not (Test-Path -LiteralPath $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null } + Copy-Item -LiteralPath $finalPath -Destination $target -Force + Write-BakNRetLog "已留存快照: $target" -Level INFO + } + + Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'backed-up' -Reason $null -ArchiveWarnings $result.Warnings | Out-Null + $processed++ +} + +# ============================================================================ +# 收尾 +# ============================================================================ + +if ($DryRun) { + Write-BakNRetLog '试运行:manifest 与归档都不会被写入' -Level INFO +} +else { + # manifest 里写了 archive 的记录,磁盘上就必须真有那个文件 + $clearedArchiveFields = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $BackupDir + if ($clearedArchiveFields.Count -gt 0) { + Write-BakNRetLog ("已清空 {0} 条记录里指向不存在归档的 archive 字段:{1}" -f $clearedArchiveFields.Count, ($clearedArchiveFields -join '、')) -Level WARN + } + + Write-BakNRetManifest -Path $manifestPath -Manifest $manifest | Out-Null + Write-BakNRetLog "manifest 已更新:$manifestPath" -Level DEBUG +} + +# 孤儿归档审计:磁盘上有、但**当前清单里任何条目都不指向**的归档。 +# Restore.ps1 是按清单条目去找归档的,所以孤儿是**恢复不到**的 —— 必须显式点名, +# 免得下次清理时把还有用的归档当垃圾删掉(重构前那个 2.8 GB 的归档就是这么成孤儿的)。 +# +# 判据只用清单,**不能用 manifest**:manifest 会一直留着历史条目, +# 于是"从清单里删掉某个条目(或把它合并进另一个条目)"留下的归档会被历史记录遮住, +# 审计就永远不会报——那正是最需要报出来的情况。 +# $seenBaseNames 在方向过滤之前就登记,所以"行首 + / -"的条目也算有主。 +# 只在整表运行时做:带 -Only/-Skip 时未选中的条目本来就不在 $seenBaseNames 里, +# 那种情况下报出来的全是假孤儿。 +if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) { + $known = @{} + foreach ($key in $seenBaseNames.Keys) { $known[$key] = $true } + + $orphanArchives = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') -and -not $known.ContainsKey($_.BaseName) }) + + if ($orphanArchives.Count -gt 0) { + Write-BakNRetLog ("发现 {0} 个孤儿归档(当前清单里没有任何条目指向,恢复不到,注意别误删):" -f $orphanArchives.Count) -Level WARN + foreach ($orphan in $orphanArchives) { + $inManifest = $manifest.items.Contains($orphan.BaseName) + Write-BakNRetLog (" - {0}({1:N1} MB,{2}){3}" -f $orphan.Name, ($orphan.Length / 1MB), $orphan.LastWriteTime, $(if ($inManifest) { ';manifest 里还留着它的历史记录,但清单里已经没有了' } else { '' })) -Level WARN + } + } + else { + Write-BakNRetLog '孤儿归档审计:没有发现(所有归档都有清单条目指向)' -Level DEBUG + } +} + +$counterText = @{ 成功 = $processed; 跳过 = $skipped; 失败 = $failed } +if ($DryRun) { $counterText['试运行计划'] = $planned } +Write-BakNRetRunSummary -Mode 'backup' -Manifest $manifest -StartedAt $runStartedAt -Failures $failures -Counters $counterText -OrphanArchives @($orphanArchives | Where-Object { $_ }) -SecurityFailed $securityFailed -SecurityErrorCount $securityErrorCount + +$logPath = Get-BakNRetLogPath +if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO } +Exit-BakNRetRunLock -Lock $runLock +Stop-BakNRetLog + +if ($failed -gt 0) { exit 1 } +exit 0 diff --git a/Backup.ps1 b/Backup.ps1 index 0c88c53..bc13e00 100644 --- a/Backup.ps1 +++ b/Backup.ps1 @@ -1,840 +1,64 @@ <# .SYNOPSIS - 按 BackupList.txt 执行备份。 + 已改名:本脚本只是转发到 Backup-Data.ps1(这一层只保留一轮)。 .DESCRIPTION - 与旧版相比的核心变化: + 为什么留一层转发(ADR-0012):入口脚本是**外部接口** —— README 里有二十多处引用、有使用者的 + 肌肉记忆、tools\Register-BackupTask.ps1 里也可能已经注册过这个路径。内部实现改名断了会当场 + 报错;外部接口改名断了是**静默没用**,而备份工具"静默没用"是最不能接受的失败方式。 - 1. 退出码可靠 —— 不再用 Start-Process -PassThru(在 PowerShell 7.7.0-preview.4 上 - ExitCode 恒为 $null,会把成功的压缩判成失败),改用 Invoke-ExternalCommand。 - 2. 先写临时归档 → 校验 → 原子替换。中断或断电只会留下 .tmp 文件, - 不会污染正式归档;也不会再出现"半个归档被下次增量续写"的情况。 - 3. 不再使用 7z 的 u(更新)模式。7z 默认是固实压缩,u 本来就要重压大部分数据, - 收益极小,却让排除规则和删除操作永远无法生效(旧归档里会一直留着已删文件)。 - 现在每次都从零打包,于是"排除规则改动"和"源里删掉的文件"都能真正反映到归档。 - 4. 每个条目写进 manifest.json:源、归档、时间、退出码、校验结果、失败原因。 - 跳过和失败从此有据可查,而不是只剩一行滚过去的控制台告警。 - 5. 结尾按失败数 exit,并写日志文件,计划任务能正确判断成败。 - 6. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。 + 为什么用子进程、而不是 `& $target`:实测 `& script.ps1` 里子脚本的 exit **不会**把退出码传到 + 父脚本的 $LASTEXITCODE —— 垫片会让失败变成"成功"(错配置时返回 0,被调用脚本返回 1),而计划 + 任务正是靠退出码判断成败。 - 与 SoftwareCatalog.psd1 的 Slot 结构配套: - * 一个软件 = 一个归档,归档内是 `\<该 Path 的内容>`; - * 打包前用暂存目录 + junction / 硬链接把 Slot 名变成包里真实的目录名 - (7z 没有"入库时改名"的能力),打包后立刻拆掉暂存目录; - * 清单行首 `+` = 仅备份、`-` = 仅恢复。 + 为什么重定向之后要**自己转发**:父进程的 stdout 常常是管道(测试与使用者的管道都在解析入口的 + 输出),而 .NET 起的进程默认只继承控制台、不继承那个管道 —— 不重定向时子进程的输出就到不了 + 调用方(实测红过)。所以显式重定向,再用**异步读**把两个流读出来转发(同步先读 stdout 再读 + stderr 会在管道写满时死锁)。代价是 stdout/stderr 的相对顺序不再保留 —— 这也正是这层垫片 + 只留一轮的原因之一。 + + 为什么导入模块时临时压掉 verbose:调用方可能给入口传 -Verbose(测试就是这么拿到详细日志的), + 那样 Import-Module 会多打一行 "VERBOSE: Loading module from path ..." —— 而测试是**解析子进程 + 输出**做断言的,多这么一行就会把它顶掉。只压这一句,$Rest 里的 -Verbose 仍会原样转发。 + + 这一层下一轮删。想用新名字就直接调 Backup-Data.ps1。 #> - [CmdletBinding()] param( - [Parameter()] - [string]$BackupListPath, - - [Parameter()] - [string]$BackupDir, - - [Parameter()] - [string]$ConfigPath, - - [Parameter()] - [string]$KeyFile, - - # 只处理匹配这些通配符的条目(匹配原始路径或归档基础名) - [Parameter()] - [string[]]$Only = @(), - - # 跳过匹配这些通配符的条目 - [Parameter()] - [string[]]$Skip = @(), - - # 忽略"源未更新"判断,强制重新打包 - [Parameter()] - [switch]$Force, - - # 成功后在 snapshots 目录留一份带时间戳的副本 - [Parameter()] - [switch]$Snapshot, - - # 额外计算归档的 SHA256 写入 manifest(大归档会更慢) - [Parameter()] - [switch]$Hash, - - # 抑制压缩工具的实时输出(日志与 manifest 不受影响) - [Parameter()] - [switch]$QuietTool, - - # 允许用"有警告"的不完整归档覆盖已有的完整归档(默认拒绝) - [Parameter()] - [switch]$AcceptWarnings, - - # 只打印将要做什么,不实际写入 - [Parameter()] - [switch]$DryRun + [Parameter(ValueFromRemainingArguments = $true)]$Rest ) $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' } - -# ============================================================================ -# 载入依赖 -# ============================================================================ - -$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 - - -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 -$snapshotDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.SnapshotDir -Root $PSScriptRoot -$catalogPath = Resolve-BakNRetCatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot -$manifestPath = Join-Path $BackupDir 'manifest.json' - -$logPath = Start-BakNRetLog -Directory $logDir -Prefix 'backup' -$runStartedAt = Get-Date -Write-BakNRetLog "日志文件:$logPath" -Write-BakNRetLog "备份目录:$BackupDir" -Write-BakNRetLog ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' })) - -if (-not (Test-BakNRetAdministrator)) { - Write-BakNRetLog '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN -} -# 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。 -# -DryRun 不取锁:它一个字节都不写,没必要被正在跑的备份挡在外面。 -$runLock = $null -if (-not $DryRun) { - $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 -} - -# ============================================================================ -# 准备 -# ============================================================================ - -if (-not (Test-Path -LiteralPath $BackupDir)) { - New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null - Write-BakNRetLog "创建备份目录: $BackupDir" -Level DEBUG -} - -if (-not (Test-Path -LiteralPath $BackupListPath)) { - $template = "# BackupList.txt`n" + - "# 语法: [+|-] <软件名 或 绝对路径> [:: <路径>] [:- <排除模式>[,<排除模式>...]] [:+ <追加项>[,<追加项>...]] [:encrypt | :!encrypt] [@ ='<值>'] [# 说明]`n" + - "# 示例: Edge`n" + - "# %UserProfile%\.ssh :encrypt`n" + - "# 完整语法见 README 与 BackupList.txt 自身的注释。`n" - [System.IO.File]::WriteAllText($BackupListPath, $template, [System.Text.UTF8Encoding]::new($true)) - Write-BakNRetLog '模板 BackupList.txt 已创建,请编辑后重试。' -Level INFO - Stop-BakNRetLog - exit 0 -} - -$tool = Resolve-BakNRetCompressionTool -if (-not $tool) { - Write-BakNRetLog '没有找到可用的压缩工具。' -Level ERROR - Stop-BakNRetLog - exit 1 -} - -$toolVersion = try { - $info = (Get-Item -LiteralPath $tool.Command -ErrorAction Stop).VersionInfo - if ($info.ProductVersion) { $info.ProductVersion } elseif ($info.FileVersion) { $info.FileVersion } else { $null } -} -catch { $null } -Write-BakNRetLog ("压缩工具:{0}{1}" -f $tool.Name, $(if ($toolVersion) { "($toolVersion)" } else { '' })) - -$manifest = Read-BakNRetManifest -Path $manifestPath -$manifest.compressor = [pscustomobject]@{ name = $tool.Name; command = $tool.Command; extension = $tool.Extension; version = $toolVersion } - -$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 -$encryptAll = [bool]$script:Config.Encryption.Enabled -$showToolOutput = (-not $QuietTool) -and ($script:Config.ToolOutput -ne 'quiet') -$toolQuietArgument = if ($showToolOutput) { @() } else { @('-bso0', '-bsp0') } - -$lines = Get-Content -Encoding UTF8 -LiteralPath $BackupListPath -$seenBaseNames = @{} -$processed = 0; $skipped = 0; $failed = 0; $planned = 0 -$securityErrorCount = 0 # 有条目"安全描述符里有读不到的对象" -$securityFailed = 0 # 有条目"安全描述符完全没存下来" -$failures = @() -$freeSpaceGB = Get-BakNRetFreeSpaceGB -Path $BackupDir -if ($freeSpaceGB -ge 0) { - Write-BakNRetLog ("备份目录所在卷剩余空间:{0} GB" -f $freeSpaceGB) - if ($freeSpaceGB -lt $script:Config.MinFreeSpaceGB) { - Write-BakNRetLog ("剩余空间低于阈值 {0} GB,大条目可能失败" -f $script:Config.MinFreeSpaceGB) -Level WARN - } -} - - - - -# 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason } -# -# 归档内容由调用方决定:它已经用 New-BakNRetArchiveStaging 把每个归档项按"归档内的名字" -# 挂进了 $StagingRoot(目录走 junction、文件走硬链接/复制),所以这里只做三件事: -# 1. 以暂存目录为工作目录调用压缩工具,把项名加进去; -# 2. 用 7z t 校验,并核对多 Slot 条目的每个顶层名字都真的在包里; -# 3. 有警告时按保护策略决定是否原子替换。 -function Invoke-BackupItem { - param( - [Parameter(Mandatory = $true)][array]$SourceItems, - [Parameter(Mandatory = $true)][string]$StagingRoot, - [Parameter(Mandatory = $true)][string]$FinalPath, - [string[]]$ExcludePatterns = @(), - [switch]$UseEncryption, - [switch]$ProtectPrevious, - [switch]$AcceptWarnings - ) - - $tempPath = "$FinalPath.tmp$($tool.Extension)" - if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue } - - $warnings = $false - $lastExitCode = 0 - $itemNames = @($SourceItems | ForEach-Object { [string]$_.ArchivePath }) - $realPaths = @($SourceItems | ForEach-Object { [string]$_.RealPath }) - - try { - if ($SourceItems.Count -eq 0) { - return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '没有可打包的源目录' } - } - - if ($tool.Name -eq '7z') { - $optimized = Get-Optimized7zArgument -SourcePath $realPaths -Level $script:Config.CompressionLevel - $argument = @($optimized.Argument) + $toolQuietArgument + @($ExcludePatterns) - - if ($UseEncryption) { - if (-not $password) { - return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(见 README「加密」)' } - } - $argument += "-p$password" - if ($script:Config.Encryption.EncryptHeaders) { $argument += '-mhe=on' } - } - - $argument += $tempPath - $argument += $itemNames - - $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot - $lastExitCode = $exitCode - # 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败 - if ($exitCode -ne 0 -and $exitCode -ne 1) { - return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" } - } - if ($exitCode -eq 1) { $warnings = $true } - } - elseif ($tool.Name -eq 'RAR') { - $argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + @($ExcludePatterns) - if ($UseEncryption) { - if (-not $password) { - return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令' } - } - $argument += "-p$password" - } - $argument += $tempPath - $argument += $itemNames - - $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot - $lastExitCode = $exitCode - if ($exitCode -ne 0) { - return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" } - } - } - else { - if ($UseEncryption) { - return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉加密' } - } - # Compress-Archive 没有排除开关,也没有加密;这是降级路径,只保证内容完整。 - # 暂存目录里的名字就是归档内的名字,所以布局与 7z 分支一致。 - $fullPaths = @($SourceItems | ForEach-Object { Join-Path $StagingRoot $_.ArchivePath }) - Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force - } - - if (-not (Test-Path -LiteralPath $tempPath)) { - return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '压缩结束但没有生成临时归档' } - } - - # 校验:确认归档可读且内容 CRC 正确 - if ($script:Config.VerifyArchive -and $tool.Name -eq '7z') { - $verifyArgument = @('t', '-bso0', '-bsp0') - if ($UseEncryption -and $password) { $verifyArgument += "-p$password" } - $verifyArgument += $tempPath - - $verifyCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $verifyArgument -WorkingDirectory $StagingRoot - if ($verifyCode -ne 0) { - Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue - return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" } - } - Write-BakNRetLog '归档校验通过(7z t)' -Level DEBUG - - # 多个 Slot / 追加项时确认每一个顶层名字都真的进了归档:7z 的"警告"可能只体现在某一部分上 - if ($SourceItems.Count -gt 1) { - $listed = @(Get-BakNRetArchiveTopLevelNames -ArchivePath $tempPath -SevenZip $tool.Command -Password $(if ($UseEncryption) { $password } else { $null })) - if ($listed.Count -gt 0) { - $expected = @($SourceItems | ForEach-Object { [string]$_.TopName } | Select-Object -Unique) - $absent = @($expected | Where-Object { $_ -notin $listed }) - if ($absent.Count -gt 0) { - Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue - return [pscustomobject]@{ Ok = $false; ExitCode = $lastExitCode; Warnings = $true; Reason = ("归档缺少这些顶层条目:{0}(归档内实际有:{1})" -f ($absent -join '、'), ($listed -join '、')) } - } - } - } - } - - # 关键保护:压缩工具报了警告(通常是有文件被占用读不到)时, - # 新归档是**不完整**的。用不完整归档覆盖已有的完整归档 = 静默丢数据。 - # 实测:Edge 运行时备份,118 个文件读不到,其中包含 Login Data(密码)、 - # Cookies、History、Web Data —— 恰恰是最不可再生的那部分。 - if ($warnings -and $ProtectPrevious -and -not $AcceptWarnings) { - Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue - return [pscustomobject]@{ - Ok = $false - ExitCode = $lastExitCode - Warnings = $true - Reason = '压缩工具报告有文件被占用而读不到,新归档不完整。为避免覆盖现有的完整归档已保留旧归档;请关闭占用该目录的程序后重跑,或确认可以接受后用 -AcceptWarnings 强制覆盖' - } - } - - Move-BakNRetArchiveIntoPlace -TempPath $tempPath -DestinationPath $FinalPath - return [pscustomobject]@{ Ok = $true; ExitCode = 0; Warnings = $warnings; Reason = $null } - } - catch { - if (Test-Path -LiteralPath $tempPath) { - Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue - } - return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "$_" } - } -} - -# ============================================================================ -# 备份前空间预估(只读,不写任何东西) -# ============================================================================ -# 只做一件事:动手之前告诉用户"这次大概要写多少、盘够不够"。 -# 不做更复杂的占用控制 —— 真正拦住某个条目的是主循环里的逐条目守卫。 -# -# 模型(按清单顺序模拟一遍): -# * 每个要重打的条目会先写一份**临时**归档,这时旧归档还在,所以那一刻占用的 -# 是"当前累计净增量 + 本次预估"; -# * 原子替换之后,本次净增量 = 预估 - 现有归档大小(换成更小的归档会把空间还回来)。 -# 于是:峰值新增 = max_i( 第 i 项之前的累计净增量 + 第 i 项的预估大小 )。 -$spacePlan = @() -$spaceSkipped = 0 -$spaceNoSource = 0 - -foreach ($planLine in $lines) { - $planItem = ConvertFrom-BackupListLine -Line $planLine - if (-not $planItem) { continue } - - $planDisplayPath = $planItem.Path - $planResolved = Resolve-BakNRetBackupEntry -Entry $planItem -CatalogPath $catalogPath - if (-not $planResolved.BaseName) { continue } - if (-not (Test-BakNRetItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName -Only $Only -Skip $Skip)) { continue } - if ($planResolved.Direction -eq 'restore') { continue } - if ($planResolved.Blocking) { continue } - - $planItems = @($planResolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath }) - if ($planItems.Count -eq 0) { $spaceNoSource++; continue } - - $planSourceBytes = [int64]0 - $planSourceFiles = 0 - $planLatest = $null - foreach ($planSource in $planItems) { - $planSummary = Get-BakNRetFolderSummary -FolderPath $planSource.RealPath - $planSourceBytes += [int64]$planSummary.TotalSize - $planSourceFiles += [int]$planSummary.FileCount - if ($planSummary.LatestModifiedTime -and (-not $planLatest -or $planSummary.LatestModifiedTime -gt $planLatest)) { - $planLatest = $planSummary.LatestModifiedTime - } - } - - $planArchiveName = $planResolved.BaseName + $tool.Extension - $planArchivePath = Join-Path $BackupDir $planArchiveName - $planExistingItem = if (Test-Path -LiteralPath $planArchivePath) { Get-Item -LiteralPath $planArchivePath } else { $null } - $planExistingBytes = if ($planExistingItem) { [int64]$planExistingItem.Length } else { [int64]0 } - - # 与主循环同一套判断:源没更新就不会重打 - if (-not $Force -and $planExistingItem -and $planLatest -and $planLatest -le $planExistingItem.LastWriteTime) { - $spaceSkipped++ - continue - } - - $planEstimate = if ($planExistingBytes -gt 0) { - [int64][math]::Min([double]$planSourceBytes, [double]$planExistingBytes * 1.3) - } - else { - # 没有历史归档可比时按"完全不压缩"的悲观值估,宁可报多不报少 - $planSourceBytes - } - - $spacePlan += [pscustomobject]@{ - Name = $planResolved.BaseName - Source = $planDisplayPath - Files = $planSourceFiles - SourceBytes = $planSourceBytes - Existing = $planExistingBytes - Estimate = $planEstimate - } -} - -$freeNowGB = Get-BakNRetFreeSpaceGB -Path $BackupDir - -if ($spacePlan.Count -eq 0) { - Write-BakNRetLog '备份前空间预估:本次没有需要重打的条目(源未更新或源不存在),不会写入新归档' -Level INFO -} -else { - $spacePeak = [double]0 - $spaceCumulative = [double]0 - foreach ($plan in $spacePlan) { - $spacePeak = [math]::Max($spacePeak, $spaceCumulative + $plan.Estimate) - $spaceCumulative += ($plan.Estimate - $plan.Existing) - } - $peakGB = $spacePeak / 1GB - $netGB = $spaceCumulative / 1GB - $estimateGB = ((($spacePlan | Measure-Object -Property Estimate -Sum).Sum)) / 1GB - $existingGB = ((($spacePlan | Measure-Object -Property Existing -Sum).Sum)) / 1GB - - Write-BakNRetLog '==== 备份前空间预估(只读)====' -Level INFO - Write-BakNRetLog (" 目标卷可用空间:{0} GB" -f $freeNowGB) - Write-BakNRetLog (" 本次要重打 {0} 个条目(另有 {1} 个源未更新会跳过、{2} 个源不存在)" -f $spacePlan.Count, $spaceSkipped, $spaceNoSource) - Write-BakNRetLog (" 新归档合计约 {0} GB;其中会替换掉的旧归档 {1} GB" -f [math]::Round($estimateGB, 2), [math]::Round($existingGB, 2)) - - foreach ($plan in ($spacePlan | Sort-Object Estimate -Descending | Select-Object -First 15)) { - Write-BakNRetLog (" - {0,-22} 源 {1,8:N1} MB / {2,6} 文件 现有 {3,7:N1} MB 预估 {4,7:N1} MB" -f ` - $plan.Name, ($plan.SourceBytes / 1MB), $plan.Files, ($plan.Existing / 1MB), ($plan.Estimate / 1MB)) - } - if ($spacePlan.Count -gt 15) { - Write-BakNRetLog (" …… 另有 {0} 个条目未逐条列出" -f ($spacePlan.Count - 15)) - } - - Write-BakNRetLog (" 预计峰值新增占用:{0} GB(全程净增量 {1} GB)" -f [math]::Round($peakGB, 2), [math]::Round($netGB, 2)) - - if ($freeNowGB -lt 0) { - Write-BakNRetLog ' 结论:读不到目标卷可用空间,请自行确认是否够用' -Level WARN - } - elseif ($peakGB -le $freeNowGB) { - Write-BakNRetLog (" 结论:空间足够(预计用 {0} GB / 可用 {1} GB)" -f [math]::Round($peakGB, 2), $freeNowGB) -Level INFO - } - else { - Write-BakNRetLog (" 结论:空间可能不够!预计需要 {0} GB,可用只有 {1} GB,差 {2} GB" -f ` - [math]::Round($peakGB, 2), $freeNowGB, [math]::Round($peakGB - $freeNowGB, 2)) -Level WARN - Write-BakNRetLog ' 仍会继续执行:真正放不下的条目会被逐条目守卫跳过。建议先腾空间,或用 -Only / -Skip 分批备份。' -Level WARN - } - 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 - - if (-not $resolved.BaseName) { - $record = New-BakNRetItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $displayPath -Phase 'parse' - Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason '无法生成归档名' | Out-Null - $failed++; $failures += $displayPath - continue - } - - $baseName = $resolved.BaseName - $sourcePath = [Environment]::ExpandEnvironmentVariables($displayPath) - - if (-not (Test-BakNRetItemSelected -DisplayPath $displayPath -BaseName $baseName -Only $Only -Skip $Skip)) { - Write-BakNRetLog "跳过(未选中): $displayPath" -Level DEBUG - continue - } - - # 归档名登记必须在方向过滤**之前**:`-`(仅恢复)的条目不会产生归档, - # 但它对应的归档是有主的,不能被下游的孤儿审计当成没人要的孤儿。 - # 备份列表里写重了会生成两个同名归档、互相覆盖 —— 直接报错,不猜。 - if ($seenBaseNames.ContainsKey($baseName)) { - $reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖" - Write-BakNRetLog "失败: $displayPath,$reason" -Level ERROR - $record = New-BakNRetItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup' - Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $reason | Out-Null - $failed++; $failures += $displayPath - continue - } - $seenBaseNames[$baseName] = $displayPath - - if ($resolved.Direction -eq 'restore') { - Write-BakNRetLog "跳过(行首 -,仅恢复): $displayPath" -Level INFO - continue - } - - $record = New-BakNRetItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup' - $record.archive = $baseName + $tool.Extension - if ($resolved.CatalogEntry) { $record.catalog = $resolved.CatalogEntry.Path } - $finalPath = Join-Path $BackupDir $record.archive - - # root= 是历史标记:包内的一层目录现在由名录里的 Slot 决定,这里只告警不静默忽略。 - if (@($item.Flags | Where-Object { $_ -like 'root=*' }).Count -gt 0) { - Write-BakNRetLog "警告: $displayPath 使用了 root= 标记。归档内的一层目录现在由 Slot 决定,该标记已废弃,本次忽略" -Level WARN - } - - # 归档内路径冲突:明确失败,绝不把两块内容静默搅进同一棵树 - if ($resolved.Blocking) { - Write-BakNRetLog "失败: $displayPath,$($resolved.Blocking)" -Level ERROR - Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $resolved.Blocking | Out-Null - $failed++; $failures += $displayPath - continue - } - - # 动手之前先把"这条会打包哪些目录、归档里叫什么、排除了什么、为什么"讲清楚 - $planListExcludes = @() - $planCatalogExcludes = @() - if ($resolved.HasExcludeOverride) { - $planListExcludes = @($resolved.ExcludePatterns) - } - else { - $planCatalogExcludes = @($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique) - } - Write-BakNRetBackupEntryPlan -Resolved $resolved -DisplayPath $displayPath ` - -ListExcludes $planListExcludes -CatalogExcludes $planCatalogExcludes ` - -ConfigExcludes @($script:Config.DefaultExcludes) -Comment $item.Comment - - # Items 为空 = 解析不出任何归档项(名录里没这个软件名、或路径拆不出末级名)。 - # 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值, - # 但 Items 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的存在性检查统一处理。 - if ($resolved.Items.Count -eq 0) { - $reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' } - Write-BakNRetLog "跳过: $displayPath,$reason" -Level WARN - Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'missing-source' -Reason $reason | Out-Null - $skipped++ - continue - } - - # 源存在性检查必须在 Get-BakNRetFolderSummary / Get-Item 之前: - # 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。 - $missingItems = @($resolved.Items | Where-Object { -not (Test-Path -LiteralPath $_.RealPath) }) - - if ($missingItems.Count -ge $resolved.Items.Count) { - $missingText = @($missingItems | ForEach-Object { $_.RealPath }) -join ';' - Write-BakNRetLog "跳过: $displayPath,源路径不存在" -Level WARN - Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + $missingText) | Out-Null - $skipped++ - continue - } - - if ($missingItems.Count -gt 0) { - Write-BakNRetLog ("警告: {0} 有 {1} 个归档项的源路径不存在,本次只打包存在的部分:{2}" -f ` - $displayPath, $missingItems.Count, (@($missingItems | ForEach-Object { $_.RealPath }) -join ';')) -Level WARN - } - - # 归档里只放真实存在的源 - $liveItems = @($resolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath }) - - # 归档内的顶层条目名 = 真实存在的归档项在包内的第一层名字(Slot 名 / 源目录名)。 - # 这里记录可核对的事实,备份成功后还会用 Get-BakNRetArchiveTopLevelNames 与归档内容对账。 - $record.roots = @($liveItems | ForEach-Object { $_.TopName } | Select-Object -Unique) - - # 恢复端要知道每个项在归档里是目录还是文件:记在 manifest 里, - # 这样目标机器上目标还不存在(全新恢复)时也判断得出来。 - $record.layouts = @($liveItems | ForEach-Object { - [ordered]@{ - name = $_.ArchivePath - kind = $(if ($_.IsFile) { 'file' } else { 'dir' }) - } - }) - - $primarySource = $liveItems[0].RealPath - if ([string]::IsNullOrWhiteSpace($primarySource)) { - Write-BakNRetLog "跳过: $displayPath,无法确定主源路径" -Level WARN - Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'invalid-path' -Reason '归档项没有可用路径' | Out-Null - $skipped++ - continue - } - - $summary = Get-BakNRetFolderSummary -FolderPath $primarySource - # 从第二个归档项开始累加。刻意不用 `$liveItems[1..($liveItems.Count-1)]`: - # 只有一项时 `1..0` 会退化成 `1,0`,把同一份源数两遍(旧实现的隐蔽 bug)。 - for ($index = 1; $index -lt $liveItems.Count; $index++) { - $extra = Get-BakNRetFolderSummary -FolderPath $liveItems[$index].RealPath - $summary.FileCount += $extra.FileCount - $summary.TotalSize += $extra.TotalSize - if ($extra.LatestModifiedTime -and $extra.LatestModifiedTime -gt $summary.LatestModifiedTime) { - $summary.LatestModifiedTime = $extra.LatestModifiedTime - } - } - $record.sourceFiles = $summary.FileCount - $record.sourceBytes = $summary.TotalSize - - $archiveExists = Test-Path -LiteralPath $finalPath - $archiveItem = if ($archiveExists) { Get-Item -LiteralPath $finalPath } else { $null } - - Write-BakNRetLog ("开始备份: {0} -> {1}({2} 个文件,{3} MB)" -f $displayPath, $record.archive, $summary.FileCount, [math]::Round(($summary.TotalSize / 1MB), 2)) - - # 空目录时 Get-BakNRetFolderSummary 拿不到任何条目,回退到源自身的修改时间 - # (源路径上面已经确认存在,这里的 Get-Item 不会再抛异常) - $sourceLatest = $summary.LatestModifiedTime - if (-not $sourceLatest) { - $sourceLatest = (Get-Item -LiteralPath $primarySource -Force).LastWriteTime - } - - if (-not $Force -and $archiveItem -and $sourceLatest -and $sourceLatest -le $archiveItem.LastWriteTime) { - Write-BakNRetLog "跳过: $displayPath,源目录未更新" -Level INFO - $record.archiveBytes = $archiveItem.Length - Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'skip-unchanged' -Reason ('源最新修改时间 {0} 不晚于归档时间 {1}' -f $sourceLatest, $archiveItem.LastWriteTime) | Out-Null - $skipped++ - continue - } - - # 空间守卫:临时归档与正式归档会同时存在,因此按"新归档预估大小"要求剩余空间 - $estimatedGB = $summary.TotalSize / 1GB - if ($archiveItem) { - $archiveGB = $archiveItem.Length / 1GB - $estimatedGB = [math]::Min($estimatedGB, $archiveGB * 1.3) - } - $freeSpaceGB = Get-BakNRetFreeSpaceGB -Path $BackupDir - if ($freeSpaceGB -ge 0 -and $estimatedGB -gt 0 -and $freeSpaceGB -lt $estimatedGB) { - $reason = ('剩余空间 {0} GB 不足以写入预估 {1} GB 的新归档' -f $freeSpaceGB, [math]::Round($estimatedGB, 2)) - Write-BakNRetLog "失败: $displayPath,$reason" -Level ERROR - Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $reason | Out-Null - $failed++; $failures += $displayPath - continue - } - - if ($DryRun) { - Write-BakNRetLog ("[试运行] 将打包 {0} -> {1}" -f $sourcePath, $finalPath) -Level INFO - $record.reason = '试运行,未执行压缩' - Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'planned' -Reason '试运行,未执行压缩' | Out-Null - $planned++ - continue - } - - $useEncryption = $encryptAll -or [bool]$resolved.Encrypt - $record.encrypted = [bool]$useEncryption - $startedAt = Get-Date - $record.attemptedAt = $startedAt.ToString('o') - - # 排除参数:条目级 `:-` / `@ Exclude` 覆盖优先,否则用名录里各 Slot 自己的 Exclude; - # 再叠上 BackupConfig.psd1 的 DefaultExcludes。 - # 模式先用 `<顶层名>\` 前缀分配到对应归档项上(`Scoop :- GlobalPersist\steam`), - # 再逐项翻译成 7z 的 -x! / -xr!,最后去重合并成一次调用的参数。 - $patternSource = if ($resolved.HasExcludeOverride) { - @($resolved.ExcludePatterns) - } - else { - @($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique) - } - $allPatterns = @($script:Config.DefaultExcludes) + $patternSource - $scopeMap = Split-BakNRetPatternScope -Items $liveItems -Patterns $allPatterns - - $excludeLists = @() - $excludeError = $null - for ($index = 0; $index -lt $liveItems.Count; $index++) { - $expanded = Get-BakNRetExcludeArgument -Item $liveItems[$index] -Patterns @($scopeMap[$index]) - if ($expanded.Error) { $excludeError = $expanded.Error } - $excludeLists += , @($expanded.Arguments) - } - $effectiveExcludes = @(Merge-BakNRetExcludeArgument -ArgumentLists $excludeLists) - - if ($excludeError) { - Write-BakNRetLog "失败: $displayPath,$excludeError" -Level ERROR - Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $excludeError | Out-Null - $failed++; $failures += $displayPath - continue - } - - # 只有在"现有归档是完整的"时才值得保护它。没有 manifest 记录 - # (本次重构之前留下的归档)时按完整处理——宁可保守。 - $protectPrevious = [bool]$archiveExists - if ($archiveExists -and $manifest.items.Contains($baseName)) { - $previousRecord = $manifest.items[$baseName] - if (($previousRecord.PSObject.Properties.Name -contains 'warnings') -and $previousRecord.warnings) { - $protectPrevious = $false - } - } - - # 归档内的一层目录名由 Slot / 追加项的归档内路径决定,所以先把它们以正确的名字 - # 挂进暂存目录(junction / 硬链接),再让压缩工具以暂存目录为工作目录打包。 - $stagingRoot = $null - try { - $stagingRoot = New-BakNRetArchiveStaging -Items $liveItems - $result = Invoke-BackupItem -SourceItems $liveItems -StagingRoot $stagingRoot ` - -FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption ` - -ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings - } - catch { - $result = [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "准备归档内容失败:$_" } - } - finally { - Remove-BakNRetArchiveStaging -Root $stagingRoot - } - - $record.exitCode = $result.ExitCode - $record.attemptWarnings = [bool]$result.Warnings - $record.verified = [bool]$result.Ok - $record.durationSec = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 1) - - if (-not $result.Ok) { - Write-BakNRetLog "备份失败: $displayPath,$($result.Reason)" -Level ERROR - Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason $result.Reason | Out-Null - $failed++; $failures += $displayPath - continue - } - - $written = Get-Item -LiteralPath $finalPath - $record.archiveBytes = $written.Length - if ($result.Warnings) { - Write-BakNRetLog "备份成功(压缩工具报告了警告,可能有文件被占用而没打进归档): $displayPath" -Level WARN - Write-BakNRetLog ' 该归档在 manifest 里标记为 warnings=true;如果以后现有归档是完整的,会拒绝被它覆盖' -Level WARN - } - else { - Write-BakNRetLog "备份成功: $baseName" -Level INFO - } - - # ------------------------------------------------------------------ - # 安全描述符(属主 / ACL)写进旁挂文件 <归档名>.acl.json - # ------------------------------------------------------------------ - # 归档格式装不下它(7z 的 -sni 官方说明是"只能写进 WIM 归档"),所以放在归档旁边, - # 和归档一样先写 .tmp 再原子替换。属主必须一起存:C:\ProgramData 的 ACL 里有 - # (A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL - # 而 CREATOR OWNER 是访问检查时才替换的占位符 —— 替换成"被检查对象的属主"。 - # 只回放 ACE 文本、不恢复属主,等于把"谁创建的东西谁有全权"里的"谁"换成跑脚本的账户。 - $securityMode = [string]$script:Config.Security.Mode - $securityFatal = $false - if ($securityMode -and ($securityMode -ne 'Off')) { - $sidecarName = "$baseName.acl.json" - $sidecarPath = Join-Path $BackupDir $sidecarName - try { - $capture = Get-BakNRetSecurityRecords -Items $liveItems -ScopeMap $scopeMap -Mode $securityMode ` - -IncludeSacl:([bool]$script:Config.Security.IncludeSacl) - Save-BakNRetSecuritySidecar -Path $sidecarPath -Records $capture.Records -Mode $securityMode ` - -IncludeSacl:([bool]$script:Config.Security.IncludeSacl) ` - -Errors $capture.Errors -Scanned $capture.Scanned | Out-Null - - $record.security = [ordered]@{ - file = $sidecarName - mode = $securityMode - objects = $capture.Kept - scanned = $capture.Scanned - errors = $capture.Errors - capturedAt = (Get-Date).ToString('o') - } - Write-BakNRetLog ("安全描述符:{0} 个对象写进 {1}(扫描 {2} 个,读不到 {3} 个)" -f ` - $capture.Kept, $sidecarName, $capture.Scanned, $capture.Errors) -Level INFO - - if ($capture.Errors -gt 0) { - $securityErrorCount++ - $unreadable = @($capture.Records | Where-Object { $_.e } | Select-Object -First 3 -ExpandProperty p) - Write-BakNRetLog (" {0} 个对象的安全描述符读不到(恢复后它们的属主/ACL 会是新建对象的默认值),例如:{1}" -f ` - $capture.Errors, ($unreadable -join '、')) -Level WARN - } - } - catch { - $securityFailed++ - Write-BakNRetLog "安全描述符采集/写盘失败:$displayPath —— $_" -Level WARN - $record.security = [ordered]@{ file = $sidecarName; error = "$_" } - if ([bool]$script:Config.Security.FailOnError) { $securityFatal = $true } - } - - if ($securityFatal) { - Write-BakNRetLog "失败: $displayPath,归档已替换,但安全描述符没能存下来(Security.FailOnError = \$true)" -Level ERROR - Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'failed' -Reason '归档已替换,但安全描述符没能存下来' | Out-Null - $failed++; $failures += $displayPath - continue - } - } - - if ($Hash -or $script:Config.ComputeHash) { - $record.sha256 = (Get-FileHash -LiteralPath $finalPath -Algorithm SHA256).Hash - Write-BakNRetLog "SHA256: $($record.sha256)" -Level DEBUG - } - - if ($Snapshot -or $script:Config.Snapshot.Enabled) { - $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' - $target = Join-Path (Join-Path $snapshotDir $stamp) $record.archive - $targetDir = Split-Path -Parent $target - if (-not (Test-Path -LiteralPath $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null } - Copy-Item -LiteralPath $finalPath -Destination $target -Force - Write-BakNRetLog "已留存快照: $target" -Level INFO - } - - Save-BakNRetItemRecord -Manifest $manifest -Record $record -Action 'backed-up' -Reason $null -ArchiveWarnings $result.Warnings | Out-Null - $processed++ -} - -# ============================================================================ -# 收尾 -# ============================================================================ - -if ($DryRun) { - Write-BakNRetLog '试运行:manifest 与归档都不会被写入' -Level INFO -} -else { - # manifest 里写了 archive 的记录,磁盘上就必须真有那个文件 - $clearedArchiveFields = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $BackupDir - if ($clearedArchiveFields.Count -gt 0) { - Write-BakNRetLog ("已清空 {0} 条记录里指向不存在归档的 archive 字段:{1}" -f $clearedArchiveFields.Count, ($clearedArchiveFields -join '、')) -Level WARN - } - - Write-BakNRetManifest -Path $manifestPath -Manifest $manifest | Out-Null - Write-BakNRetLog "manifest 已更新:$manifestPath" -Level DEBUG -} - -# 孤儿归档审计:磁盘上有、但**当前清单里任何条目都不指向**的归档。 -# Restore.ps1 是按清单条目去找归档的,所以孤儿是**恢复不到**的 —— 必须显式点名, -# 免得下次清理时把还有用的归档当垃圾删掉(重构前那个 2.8 GB 的归档就是这么成孤儿的)。 -# -# 判据只用清单,**不能用 manifest**:manifest 会一直留着历史条目, -# 于是"从清单里删掉某个条目(或把它合并进另一个条目)"留下的归档会被历史记录遮住, -# 审计就永远不会报——那正是最需要报出来的情况。 -# $seenBaseNames 在方向过滤之前就登记,所以"行首 + / -"的条目也算有主。 -# 只在整表运行时做:带 -Only/-Skip 时未选中的条目本来就不在 $seenBaseNames 里, -# 那种情况下报出来的全是假孤儿。 -if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) { - $known = @{} - foreach ($key in $seenBaseNames.Keys) { $known[$key] = $true } - - $orphanArchives = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | - Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') -and -not $known.ContainsKey($_.BaseName) }) - - if ($orphanArchives.Count -gt 0) { - Write-BakNRetLog ("发现 {0} 个孤儿归档(当前清单里没有任何条目指向,恢复不到,注意别误删):" -f $orphanArchives.Count) -Level WARN - foreach ($orphan in $orphanArchives) { - $inManifest = $manifest.items.Contains($orphan.BaseName) - Write-BakNRetLog (" - {0}({1:N1} MB,{2}){3}" -f $orphan.Name, ($orphan.Length / 1MB), $orphan.LastWriteTime, $(if ($inManifest) { ';manifest 里还留着它的历史记录,但清单里已经没有了' } else { '' })) -Level WARN - } - } - else { - Write-BakNRetLog '孤儿归档审计:没有发现(所有归档都有清单条目指向)' -Level DEBUG - } -} - -$counterText = @{ 成功 = $processed; 跳过 = $skipped; 失败 = $failed } -if ($DryRun) { $counterText['试运行计划'] = $planned } -Write-BakNRetRunSummary -Mode 'backup' -Manifest $manifest -StartedAt $runStartedAt -Failures $failures -Counters $counterText -OrphanArchives @($orphanArchives | Where-Object { $_ }) -SecurityFailed $securityFailed -SecurityErrorCount $securityErrorCount - -$logPath = Get-BakNRetLogPath -if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO } -Exit-BakNRetRunLock -Lock $runLock -Stop-BakNRetLog - -if ($failed -gt 0) { exit 1 } -exit 0 +$savedVerbosePreference = $VerbosePreference +$VerbosePreference = 'SilentlyContinue' +Import-Module (Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1') -Force +$VerbosePreference = $savedVerbosePreference + +$target = Join-Path $PSScriptRoot 'Backup-Data.ps1' +Write-Host '注意:Backup.ps1 已改名为 Backup-Data.ps1(这层转发只保留一轮)。' -ForegroundColor Yellow + +$hostExe = (Get-Process -Id $PID).Path +$forwardArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $target) + @($Rest)+ @(if ($PSBoundParameters.ContainsKey('Verbose')) { '-Verbose' })+ @(if ($PSBoundParameters.ContainsKey('Debug')) { '-Debug' }) + +$startInfo = New-Object System.Diagnostics.ProcessStartInfo +$startInfo.FileName = $hostExe +$startInfo.Arguments = ConvertTo-BakNRetNativeArgumentString -ArgumentList $forwardArguments +$startInfo.UseShellExecute = $false +$startInfo.RedirectStandardOutput = $true +$startInfo.RedirectStandardError = $true + +$process = New-Object System.Diagnostics.Process +$process.StartInfo = $startInfo +[void]$process.Start() + +$stdoutTask = $process.StandardOutput.ReadToEndAsync() +$stderrTask = $process.StandardError.ReadToEndAsync() +$process.WaitForExit() + +$standardOutput = $stdoutTask.Result +$standardError = $stderrTask.Result +if ($standardOutput) { Write-Host -NoNewline $standardOutput } +if ($standardError) { [Console]::Error.Write($standardError) } + +exit $process.ExitCode \ No newline at end of file diff --git a/Restore-Data.ps1 b/Restore-Data.ps1 new file mode 100644 index 0000000..85239be --- /dev/null +++ b/Restore-Data.ps1 @@ -0,0 +1,858 @@ +<# +.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 那棵子树,并通过"目标父目录下的 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 就是归档内的一层目录), + 手写路径条目是 `<源目录名>\...`。这里只负责把指定的那条路径解出来。 + #> + 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 + 按**当前归档布局**(软件名条目 = `\<内容>`)解出一个归档项并落到目标位置。 + + .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 布局(`\<内容>`)是本次重构才开始用的,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 diff --git a/Restore.ps1 b/Restore.ps1 index 85239be..f0d434c 100644 --- a/Restore.ps1 +++ b/Restore.ps1 @@ -1,858 +1,64 @@ <# .SYNOPSIS - 按 BackupList.txt 执行恢复。 + 已改名:本脚本只是转发到 Restore-Data.ps1(这一层只保留一轮)。 .DESCRIPTION - 与旧版相比的核心变化: + 为什么留一层转发(ADR-0012):入口脚本是**外部接口** —— README 里有二十多处引用、有使用者的 + 肌肉记忆、tools\Register-BackupTask.ps1 里也可能已经注册过这个路径。内部实现改名断了会当场 + 报错;外部接口改名断了是**静默没用**,而备份工具"静默没用"是最不能接受的失败方式。 - 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 那棵子树,并通过"目标父目录下的 junction"直接落地 - (零拷贝;建不出连接点时退回先解到临时目录再合并)。 + 为什么用子进程、而不是 `& $target`:实测 `& script.ps1` 里子脚本的 exit **不会**把退出码传到 + 父脚本的 $LASTEXITCODE —— 垫片会让失败变成"成功"(错配置时返回 0,被调用脚本返回 1),而计划 + 任务正是靠退出码判断成败。 + + 为什么重定向之后要**自己转发**:父进程的 stdout 常常是管道(测试与使用者的管道都在解析入口的 + 输出),而 .NET 起的进程默认只继承控制台、不继承那个管道 —— 不重定向时子进程的输出就到不了 + 调用方(实测红过)。所以显式重定向,再用**异步读**把两个流读出来转发(同步先读 stdout 再读 + stderr 会在管道写满时死锁)。代价是 stdout/stderr 的相对顺序不再保留 —— 这也正是这层垫片 + 只留一轮的原因之一。 + + 为什么导入模块时临时压掉 verbose:调用方可能给入口传 -Verbose(测试就是这么拿到详细日志的), + 那样 Import-Module 会多打一行 "VERBOSE: Loading module from path ..." —— 而测试是**解析子进程 + 输出**做断言的,多这么一行就会把它顶掉。只压这一句,$Rest 里的 -Verbose 仍会原样转发。 + + 这一层下一轮删。想用新名字就直接调 Restore-Data.ps1。 #> - -[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] +[CmdletBinding()] 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 + [Parameter(ValueFromRemainingArguments = $true)]$Rest ) $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 就是归档内的一层目录), - 手写路径条目是 `<源目录名>\...`。这里只负责把指定的那条路径解出来。 - #> - 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 - 按**当前归档布局**(软件名条目 = `\<内容>`)解出一个归档项并落到目标位置。 - - .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 布局(`\<内容>`)是本次重构才开始用的,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 +$savedVerbosePreference = $VerbosePreference +$VerbosePreference = 'SilentlyContinue' +Import-Module (Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1') -Force +$VerbosePreference = $savedVerbosePreference + +$target = Join-Path $PSScriptRoot 'Restore-Data.ps1' +Write-Host '注意:Restore.ps1 已改名为 Restore-Data.ps1(这层转发只保留一轮)。' -ForegroundColor Yellow + +$hostExe = (Get-Process -Id $PID).Path +$forwardArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $target) + @($Rest)+ @(if ($PSBoundParameters.ContainsKey('Verbose')) { '-Verbose' })+ @(if ($PSBoundParameters.ContainsKey('Debug')) { '-Debug' }) + +$startInfo = New-Object System.Diagnostics.ProcessStartInfo +$startInfo.FileName = $hostExe +$startInfo.Arguments = ConvertTo-BakNRetNativeArgumentString -ArgumentList $forwardArguments +$startInfo.UseShellExecute = $false +$startInfo.RedirectStandardOutput = $true +$startInfo.RedirectStandardError = $true + +$process = New-Object System.Diagnostics.Process +$process.StartInfo = $startInfo +[void]$process.Start() + +$stdoutTask = $process.StandardOutput.ReadToEndAsync() +$stderrTask = $process.StandardError.ReadToEndAsync() +$process.WaitForExit() + +$standardOutput = $stdoutTask.Result +$standardError = $stderrTask.Result +if ($standardOutput) { Write-Host -NoNewline $standardOutput } +if ($standardError) { [Console]::Error.Write($standardError) } + +exit $process.ExitCode \ No newline at end of file diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index 49ba7cc..9aa340c 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -1685,6 +1685,24 @@ Test-Case '清单编辑器:脚本驱动走完"选行→改方向→保存", } } +Test-Case '源码里不得出现"左边空的赋值"(用脚本生成代码时插值把左边吃掉的指纹)' { + # 这条检查是为一个反复出现的坑立的:用双引号拼一段 PowerShell 代码时,$变量 会在**生成脚本 + # 的那一刻**被插值成空,写出来的文件里就出现 ` = 'SilentlyContinue'` 这种行 —— 而它是 + # **合法语法**(等于调用一个叫 'SilentlyContinue' 的命令),Parse 层抓不到,只有跑到那一步 + # 才炸。这个坑在同一块代码里出现过五次、症状每次都不一样,所以不再靠"我记得"。 + $repoRoot = Split-Path -Parent $PSScriptRoot + $skip = '\\(\.git|\.tools|\.scratch|Backups|logs|dist)\\' + $offenders = @() + foreach ($file in @(Get-ChildItem -LiteralPath $repoRoot -Recurse -File -Include *.ps1, *.psm1 | Where-Object { $_.FullName -notmatch $skip })) { + $number = 0 + foreach ($line in @(Get-Content -Encoding UTF8 -LiteralPath $file.FullName)) { + $number++ + if ($line.TrimStart() -match '^=\s') { $offenders += ($file.Name + ':' + $number + ' ' + $line.Trim()) } + } + } + Assert-Equal 0 $offenders.Count ('这些行看起来是赋值、左边却是空的:' + ($offenders -join ' / ')) +} + # ============================================================================ Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue diff --git a/tools/Register-BackupTask.ps1 b/tools/Register-BackupTask.ps1 index 13232c3..eccbf2e 100644 --- a/tools/Register-BackupTask.ps1 +++ b/tools/Register-BackupTask.ps1 @@ -3,7 +3,7 @@ 注册 / 移除 BakNRet 的每日备份计划任务。 .DESCRIPTION - 任务直接调用 Backup.ps1。脚本自身会写日志并按失败数返回退出码, + 任务直接调用 Backup-Data.ps1。脚本自身会写日志并按失败数返回退出码, 因此「上次运行结果」在任务计划程序里是可读的,不需要额外包装。 注册计划任务需要管理员权限(本脚本不自己提权,请从管理员终端运行)。 @@ -27,7 +27,7 @@ param( [ValidatePattern('^\d{1,2}:\d{2}$')] [string]$At = '21:30', - # 额外传给 Backup.ps1 的参数,例如 @('-Snapshot') + # 额外传给 Backup-Data.ps1 的参数,例如 @('-Snapshot') [string[]]$BackupArgument = @(), [ValidateSet('S4U', 'Interactive')] @@ -43,10 +43,10 @@ param( $ErrorActionPreference = 'Stop' $projectRoot = Split-Path -Parent $PSScriptRoot -$backupScript = Join-Path $projectRoot 'Backup.ps1' +$backupScript = Join-Path $projectRoot 'Backup-Data.ps1' if (-not (Test-Path -LiteralPath $backupScript)) { - Write-Error "找不到 Backup.ps1:$backupScript" + Write-Error "找不到 Backup-Data.ps1:$backupScript" exit 1 }