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