refactor: 入口改名(Backup/Restore -> Backup-Data/Restore-Data)+ 只留一轮的垫片

git mv 保留历史。旧名字留薄垫片:入口脚本是**外部接口**(README 二十多处引用、使用者的肌肉记忆、注册脚本里的路径),内部实现改名断了会当场报错,外部接口改名断了是静默没用 —— 后者对备份工具尤其不能接受(ADR-0012)。

垫片踩到四个坑,全部由门禁报出(E2E 与 Pester 集成用例本来就是通过子进程调这两个入口的,于是它们原封不动成了垫片的验收):① `& script.ps1` 里子脚本的 exit 不会把退出码传到父脚本的 $LASTEXITCODE —— 会把失败变成成功,而计划任务靠退出码判断成败;② 调用方传 -Verbose 时垫片里的 Import-Module 会多打一行加载信息,顶掉测试的输出断言;③ 不重定向时子进程的输出到不了调用方(父进程的 stdout 常常是管道,而 .NET 起的进程默认只继承控制台)—— 改成显式重定向 + 异步读转发(同步先读 stdout 再读 stderr 会在管道写满时死锁);④ **`[CmdletBinding()]` 会把 -Verbose 当通用参数绑走,它不会落进 $Rest,于是没被转发给子进程** —— 而测试正是靠 -Verbose 拿那条 VERBOSE 级日志的。现在显式把 -Verbose / -Debug 加进转发参数。

参数引号化用模块自己的 ConvertTo-BakNRetNativeArgumentString(5.1 上 ProcessStartInfo.ArgumentList 不存在)。tools\Register-BackupTask.ps1 已同步改指 Backup-Data.ps1(4 处)。

门禁新增一条机械检查:**源码里不得出现"左边空的赋值"** —— 它是"用双引号拼代码导致 $变量 被插值成空"那个坑的指纹(合法语法、Parse 层抓不到、只有跑到才炸,同一块代码里出现过五次)。垫片全部用单引号 here-string 生成,零插值。

验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。
This commit is contained in:
Shuery committed 2026-09-27 19:51:24 +08:00
1 parent 5df40d3341
commit 4e3c0461c0
6 files changed
+1824 -1678

No files matched your search

