重构为可核对、可恢复的备份工具(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

+111
View File
@@ -0,0 +1,111 @@
<#
.SYNOPSIS
BakNRet 测试用的极简断言运行器(零依赖,PS 5.1 / 7.x 通用)。
.DESCRIPTION
不用 Pester:本机只装了 3.4.0,缺少 `Should -Be` 这类 Pester 4+ 语法,
为跑几个测试去装 Pester 5 不划算。这里只需要 Test-Case + Assert-* 就够。
#>
$script:Passed = 0
$script:Failed = 0
$script:Failures = @()
function Reset-TestResult {
$script:Passed = 0
$script:Failed = 0
$script:Failures = @()
}
function Get-TestResult {
return [pscustomobject]@{
Passed = $script:Passed
Failed = $script:Failed
Failures = @($script:Failures)
}
}
function Test-Case {
param([string]$Name, [scriptblock]$Body)
try {
& $Body
$script:Passed++
Write-Host " [PASS] $Name" -ForegroundColor Green
} catch {
$script:Failed++
$script:Failures += "$Name —— $($_.Exception.Message)"
Write-Host " [FAIL] $Name" -ForegroundColor Red
Write-Host " $($_.Exception.Message)" -ForegroundColor DarkGray
}
}
function Assert-Equal {
param($Expected, $Actual, [string]$Because)
if ($Expected -ne $Actual) {
throw ("期望 [{0}],实际 [{1}]{2}" -f $Expected, $Actual, $(if ($Because) { "($Because)" } else { '' }))
}
}
function Assert-True {
param($Condition, [string]$Because)
if (-not $Condition) { throw ("断言为真失败{0}" -f $(if ($Because) { ":$Because" } else { '' })) }
}
function Assert-False {
param($Condition, [string]$Because)
if ($Condition) { throw ("断言为假失败{0}" -f $(if ($Because) { ":$Because" } else { '' })) }
}
function Assert-Null {
param($Value, [string]$Because)
if ($null -ne $Value) {
throw ("期望为 null,实际 [{0}]{1}" -f $Value, $(if ($Because) { "($Because)" } else { '' }))
}
}
function Assert-FileExists {
param([string]$Path, [string]$Because)
if (-not (Test-Path -LiteralPath $Path)) {
throw ("文件应当存在但不存在:{0}{1}" -f $Path, $(if ($Because) { "($Because)" } else { '' }))
}
}
function Assert-FileMissing {
param([string]$Path, [string]$Because)
if (Test-Path -LiteralPath $Path) {
throw ("文件不应存在但存在:{0}{1}" -f $Path, $(if ($Because) { "($Because)" } else { '' }))
}
}
function Assert-FileHashEqual {
param([string]$ExpectedPath, [string]$ActualPath)
Assert-FileExists $ExpectedPath
Assert-FileExists $ActualPath
$a = (Get-FileHash -LiteralPath $ExpectedPath -Algorithm SHA256).Hash
$b = (Get-FileHash -LiteralPath $ActualPath -Algorithm SHA256).Hash
if ($a -ne $b) { throw ("内容不一致:{0} vs {1}" -f $ExpectedPath, $ActualPath) }
}
function Write-TestSummary {
param([string]$Title = '测试')
Write-Host ""
Write-Host ("=" * 60)
if ($script:Failed -eq 0) {
Write-Host "$Title 全部通过:$($script:Passed) 项" -ForegroundColor Green
} else {
Write-Host "$Title 通过 $($script:Passed) 项,失败 $($script:Failed) 项" -ForegroundColor Red
foreach ($failure in $script:Failures) { Write-Host " - $failure" -ForegroundColor Red }
}
Write-Host ("=" * 60)
return $script:Failed
}
Export-ModuleMember -Function @(
'Reset-TestResult', 'Get-TestResult', 'Test-Case',
'Assert-Equal', 'Assert-True', 'Assert-False', 'Assert-Null',
'Assert-FileExists', 'Assert-FileMissing', 'Assert-FileHashEqual',
'Write-TestSummary'
)