Files
BakNRet/Backup.ps1
T
Shuery 7173e8ae10 备份前空间预估;manifest 的 archive 字段只在文件真的存在时才写
需求
- 写满盘这件事只做一件事:**备份前预测本次所需大小,并提示用户存储够不够**,
  不再往"更复杂的占用控制"方向做。

实现
- Backup.ps1 新增只读的"备份前空间预估",在动手之前按清单顺序模拟一遍:
  * 逐个条目枚举源目录得到真实源大小/文件数,并读取现有归档大小;
  * 沿用主循环那套"源未更新就跳过"的判断,所以列出来的就是**本次真的会重打**的条目;
  * 估算模型:临时归档写完时旧归档还在,那一刻占用 = 当前累计净增量 + 本次预估;
    原子替换后净增量 = 预估 − 旧归档大小;峰值取整个过程的最大值;
  * 预估归档大小:有历史归档取 min(源大小, 旧归档 × 1.3),没有则按"不压缩"的悲观值;
  * 打印:可用空间、要重打的条目数(及跳过/缺源的数量)、逐条目明细(前 15 条)、
    预计峰值新增与净增量,最后给一句结论——"空间足够"或"空间可能不够!…差 Z GB"。
  * 不够时**只告警、不中断**:真正放不下的条目仍由逐条目守卫跳过。
- 新增 Sync-BaknretManifestArchive(Common.psm1,Backup/Restore 都在写 manifest 前调用):
  维持不变式"manifest 里写了 archive 的记录,磁盘上就一定有那个文件",
  把指向不存在归档的 archive 字段清空,但保留 source / action / 历史计数。
  这样"源不存在的条目"不会再让 Restore 反复报"manifest 记录的归档不存在",
  人工删掉归档(例如把它并进别的条目)之后记录也会自我纠正。

整理(本机备份集)
- 按用户确认,删除了已被 scoop.7z 覆盖的 scoop-config.7z 与 scoop-persist.7z
  (删除前先 7z t 复验 scoop.7z:233 MB / 29478 项 / 顶层 [persist, scoop]),
  并清掉 manifest 里这两条历史记录;24 条记录的 archive 现在全部存在于磁盘上。

测试
- Pester 88 项、零依赖单元 49 项、端到端 23 项,全部通过。
  新增覆盖:对象数组名录、两种写法 × :+/-、同名目录拒绝执行、行尾 # 说明、
  孤儿审计(含"manifest 里还有历史记录"的说明)、archive 字段一致性、空间预估输出。
2026-09-22 08:26:09 +08:00