+840
View File
@@ -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 结构配套:
* 一个软件 = 一个归档,归档内是 `<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] [@ <Key>='<值>'] [# 说明]`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
+51 -827
View File
@@ -1,840 +1,64 @@
<# <#
.SYNOPSIS .SYNOPSIS
按 BackupList.txt 执行备份。 已改名:本脚本只是转发到 Backup-Data.ps1(这一层只保留一轮)。
.DESCRIPTION .DESCRIPTION
与旧版相比的核心变化: 为什么留一层转发(ADR-0012):入口脚本是**外部接口** —— README 里有二十多处引用、有使用者的
肌肉记忆、tools\Register-BackupTask.ps1 里也可能已经注册过这个路径。内部实现改名断了会当场
报错;外部接口改名断了是**静默没用**,而备份工具"静默没用"是最不能接受的失败方式。
1. 退出码可靠 —— 不再用 Start-Process -PassThru(在 PowerShell 7.7.0-preview.4 上 为什么用子进程、而不是 `& $target`:实测 `& script.ps1` 里子脚本的 exit **不会**把退出码传到
ExitCode 恒为 $null,会把成功的压缩判成失败),改用 Invoke-ExternalCommand。 父脚本的 $LASTEXITCODE —— 垫片会让失败变成"成功"(错配置时返回 0,被调用脚本返回 1),而计划
2. 先写临时归档 → 校验 → 原子替换。中断或断电只会留下 .tmp 文件, 任务正是靠退出码判断成败。
不会污染正式归档;也不会再出现"半个归档被下次增量续写"的情况。
3. 不再使用 7z 的 u(更新)模式。7z 默认是固实压缩,u 本来就要重压大部分数据,
收益极小,却让排除规则和删除操作永远无法生效(旧归档里会一直留着已删文件)。
现在每次都从零打包,于是"排除规则改动"和"源里删掉的文件"都能真正反映到归档。
4. 每个条目写进 manifest.json:源、归档、时间、退出码、校验结果、失败原因。
跳过和失败从此有据可查,而不是只剩一行滚过去的控制台告警。
5. 结尾按失败数 exit,并写日志文件,计划任务能正确判断成败。
6. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。
与 SoftwareCatalog.psd1 的 Slot 结构配套: 为什么重定向之后要**自己转发**:父进程的 stdout 常常是管道(测试与使用者的管道都在解析入口的
* 一个软件 = 一个归档,归档内是 `<Slot>\<该 Path 的内容>`; 输出),而 .NET 起的进程默认只继承控制台、不继承那个管道 —— 不重定向时子进程的输出就到不了
* 打包前用暂存目录 + junction / 硬链接把 Slot 名变成包里真实的目录名 调用方(实测红过)。所以显式重定向,再用**异步读**把两个流读出来转发(同步先读 stdout 再读
(7z 没有"入库时改名"的能力),打包后立刻拆掉暂存目录; stderr 会在管道写满时死锁)。代价是 stdout/stderr 的相对顺序不再保留 —— 这也正是这层垫片
* 清单行首 `+` = 仅备份、`-` = 仅恢复。 只留一轮的原因之一。
为什么导入模块时临时压掉 verbose:调用方可能给入口传 -Verbose(测试就是这么拿到详细日志的),
那样 Import-Module 会多打一行 "VERBOSE: Loading module from path ..." —— 而测试是**解析子进程
输出**做断言的,多这么一行就会把它顶掉。只压这一句,$Rest 里的 -Verbose 仍会原样转发。
这一层下一轮删。想用新名字就直接调 Backup-Data.ps1。
#> #>
[CmdletBinding()] [CmdletBinding()]
param( param(
[Parameter()] [Parameter(ValueFromRemainingArguments = $true)]$Rest
[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' $ErrorActionPreference = 'Stop'
# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上, $savedVerbosePreference = $VerbosePreference
# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带 $VerbosePreference = 'SilentlyContinue'
# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值 Import-Module (Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1') -Force
# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。 $VerbosePreference = $savedVerbosePreference
if (-not $BackupListPath) { $BackupListPath = Join-Path $PSScriptRoot 'BackupList.txt' }
if (-not $ConfigPath) { $ConfigPath = Join-Path $PSScriptRoot 'BackupConfig.psd1' } $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' })
$modulePath = Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1' $startInfo = New-Object System.Diagnostics.ProcessStartInfo
if (-not (Test-Path -LiteralPath $modulePath)) { $startInfo.FileName = $hostExe
Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。" $startInfo.Arguments = ConvertTo-BakNRetNativeArgumentString -ArgumentList $forwardArguments
exit 1 $startInfo.UseShellExecute = $false
} $startInfo.RedirectStandardOutput = $true
Import-Module $modulePath -Force $startInfo.RedirectStandardError = $true
if ($PSBoundParameters.ContainsKey('Verbose')) { Set-BakNRetDebug } $process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
$script:Config = Get-BakNRetConfig -Path $ConfigPath [void]$process.Start()
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
if (-not $BackupDir) { $BackupDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.BackupDir -Root $PSScriptRoot } $stderrTask = $process.StandardError.ReadToEndAsync()
$logDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.LogDir -Root $PSScriptRoot $process.WaitForExit()
$snapshotDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.SnapshotDir -Root $PSScriptRoot
$catalogPath = Resolve-BakNRetCatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot $standardOutput = $stdoutTask.Result
$manifestPath = Join-Path $BackupDir 'manifest.json' $standardError = $stderrTask.Result
if ($standardOutput) { Write-Host -NoNewline $standardOutput }
$logPath = Start-BakNRetLog -Directory $logDir -Prefix 'backup' if ($standardError) { [Console]::Error.Write($standardError) }
$runStartedAt = Get-Date
Write-BakNRetLog "日志文件:$logPath" exit $process.ExitCode
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] [@ <Key>='<值>'] [# 说明]`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
+858
View File
@@ -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>\<内容>`),
恢复时只解出该 Slot 那棵子树,并通过"目标父目录下的 junction"直接落地
(零拷贝;建不出连接点时退回先解到临时目录再合并)。
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param(
[Parameter()]
[string]$BackupListPath,
[Parameter()]
[string]$BackupDir,
[Parameter()]
[string]$ConfigPath,
[Parameter()]
[string]$KeyFile,
[Parameter()]
[string[]]$Only = @(),
[Parameter()]
[string[]]$Skip = @(),
# 忽略"目标比归档新"的保护,强制解压
[Parameter()]
[switch]$Force,
# 只打印计划,不解压(等价于 -WhatIf)
[Parameter()]
[switch]$DryRun,
# 只对归档做 7z t 校验,不解压
[Parameter()]
[switch]$VerifyOnly,
# 不恢复安全描述符(属主 / ACL):默认会按 <归档名>.acl.json 回放
[Parameter()]
[switch]$SkipSecurity
)
$ErrorActionPreference = 'Stop'
# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上,
# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带
# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值
# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。
if (-not $BackupListPath) { $BackupListPath = Join-Path $PSScriptRoot 'BackupList.txt' }
if (-not $ConfigPath) { $ConfigPath = Join-Path $PSScriptRoot 'BackupConfig.psd1' }
if ($DryRun) { $WhatIfPreference = $true }
# ============================================================================
# 载入依赖
# ============================================================================
$modulePath = Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1'
if (-not (Test-Path -LiteralPath $modulePath)) {
Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。"
exit 1
}
Import-Module $modulePath -Force
if ($PSBoundParameters.ContainsKey('Verbose')) { Set-BakNRetDebug }
$script:Config = Get-BakNRetConfig -Path $ConfigPath
$SupportedFormats = @('.7z', '.rar', '.zip', '.tar')
if (-not $BackupDir) { $BackupDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.BackupDir -Root $PSScriptRoot }
$logDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.LogDir -Root $PSScriptRoot
$catalogPath = Resolve-BakNRetCatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot
$manifestPath = Join-Path $BackupDir 'manifest.json'
$logPath = Start-BakNRetLog -Directory $logDir -Prefix 'restore'
$runStartedAt = Get-Date
Write-BakNRetLog "日志文件:$logPath"
Write-BakNRetLog "备份目录:$BackupDir"
Write-BakNRetLog ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
if ($WhatIfPreference) { Write-BakNRetLog '试运行模式(-WhatIf / -DryRun):不会写入任何文件' -Level WARN }
if (-not (Test-BakNRetAdministrator)) {
Write-BakNRetLog '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
}
# 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。
# 三种只读模式不取锁:它们一个字节都不写,没必要被正在跑的备份挡在外面。
$runLock = $null
if (-not $WhatIfPreference -and -not $VerifyOnly) {
$runLock = Enter-BakNRetRunLock -Directory $BackupDir
if (-not $runLock) {
Write-BakNRetLog ("另一次运行正在进行中(锁文件:{0},里面写明了持有者)。本次不执行。" -f (Get-BakNRetRunLockPath -Directory $BackupDir)) -Level ERROR
Stop-BakNRetLog
exit 1
}
Write-BakNRetLog ("已取得运行锁:{0}" -f (Get-BakNRetRunLockPath -Directory $BackupDir)) -Level DEBUG
}
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
if ($passwordFile) {
# 相对路径按**仓库根**解析,而不是按当前工作目录:计划任务的工作目录是 C:\Windows\System32,
# 在那里 Test-Path 找不到 baknret.key,加密条目就会以"拿不到口令"失败,而配置看上去没问题。
$passwordFile = Resolve-BakNRetRootedPath -Path $passwordFile -Default $passwordFile -Root $PSScriptRoot
}
$password = Get-BakNRetPassword -PasswordFile $passwordFile
# ============================================================================
# 归档查找
# ============================================================================
function Find-ArchiveByBaseName {
<#
.SYNOPSIS
按归档基础名精确定位归档文件。
.DESCRIPTION
旧版用 Get-ChildItem -Filter "$baseName.*",-Filter 会做通配符解释,
路径里含 `[` `]` 时会失配;这里改为精确比较 BaseName。
#>
param([string]$BaseName)
$candidate = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.BaseName -eq $BaseName -and $_.Extension.ToLower() -in $SupportedFormats } |
Select-Object -First 1
return $candidate
}
function Get-ArchiveForEntry {
param($Entry, $Manifest)
if ($Manifest -and $Manifest.items.Contains($Entry.baseName)) {
$record = $Manifest.items[$Entry.baseName]
$archiveName = $null
if ($record.PSObject.Properties.Name -contains 'archive') { $archiveName = $record.archive }
if ($archiveName) {
$path = Join-Path $BackupDir $archiveName
if (Test-Path -LiteralPath $path) {
return [pscustomobject]@{ File = (Get-Item -LiteralPath $path); Source = 'manifest'; Record = $record }
}
Write-BakNRetLog "manifest 记录的归档不存在,回退按文件名查找:$archiveName" -Level WARN
}
}
$fallback = Find-ArchiveByBaseName -BaseName $Entry.baseName
if ($fallback) {
$record = $null
if ($Manifest -and $Manifest.items.Contains($Entry.baseName)) { $record = $Manifest.items[$Entry.baseName] }
return [pscustomobject]@{ File = $fallback; Source = 'filename'; Record = $record }
}
return $null
}
function Invoke-ExtractionRaw {
<#
.SYNOPSIS
把归档里某个子树解到指定目录,不关心"落地"问题。
.DESCRIPTION
归档布局:软件名条目是 `<Slot>\...`(Slot 就是归档内的一层目录),
手写路径条目是 `<源目录名>\...`。这里只负责把指定的那条路径解出来。
#>
param(
[Parameter(Mandatory = $true)][object]$ArchiveFile,
[Parameter(Mandatory = $true)][string]$Destination,
[string]$RelativePath,
[string]$Password
)
$extension = $ArchiveFile.Extension.ToLower()
if (-not (Test-Path -LiteralPath $Destination)) {
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
}
$sevenZip = Find-BakNRet7zExecutable
if ($sevenZip) {
Write-BakNRetLog '使用 7z 解压' -Level DEBUG
$argument = @('x', '-bsp2', '-y', "-o$Destination")
if ($Password) { $argument += "-p$Password" }
$argument += $ArchiveFile.FullName
if ($RelativePath) { $argument += $RelativePath }
$exitCode = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument
if ($exitCode -ne 0) { throw "7z 解压失败(退出码:$exitCode)" }
return $true
}
switch ($extension) {
'.rar' {
$rarExe = Get-Command rar, unrar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $rarExe) { throw '未找到 RAR 工具' }
Write-BakNRetLog '使用 RAR 解压' -Level DEBUG
$argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$Destination\")
if ($RelativePath) { $argument += $RelativePath }
$exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument
if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" }
}
'.zip' {
Write-BakNRetLog '使用内置 ZIP 解压' -Level DEBUG
if ($RelativePath) {
Write-BakNRetLog "内置 ZIP 不支持只解子树,将整包解压($RelativePath)" -Level WARN
}
Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $Destination -Force
}
'.tar' {
$tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $tarExe) { throw '未找到 TAR 工具' }
Write-BakNRetLog '使用 TAR 解压' -Level DEBUG
$argument = @('-xf', $ArchiveFile.FullName, '-C', $Destination)
if ($RelativePath) { $argument += $RelativePath }
$exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList $argument
if ($exitCode -ne 0) { throw "TAR 解压失败(退出码:$exitCode)" }
}
default { throw "不支持的文件格式:$extension" }
}
return $true
}
function Invoke-ExtractionByLayout {
<#
.SYNOPSIS
按**当前归档布局**(软件名条目 = `<Slot>\<内容>`)解出一个归档项并落到目标位置。
.DESCRIPTION
$Item:ArchivePath(归档内相对路径)、RealPath(宿主机目标)、IsFile。
落地方式(关键:不整包往目标里倒,只解出这一项自己那棵子树):
* 目录项 -> 在目标的父目录下建一个**指向目标目录的 junction**,
让 7z 直接写穿连接点落地(零拷贝,不需要"先解到临时目录再整体搬一遍"),
解完立刻拆掉连接点。建不出连接点(父目录里已有同名实体等)时,
退回"解到临时目录再逐项合并",只慢不错。
* 文件项 -> 解到临时目录后把文件搬到目标位置(保留 Path 里的原始文件名)。
目标目录只覆盖同名文件,不删除多余文件(镜像同步不是这里的语义)。
#>
param(
[Parameter(Mandatory = $true)][object]$ArchiveFile,
[Parameter(Mandatory = $true)][object]$Item,
[string]$Password
)
$archivePath = [string]$Item.ArchivePath
$destPath = [string]$Item.RealPath
if ([string]::IsNullOrWhiteSpace($archivePath)) { throw "归档项缺少归档内路径($destPath)" }
if ([string]::IsNullOrWhiteSpace($destPath)) { throw "归档项缺少目标路径($archivePath)" }
$destParent = Split-Path -Path $destPath -Parent
if (-not $destParent) { throw "无法确定目标父目录:$destPath" }
if ($Item.IsFile) {
$temp = Join-Path $env:TEMP ('bnr-file-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $temp -Force | Out-Null
try {
if (-not (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $temp -RelativePath $archivePath -Password $Password)) {
return $false
}
$produced = Join-Path $temp $archivePath
if (-not (Test-Path -LiteralPath $produced -PathType Leaf)) {
throw "归档里的 $archivePath 不是一个文件"
}
if (-not (Test-Path -LiteralPath $destParent)) {
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
}
Move-Item -LiteralPath $produced -Destination $destPath -Force
}
finally {
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
}
return $true
}
# 目录项:先把目标目录准备好(不存在就建),再决定用连接点还是合并兜底
if (-not (Test-Path -LiteralPath $destPath)) {
New-Item -ItemType Directory -Path $destPath -Force | Out-Null
}
$anchorName = Get-BakNRetArchiveTopName -ArchivePath $archivePath
$anchorPath = if ($anchorName) { Join-Path $destParent $anchorName } else { $null }
$junctionCreated = $false
if ($anchorPath -and -not (Test-Path -LiteralPath $anchorPath)) {
try {
New-BakNRetJunction -Path $anchorPath -Target $destPath | Out-Null
$junctionCreated = $true
Write-BakNRetLog ("落地:{0} -> {1}(经连接点 {2})" -f $archivePath, $destPath, $anchorPath) -Level DEBUG
}
catch {
Write-BakNRetLog "无法建连接点($($_.Exception.Message)),改为先解到临时目录再合并" -Level WARN
}
}
if ($junctionCreated) {
try {
return (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $destParent -RelativePath $archivePath -Password $Password)
}
finally {
Remove-BakNRetJunction -Path $anchorPath
}
}
Write-BakNRetLog ("落地:{0} -> {1}(先解到临时目录再合并)" -f $archivePath, $destPath) -Level WARN
$temp = Join-Path $env:TEMP ('bnr-merge-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $temp -Force | Out-Null
try {
if (-not (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $temp -RelativePath $archivePath -Password $Password)) {
return $false
}
$source = Join-Path $temp $archivePath
if (-not (Test-Path -LiteralPath $source)) { throw "归档里没有 $archivePath" }
# 逐个顶层子项复制(而不是 `Copy-Item '<源>\*'`):空目录时通配符匹配不到任何东西,
# Copy-Item 会直接报 "Cannot find path"。合并语义:覆盖同名文件,不删多余文件。
foreach ($child in @(Get-ChildItem -LiteralPath $source -Force -ErrorAction SilentlyContinue)) {
Copy-Item -LiteralPath $child.FullName -Destination $destPath -Recurse -Force
}
}
finally {
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
}
return $true
}
function Test-BakNRetArchivePath {
<#
.SYNOPSIS
归档里有没有这条路径。
.DESCRIPTION
必须问,不能靠退出码猜:7z 在"归档里没有这个名字"时**同样返回 0**
(打印一句 "No files to process" 就结束),所以只解压、然后看退出码,
会把"什么都没解出来"当成成功 —— 那正是最危险的静默失败。
7z 的列表输出没法用管道读(受限环境会拒绝创建管道),所以用
`Start-Process -RedirectStandardOutput <文件>` 把它重定向到文件再读
(Start-Process 的重定向是直接给子进程一个文件句柄,不经过管道);
用 -sccUTF-8 保证非 ASCII 路径不会因为控制台代码页而丢字。
列表为空 = 这条路径不在归档里。
注意这里刻意**不用** ExitCode:本机的 PowerShell 预览版上
`Start-Process -PassThru` 的 ExitCode 恒为 $null(见 README「设计取舍」),
而 7z 在"路径不存在"时退出码同样是 0,所以退出码本来也不可用。
#>
param(
[Parameter(Mandatory = $true)][object]$ArchiveFile,
[Parameter(Mandatory = $true)][string]$RelativePath,
[string]$Password
)
$sevenZip = Find-BakNRet7zExecutable
if (-not $sevenZip) { return $true } # 没有 7z 时不预判,交给解压分支自己去失败
$item = ([string]$RelativePath).Trim([char[]]@('\', '/'))
if ([string]::IsNullOrWhiteSpace($item)) { return $false }
$outFile = Join-Path $env:TEMP ('bnr-list-' + [guid]::NewGuid().ToString('N') + '.txt')
$errFile = "$outFile.err"
try {
$argument = @('l', '-ba', '-sccUTF-8')
if ($Password) { $argument += "-p$Password" }
$argument += $ArchiveFile.FullName
$argument += $item
$null = Start-Process -FilePath $sevenZip `
-ArgumentList (ConvertTo-BakNRetNativeArgumentString -ArgumentList $argument) `
-RedirectStandardOutput $outFile -RedirectStandardError $errFile `
-NoNewWindow -Wait -PassThru
$lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue)
}
catch {
Write-BakNRetLog "无法列出归档内容(跳过预判):$($_.Exception.Message)" -Level DEBUG
return $true
}
finally {
Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $errFile -Force -ErrorAction SilentlyContinue
}
# 列表每行的末尾才是路径(前面是时间 / 属性 / 大小),所以按"路径记号"来判定
$escaped = [regex]::Escape($item)
foreach ($line in $lines) {
$text = ([string]$line).Trim()
if (-not $text) { continue }
if ($text -match "(?i)(^|[\s\\/])$escaped($|[\s\\/])") { return $true }
}
return $false
}
function Invoke-Extraction {
<#
.SYNOPSIS
解出一个归档项并落地;包内布局对不上时回退到重构前的旧布局。
.DESCRIPTION
Slot 布局(`<Slot>\<内容>`)是本次重构才开始用的,Backups/ 里还躺着不少
按旧布局(包内直接是 `<源目录名>\...`)生成的归档。所以先问归档"这条路径在不在":
* 在 -> 按当前布局解(junction 零拷贝落地,见 Invoke-ExtractionByLayout);
* 不在,但有旧布局的 `<目标末级名>` -> 打印告警并按旧布局解,
与重构前的恢复语义完全一致;
* 两个都没有 -> 明确失败,而不是"成功地什么都没恢复"。
#>
param(
[Parameter(Mandatory = $true)][object]$ArchiveFile,
[Parameter(Mandatory = $true)][object]$Item,
[string]$Password
)
$archivePath = [string]$Item.ArchivePath
$destPath = [string]$Item.RealPath
$legacyName = Split-Path -Path $destPath -Leaf
if (Test-BakNRetArchivePath -ArchiveFile $ArchiveFile -RelativePath $archivePath -Password $Password) {
return (Invoke-ExtractionByLayout -ArchiveFile $ArchiveFile -Item $Item -Password $Password)
}
if ($legacyName -and ($legacyName -ine $archivePath) -and
(Test-BakNRetArchivePath -ArchiveFile $ArchiveFile -RelativePath $legacyName -Password $Password)) {
Write-BakNRetLog ("归档里没有 '{0}'(可能是重构前的旧归档),按旧布局回退为 '{1}'" -f $archivePath, $legacyName) -Level WARN
$parent = Split-Path -Path $destPath -Parent
if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
return (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $parent -RelativePath $legacyName -Password $Password)
}
throw ("归档 {0} 里既没有 '{1}',也没有旧布局的 '{2}';请确认归档与清单/名录是否匹配" -f `
$ArchiveFile.Name, $archivePath, $legacyName)
}
# ============================================================================
# 准备
# ============================================================================
if (-not (Test-Path -LiteralPath $BackupDir)) {
Write-BakNRetLog "备份目录不存在: $BackupDir" -Level ERROR
Stop-BakNRetLog
exit 1
}
$manifest = Read-BakNRetManifest -Path $manifestPath
if (-not (Test-Path -LiteralPath $BackupListPath)) {
Write-BakNRetLog '未找到配置文件,正在从备份内容生成...' -Level INFO
$paths = @()
if ($manifest.items.Count -gt 0) {
foreach ($key in $manifest.items.Keys) {
$record = $manifest.items[$key]
if ($record.PSObject.Properties.Name -contains 'source' -and $record.source) {
$paths += $record.source
}
}
}
if ($paths.Count -eq 0) {
$backupFiles = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -match '_from_' }
foreach ($file in $backupFiles) {
$original = Convert-BakNRetBackupFileNameToPath -FileName $file.Name
if ($original) { $paths += $original }
}
}
$paths = @($paths | Sort-Object -Unique)
if ($paths.Count -eq 0) {
Write-BakNRetLog '无法从备份内容还原出任何路径。' -Level ERROR
Stop-BakNRetLog
exit 1
}
$content = "# BackupList.txt(自动生成,排除规则需要手工补回)`n" + (($paths -join [Environment]::NewLine) + [Environment]::NewLine)
[System.IO.File]::WriteAllText($BackupListPath, $content, [System.Text.UTF8Encoding]::new($true))
Write-BakNRetLog "已生成配置,包含 $($paths.Count) 个项目,请检查后重新运行" -Level INFO
Stop-BakNRetLog
exit 0
}
# ============================================================================
# 主流程
# ============================================================================
$lines = Get-Content -Encoding UTF8 -LiteralPath $BackupListPath -ErrorAction Stop
$stats = @{ restored = 0; skipped = 0; failed = 0; verified = 0; planned = 0 }
$securityApplied = 0 # 本次回放成功的安全描述符对象数
$failures = @()
$referencedArchives = @()
# 只有真的恢复成功了才允许写回 manifest。
# -WhatIf / -DryRun / -VerifyOnly 以及"全部跳过"的运行必须一个字节都不写:
# 之前这里无条件写回,实际上只是把 updatedAt 改了,却直接违背了
# "试运行不会写入任何文件" 的承诺(已用 manifest 的 SHA256 复现)。
$manifestDirty = $false
Write-BakNRetLog '开始执行恢复' -Level INFO
foreach ($line in $lines) {
$item = ConvertFrom-BackupListLine -Line $line
if (-not $item) { continue }
$displayPath = $item.Path
$resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $catalogPath
$baseName = $resolved.BaseName
if (-not $baseName) { $stats.skipped++; continue }
if (-not (Test-BakNRetItemSelected -DisplayPath $displayPath -BaseName $baseName -Only $Only -Skip $Skip)) { continue }
if ($resolved.Direction -eq 'backup') {
# 仅备份的条目照样要登记归档名:审计要能看出"这个归档是有主的",
# 否则它会被误报成孤儿(只是它本来就恢复不到,因为行首写了 +)。
$referencedArchives += $baseName
Write-BakNRetLog "跳过(行首 +,仅备份): $displayPath" -Level DEBUG
continue
}
# 解析阶段就定死的结构性错误(名录条目有问题、归档内路径冲突):
# 恢复一半比明确失败更危险,所以整条失败。
if ($resolved.Blocking) {
Write-BakNRetLog "失败: $displayPath,$($resolved.Blocking)" -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
$found = Get-ArchiveForEntry -Entry ([pscustomobject]@{ baseName = $baseName }) -Manifest $manifest
if (-not $found) {
Write-BakNRetLog "跳过: $displayPath,未找到归档 $baseName" -Level WARN
$stats.skipped++
continue
}
# "是目录还是文件"的判据,按可靠性排序:
# 1. 目标在磁盘上真实存在 -> 直接看它;
# 2. manifest 记的 layouts(备份时记录的归档内条目类型)-> 全新恢复时靠它;
# 3. 名录解析出来的 IsFile(源当前存在时才有值);
# 4. 都没有就按目录处理。
$layouts = @{}
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'layouts')) {
foreach ($layout in @($found.Record.layouts)) {
if (-not $layout) { continue }
$layoutName = [string]$layout.name
if ([string]::IsNullOrWhiteSpace($layoutName)) { continue }
$layouts[$layoutName.ToLower()] = [string]$layout.kind
}
}
# 恢复目的地:一个条目可以挂多个归档项(名录里多个 Slot、`:+` 追加),
# 每一项只还原**它自己那棵子树**,不会把兄弟项也复制过去。
$targets = @()
foreach ($entryItem in @($resolved.Items)) {
$dest = [string]$entryItem.RealPath
if ([string]::IsNullOrWhiteSpace($dest)) { continue }
$isFile = [bool]$entryItem.IsFile
if (Test-Path -LiteralPath $dest -PathType Leaf) {
$isFile = $true
}
elseif (Test-Path -LiteralPath $dest -PathType Container) {
$isFile = $false
}
elseif ($layouts.ContainsKey(([string]$entryItem.ArchivePath).ToLower())) {
$isFile = ($layouts[([string]$entryItem.ArchivePath).ToLower()] -eq 'file')
}
$targets += [pscustomobject]@{
ArchivePath = [string]$entryItem.ArchivePath
RealPath = $dest
DestPath = $dest
IsFile = $isFile
Description = $entryItem.Description
Origin = $entryItem.Origin
}
}
# 兜底:解析不出归档项时按字面路径处理(历史清单里的裸路径)
if ($targets.Count -eq 0 -and -not $resolved.IsName) {
$expanded = [Environment]::ExpandEnvironmentVariables($displayPath)
if (-not [string]::IsNullOrWhiteSpace($expanded)) {
$targets += [pscustomobject]@{
ArchivePath = (Split-Path -Path $expanded -Leaf)
RealPath = $expanded
DestPath = $expanded
IsFile = (Test-Path -LiteralPath $expanded -PathType Leaf)
Description = $null
Origin = 'path'
}
}
}
# 防御:解析不出目的地时明确失败,别把空字符串喂给 Split-Path/Test-Path
# (那种报错是 "Cannot bind argument to parameter 'Path' because it is an empty string")
$targets = @($targets | Where-Object { $_.DestPath -and -not [string]::IsNullOrWhiteSpace($_.DestPath) })
if ($targets.Count -eq 0) {
$reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何归档项)"
Write-BakNRetLog "失败: $displayPath,$reason" -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
$destPath = $targets[0].DestPath
$archiveFile = $found.File
$referencedArchives += $archiveFile.BaseName
# 加密归档在取不到口令时必须直接失败:7z 在没有 -p 时会在控制台等输入,
# 在计划任务里会静默挂起,比报错更糟。
$isEncrypted = $false
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'encrypted')) {
$isEncrypted = [bool]$found.Record.encrypted
}
if ($isEncrypted -and -not $password) {
Write-BakNRetLog "失败: $displayPath,归档已加密但取不到口令(设置 BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件)" -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
if ($VerifyOnly) {
if ($archiveFile.Extension.ToLower() -ne '.7z') {
Write-BakNRetLog "跳过校验(非 7z): $($archiveFile.Name)" -Level DEBUG
continue
}
$verifyTool = Find-BakNRet7zExecutable
if (-not $verifyTool) {
Write-BakNRetLog '未找到 7z,无法校验' -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
$verifyArgument = @('t', '-bso0', '-bsp0')
if ($password) { $verifyArgument += "-p$password" }
$verifyArgument += $archiveFile.FullName
$verifyCode = Invoke-ExternalCommand -FilePath $verifyTool -ArgumentList $verifyArgument
if ($verifyCode -eq 0) {
Write-BakNRetLog "校验通过: $($archiveFile.Name)" -Level INFO
$stats.verified++
}
else {
Write-BakNRetLog "校验失败: $($archiveFile.Name)(退出码 $verifyCode)" -Level ERROR
$stats.failed++
$failures += $displayPath
}
continue
}
Write-BakNRetLog "准备恢复: $displayPath <- $($archiveFile.Name)(来源:$($found.Source))" -Level INFO
if ((Test-Path -LiteralPath $destPath) -and -not $Force) {
try {
$destSummary = Get-BakNRetFolderSummary -FolderPath $destPath
$archiveTime = $archiveFile.LastWriteTime
if ($destSummary.LatestModifiedTime -and $destSummary.LatestModifiedTime -gt $archiveTime) {
Write-BakNRetLog "跳过: $displayPath,目标目录比归档新(用 -Force 覆盖)" -Level WARN
$stats.skipped++
continue
}
}
catch {
Write-BakNRetLog "目标目录摘要读取失败,继续恢复:$_" -Level DEBUG
}
}
$plannedTargets = @($targets | Where-Object { $_.DestPath })
# 说清楚"这条会把哪些目录还原到哪儿、为什么"
Write-BakNRetLog ("恢复计划:{0}(归档 {1})" -f $displayPath, $archiveFile.Name)
foreach ($target in $plannedTargets) {
$targetExists = Test-Path -LiteralPath $target.DestPath
Write-BakNRetLog (" 目标:{0}" -f $target.DestPath)
Write-BakNRetLog (" 归档内路径:{0}({1});{2}" -f $target.ArchivePath,
$(if ($target.IsFile) { '文件' } else { '目录' }),
$(if ($targetExists) { '已存在,将覆盖同名文件' } else { '不存在,将新建' }))
if ($target.Description) { Write-BakNRetLog (" 介绍:{0}" -f $target.Description) }
}
foreach ($target in $plannedTargets) {
if (Test-Path -LiteralPath $target.DestPath) { continue }
# Split-Path -Parent 对根路径(如 "E:\")返回空串,此时无父目录可建
$targetParent = Split-Path -Path $target.DestPath -Parent
if ($targetParent) {
Write-BakNRetLog "提示: 目标不存在,将新建 $targetParent" -Level DEBUG
}
else {
Write-BakNRetLog "提示: 目标不存在,且没有可创建的父目录:$($target.DestPath)" -Level DEBUG
}
}
$shouldRun = $true
foreach ($target in $plannedTargets) {
if (-not $PSCmdlet.ShouldProcess($target.DestPath, "从 $($archiveFile.Name) 解压")) { $shouldRun = $false }
}
if (-not $shouldRun) {
foreach ($target in $plannedTargets) {
Write-BakNRetLog "[试运行] 将解压 $($archiveFile.Name) -> $($target.DestPath)" -Level INFO
}
$stats.planned++
continue
}
$restoreFailed = $false
try {
foreach ($target in $plannedTargets) {
if (-not (Invoke-Extraction -ArchiveFile $archiveFile -Item $target -Password $password)) {
$restoreFailed = $true
break
}
}
if (-not $restoreFailed) {
# ------------------------------------------------------------------
# 安全描述符(属主 / ACL)回放
# ------------------------------------------------------------------
# 解压出来的对象:属主是"跑恢复脚本的进程"、DACL 是"从目标父目录继承 + 进程默认"。
# 对 C:\ProgramData 这类目录是致命的 —— 那里的 (A;OICIIO;GA;;;CO) 靠
# CREATOR OWNER 把全权给"对象的属主",属主一变,原程序就没了权限。
# 必须在解压**之后**、对真实目标路径做(连接点在 Invoke-Extraction 里已经拆掉了)。
if ($SkipSecurity) {
Write-BakNRetLog '按 -SkipSecurity 跳过了安全描述符恢复' -Level DEBUG
}
elseif (([string]$script:Config.Security.Mode) -eq 'Off') {
Write-BakNRetLog '配置里 Security.Mode = Off,跳过安全描述符恢复' -Level DEBUG
}
else {
$sidecarName = $null
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'security') -and $found.Record.security) {
$sidecarName = [string]$found.Record.security.file
}
if (-not $sidecarName) { $sidecarName = "$baseName.acl.json" }
$sidecar = Read-BakNRetSecuritySidecar -Path (Join-Path $BackupDir $sidecarName)
if (-not $sidecar) {
Write-BakNRetLog ("这个归档没有安全描述符旁挂文件({0}):恢复出来的属主/ACL 是新建对象的默认值 —— 原程序若依赖特殊权限(ProgramData 下的 CREATOR OWNER 最典型),会报无读写权限" -f $sidecarName) -Level WARN
}
else {
$sidMap = @{}
if ($script:Config.Security.SidMap) { $sidMap = $script:Config.Security.SidMap }
$secTotal = 0; $secApplied = 0; $secOwnerFailed = 0; $secSkipped = 0; $secFailed = 0
$secMessages = @()
foreach ($target in $plannedTargets) {
$sec = Restore-BakNRetSecurity -Sidecar $sidecar -ArchiveRoot $target.ArchivePath `
-TargetPath $target.DestPath -SidMap $sidMap
$secTotal += $sec.Total
$secApplied += $sec.Applied
$secOwnerFailed += $sec.OwnerFailed
$secSkipped += $sec.Skipped
$secFailed += $sec.Failed
$secMessages += @($sec.Failures)
}
$securityApplied += $secApplied
Write-BakNRetLog ("安全描述符:回放 {0}/{1} 个对象(属主/属组未恢复 {2},跳过 {3},失败 {4})" -f `
$secApplied, $secTotal, $secOwnerFailed, $secSkipped, $secFailed) -Level INFO
foreach ($message in @($secMessages | Select-Object -First 5)) {
Write-BakNRetLog (" ! {0}" -f $message) -Level WARN
}
if ($secFailed -gt 0) {
Write-BakNRetLog ("恢复成功但安全描述符有 {0} 个对象失败,已计入失败条目(退出码 1)" -f $secFailed) -Level ERROR
$failures += $displayPath
}
}
}
$stats.restored++
Write-BakNRetLog "恢复成功: $baseName" -Level INFO
if ($manifest.items.Contains($baseName)) {
$record = $manifest.items[$baseName]
if ($record -is [System.Collections.IDictionary]) {
$record['lastRestoreAt'] = (Get-Date).ToString('o')
}
else {
$record | Add-Member -NotePropertyName lastRestoreAt -NotePropertyValue ((Get-Date).ToString('o')) -Force
}
$manifestDirty = $true
}
}
else {
$stats.failed++
$failures += $displayPath
}
}
catch {
Write-BakNRetLog "恢复失败: $displayPath,$_" -Level ERROR
$stats.failed++
$failures += $displayPath
}
}
# ============================================================================
# 收尾:报告孤儿归档
# ============================================================================
if (-not $VerifyOnly -and $Only.Count -eq 0 -and $Skip.Count -eq 0) {
$orphans = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -notin $referencedArchives })
if ($orphans.Count -gt 0) {
Write-BakNRetLog '以下归档没有任何清单条目指向(恢复不到,注意别误删):' -Level WARN
foreach ($orphan in $orphans) {
Write-BakNRetLog (" - {0}({1} MB,{2})" -f $orphan.Name, [math]::Round($orphan.Length / 1MB, 2), $orphan.LastWriteTime) -Level WARN
}
}
}
elseif (-not $VerifyOnly) {
# 带 -Only/-Skip 时只有被选中的条目会被处理,其余归档都不在 $referencedArchives 里,
# 按上面的算法报出来全是假孤儿(还会吓唬人说"注意别误删"),所以整段跳过。
Write-BakNRetLog '本次只恢复了部分条目,跳过孤儿归档审计(避免把未选中的归档误报成孤儿)' -Level DEBUG
}
try {
if ($manifestDirty) {
# 顺手维持"manifest 写了 archive,磁盘上就真有那个文件"这条不变式
$null = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $BackupDir
Write-BakNRetManifest -Path $manifestPath -Manifest $manifest | Out-Null
Write-BakNRetLog 'manifest 已更新(记下本次恢复时间)' -Level DEBUG
}
else {
Write-BakNRetLog 'manifest 无需更新:本次没有实际恢复任何条目' -Level DEBUG
}
}
catch {
Write-BakNRetLog "manifest 写回失败(不影响本次恢复):$_" -Level WARN
}
$summaryMode = if ($VerifyOnly) { 'verify' } else { 'restore' }
$counterText = @{ 成功 = $stats.restored; 跳过 = $stats.skipped; 失败 = $stats.failed; 校验通过 = $stats.verified }
if ($stats.planned -gt 0) { $counterText['试运行计划'] = $stats.planned }
Write-BakNRetRunSummary -Mode $summaryMode -Manifest $manifest -StartedAt $runStartedAt -Failures $failures -Counters $counterText -SecurityApplied $securityApplied
$logPath = Get-BakNRetLogPath
if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO }
Exit-BakNRetRunLock -Lock $runLock
Stop-BakNRetLog
if ($stats.failed -gt 0) { exit 1 }
exit 0
+53 -847
View File
@@ -1,858 +1,64 @@
<# <#
.SYNOPSIS .SYNOPSIS
按 BackupList.txt 执行恢复。 已改名:本脚本只是转发到 Restore-Data.ps1(这一层只保留一轮)。
.DESCRIPTION .DESCRIPTION
与旧版相比的核心变化: 为什么留一层转发(ADR-0012):入口脚本是**外部接口** —— README 里有二十多处引用、有使用者的
肌肉记忆、tools\Register-BackupTask.ps1 里也可能已经注册过这个路径。内部实现改名断了会当场
报错;外部接口改名断了是**静默没用**,而备份工具"静默没用"是最不能接受的失败方式。
1. 归档查找以 manifest.json 为准(按归档基础名索引),拿不到才退回 为什么用子进程、而不是 `& $target`:实测 `& script.ps1` 里子脚本的 exit **不会**把退出码传到
"从文件名反推路径"。旧版只靠文件名反推,且用 -Filter "$baseName.*" 通配匹配, 父脚本的 $LASTEXITCODE —— 垫片会让失败变成"成功"(错配置时返回 0,被调用脚本返回 1),而计划
一旦解析出偏差,归档就变成谁都找不到的孤儿。 任务正是靠退出码判断成败。
2. 退出码可靠:三条解压分支(7z / RAR / tar)统一走 Invoke-ExternalCommand。
旧版 tar 分支写成 `$LASTEXITCODE -ne 0 -and $proc.ExitCode -ne 0`, 为什么重定向之后要**自己转发**:父进程的 stdout 常常是管道(测试与使用者的管道都在解析入口的
而 $LASTEXITCODE 是上一条原生命令的残留值,跟 Start-Process 无关, 输出),而 .NET 起的进程默认只继承控制台、不继承那个管道 —— 不重定向时子进程的输出就到不了
恰为 0 时会把解压失败吞掉并报成功。 调用方(实测红过)。所以显式重定向,再用**异步读**把两个流读出来转发(同步先读 stdout 再读
3. 支持 -WhatIf / -DryRun:恢复是会覆盖 E:\CodeSpace、Edge User Data 这种 stderr 会在管道写满时死锁)。代价是 stdout/stderr 的相对顺序不再保留 —— 这也正是这层垫片
真实目录的破坏性操作,必须能先看清单再决定。 只留一轮的原因之一。
4. 支持 -Only / -Skip 只恢复指定条目,-VerifyOnly 只校验不写盘。
5. 结尾按失败数 exit。 为什么导入模块时临时压掉 verbose:调用方可能给入口传 -Verbose(测试就是这么拿到详细日志的),
6. 清单行首 `+`(仅备份)的条目会跳过;`-`(仅恢复)的条目照常恢复。 那样 Import-Module 会多打一行 "VERBOSE: Loading module from path ..." —— 而测试是**解析子进程
7. 归档内的一层目录由 SoftwareCatalog 的 Slot 决定(`<Slot>\<内容>`), 输出**做断言的,多这么一行就会把它顶掉。只压这一句,$Rest 里的 -Verbose 仍会原样转发。
恢复时只解出该 Slot 那棵子树,并通过"目标父目录下的 junction"直接落地
(零拷贝;建不出连接点时退回先解到临时目录再合并)。 这一层下一轮删。想用新名字就直接调 Restore-Data.ps1。
#> #>
[CmdletBinding()]
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param( param(
[Parameter()] [Parameter(ValueFromRemainingArguments = $true)]$Rest
[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' $ErrorActionPreference = 'Stop'
# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上, $savedVerbosePreference = $VerbosePreference
# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带 $VerbosePreference = 'SilentlyContinue'
# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值 Import-Module (Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1') -Force
# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。 $VerbosePreference = $savedVerbosePreference
if (-not $BackupListPath) { $BackupListPath = Join-Path $PSScriptRoot 'BackupList.txt' }
if (-not $ConfigPath) { $ConfigPath = Join-Path $PSScriptRoot 'BackupConfig.psd1' } $target = Join-Path $PSScriptRoot 'Restore-Data.ps1'
Write-Host '注意:Restore.ps1 已改名为 Restore-Data.ps1(这层转发只保留一轮)。' -ForegroundColor Yellow
if ($DryRun) { $WhatIfPreference = $true }
$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
$modulePath = Join-Path $PSScriptRoot 'BakNRet\BakNRet.psd1' $startInfo.Arguments = ConvertTo-BakNRetNativeArgumentString -ArgumentList $forwardArguments
if (-not (Test-Path -LiteralPath $modulePath)) { $startInfo.UseShellExecute = $false
Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。" $startInfo.RedirectStandardOutput = $true
exit 1 $startInfo.RedirectStandardError = $true
}
Import-Module $modulePath -Force $process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
if ($PSBoundParameters.ContainsKey('Verbose')) { Set-BakNRetDebug } [void]$process.Start()
$script:Config = Get-BakNRetConfig -Path $ConfigPath $stdoutTask = $process.StandardOutput.ReadToEndAsync()
$SupportedFormats = @('.7z', '.rar', '.zip', '.tar') $stderrTask = $process.StandardError.ReadToEndAsync()
$process.WaitForExit()
if (-not $BackupDir) { $BackupDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.BackupDir -Root $PSScriptRoot } $standardOutput = $stdoutTask.Result
$logDir = Resolve-BakNRetRootedPath -Path $null -Default $script:Config.LogDir -Root $PSScriptRoot $standardError = $stderrTask.Result
$catalogPath = Resolve-BakNRetCatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot if ($standardOutput) { Write-Host -NoNewline $standardOutput }
$manifestPath = Join-Path $BackupDir 'manifest.json' if ($standardError) { [Console]::Error.Write($standardError) }
$logPath = Start-BakNRetLog -Directory $logDir -Prefix 'restore' exit $process.ExitCode
$runStartedAt = Get-Date
Write-BakNRetLog "日志文件:$logPath"
Write-BakNRetLog "备份目录:$BackupDir"
Write-BakNRetLog ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
if ($WhatIfPreference) { Write-BakNRetLog '试运行模式(-WhatIf / -DryRun):不会写入任何文件' -Level WARN }
if (-not (Test-BakNRetAdministrator)) {
Write-BakNRetLog '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
}
# 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。
# 三种只读模式不取锁:它们一个字节都不写,没必要被正在跑的备份挡在外面。
$runLock = $null
if (-not $WhatIfPreference -and -not $VerifyOnly) {
$runLock = Enter-BakNRetRunLock -Directory $BackupDir
if (-not $runLock) {
Write-BakNRetLog ("另一次运行正在进行中(锁文件:{0},里面写明了持有者)。本次不执行。" -f (Get-BakNRetRunLockPath -Directory $BackupDir)) -Level ERROR
Stop-BakNRetLog
exit 1
}
Write-BakNRetLog ("已取得运行锁:{0}" -f (Get-BakNRetRunLockPath -Directory $BackupDir)) -Level DEBUG
}
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
if ($passwordFile) {
# 相对路径按**仓库根**解析,而不是按当前工作目录:计划任务的工作目录是 C:\Windows\System32,
# 在那里 Test-Path 找不到 baknret.key,加密条目就会以"拿不到口令"失败,而配置看上去没问题。
$passwordFile = Resolve-BakNRetRootedPath -Path $passwordFile -Default $passwordFile -Root $PSScriptRoot
}
$password = Get-BakNRetPassword -PasswordFile $passwordFile
# ============================================================================
# 归档查找
# ============================================================================
function Find-ArchiveByBaseName {
<#
.SYNOPSIS
按归档基础名精确定位归档文件。
.DESCRIPTION
旧版用 Get-ChildItem -Filter "$baseName.*",-Filter 会做通配符解释,
路径里含 `[` `]` 时会失配;这里改为精确比较 BaseName。
#>
param([string]$BaseName)
$candidate = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.BaseName -eq $BaseName -and $_.Extension.ToLower() -in $SupportedFormats } |
Select-Object -First 1
return $candidate
}
function Get-ArchiveForEntry {
param($Entry, $Manifest)
if ($Manifest -and $Manifest.items.Contains($Entry.baseName)) {
$record = $Manifest.items[$Entry.baseName]
$archiveName = $null
if ($record.PSObject.Properties.Name -contains 'archive') { $archiveName = $record.archive }
if ($archiveName) {
$path = Join-Path $BackupDir $archiveName
if (Test-Path -LiteralPath $path) {
return [pscustomobject]@{ File = (Get-Item -LiteralPath $path); Source = 'manifest'; Record = $record }
}
Write-BakNRetLog "manifest 记录的归档不存在,回退按文件名查找:$archiveName" -Level WARN
}
}
$fallback = Find-ArchiveByBaseName -BaseName $Entry.baseName
if ($fallback) {
$record = $null
if ($Manifest -and $Manifest.items.Contains($Entry.baseName)) { $record = $Manifest.items[$Entry.baseName] }
return [pscustomobject]@{ File = $fallback; Source = 'filename'; Record = $record }
}
return $null
}
function Invoke-ExtractionRaw {
<#
.SYNOPSIS
把归档里某个子树解到指定目录,不关心"落地"问题。
.DESCRIPTION
归档布局:软件名条目是 `<Slot>\...`(Slot 就是归档内的一层目录),
手写路径条目是 `<源目录名>\...`。这里只负责把指定的那条路径解出来。
#>
param(
[Parameter(Mandatory = $true)][object]$ArchiveFile,
[Parameter(Mandatory = $true)][string]$Destination,
[string]$RelativePath,
[string]$Password
)
$extension = $ArchiveFile.Extension.ToLower()
if (-not (Test-Path -LiteralPath $Destination)) {
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
}
$sevenZip = Find-BakNRet7zExecutable
if ($sevenZip) {
Write-BakNRetLog '使用 7z 解压' -Level DEBUG
$argument = @('x', '-bsp2', '-y', "-o$Destination")
if ($Password) { $argument += "-p$Password" }
$argument += $ArchiveFile.FullName
if ($RelativePath) { $argument += $RelativePath }
$exitCode = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument
if ($exitCode -ne 0) { throw "7z 解压失败(退出码:$exitCode)" }
return $true
}
switch ($extension) {
'.rar' {
$rarExe = Get-Command rar, unrar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $rarExe) { throw '未找到 RAR 工具' }
Write-BakNRetLog '使用 RAR 解压' -Level DEBUG
$argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$Destination\")
if ($RelativePath) { $argument += $RelativePath }
$exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument
if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" }
}
'.zip' {
Write-BakNRetLog '使用内置 ZIP 解压' -Level DEBUG
if ($RelativePath) {
Write-BakNRetLog "内置 ZIP 不支持只解子树,将整包解压($RelativePath)" -Level WARN
}
Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $Destination -Force
}
'.tar' {
$tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $tarExe) { throw '未找到 TAR 工具' }
Write-BakNRetLog '使用 TAR 解压' -Level DEBUG
$argument = @('-xf', $ArchiveFile.FullName, '-C', $Destination)
if ($RelativePath) { $argument += $RelativePath }
$exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList $argument
if ($exitCode -ne 0) { throw "TAR 解压失败(退出码:$exitCode)" }
}
default { throw "不支持的文件格式:$extension" }
}
return $true
}
function Invoke-ExtractionByLayout {
<#
.SYNOPSIS
按**当前归档布局**(软件名条目 = `<Slot>\<内容>`)解出一个归档项并落到目标位置。
.DESCRIPTION
$Item:ArchivePath(归档内相对路径)、RealPath(宿主机目标)、IsFile。
落地方式(关键:不整包往目标里倒,只解出这一项自己那棵子树):
* 目录项 -> 在目标的父目录下建一个**指向目标目录的 junction**,
让 7z 直接写穿连接点落地(零拷贝,不需要"先解到临时目录再整体搬一遍"),
解完立刻拆掉连接点。建不出连接点(父目录里已有同名实体等)时,
退回"解到临时目录再逐项合并",只慢不错。
* 文件项 -> 解到临时目录后把文件搬到目标位置(保留 Path 里的原始文件名)。
目标目录只覆盖同名文件,不删除多余文件(镜像同步不是这里的语义)。
#>
param(
[Parameter(Mandatory = $true)][object]$ArchiveFile,
[Parameter(Mandatory = $true)][object]$Item,
[string]$Password
)
$archivePath = [string]$Item.ArchivePath
$destPath = [string]$Item.RealPath
if ([string]::IsNullOrWhiteSpace($archivePath)) { throw "归档项缺少归档内路径($destPath)" }
if ([string]::IsNullOrWhiteSpace($destPath)) { throw "归档项缺少目标路径($archivePath)" }
$destParent = Split-Path -Path $destPath -Parent
if (-not $destParent) { throw "无法确定目标父目录:$destPath" }
if ($Item.IsFile) {
$temp = Join-Path $env:TEMP ('bnr-file-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $temp -Force | Out-Null
try {
if (-not (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $temp -RelativePath $archivePath -Password $Password)) {
return $false
}
$produced = Join-Path $temp $archivePath
if (-not (Test-Path -LiteralPath $produced -PathType Leaf)) {
throw "归档里的 $archivePath 不是一个文件"
}
if (-not (Test-Path -LiteralPath $destParent)) {
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
}
Move-Item -LiteralPath $produced -Destination $destPath -Force
}
finally {
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
}
return $true
}
# 目录项:先把目标目录准备好(不存在就建),再决定用连接点还是合并兜底
if (-not (Test-Path -LiteralPath $destPath)) {
New-Item -ItemType Directory -Path $destPath -Force | Out-Null
}
$anchorName = Get-BakNRetArchiveTopName -ArchivePath $archivePath
$anchorPath = if ($anchorName) { Join-Path $destParent $anchorName } else { $null }
$junctionCreated = $false
if ($anchorPath -and -not (Test-Path -LiteralPath $anchorPath)) {
try {
New-BakNRetJunction -Path $anchorPath -Target $destPath | Out-Null
$junctionCreated = $true
Write-BakNRetLog ("落地:{0} -> {1}(经连接点 {2})" -f $archivePath, $destPath, $anchorPath) -Level DEBUG
}
catch {
Write-BakNRetLog "无法建连接点($($_.Exception.Message)),改为先解到临时目录再合并" -Level WARN
}
}
if ($junctionCreated) {
try {
return (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $destParent -RelativePath $archivePath -Password $Password)
}
finally {
Remove-BakNRetJunction -Path $anchorPath
}
}
Write-BakNRetLog ("落地:{0} -> {1}(先解到临时目录再合并)" -f $archivePath, $destPath) -Level WARN
$temp = Join-Path $env:TEMP ('bnr-merge-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $temp -Force | Out-Null
try {
if (-not (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $temp -RelativePath $archivePath -Password $Password)) {
return $false
}
$source = Join-Path $temp $archivePath
if (-not (Test-Path -LiteralPath $source)) { throw "归档里没有 $archivePath" }
# 逐个顶层子项复制(而不是 `Copy-Item '<源>\*'`):空目录时通配符匹配不到任何东西,
# Copy-Item 会直接报 "Cannot find path"。合并语义:覆盖同名文件,不删多余文件。
foreach ($child in @(Get-ChildItem -LiteralPath $source -Force -ErrorAction SilentlyContinue)) {
Copy-Item -LiteralPath $child.FullName -Destination $destPath -Recurse -Force
}
}
finally {
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
}
return $true
}
function Test-BakNRetArchivePath {
<#
.SYNOPSIS
归档里有没有这条路径。
.DESCRIPTION
必须问,不能靠退出码猜:7z 在"归档里没有这个名字"时**同样返回 0**
(打印一句 "No files to process" 就结束),所以只解压、然后看退出码,
会把"什么都没解出来"当成成功 —— 那正是最危险的静默失败。
7z 的列表输出没法用管道读(受限环境会拒绝创建管道),所以用
`Start-Process -RedirectStandardOutput <文件>` 把它重定向到文件再读
(Start-Process 的重定向是直接给子进程一个文件句柄,不经过管道);
用 -sccUTF-8 保证非 ASCII 路径不会因为控制台代码页而丢字。
列表为空 = 这条路径不在归档里。
注意这里刻意**不用** ExitCode:本机的 PowerShell 预览版上
`Start-Process -PassThru` 的 ExitCode 恒为 $null(见 README「设计取舍」),
而 7z 在"路径不存在"时退出码同样是 0,所以退出码本来也不可用。
#>
param(
[Parameter(Mandatory = $true)][object]$ArchiveFile,
[Parameter(Mandatory = $true)][string]$RelativePath,
[string]$Password
)
$sevenZip = Find-BakNRet7zExecutable
if (-not $sevenZip) { return $true } # 没有 7z 时不预判,交给解压分支自己去失败
$item = ([string]$RelativePath).Trim([char[]]@('\', '/'))
if ([string]::IsNullOrWhiteSpace($item)) { return $false }
$outFile = Join-Path $env:TEMP ('bnr-list-' + [guid]::NewGuid().ToString('N') + '.txt')
$errFile = "$outFile.err"
try {
$argument = @('l', '-ba', '-sccUTF-8')
if ($Password) { $argument += "-p$Password" }
$argument += $ArchiveFile.FullName
$argument += $item
$null = Start-Process -FilePath $sevenZip `
-ArgumentList (ConvertTo-BakNRetNativeArgumentString -ArgumentList $argument) `
-RedirectStandardOutput $outFile -RedirectStandardError $errFile `
-NoNewWindow -Wait -PassThru
$lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue)
}
catch {
Write-BakNRetLog "无法列出归档内容(跳过预判):$($_.Exception.Message)" -Level DEBUG
return $true
}
finally {
Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $errFile -Force -ErrorAction SilentlyContinue
}
# 列表每行的末尾才是路径(前面是时间 / 属性 / 大小),所以按"路径记号"来判定
$escaped = [regex]::Escape($item)
foreach ($line in $lines) {
$text = ([string]$line).Trim()
if (-not $text) { continue }
if ($text -match "(?i)(^|[\s\\/])$escaped($|[\s\\/])") { return $true }
}
return $false
}
function Invoke-Extraction {
<#
.SYNOPSIS
解出一个归档项并落地;包内布局对不上时回退到重构前的旧布局。
.DESCRIPTION
Slot 布局(`<Slot>\<内容>`)是本次重构才开始用的,Backups/ 里还躺着不少
按旧布局(包内直接是 `<源目录名>\...`)生成的归档。所以先问归档"这条路径在不在":
* 在 -> 按当前布局解(junction 零拷贝落地,见 Invoke-ExtractionByLayout);
* 不在,但有旧布局的 `<目标末级名>` -> 打印告警并按旧布局解,
与重构前的恢复语义完全一致;
* 两个都没有 -> 明确失败,而不是"成功地什么都没恢复"。
#>
param(
[Parameter(Mandatory = $true)][object]$ArchiveFile,
[Parameter(Mandatory = $true)][object]$Item,
[string]$Password
)
$archivePath = [string]$Item.ArchivePath
$destPath = [string]$Item.RealPath
$legacyName = Split-Path -Path $destPath -Leaf
if (Test-BakNRetArchivePath -ArchiveFile $ArchiveFile -RelativePath $archivePath -Password $Password) {
return (Invoke-ExtractionByLayout -ArchiveFile $ArchiveFile -Item $Item -Password $Password)
}
if ($legacyName -and ($legacyName -ine $archivePath) -and
(Test-BakNRetArchivePath -ArchiveFile $ArchiveFile -RelativePath $legacyName -Password $Password)) {
Write-BakNRetLog ("归档里没有 '{0}'(可能是重构前的旧归档),按旧布局回退为 '{1}'" -f $archivePath, $legacyName) -Level WARN
$parent = Split-Path -Path $destPath -Parent
if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
return (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $parent -RelativePath $legacyName -Password $Password)
}
throw ("归档 {0} 里既没有 '{1}',也没有旧布局的 '{2}';请确认归档与清单/名录是否匹配" -f `
$ArchiveFile.Name, $archivePath, $legacyName)
}
# ============================================================================
# 准备
# ============================================================================
if (-not (Test-Path -LiteralPath $BackupDir)) {
Write-BakNRetLog "备份目录不存在: $BackupDir" -Level ERROR
Stop-BakNRetLog
exit 1
}
$manifest = Read-BakNRetManifest -Path $manifestPath
if (-not (Test-Path -LiteralPath $BackupListPath)) {
Write-BakNRetLog '未找到配置文件,正在从备份内容生成...' -Level INFO
$paths = @()
if ($manifest.items.Count -gt 0) {
foreach ($key in $manifest.items.Keys) {
$record = $manifest.items[$key]
if ($record.PSObject.Properties.Name -contains 'source' -and $record.source) {
$paths += $record.source
}
}
}
if ($paths.Count -eq 0) {
$backupFiles = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -match '_from_' }
foreach ($file in $backupFiles) {
$original = Convert-BakNRetBackupFileNameToPath -FileName $file.Name
if ($original) { $paths += $original }
}
}
$paths = @($paths | Sort-Object -Unique)
if ($paths.Count -eq 0) {
Write-BakNRetLog '无法从备份内容还原出任何路径。' -Level ERROR
Stop-BakNRetLog
exit 1
}
$content = "# BackupList.txt(自动生成,排除规则需要手工补回)`n" + (($paths -join [Environment]::NewLine) + [Environment]::NewLine)
[System.IO.File]::WriteAllText($BackupListPath, $content, [System.Text.UTF8Encoding]::new($true))
Write-BakNRetLog "已生成配置,包含 $($paths.Count) 个项目,请检查后重新运行" -Level INFO
Stop-BakNRetLog
exit 0
}
# ============================================================================
# 主流程
# ============================================================================
$lines = Get-Content -Encoding UTF8 -LiteralPath $BackupListPath -ErrorAction Stop
$stats = @{ restored = 0; skipped = 0; failed = 0; verified = 0; planned = 0 }
$securityApplied = 0 # 本次回放成功的安全描述符对象数
$failures = @()
$referencedArchives = @()
# 只有真的恢复成功了才允许写回 manifest。
# -WhatIf / -DryRun / -VerifyOnly 以及"全部跳过"的运行必须一个字节都不写:
# 之前这里无条件写回,实际上只是把 updatedAt 改了,却直接违背了
# "试运行不会写入任何文件" 的承诺(已用 manifest 的 SHA256 复现)。
$manifestDirty = $false
Write-BakNRetLog '开始执行恢复' -Level INFO
foreach ($line in $lines) {
$item = ConvertFrom-BackupListLine -Line $line
if (-not $item) { continue }
$displayPath = $item.Path
$resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $catalogPath
$baseName = $resolved.BaseName
if (-not $baseName) { $stats.skipped++; continue }
if (-not (Test-BakNRetItemSelected -DisplayPath $displayPath -BaseName $baseName -Only $Only -Skip $Skip)) { continue }
if ($resolved.Direction -eq 'backup') {
# 仅备份的条目照样要登记归档名:审计要能看出"这个归档是有主的",
# 否则它会被误报成孤儿(只是它本来就恢复不到,因为行首写了 +)。
$referencedArchives += $baseName
Write-BakNRetLog "跳过(行首 +,仅备份): $displayPath" -Level DEBUG
continue
}
# 解析阶段就定死的结构性错误(名录条目有问题、归档内路径冲突):
# 恢复一半比明确失败更危险,所以整条失败。
if ($resolved.Blocking) {
Write-BakNRetLog "失败: $displayPath,$($resolved.Blocking)" -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
$found = Get-ArchiveForEntry -Entry ([pscustomobject]@{ baseName = $baseName }) -Manifest $manifest
if (-not $found) {
Write-BakNRetLog "跳过: $displayPath,未找到归档 $baseName" -Level WARN
$stats.skipped++
continue
}
# "是目录还是文件"的判据,按可靠性排序:
# 1. 目标在磁盘上真实存在 -> 直接看它;
# 2. manifest 记的 layouts(备份时记录的归档内条目类型)-> 全新恢复时靠它;
# 3. 名录解析出来的 IsFile(源当前存在时才有值);
# 4. 都没有就按目录处理。
$layouts = @{}
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'layouts')) {
foreach ($layout in @($found.Record.layouts)) {
if (-not $layout) { continue }
$layoutName = [string]$layout.name
if ([string]::IsNullOrWhiteSpace($layoutName)) { continue }
$layouts[$layoutName.ToLower()] = [string]$layout.kind
}
}
# 恢复目的地:一个条目可以挂多个归档项(名录里多个 Slot、`:+` 追加),
# 每一项只还原**它自己那棵子树**,不会把兄弟项也复制过去。
$targets = @()
foreach ($entryItem in @($resolved.Items)) {
$dest = [string]$entryItem.RealPath
if ([string]::IsNullOrWhiteSpace($dest)) { continue }
$isFile = [bool]$entryItem.IsFile
if (Test-Path -LiteralPath $dest -PathType Leaf) {
$isFile = $true
}
elseif (Test-Path -LiteralPath $dest -PathType Container) {
$isFile = $false
}
elseif ($layouts.ContainsKey(([string]$entryItem.ArchivePath).ToLower())) {
$isFile = ($layouts[([string]$entryItem.ArchivePath).ToLower()] -eq 'file')
}
$targets += [pscustomobject]@{
ArchivePath = [string]$entryItem.ArchivePath
RealPath = $dest
DestPath = $dest
IsFile = $isFile
Description = $entryItem.Description
Origin = $entryItem.Origin
}
}
# 兜底:解析不出归档项时按字面路径处理(历史清单里的裸路径)
if ($targets.Count -eq 0 -and -not $resolved.IsName) {
$expanded = [Environment]::ExpandEnvironmentVariables($displayPath)
if (-not [string]::IsNullOrWhiteSpace($expanded)) {
$targets += [pscustomobject]@{
ArchivePath = (Split-Path -Path $expanded -Leaf)
RealPath = $expanded
DestPath = $expanded
IsFile = (Test-Path -LiteralPath $expanded -PathType Leaf)
Description = $null
Origin = 'path'
}
}
}
# 防御:解析不出目的地时明确失败,别把空字符串喂给 Split-Path/Test-Path
# (那种报错是 "Cannot bind argument to parameter 'Path' because it is an empty string")
$targets = @($targets | Where-Object { $_.DestPath -and -not [string]::IsNullOrWhiteSpace($_.DestPath) })
if ($targets.Count -eq 0) {
$reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何归档项)"
Write-BakNRetLog "失败: $displayPath,$reason" -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
$destPath = $targets[0].DestPath
$archiveFile = $found.File
$referencedArchives += $archiveFile.BaseName
# 加密归档在取不到口令时必须直接失败:7z 在没有 -p 时会在控制台等输入,
# 在计划任务里会静默挂起,比报错更糟。
$isEncrypted = $false
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'encrypted')) {
$isEncrypted = [bool]$found.Record.encrypted
}
if ($isEncrypted -and -not $password) {
Write-BakNRetLog "失败: $displayPath,归档已加密但取不到口令(设置 BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件)" -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
if ($VerifyOnly) {
if ($archiveFile.Extension.ToLower() -ne '.7z') {
Write-BakNRetLog "跳过校验(非 7z): $($archiveFile.Name)" -Level DEBUG
continue
}
$verifyTool = Find-BakNRet7zExecutable
if (-not $verifyTool) {
Write-BakNRetLog '未找到 7z,无法校验' -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
$verifyArgument = @('t', '-bso0', '-bsp0')
if ($password) { $verifyArgument += "-p$password" }
$verifyArgument += $archiveFile.FullName
$verifyCode = Invoke-ExternalCommand -FilePath $verifyTool -ArgumentList $verifyArgument
if ($verifyCode -eq 0) {
Write-BakNRetLog "校验通过: $($archiveFile.Name)" -Level INFO
$stats.verified++
}
else {
Write-BakNRetLog "校验失败: $($archiveFile.Name)(退出码 $verifyCode)" -Level ERROR
$stats.failed++
$failures += $displayPath
}
continue
}
Write-BakNRetLog "准备恢复: $displayPath <- $($archiveFile.Name)(来源:$($found.Source))" -Level INFO
if ((Test-Path -LiteralPath $destPath) -and -not $Force) {
try {
$destSummary = Get-BakNRetFolderSummary -FolderPath $destPath
$archiveTime = $archiveFile.LastWriteTime
if ($destSummary.LatestModifiedTime -and $destSummary.LatestModifiedTime -gt $archiveTime) {
Write-BakNRetLog "跳过: $displayPath,目标目录比归档新(用 -Force 覆盖)" -Level WARN
$stats.skipped++
continue
}
}
catch {
Write-BakNRetLog "目标目录摘要读取失败,继续恢复:$_" -Level DEBUG
}
}
$plannedTargets = @($targets | Where-Object { $_.DestPath })
# 说清楚"这条会把哪些目录还原到哪儿、为什么"
Write-BakNRetLog ("恢复计划:{0}(归档 {1})" -f $displayPath, $archiveFile.Name)
foreach ($target in $plannedTargets) {
$targetExists = Test-Path -LiteralPath $target.DestPath
Write-BakNRetLog (" 目标:{0}" -f $target.DestPath)
Write-BakNRetLog (" 归档内路径:{0}({1});{2}" -f $target.ArchivePath,
$(if ($target.IsFile) { '文件' } else { '目录' }),
$(if ($targetExists) { '已存在,将覆盖同名文件' } else { '不存在,将新建' }))
if ($target.Description) { Write-BakNRetLog (" 介绍:{0}" -f $target.Description) }
}
foreach ($target in $plannedTargets) {
if (Test-Path -LiteralPath $target.DestPath) { continue }
# Split-Path -Parent 对根路径(如 "E:\")返回空串,此时无父目录可建
$targetParent = Split-Path -Path $target.DestPath -Parent
if ($targetParent) {
Write-BakNRetLog "提示: 目标不存在,将新建 $targetParent" -Level DEBUG
}
else {
Write-BakNRetLog "提示: 目标不存在,且没有可创建的父目录:$($target.DestPath)" -Level DEBUG
}
}
$shouldRun = $true
foreach ($target in $plannedTargets) {
if (-not $PSCmdlet.ShouldProcess($target.DestPath, "从 $($archiveFile.Name) 解压")) { $shouldRun = $false }
}
if (-not $shouldRun) {
foreach ($target in $plannedTargets) {
Write-BakNRetLog "[试运行] 将解压 $($archiveFile.Name) -> $($target.DestPath)" -Level INFO
}
$stats.planned++
continue
}
$restoreFailed = $false
try {
foreach ($target in $plannedTargets) {
if (-not (Invoke-Extraction -ArchiveFile $archiveFile -Item $target -Password $password)) {
$restoreFailed = $true
break
}
}
if (-not $restoreFailed) {
# ------------------------------------------------------------------
# 安全描述符(属主 / ACL)回放
# ------------------------------------------------------------------
# 解压出来的对象:属主是"跑恢复脚本的进程"、DACL 是"从目标父目录继承 + 进程默认"。
# 对 C:\ProgramData 这类目录是致命的 —— 那里的 (A;OICIIO;GA;;;CO) 靠
# CREATOR OWNER 把全权给"对象的属主",属主一变,原程序就没了权限。
# 必须在解压**之后**、对真实目标路径做(连接点在 Invoke-Extraction 里已经拆掉了)。
if ($SkipSecurity) {
Write-BakNRetLog '按 -SkipSecurity 跳过了安全描述符恢复' -Level DEBUG
}
elseif (([string]$script:Config.Security.Mode) -eq 'Off') {
Write-BakNRetLog '配置里 Security.Mode = Off,跳过安全描述符恢复' -Level DEBUG
}
else {
$sidecarName = $null
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'security') -and $found.Record.security) {
$sidecarName = [string]$found.Record.security.file
}
if (-not $sidecarName) { $sidecarName = "$baseName.acl.json" }
$sidecar = Read-BakNRetSecuritySidecar -Path (Join-Path $BackupDir $sidecarName)
if (-not $sidecar) {
Write-BakNRetLog ("这个归档没有安全描述符旁挂文件({0}):恢复出来的属主/ACL 是新建对象的默认值 —— 原程序若依赖特殊权限(ProgramData 下的 CREATOR OWNER 最典型),会报无读写权限" -f $sidecarName) -Level WARN
}
else {
$sidMap = @{}
if ($script:Config.Security.SidMap) { $sidMap = $script:Config.Security.SidMap }
$secTotal = 0; $secApplied = 0; $secOwnerFailed = 0; $secSkipped = 0; $secFailed = 0
$secMessages = @()
foreach ($target in $plannedTargets) {
$sec = Restore-BakNRetSecurity -Sidecar $sidecar -ArchiveRoot $target.ArchivePath `
-TargetPath $target.DestPath -SidMap $sidMap
$secTotal += $sec.Total
$secApplied += $sec.Applied
$secOwnerFailed += $sec.OwnerFailed
$secSkipped += $sec.Skipped
$secFailed += $sec.Failed
$secMessages += @($sec.Failures)
}
$securityApplied += $secApplied
Write-BakNRetLog ("安全描述符:回放 {0}/{1} 个对象(属主/属组未恢复 {2},跳过 {3},失败 {4})" -f `
$secApplied, $secTotal, $secOwnerFailed, $secSkipped, $secFailed) -Level INFO
foreach ($message in @($secMessages | Select-Object -First 5)) {
Write-BakNRetLog (" ! {0}" -f $message) -Level WARN
}
if ($secFailed -gt 0) {
Write-BakNRetLog ("恢复成功但安全描述符有 {0} 个对象失败,已计入失败条目(退出码 1)" -f $secFailed) -Level ERROR
$failures += $displayPath
}
}
}
$stats.restored++
Write-BakNRetLog "恢复成功: $baseName" -Level INFO
if ($manifest.items.Contains($baseName)) {
$record = $manifest.items[$baseName]
if ($record -is [System.Collections.IDictionary]) {
$record['lastRestoreAt'] = (Get-Date).ToString('o')
}
else {
$record | Add-Member -NotePropertyName lastRestoreAt -NotePropertyValue ((Get-Date).ToString('o')) -Force
}
$manifestDirty = $true
}
}
else {
$stats.failed++
$failures += $displayPath
}
}
catch {
Write-BakNRetLog "恢复失败: $displayPath,$_" -Level ERROR
$stats.failed++
$failures += $displayPath
}
}
# ============================================================================
# 收尾:报告孤儿归档
# ============================================================================
if (-not $VerifyOnly -and $Only.Count -eq 0 -and $Skip.Count -eq 0) {
$orphans = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -notin $referencedArchives })
if ($orphans.Count -gt 0) {
Write-BakNRetLog '以下归档没有任何清单条目指向(恢复不到,注意别误删):' -Level WARN
foreach ($orphan in $orphans) {
Write-BakNRetLog (" - {0}({1} MB,{2})" -f $orphan.Name, [math]::Round($orphan.Length / 1MB, 2), $orphan.LastWriteTime) -Level WARN
}
}
}
elseif (-not $VerifyOnly) {
# 带 -Only/-Skip 时只有被选中的条目会被处理,其余归档都不在 $referencedArchives 里,
# 按上面的算法报出来全是假孤儿(还会吓唬人说"注意别误删"),所以整段跳过。
Write-BakNRetLog '本次只恢复了部分条目,跳过孤儿归档审计(避免把未选中的归档误报成孤儿)' -Level DEBUG
}
try {
if ($manifestDirty) {
# 顺手维持"manifest 写了 archive,磁盘上就真有那个文件"这条不变式
$null = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $BackupDir
Write-BakNRetManifest -Path $manifestPath -Manifest $manifest | Out-Null
Write-BakNRetLog 'manifest 已更新(记下本次恢复时间)' -Level DEBUG
}
else {
Write-BakNRetLog 'manifest 无需更新:本次没有实际恢复任何条目' -Level DEBUG
}
}
catch {
Write-BakNRetLog "manifest 写回失败(不影响本次恢复):$_" -Level WARN
}
$summaryMode = if ($VerifyOnly) { 'verify' } else { 'restore' }
$counterText = @{ 成功 = $stats.restored; 跳过 = $stats.skipped; 失败 = $stats.failed; 校验通过 = $stats.verified }
if ($stats.planned -gt 0) { $counterText['试运行计划'] = $stats.planned }
Write-BakNRetRunSummary -Mode $summaryMode -Manifest $manifest -StartedAt $runStartedAt -Failures $failures -Counters $counterText -SecurityApplied $securityApplied
$logPath = Get-BakNRetLogPath
if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO }
Exit-BakNRetRunLock -Lock $runLock
Stop-BakNRetLog
if ($stats.failed -gt 0) { exit 1 }
exit 0
+18
View File
@@ -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 $sandbox -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue
+4 -4
View File
@@ -3,7 +3,7 @@
注册 / 移除 BakNRet 的每日备份计划任务。 注册 / 移除 BakNRet 的每日备份计划任务。
.DESCRIPTION .DESCRIPTION
任务直接调用 Backup.ps1。脚本自身会写日志并按失败数返回退出码, 任务直接调用 Backup-Data.ps1。脚本自身会写日志并按失败数返回退出码,
因此「上次运行结果」在任务计划程序里是可读的,不需要额外包装。 因此「上次运行结果」在任务计划程序里是可读的,不需要额外包装。
注册计划任务需要管理员权限(本脚本不自己提权,请从管理员终端运行)。 注册计划任务需要管理员权限(本脚本不自己提权,请从管理员终端运行)。
@@ -27,7 +27,7 @@ param(
[ValidatePattern('^\d{1,2}:\d{2}$')] [ValidatePattern('^\d{1,2}:\d{2}$')]
[string]$At = '21:30', [string]$At = '21:30',
# 额外传给 Backup.ps1 的参数,例如 @('-Snapshot') # 额外传给 Backup-Data.ps1 的参数,例如 @('-Snapshot')
[string[]]$BackupArgument = @(), [string[]]$BackupArgument = @(),
[ValidateSet('S4U', 'Interactive')] [ValidateSet('S4U', 'Interactive')]
@@ -43,10 +43,10 @@ param(
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot $projectRoot = Split-Path -Parent $PSScriptRoot
$backupScript = Join-Path $projectRoot 'Backup.ps1' $backupScript = Join-Path $projectRoot 'Backup-Data.ps1'
if (-not (Test-Path -LiteralPath $backupScript)) { if (-not (Test-Path -LiteralPath $backupScript)) {
Write-Error "找不到 Backup.ps1:$backupScript" Write-Error "找不到 Backup-Data.ps1:$backupScript"
exit 1 exit 1
} }