P0-P3 全量重构:退出码 / 解析修复、manifest 与 7z t 校验、干跑、排除规则、日志、测试与计划任务
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 全部一致。
This commit is contained in:
1 parent
045d51ac9c
commit
e114cae8c8
13 files changed
+1761
-142
No files matched your search
+149
-67
@@ -239,13 +239,14 @@ function Save-ItemRecord {
|
||||
}
|
||||
|
||||
# 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason }
|
||||
#
|
||||
# $SourceGroups 支持"一个软件包含多个目录":每个元素是
|
||||
# @{ ParentDir; RelativePaths; Label }。7z/RAR 对同一归档多次 `a` 会把内容并入,
|
||||
# 所以按父目录分组、逐组追加,归档里每个目录仍保留自己的名字与层级。
|
||||
function Invoke-BackupItem {
|
||||
param(
|
||||
[string]$SourcePath,
|
||||
[string[]]$RelativePaths,
|
||||
[string]$ItemName,
|
||||
[string]$ParentDir,
|
||||
[string]$FinalPath,
|
||||
[Parameter(Mandatory = $true)][array]$SourceGroups,
|
||||
[Parameter(Mandatory = $true)][string]$FinalPath,
|
||||
[string[]]$ExcludePatterns,
|
||||
[switch]$UseEncryption,
|
||||
[switch]$ProtectPrevious,
|
||||
@@ -255,64 +256,89 @@ function Invoke-BackupItem {
|
||||
$tempPath = "$FinalPath.tmp$($tool.Extension)"
|
||||
if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue }
|
||||
|
||||
# 排除模式用**源目录名**作前缀(归档里就是这个名字),
|
||||
# 与 7z 的路径匹配语义一致;软件名条目的归档根目录是软件名,但源目录名仍在其下一层。
|
||||
$excludeArgument = Get-ArchiveExcludeArgument -ItemName $ItemName -Patterns $ExcludePatterns
|
||||
if ($excludeArgument.Count -gt 0) {
|
||||
Write-Log ("排除 {0} 项:{1}" -f $excludeArgument.Count, ($excludeArgument -join ' ')) -Level DEBUG
|
||||
}
|
||||
|
||||
$sourceListFile = $null
|
||||
$warnings = $false
|
||||
$lastExitCode = 0
|
||||
$lastParentDir = $null
|
||||
|
||||
try {
|
||||
if ($tool.Name -eq '7z') {
|
||||
$optimized = Get-Optimized7zArgument -SourcePath $SourcePath -Level $script:Config.CompressionLevel
|
||||
$argument = @($optimized.Argument) + $toolQuietArgument + $excludeArgument
|
||||
|
||||
if ($UseEncryption) {
|
||||
if (-not $password) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(设置 BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件)' }
|
||||
}
|
||||
$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
|
||||
# 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败
|
||||
if ($exitCode -ne 0 -and $exitCode -ne 1) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $false; Reason = "压缩工具退出码 $exitCode" }
|
||||
}
|
||||
$warnings = ($exitCode -eq 1)
|
||||
if ($SourceGroups.Count -eq 0) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '没有可打包的源目录' }
|
||||
}
|
||||
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
|
||||
# RAR 没有 -spf,退回"直接打包源目录",归档根目录就是源目录名
|
||||
foreach ($relative in $RelativePaths) { $argument += $relative }
|
||||
|
||||
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $ParentDir
|
||||
if ($exitCode -ne 0) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $false; Reason = "压缩工具退出码 $exitCode" }
|
||||
$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 }
|
||||
}
|
||||
$warnings = $false
|
||||
}
|
||||
else {
|
||||
if ($UseEncryption) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉 encrypt 标记' }
|
||||
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
|
||||
}
|
||||
}
|
||||
$fullPaths = @($RelativePaths | ForEach-Object { Join-Path $ParentDir $_ })
|
||||
Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force
|
||||
$warnings = $false
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $tempPath)) {
|
||||
@@ -325,12 +351,25 @@ function Invoke-BackupItem {
|
||||
if ($UseEncryption -and $password) { $verifyArgument += "-p$password" }
|
||||
$verifyArgument += $tempPath
|
||||
|
||||
$verifyCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $verifyArgument -WorkingDirectory $ParentDir
|
||||
$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 '、')) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 关键保护:压缩工具报了警告(通常是有文件被占用读不到)时,
|
||||
@@ -341,7 +380,7 @@ function Invoke-BackupItem {
|
||||
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
|
||||
return [pscustomobject]@{
|
||||
Ok = $false
|
||||
ExitCode = $exitCode
|
||||
ExitCode = $lastExitCode
|
||||
Warnings = $true
|
||||
Reason = '压缩工具报告有文件被占用而读不到,新归档不完整。为避免覆盖现有的完整归档已保留旧归档;请关闭占用该目录的程序后重跑,或确认可以接受后用 -AcceptWarnings 强制覆盖'
|
||||
}
|
||||
@@ -354,10 +393,6 @@ function Invoke-BackupItem {
|
||||
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "$_" }
|
||||
} finally {
|
||||
if ($sourceListFile -and (Test-Path -LiteralPath $sourceListFile)) {
|
||||
Remove-Item -LiteralPath $sourceListFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,10 +424,16 @@ foreach ($line in $lines) {
|
||||
|
||||
$record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup'
|
||||
$record.archive = $baseName + $tool.Extension
|
||||
$record.roots = @($resolved.Sources | ForEach-Object { $_.RootName })
|
||||
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' 生成),两者会互相覆盖"
|
||||
@@ -453,6 +494,14 @@ foreach ($line in $lines) {
|
||||
}
|
||||
}
|
||||
|
||||
# 归档内的顶层条目名 = 每个**真实存在**的源在归档里的第一层名字,也就是源目录
|
||||
# (或源文件)自己的名字。刻意不用 $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
|
||||
# 排除模式的前缀始终用**源目录名**(归档里就是这个层级)
|
||||
@@ -538,9 +587,19 @@ foreach ($line in $lines) {
|
||||
}
|
||||
}
|
||||
|
||||
$firstSource = $liveSources[0]
|
||||
$result = Invoke-BackupItem -SourcePath $firstSource.SourcePath -RelativePaths $firstSource.RelativePaths `
|
||||
-ItemName $itemName -ParentDir $firstSource.ParentDir `
|
||||
# 多目录:每个源组各带自己的父目录与相对名。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
|
||||
|
||||
@@ -594,6 +653,29 @@ if ($DryRun) {
|
||||
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 }
|
||||
|
||||
Reference in new issue
Block a user