828 lines
40 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. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。
#>
[CmdletBinding()]
param(
[Parameter()]
[string]$BackupListPath = (Join-Path $PSScriptRoot 'BackupList.txt'),
[Parameter()]
[string]$BackupDir,
[Parameter()]
[string]$ConfigPath = (Join-Path $PSScriptRoot 'BackupConfig.psd1'),
[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'
# ============================================================================
# 载入依赖
# ============================================================================
$modulePath = Join-Path $PSScriptRoot 'Common.psm1'
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
function Resolve-ConfigPath {
param([string]$Path, [string]$Default)
$value = if ($Path) { $Path } else { $Default }
if (-not [System.IO.Path]::IsPathRooted($value)) {
$value = Join-Path $PSScriptRoot $value
}
return $value
}
if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir }
$logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir
$snapshotDir = Resolve-ConfigPath -Path $null -Default $script:Config.SnapshotDir
$catalogPath = Resolve-CatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot
$manifestPath = Join-Path $BackupDir 'manifest.json'
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'backup'
Write-Log "日志文件:$logPath"
Write-Log "备份目录:$BackupDir"
Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
if (-not (Test-Administrator)) {
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
}
# ============================================================================
# 准备
# ============================================================================
if (-not (Test-Path -LiteralPath $BackupDir)) {
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
Write-Log "创建备份目录: $BackupDir" -Level DEBUG
}
if (-not (Test-Path -LiteralPath $BackupListPath)) {
$template = "# BackupList.txt`n# 语法: <路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]`n# 示例: %UserProfile%\.ssh`n"
[System.IO.File]::WriteAllText($BackupListPath, $template, [System.Text.UTF8Encoding]::new($false))
Write-Log '模板 BackupList.txt 已创建,请编辑后重试。' -Level INFO
Stop-BaknretLog
exit 0
}
$tool = Resolve-CompressionTool
if (-not $tool) {
Write-Log '没有找到可用的压缩工具。' -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-Log ("压缩工具:{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 }
$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 -LiteralPath $BackupListPath
$seenBaseNames = @{}
$processed = 0; $skipped = 0; $failed = 0; $planned = 0
$failures = @()
$freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir
if ($freeSpaceGB -ge 0) {
Write-Log ("备份目录所在卷剩余空间:{0} GB" -f $freeSpaceGB)
if ($freeSpaceGB -lt $script:Config.MinFreeSpaceGB) {
Write-Log ("剩余空间低于阈值 {0} GB,大条目可能失败" -f $script:Config.MinFreeSpaceGB) -Level WARN
}
}
function Test-ItemSelected {
param([string]$DisplayPath, [string]$BaseName)
if ($Only.Count -gt 0) {
$matched = $false
foreach ($pattern in $Only) {
if ($DisplayPath -like $pattern -or $BaseName -like $pattern) { $matched = $true; break }
}
if (-not $matched) { return $false }
}
foreach ($pattern in $Skip) {
if ($DisplayPath -like $pattern -or $BaseName -like $pattern) { return $false }
}
return $true
}
function New-ItemRecord {
param([string]$BaseName, [string]$Source, [string]$ResolvedSource, [string]$Phase)
return [ordered]@{
baseName = $BaseName
source = $Source
resolvedSource = $ResolvedSource
roots = @()
catalog = $null
archive = $null
action = $null
reason = $null
phase = $Phase
attemptedAt = (Get-Date).ToString('o')
finishedAt = $null
durationSec = $null
exitCode = $null
verified = $false
warnings = $false
attemptWarnings = $false
encrypted = $false
sourceFiles = $null
sourceBytes = $null
archiveBytes = $null
sha256 = $null
lastSuccessAt = $null
successCount = 0
failCount = 0
}
}
function Save-ItemRecord {
param($Record, [string]$Action, [string]$Reason, [bool]$ArchiveWarnings = $false)
$previous = $null
if ($manifest.items.Contains($Record.baseName)) { $previous = $manifest.items[$Record.baseName] }
$Record.action = $Action
$Record.reason = $Reason
$Record.finishedAt = (Get-Date).ToString('o')
# warnings 描述的是"当前在位的归档",不是"这次尝试"。
# 只有真正换掉了归档才更新它;否则沿用上一条记录,
# 否则"因为不完整而保留旧归档"之后,下一次就失去保护了。
if ($Action -eq 'backed-up') {
$Record.warnings = $ArchiveWarnings
} elseif ($previous -and ($previous.PSObject.Properties.Name -contains 'warnings')) {
$Record.warnings = [bool]$previous.warnings
}
if ($previous) {
if ($previous.PSObject.Properties.Name -contains 'lastSuccessAt') { $Record.lastSuccessAt = $previous.lastSuccessAt }
if ($previous.PSObject.Properties.Name -contains 'successCount') { $Record.successCount = [int]$previous.successCount }
if ($previous.PSObject.Properties.Name -contains 'failCount') { $Record.failCount = [int]$previous.failCount }
}
if ($Action -eq 'backed-up') {
$Record.lastSuccessAt = $Record.finishedAt
$Record.successCount = [int]$Record.successCount + 1
} elseif ($Action -eq 'failed') {
$Record.failCount = [int]$Record.failCount + 1
}
$manifest.items[$Record.baseName] = $Record
return $Record
}
# 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason }
#
# $SourceGroups 支持"一个软件包含多个目录":每个元素是
# @{ ParentDir; RelativePaths; Label }。7z/RAR 对同一归档多次 `a` 会把内容并入,
# 所以按父目录分组、逐组追加,归档里每个目录仍保留自己的名字与层级。
function Invoke-BackupItem {
param(
[Parameter(Mandatory = $true)][array]$SourceGroups,
[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
$lastParentDir = $null
try {
if ($SourceGroups.Count -eq 0) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '没有可打包的源目录' }
}
$groupIndex = 0
foreach ($group in $SourceGroups) {
$groupIndex++
$parentDir = $group.ParentDir
$relativePaths = @($group.RelativePaths)
if ($relativePaths.Count -eq 0) { continue }
$lastParentDir = $parentDir
# 排除模式的**前缀用这一组的源目录名**(归档里就是这个层级)。
$prefixName = if ($group.Label) { $group.Label } else { Split-Path -Path $relativePaths[0] -Leaf }
$excludeArgument = Get-ArchiveExcludeArgument -ItemName $prefixName -Patterns $ExcludePatterns
if ($tool.Name -eq '7z') {
$probePath = "$($parentDir.TrimEnd('\'))\$($relativePaths[0])"
$optimized = Get-Optimized7zArgument -SourcePath $probePath -Level $script:Config.CompressionLevel
$argument = @($optimized.Argument) + $toolQuietArgument + $excludeArgument
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
foreach ($relative in $relativePaths) { $argument += $relative }
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $parentDir
$lastExitCode = $exitCode
# 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败
if ($exitCode -ne 0 -and $exitCode -ne 1) {
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "第 $groupIndex 组($prefixName)压缩工具退出码 $exitCode" }
}
if ($exitCode -eq 1) { $warnings = $true }
}
elseif ($tool.Name -eq 'RAR') {
$argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + $excludeArgument
if ($UseEncryption) {
if (-not $password) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令' }
}
$argument += "-p$password"
}
$argument += $tempPath
foreach ($relative in $relativePaths) { $argument += $relative }
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $parentDir
$lastExitCode = $exitCode
if ($exitCode -ne 0) {
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "第 $groupIndex 组($prefixName)压缩工具退出码 $exitCode" }
}
}
else {
if ($UseEncryption) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉 encrypt 标记' }
}
# Compress-Archive 不能追加;多组时逐组重打(先把已有临时归档解开再合并会让代码复杂得多,
# 而 ZIP 本来就是降级路径,这里只保证内容完整)
$fullPaths = @($relativePaths | ForEach-Object { Join-Path $parentDir $_ })
if ($groupIndex -gt 1 -and (Test-Path -LiteralPath $tempPath)) {
$staging = Join-Path $env:TEMP ("bnr-zip-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $staging -Force | Out-Null
try {
Expand-Archive -LiteralPath $tempPath -DestinationPath $staging -Force
$fullPaths += @(Get-ChildItem -LiteralPath $staging -Force | Select-Object -ExpandProperty FullName)
Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force
} finally {
Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
}
} else {
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 $lastParentDir
if ($verifyCode -ne 0) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" }
}
Write-Log '归档校验通过(7z t)' -Level DEBUG
# 多目录时确认每个目录都真的进了归档:7z 的"警告"可能只体现在某一组里
if ($SourceGroups.Count -gt 1) {
$listed = @(Get-ArchiveTopLevelNames -ArchivePath $tempPath -SevenZip $tool.Command -Password $(if ($UseEncryption) { $password } else { $null }))
if ($listed.Count -gt 0) {
$expected = @($SourceGroups | ForEach-Object { Split-Path -Path $_.RelativePaths[0] -Leaf })
$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-BackupEntry -Entry $planItem -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
if (-not $planResolved.BaseName) { continue }
if (-not (Test-ItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName)) { continue }
if ($planResolved.Blocking) { continue }
$planSources = @($planResolved.Sources | Where-Object { Test-Path -LiteralPath $_.SourcePath })
if ($planSources.Count -eq 0) { $spaceNoSource++; continue }
$planSourceBytes = [int64]0
$planSourceFiles = 0
$planLatest = $null
foreach ($planSource in $planSources) {
$planSummary = Get-FolderSummary -FolderPath $planSource.SourcePath
$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-Log '备份前空间预估:本次没有需要重打的条目(源未更新或源不存在),不会写入新归档' -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-Log '==== 备份前空间预估(只读)====' -Level INFO
Write-Log (" 目标卷可用空间:{0} GB" -f $freeNowGB)
Write-Log (" 本次要重打 {0} 个条目(另有 {1} 个源未更新会跳过、{2} 个源不存在)" -f $spacePlan.Count, $spaceSkipped, $spaceNoSource)
Write-Log (" 新归档合计约 {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-Log (" - {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-Log (" …… 另有 {0} 个条目未逐条列出" -f ($spacePlan.Count - 15))
}
Write-Log (" 预计峰值新增占用:{0} GB(全程净增量 {1} GB)" -f [math]::Round($peakGB, 2), [math]::Round($netGB, 2))
if ($freeNowGB -lt 0) {
Write-Log ' 结论:读不到目标卷可用空间,请自行确认是否够用' -Level WARN
} elseif ($peakGB -le $freeNowGB) {
Write-Log (" 结论:空间足够(预计用 {0} GB / 可用 {1} GB)" -f [math]::Round($peakGB, 2), $freeNowGB) -Level INFO
} else {
Write-Log (" 结论:空间可能不够!预计需要 {0} GB,可用只有 {1} GB,差 {2} GB" -f `
[math]::Round($peakGB, 2), $freeNowGB, [math]::Round($peakGB - $freeNowGB, 2)) -Level WARN
Write-Log ' 仍会继续执行:真正放不下的条目会被逐条目守卫跳过。建议先腾空间,或用 -Only / -Skip 分批备份。' -Level WARN
}
Write-Log '============================' -Level INFO
}
# ============================================================================
# 主流程
# ============================================================================
foreach ($line in $lines) {
$item = ConvertFrom-BackupListLine -Line $line
if (-not $item) { continue }
$displayPath = $item.Path
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
if (-not $resolved.BaseName) {
$record = New-ItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $displayPath -Phase 'parse'
Save-ItemRecord -Record $record -Action 'failed' -Reason '无法生成归档名' | Out-Null
$failed++; $failures += $displayPath
continue
}
$baseName = $resolved.BaseName
$sourcePath = [Environment]::ExpandEnvironmentVariables($displayPath)
if (-not (Test-ItemSelected -DisplayPath $displayPath -BaseName $baseName)) {
Write-Log "跳过(未选中): $displayPath" -Level DEBUG
continue
}
$record = New-ItemRecord -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= 在 README 里被列为可用标记,但归档内的根目录实际上始终是源目录名
# (见 README「设计取舍」:不套一层软件名目录)。7z 命令行也没有"入库时改名"
# 的能力,所以这里明确告警而不是让它静默失效——静默失效正是本次重构要消灭的东西。
if (@($item.Flags | Where-Object { $_ -like 'root=*' }).Count -gt 0) {
Write-Log "警告: $displayPath 使用了 root= 标记,该功能尚未实现(归档内的根目录始终是源目录名),本次忽略" -Level WARN
}
# 备份列表里写重了会生成两个同名归档,互相覆盖 —— 直接报错,不猜。
if ($seenBaseNames.ContainsKey($baseName)) {
$reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖"
Write-Log "失败: $displayPath,$reason" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null
$failed++; $failures += $displayPath
continue
}
$seenBaseNames[$baseName] = $displayPath
# 归档内顶层同名冲突:明确失败,绝不把两个目录静默搅进同一棵树
if ($resolved.Blocking) {
Write-Log "失败: $displayPath,$($resolved.Blocking)" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $resolved.Blocking | Out-Null
$failed++; $failures += $displayPath
continue
}
# 动手之前先把"这条会打包哪些目录、排除了什么、为什么"讲清楚
Write-BackupEntryPlan -Resolved $resolved -DisplayPath $displayPath `
-ListExcludes @($item.ExcludePatterns) -ConfigExcludes @($script:Config.DefaultExcludes) `
-Comment $item.Comment
# Sources 为空 = 解析不出任何源(名录里没这个软件名、或路径拆不出父/子级)。
# 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值,
# 但 Sources 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的
# 存在性检查统一处理。
if ($resolved.Sources.Count -eq 0) {
$reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' }
Write-Log "跳过: $displayPath,$reason" -Level WARN
Save-ItemRecord -Record $record -Action 'missing-source' -Reason $reason | Out-Null
$skipped++
continue
}
# 源存在性检查必须在 Get-FolderSummary / Get-Item 之前:
# 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。
# 注意不能用 Join-Path 探测:目标盘符不存在时它会直接抛异常。
# 源路径存在性以 SourcePath 为准:RelativePaths 是"归档里的名字",
# 目前两者一致,但 SourcePath 才是磁盘上的真实位置。
$expectedRoots = 0
$missingRoots = @()
foreach ($source in $resolved.Sources) {
$expectedRoots++
if (-not (Test-Path -LiteralPath $source.SourcePath)) { $missingRoots += $source.SourcePath }
}
if ($missingRoots.Count -ge $expectedRoots) {
Write-Log "跳过: $displayPath,源路径不存在" -Level WARN
Save-ItemRecord -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + ($missingRoots -join ';')) | Out-Null
$skipped++
continue
}
if ($missingRoots.Count -gt 0) {
Write-Log ("警告: {0} 有 {1} 个源路径不存在,本次只备份存在的部分:{2}" -f $displayPath, $missingRoots.Count, ($missingRoots -join ';')) -Level WARN
}
# 归档里只放真实存在的源
$liveSources = @()
foreach ($source in $resolved.Sources) {
if (Test-Path -LiteralPath $source.SourcePath) {
$liveSources += [pscustomobject]@{
RootName = $source.RootName
ParentDir = $source.ParentDir
RelativePaths = @($source.RelativePaths)
SourcePath = $source.SourcePath
Description = $source.Description
Origin = $source.Origin
}
}
}
# 归档内的顶层条目名 = 每个**真实存在**的源在归档里的第一层名字,也就是源目录
# (或源文件)自己的名字。刻意不用 $resolved.Sources[].RootName:那套"归档内套一层
# 软件名"的设想已按设计取舍放弃,实际布局始终是 <源目录名>\...。
# 这里记录可核对的事实,之前写成软件名会让 Edge(实际是 "User Data")之类的条目对不上。
$record.roots = @($liveSources | ForEach-Object {
$_.RelativePaths | ForEach-Object { ($_ -split '[\\/]')[0] }
} | Select-Object -Unique)
$primarySource = $liveSources[0].SourcePath
$parentDir = $liveSources[0].ParentDir
# 排除模式的前缀始终用**源目录名**(归档里就是这个层级)
$itemName = Split-Path -Path $primarySource -Leaf
if (-not $parentDir -or -not $itemName) {
Write-Log "跳过: $displayPath,无法处理根目录" -Level WARN
Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '无法拆出父目录或末级名' | Out-Null
$skipped++
continue
}
$summary = Get-FolderSummary -FolderPath $primarySource
foreach ($source in $liveSources[1..($liveSources.Count - 1)]) {
$extra = Get-FolderSummary -FolderPath $source.SourcePath
$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-Log ("开始备份: {0} -> {1}({2} 个文件,{3} MB)" -f $displayPath, $record.archive, $summary.FileCount, [math]::Round(($summary.TotalSize / 1MB), 2))
# 空目录时 Get-FolderSummary 拿不到任何条目,回退到源自身的修改时间
# (源路径上面已经确认存在,这里的 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-Log "跳过: $displayPath,源目录未更新" -Level INFO
$record.archiveBytes = $archiveItem.Length
Save-ItemRecord -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-Log "失败: $displayPath,$reason" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null
$failed++; $failures += $displayPath
continue
}
if ($DryRun) {
Write-Log ("[试运行] 将打包 {0} -> {1}" -f $sourcePath, $finalPath) -Level INFO
$record.reason = '试运行,未执行压缩'
Save-ItemRecord -Record $record -Action 'planned' -Reason '试运行,未执行压缩' | Out-Null
$planned++
continue
}
$useEncryption = $encryptAll -or ($item.Flags -contains 'encrypt')
$record.encrypted = [bool]$useEncryption
$startedAt = Get-Date
$record.attemptedAt = $startedAt.ToString('o')
# 配置里的全局排除 + 本条目的排除
$effectiveExcludes = @($script:Config.DefaultExcludes) + @($item.ExcludePatterns)
# 只有在"现有归档是完整的"时才值得保护它。没有 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
}
}
# 多目录:每个源组各带自己的父目录与相对名。7z 会对同一归档逐组追加。
# Label 刻意留空:排除模式的前缀必须是**归档里的那一层名字**,也就是源目录名
# (归档内布局是 `<源目录名>\...`)。若把软件名当 Label 传下去,
# 排除模式就会变成 `软件名\skip.bin`,与实际路径对不上而静默失效。
$sourceGroups = @($liveSources | ForEach-Object {
[pscustomobject]@{
ParentDir = $_.ParentDir
RelativePaths = @($_.RelativePaths)
Label = $null
}
})
$result = Invoke-BackupItem -SourceGroups $sourceGroups `
-FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption `
-ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings
$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-Log "备份失败: $displayPath,$($result.Reason)" -Level ERROR
Save-ItemRecord -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-Log "备份成功(压缩工具报告了警告,可能有文件被占用而没打进归档): $displayPath" -Level WARN
Write-Log ' 该归档在 manifest 里标记为 warnings=true;如果以后现有归档是完整的,会拒绝被它覆盖' -Level WARN
} else {
Write-Log "备份成功: $baseName" -Level INFO
}
if ($Hash -or $script:Config.ComputeHash) {
$record.sha256 = (Get-FileHash -LiteralPath $finalPath -Algorithm SHA256).Hash
Write-Log "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-Log "已留存快照: $target" -Level INFO
}
Save-ItemRecord -Record $record -Action 'backed-up' -Reason $null -ArchiveWarnings $result.Warnings | Out-Null
$processed++
}
# ============================================================================
# 收尾
# ============================================================================
if ($DryRun) {
Write-Log '试运行:manifest 与归档都不会被写入' -Level INFO
} else {
# manifest 里写了 archive 的记录,磁盘上就必须真有那个文件
$clearedArchiveFields = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $BackupDir
if ($clearedArchiveFields.Count -gt 0) {
Write-Log ("已清空 {0} 条记录里指向不存在归档的 archive 字段:{1}" -f $clearedArchiveFields.Count, ($clearedArchiveFields -join '、')) -Level WARN
}
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null
Write-Log "manifest 已更新:$manifestPath" -Level DEBUG
}
# 孤儿归档审计:磁盘上有、但**当前清单里任何条目都不指向**的归档。
# Restore.ps1 是按清单条目去找归档的,所以孤儿是**恢复不到**的 —— 必须显式点名,
# 免得下次清理时把还有用的归档当垃圾删掉(重构前那个 2.8 GB 的归档就是这么成孤儿的)。
#
# 判据只用清单,**不能用 manifest**:manifest 会一直留着历史条目,
# 于是"从清单里删掉某个条目(或把它合并进另一个条目)"留下的归档会被历史记录遮住,
# 审计就永远不会报——那正是最需要报出来的情况。
# 只在整表运行时做:带 -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-Log ("发现 {0} 个孤儿归档(当前清单里没有任何条目指向,恢复不到,注意别误删):" -f $orphanArchives.Count) -Level WARN
foreach ($orphan in $orphanArchives) {
$inManifest = $manifest.items.Contains($orphan.BaseName)
Write-Log (" - {0}({1:N1} MB,{2}){3}" -f $orphan.Name, ($orphan.Length / 1MB), $orphan.LastWriteTime, $(if ($inManifest) { ';manifest 里还留着它的历史记录,但清单里已经没有了' } else { '' })) -Level WARN
}
} else {
Write-Log '孤儿归档审计:没有发现(所有归档都有清单条目指向)' -Level DEBUG
}
}
if ($failures.Count -gt 0) {
Write-Log '失败条目:' -Level ERROR
foreach ($failure in $failures) { Write-Log " - $failure" -Level ERROR }
}
$summaryText = "备份完成。成功: $processed, 跳过: $skipped, 失败: $failed"
if ($DryRun) { $summaryText += ", 试运行计划: $planned" }
Write-Log $summaryText -Level INFO
$logPath = Get-BaknretLogPath
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO }
Stop-BaknretLog
if ($failed -gt 0) { exit 1 }
exit 0