重构为可核对、可恢复的备份工具(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:
commit
dbc0c00554
11 files changed
+2934
No files matched your search
+411
@@ -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
|
||||
Reference in new issue
Block a user