P0 正确性 - 退出码:改用 .NET Process 直接启动、让子进程继承控制台,不再用 Start-Process -PassThru (在 7.7.0-preview.4 上 ExitCode 恒为 $null,会把成功的压缩判成失败); 7z / RAR / tar 三条解压分支统一走同一个取退出码的封装。 - BackupList 解析:先按第一个 :: 切段再处理引号(整行被一对引号包住的写法不再把排除表 吞进路径);排除表同时接受 , 与 ;(旧实现只认 ;,导致排除从未生效);支持 :- / :+ / @flag。 - 补回 .ssh 与孤儿归档:.ssh 进清单;孤儿归档在备份端也做审计并点名; 带 -Only / -Skip 时不再把未选中的归档误报成孤儿。 - Resolve-BackupEntry 里 $rootName 在赋值前被引用(会读到外层作用域残留值),已提前赋值。 P1 归档可靠性 - 每个条目写进 manifest.json:源、归档、时间、退出码、校验结果、失败原因, 并区分 warnings(在位归档)与 attemptWarnings(本次尝试)。 - 归档后做 7z t 内容校验,先写 .tmp、校验通过再原子替换(File.Move overwrite)。 - manifest.roots 记录归档内**真实**的顶层条目名(原先记的是软件名,Edge 实际是 "User Data")。 P2 可用性 - Restore 支持 -WhatIf / -DryRun / -VerifyOnly / -Only / -Skip; 这三种"只看不写"的模式一个字节都不写(原先会写回 manifest.json)。 - Edge 等高缓存条目加排除规则并实测:1781 MB / 27961 项 -> 72 MB / 2294 项; 书签、密码、Cookies、偏好、历史、IndexedDB、Local Storage 全部保留。 普通模式是相对归档根目录锚定的,嵌套的那些(如 OneAuth\WebView2 里的 Crashpad) 改用 ! 组件形式才会命中。 - 日志落盘 logs/<backup|restore>-<时间戳>.log;退出码按失败数返回。 - tools/Register-BackupTask.ps1 注册每日计划任务;tools/Rename-Archives.ps1 迁移旧归档名。 - root= 标记此前静默失效,现在明确告警(该功能尚未实现)。 P3 测试与验证 - tests/BakNRet.Tests.ps1:Pester 5 套件 62 项(含用子进程跑 Backup.ps1 / Restore.ps1 的端到端与针对上述缺陷的回归)。 - tests/Run-Pester.ps1 + tools/Install-TestDependencies.ps1:把 Pester 装到仓库内 .tools/, 不动机器上的全局模块(系统自带的 3.4.0 缺 Should -Be)。 - tests/Restore-Drill.ps1:真实归档恢复演练,明确区分"源在备份后变过"与"归档/解压有问题"。 - tests/Run-Tests.ps1(49 项,零依赖)与 tests/Run-E2E.ps1(23 项)继续可用;三套共 134 项全通过。 真实机器验证 - 生产归档 22/22 通过 7z t;-VerifyOnly 不再改动 manifest.json(SHA256 前后一致)。 - 真实恢复演练 12/12 通过,27,670 个文件与活源逐字节一致。 - 修复了生产 scoop-persist.7z:原先只有 90 字节(空归档)而源有 1.3 GB, 重打包后 233 MB,恢复演练 26981/26981 全部一致。
694 lines
32 KiB
PowerShell
694 lines
32 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. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。
|
||
#>
|
||
|
||
[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 = "$_" }
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
# 主流程
|
||
# ============================================================================
|
||
|
||
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
|
||
|
||
# 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 探测:目标盘符不存在时它会直接抛异常。
|
||
$expectedRoots = 0
|
||
$missingRoots = @()
|
||
foreach ($source in $resolved.Sources) {
|
||
foreach ($relative in $source.RelativePaths) {
|
||
$expectedRoots++
|
||
$candidate = "$($source.ParentDir.TrimEnd('\'))\$relative"
|
||
if (-not (Test-Path -LiteralPath $candidate)) { $missingRoots += $candidate }
|
||
}
|
||
}
|
||
|
||
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) {
|
||
$live = @($source.RelativePaths | Where-Object { Test-Path -LiteralPath "$($source.ParentDir.TrimEnd('\'))\$_" })
|
||
if ($live.Count -gt 0) {
|
||
$liveSources += [pscustomobject]@{
|
||
RootName = $source.RootName
|
||
ParentDir = $source.ParentDir
|
||
RelativePaths = $live
|
||
SourcePath = $source.SourcePath
|
||
}
|
||
}
|
||
}
|
||
|
||
# 归档内的顶层条目名 = 每个**真实存在**的源在归档里的第一层名字,也就是源目录
|
||
# (或源文件)自己的名字。刻意不用 $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 {
|
||
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null
|
||
Write-Log "manifest 已更新:$manifestPath" -Level DEBUG
|
||
}
|
||
|
||
# 孤儿归档审计:磁盘上有、但清单里任何条目都不指向的归档。
|
||
# Restore.ps1 只能按条目名找归档,所以孤儿是**恢复不到**的 —— 必须显式点名,
|
||
# 免得下次清理时把还有用的归档当垃圾删掉(重构前那个 2.8 GB 的归档就是这么成孤儿的)。
|
||
# 只在整表运行时做:带 -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 }
|
||
foreach ($key in $manifest.items.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) {
|
||
Write-Log (" - {0}({1:N1} MB,{2})" -f $orphan.Name, ($orphan.Length / 1MB), $orphan.LastWriteTime) -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
|