Files
BakNRet/Backup-Data.ps1
T
Shuery 7149ea691e fix(backup,restore): 显式指定的清单不存在时报失败,不再静默成功
传 -BackupListPath 指向一个不存在的文件时,原实现把它当成"首次运行"处理:备份端会在
那个位置凭空建一份模板,恢复端则"从备份内容生成清单",然后两者都 exit 0。

后果在计划任务里最明显:任务计划程序读到的是"上次运行结果 = 成功",而实际上一个条目
都没处理。这与本仓库已经修过的"27 条被静默跳过、退出码仍是 0"是同一类缺陷 —— 退出码
是自动化唯一能读到的信号,它必须对得上"到底干了什么"。

现在按 $PSBoundParameters.ContainsKey('BackupListPath') 把两条语义分开:

  * 显式指定了清单却不存在 -> 报 ERROR 并 exit 1。路径写错、或相对路径按了别的工作
    目录解析(计划任务的工作目录通常是 C:\Windows\System32),都是调用方的错误,
    不能伪装成"已经帮你建好模板了"。
  * 没指定(首次运行引导)-> 保持原行为:备份端建模板、恢复端从 manifest 生成清单,
    仍然 exit 0。这是"开箱即用",不是失败。

顺带补 2 处运算符前的空格(Backup.ps1 / Restore.ps1 的 PSUseConsistentWhitespace)。

测试:
  * 2 条成对回归用例(显式缺失报失败 / 首次运行仍建模板)。必须成对 —— 只测一条的话,
    "把所有缺失都改成 exit 1"这种错误实现也能骗过测试。
  * 7 条断言覆盖归档原子替换与路径解析基准。其中 3 条用 AST 判定,因为文本匹配会被
    函数自己的注释绊倒(注释里正当地提到了 $PSScriptRoot,解释为什么不许用)。

验收:test.ps1 9/9 全绿(5.1 与 7);Pester 183 -> 192,0 失败;静态分析 80 条、
0 Error(与改造前持平)。
2026-10-02 01:16:25 +08:00

856 lines
44 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
按 BackupList.txt 执行备份。
.DESCRIPTION
与旧版相比的核心变化:
1. 退出码可靠 —— 不再用 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)) {
# 清单缺失有两条语义完全不同的路:
#
# ① 首次运行(没传 -BackupListPath,用的是仓库自带的那份)-> 建一份模板,退出 0。
# 这是「开箱即用」的引导,**不是失败**。
# ② 显式传了 -BackupListPath 却不存在 -> 是调用方的错误(路径写错、计划任务里
# 的相对路径按了别的工作目录解析)。此时绝不能建模板就退出 0:计划任务会读到
# 「上次运行结果 = 成功」,而实际上一个条目都没处理 —— 这个仓库刚把
# 「27 条被静默跳过、退出码仍是 0」当作一类缺陷修过,这条是同一类。
if ($PSBoundParameters.ContainsKey('BackupListPath')) {
Write-BakNRetLog ("指定的清单不存在:{0}" -f $BackupListPath) -Level ERROR
Write-BakNRetLog '显式指定 -BackupListPath 时不会自动创建模板:请检查路径是否写错;要生成一份起步模板,就去掉该参数。' -Level ERROR
Stop-BakNRetLog
exit 1
}
$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