重构为可核对、可恢复的备份工具(P0-P3)

修复(P0)
- 退出码:改用 .NET Process 继承控制台启动外部命令。Start-Process -PassThru 的
  ExitCode 在 PowerShell 7.7.0-preview.4 上恒为 $null,会把成功的压缩判成失败,
  并让 "exit 2 -> 删档重试" 的自愈分支永远不可达。
- BackupList.txt 解析:先按第一个 :: 切开再处理引号,修正整行被引号包住时
  排除表被吞进路径的问题(该条目此前被静默跳过,其 2.8 GB 归档成了孤儿)。
- 排除分隔符同时接受 , 与 ;:此前解析器只认 ; 而清单里写的是 ,,
  等于所有排除规则都没生效。
- 7z 排除参数不再嵌引号,含空格的模式自动转成 ?:旧写法 -x!"路径" 会让引号
  成为模式的一部分,导致排除对所有条目都失效。

加固(P1)
- 先写临时归档 -> 7z t 校验 -> 原子替换,中断不再污染正式归档。
- 放弃 7z 的更新模式 u:固实压缩下收益极小,却让排除规则改动与已删文件
  永远进不了归档。
- 新增 Backups/manifest.json 与 logs/*.log,跳过/失败有据可查。
- 结尾按失败数 exit;恢复支持 -WhatIf / -DryRun / -VerifyOnly / -Only。
- 恢复优先用 manifest 定位归档,并精确比较 BaseName(不再用 -Filter 通配)。
- 修正 tar 分支用 $LASTEXITCODE 判断成功与否的缺陷。
- 有警告(文件被占用)时拒绝用不完整的归档覆盖完整归档,需显式 -AcceptWarnings。

策略与安全(P2)
- Edge 条目加排除规则:解压后 4.22 GB 中 3.79 GB 是可再生的缓存/遥测/扩展本体,
  保留书签、密码、偏好、历史与站点数据。
- 可选 7z 加密(@encrypt 标记或全局开关),取不到口令时明确失败,绝不写明文。
- 磁盘空间守卫:放不下就跳过该条目,低于阈值告警。

工程化(P3)
- 新增 BackupConfig.psd1、README.md、.gitignore。
- tests/Run-Tests.ps1(32 项)与 tests/Run-E2E.ps1(16 项端到端验收)。
- tools/Register-BackupTask.ps1 注册每日计划任务。
- 归档命名算法保持不变,已有归档不会失联。
This commit is contained in:
Shuery committed 2026-09-21 20:10:18 +08:00
commit dbc0c00554
11 files changed
+2934

No files matched your search

+411
View File
@@ -0,0 +1,411 @@
<#
.SYNOPSIS
按 BackupList.txt 执行恢复。
.DESCRIPTION
与旧版相比的核心变化:
1. 归档查找以 manifest.json 为准(按归档基础名索引),拿不到才退回
"从文件名反推路径"。旧版只靠文件名反推,且用 -Filter "$baseName.*" 通配匹配,
一旦解析出偏差,归档就变成谁都找不到的孤儿。
2. 退出码可靠:三条解压分支(7z / RAR / tar)统一走 Invoke-ExternalCommand。
旧版 tar 分支写成 `$LASTEXITCODE -ne 0 -and $proc.ExitCode -ne 0`,
而 $LASTEXITCODE 是上一条原生命令的残留值,跟 Start-Process 无关,
恰为 0 时会把解压失败吞掉并报成功。
3. 支持 -WhatIf / -DryRun:恢复是会覆盖 E:\CodeSpace、Edge User Data 这种
真实目录的破坏性操作,必须能先看清单再决定。
4. 支持 -Only / -Skip 只恢复指定条目,-VerifyOnly 只校验不写盘。
5. 结尾按失败数 exit。
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
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,
# 只打印计划,不解压(等价于 -WhatIf)
[Parameter()]
[switch]$DryRun,
# 只对归档做 7z t 校验,不解压
[Parameter()]
[switch]$VerifyOnly
)
$ErrorActionPreference = 'Stop'
if ($DryRun) { $WhatIfPreference = $true }
# ============================================================================
# 载入依赖
# ============================================================================
$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
$SupportedFormats = @('.7z', '.rar', '.zip', '.tar')
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
$manifestPath = Join-Path $BackupDir 'manifest.json'
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'restore'
Write-Log "日志文件:$logPath"
Write-Log "备份目录:$BackupDir"
if ($WhatIfPreference) { Write-Log '试运行模式(-WhatIf / -DryRun):不会写入任何文件' -Level WARN }
if (-not (Test-Administrator)) {
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
}
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
$password = Get-BaknretPassword -PasswordFile $passwordFile
# ============================================================================
# 归档查找
# ============================================================================
function Find-ArchiveByBaseName {
<#
.SYNOPSIS
按归档基础名精确定位归档文件。
.DESCRIPTION
旧版用 Get-ChildItem -Filter "$baseName.*",-Filter 会做通配符解释,
路径里含 `[` `]` 时会失配;这里改为精确比较 BaseName。
#>
param([string]$BaseName)
$candidate = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.BaseName -eq $BaseName -and $_.Extension.ToLower() -in $SupportedFormats } |
Select-Object -First 1
return $candidate
}
function Get-ArchiveForEntry {
param($Entry, $Manifest)
if ($Manifest -and $Manifest.items.Contains($Entry.baseName)) {
$record = $Manifest.items[$Entry.baseName]
$archiveName = $null
if ($record.PSObject.Properties.Name -contains 'archive') { $archiveName = $record.archive }
if ($archiveName) {
$path = Join-Path $BackupDir $archiveName
if (Test-Path -LiteralPath $path) {
return [pscustomobject]@{ File = (Get-Item -LiteralPath $path); Source = 'manifest'; Record = $record }
}
Write-Log "manifest 记录的归档不存在,回退按文件名查找:$archiveName" -Level WARN
}
}
$fallback = Find-ArchiveByBaseName -BaseName $Entry.baseName
if ($fallback) {
$record = $null
if ($Manifest -and $Manifest.items.Contains($Entry.baseName)) { $record = $Manifest.items[$Entry.baseName] }
return [pscustomobject]@{ File = $fallback; Source = 'filename'; Record = $record }
}
return $null
}
function Invoke-Extraction {
param([object]$ArchiveFile, [string]$DestinationPath)
$extension = $ArchiveFile.Extension.ToLower()
$destParent = Split-Path -Path $DestinationPath -Parent
if (-not (Test-Path -LiteralPath $destParent)) {
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
}
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $sevenZip) {
$candidates = @(
(Join-Path $env:ProgramFiles '7-Zip\7z.exe'),
(Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe')
)
$sevenZip = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
}
if ($sevenZip) {
Write-Log '使用 7z 解压' -Level DEBUG
$argument = @('x', '-bsp2', '-y', "-o$destParent")
if ($password) { $argument += "-p$password" }
$argument += $ArchiveFile.FullName
$exitCode = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument
if ($exitCode -ne 0) { throw "7z 解压失败(退出码:$exitCode)" }
return $true
}
switch ($extension) {
'.rar' {
$rarExe = Get-Command rar, unrar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $rarExe) { throw '未找到 RAR 工具' }
Write-Log '使用 RAR 解压' -Level DEBUG
$argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$destParent\")
$exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument
if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" }
}
'.zip' {
Write-Log '使用内置 ZIP 解压' -Level DEBUG
Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $destParent -Force
}
'.tar' {
$tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $tarExe) { throw '未找到 TAR 工具' }
Write-Log '使用 TAR 解压' -Level DEBUG
$exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList @('-xf', $ArchiveFile.FullName, '-C', $destParent)
if ($exitCode -ne 0) { throw "TAR 解压失败(退出码:$exitCode)" }
}
default { throw "不支持的文件格式:$extension" }
}
return $true
}
# ============================================================================
# 准备
# ============================================================================
if (-not (Test-Path -LiteralPath $BackupDir)) {
Write-Log "备份目录不存在: $BackupDir" -Level ERROR
Stop-BaknretLog
exit 1
}
$manifest = Read-BaknretManifest -Path $manifestPath
if (-not (Test-Path -LiteralPath $BackupListPath)) {
Write-Log '未找到配置文件,正在从备份内容生成...' -Level INFO
$paths = @()
if ($manifest.items.Count -gt 0) {
foreach ($key in $manifest.items.Keys) {
$record = $manifest.items[$key]
if ($record.PSObject.Properties.Name -contains 'source' -and $record.source) {
$paths += $record.source
}
}
}
if ($paths.Count -eq 0) {
$backupFiles = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -match '_from_' }
foreach ($file in $backupFiles) {
$original = Convert-BackupFileNameToPath -FileName $file.Name
if ($original) { $paths += $original }
}
}
$paths = @($paths | Sort-Object -Unique)
if ($paths.Count -eq 0) {
Write-Log '无法从备份内容还原出任何路径。' -Level ERROR
Stop-BaknretLog
exit 1
}
$content = "# BackupList.txt(自动生成,排除规则需要手工补回)`n" + (($paths -join [Environment]::NewLine) + [Environment]::NewLine)
[System.IO.File]::WriteAllText($BackupListPath, $content, [System.Text.UTF8Encoding]::new($false))
Write-Log "已生成配置,包含 $($paths.Count) 个项目,请检查后重新运行" -Level INFO
Stop-BaknretLog
exit 0
}
function Test-EntrySelected {
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
}
# ============================================================================
# 主流程
# ============================================================================
$lines = Get-Content -LiteralPath $BackupListPath -ErrorAction Stop
$stats = @{ restored = 0; skipped = 0; failed = 0; verified = 0; planned = 0 }
$failures = @()
$referencedArchives = @()
Write-Log '开始执行恢复' -Level INFO
foreach ($line in $lines) {
$item = ConvertFrom-BackupListLine -Line $line
if (-not $item) { continue }
$displayPath = $item.Path
$destPath = [Environment]::ExpandEnvironmentVariables($displayPath)
$baseName = Get-BackupBaseName -RawPath $displayPath
if (-not $baseName) { $stats.skipped++; continue }
if (-not (Test-EntrySelected -DisplayPath $displayPath -BaseName $baseName)) { continue }
$found = Get-ArchiveForEntry -Entry ([pscustomobject]@{ baseName = $baseName }) -Manifest $manifest
if (-not $found) {
Write-Log "跳过: $displayPath,未找到归档 $baseName" -Level WARN
$stats.skipped++
continue
}
$archiveFile = $found.File
$referencedArchives += $archiveFile.BaseName
# 加密归档在取不到口令时必须直接失败:7z 在没有 -p 时会在控制台等输入,
# 在计划任务里会静默挂起,比报错更糟。
$isEncrypted = $false
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'encrypted')) {
$isEncrypted = [bool]$found.Record.encrypted
}
if ($isEncrypted -and -not $password) {
Write-Log "失败: $displayPath,归档已加密但取不到口令(设置 BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件)" -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
if ($VerifyOnly) {
if ($archiveFile.Extension.ToLower() -ne '.7z') {
Write-Log "跳过校验(非 7z): $($archiveFile.Name)" -Level DEBUG
continue
}
$verifyCode = Invoke-ExternalCommand -FilePath (Get-Command 7z | Select-Object -First 1 -ExpandProperty Source) `
-ArgumentList @('t', '-bso0', '-bsp0', $archiveFile.FullName)
if ($verifyCode -eq 0) {
Write-Log "校验通过: $($archiveFile.Name)" -Level INFO
$stats.verified++
} else {
Write-Log "校验失败: $($archiveFile.Name)(退出码 $verifyCode)" -Level ERROR
$stats.failed++
$failures += $displayPath
}
continue
}
Write-Log "准备恢复: $displayPath <- $($archiveFile.Name)(来源:$($found.Source))" -Level INFO
if ((Test-Path -LiteralPath $destPath) -and -not $Force) {
try {
$destSummary = Get-FolderSummary -FolderPath $destPath
$archiveTime = $archiveFile.LastWriteTime
if ($destSummary.LatestModifiedTime -and $destSummary.LatestModifiedTime -gt $archiveTime) {
Write-Log "跳过: $displayPath,目标目录比归档新(用 -Force 覆盖)" -Level WARN
$stats.skipped++
continue
}
} catch {
Write-Log "目标目录摘要读取失败,继续恢复:$_" -Level DEBUG
}
}
if (-not $PSCmdlet.ShouldProcess($destPath, "从 $($archiveFile.Name) 解压")) {
Write-Log "[试运行] 将解压 $($archiveFile.Name) -> $(Split-Path -Path $destPath -Parent)" -Level INFO
$stats.planned++
continue
}
try {
if (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $destPath) {
$stats.restored++
Write-Log "恢复成功: $baseName" -Level INFO
if ($manifest.items.Contains($baseName)) {
$record = $manifest.items[$baseName]
if ($record -is [System.Collections.IDictionary]) {
$record['lastRestoreAt'] = (Get-Date).ToString('o')
} else {
$record | Add-Member -NotePropertyName lastRestoreAt -NotePropertyValue ((Get-Date).ToString('o')) -Force
}
}
} else {
$stats.failed++
$failures += $displayPath
}
} catch {
Write-Log "恢复失败: $displayPath,$_" -Level ERROR
$stats.failed++
$failures += $displayPath
}
}
# ============================================================================
# 收尾:报告孤儿归档
# ============================================================================
if (-not $VerifyOnly) {
$orphans = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -notin $referencedArchives })
if ($orphans.Count -gt 0) {
Write-Log '以下归档没有任何清单条目指向(恢复不到,注意别误删):' -Level WARN
foreach ($orphan in $orphans) {
Write-Log (" - {0}({1} MB,{2})" -f $orphan.Name, [math]::Round($orphan.Length / 1MB, 2), $orphan.LastWriteTime) -Level WARN
}
}
}
try {
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null
} catch {
Write-Log "manifest 写回失败(不影响本次恢复):$_" -Level WARN
}
if ($failures.Count -gt 0) {
Write-Log '失败条目:' -Level ERROR
foreach ($failure in $failures) { Write-Log " - $failure" -Level ERROR }
}
$summaryText = "恢复完成 - 成功:$($stats.restored),跳过:$($stats.skipped),失败:$($stats.failed)"
if ($VerifyOnly) { $summaryText = "校验完成 - 通过:$($stats.verified),失败:$($stats.failed)" }
if ($stats.planned -gt 0) { $summaryText += ",试运行计划:$($stats.planned)" }
Write-Log $summaryText -Level INFO
$logPath = Get-BaknretLogPath
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO }
Stop-BaknretLog
if ($stats.failed -gt 0) { exit 1 }
exit 0