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

+282
View File
@@ -0,0 +1,282 @@
<#
.SYNOPSIS
BakNRet 端到端验收:真实备份 -> 校验排除 -> 删源 -> 恢复 -> 逐字节对拍。
.DESCRIPTION
单元测试只验证函数行为,这个脚本验证整条链路真的能用:
1. 造一个含可排除内容的源目录(目录名故意带空格,顺带验证命令行引用);
2. 跑 Backup.ps1,断言退出码为 0、归档生成、manifest 记录正确;
3. 解压归档,断言被排除的内容确实不在里面;
4. 删掉源目录,跑 Restore.ps1,断言文件逐字节还原、被排除的内容没有被还原;
5. 断言 Backup -DryRun 与 Restore -DryRun 都不写盘;
6. 源路径不存在时记为 missing-source,而不是静默忽略。
全程只在临时目录里操作,不会碰到真实备份。
.EXAMPLE
pwsh -File .\tests\Run-E2E.ps1
#>
[CmdletBinding()]
param(
[string]$WorkRoot,
[switch]$KeepWorkRoot
)
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$projectRoot = Split-Path -Parent $PSScriptRoot
$backupScript = Join-Path $projectRoot 'Backup.ps1'
$restoreScript = Join-Path $projectRoot 'Restore.ps1'
Import-Module (Join-Path $projectRoot 'Common.psm1') -Force
Reset-TestResult
if (-not $WorkRoot) {
$WorkRoot = Join-Path $env:TEMP ('bnr-' + [guid]::NewGuid().ToString('N').Substring(0, 6))
}
$sourceParent = Join-Path $WorkRoot 'src'
$source = Join-Path $sourceParent 'My Code Space' # 名字带空格,专门压一下命令行引用
$backupDir = Join-Path $WorkRoot 'Backups'
$listPath = Join-Path $WorkRoot 'list.txt'
$dryBackupDir = Join-Path $WorkRoot 'Backups-dry'
$verifyDir = Join-Path $WorkRoot 'verify'
Write-Host ""
Write-Host '== 端到端:备份 -> 排除 -> 删源 -> 恢复 -> 对拍 ==' -ForegroundColor Cyan
Write-Host " 工作目录:$WorkRoot"
# ============================================================================
# 1. 造数据
# ============================================================================
foreach ($dir in 'logs', 'sub', 'Cache') {
New-Item -ItemType Directory -Path (Join-Path $source $dir) -Force | Out-Null
}
Set-Content -LiteralPath (Join-Path $source 'keep.txt') -Value 'keep-me' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $source 'sub\b.txt') -Value 'keep-me-too' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $source 'logs\a.log') -Value 'must-be-excluded' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $source 'Cache\c.bin') -Value 'must-be-excluded-too' -Encoding UTF8
# 一个确定性随机二进制文件,用于逐字节对拍
$blob = New-Object byte[] 8192
(New-Object System.Random 42).NextBytes($blob)
[System.IO.File]::WriteAllBytes((Join-Path $source 'blob.bin'), $blob)
[System.IO.File]::WriteAllText($listPath, "# e2e`n$source :: logs\,!*Cache`n", [System.Text.UTF8Encoding]::new($false))
$expectedHashes = @{}
foreach ($relative in 'keep.txt', 'sub\b.txt', 'blob.bin') {
$expectedHashes[$relative] = (Get-FileHash -LiteralPath (Join-Path $source $relative) -Algorithm SHA256).Hash
}
# ============================================================================
# 2. 备份
# ============================================================================
& $backupScript -BackupListPath $listPath -BackupDir $backupDir -Force -QuietTool
$backupExitCode = $LASTEXITCODE
Test-Case '备份退出码为 0(旧实现会把成功的压缩判成失败)' {
Assert-Equal 0 $backupExitCode
}
$archives = @(Get-ChildItem -LiteralPath $backupDir -File -Filter *.7z -ErrorAction SilentlyContinue)
Test-Case '归档已生成' {
Assert-Equal 1 $archives.Count
Assert-True ($archives[0].Length -gt 0) '归档不应为空'
}
$manifestPath = Join-Path $backupDir 'manifest.json'
Test-Case 'manifest 记录了条目、动作与校验结果' {
Assert-FileExists $manifestPath
$manifest = Read-BaknretManifest -Path $manifestPath
Assert-Equal 1 $manifest.items.Count
$record = $manifest.items[$archives[0].BaseName]
Assert-True ($null -ne $record) 'manifest 里应有对应条目'
Assert-Equal 'backed-up' $record.action
Assert-Equal $true $record.verified
Assert-Equal 0 $record.exitCode
Assert-Equal $source $record.source
Assert-True ($record.sourceFiles -ge 4) '源文件数应不少于 4'
}
Test-Case '备份过程写了日志文件' {
$logDir = Join-Path $projectRoot 'logs'
$logs = @(Get-ChildItem -LiteralPath $logDir -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
Assert-True ($logs.Count -gt 0) '应生成 backup-*.log'
}
# ============================================================================
# 3. 解压归档,验证排除真的生效
# ============================================================================
New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if ($sevenZip) {
$null = Invoke-ExternalCommand -FilePath $sevenZip `
-ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verifyDir", $archives[0].FullName)
}
Test-Case '归档里保留了应当保留的内容' {
Assert-FileExists (Join-Path $verifyDir 'My Code Space\keep.txt')
Assert-FileExists (Join-Path $verifyDir 'My Code Space\sub\b.txt')
Assert-FileExists (Join-Path $verifyDir 'My Code Space\blob.bin')
}
Test-Case '归档里不含被排除的 logs\ 与 !*Cache 命中项' {
Assert-FileMissing (Join-Path $verifyDir 'My Code Space\logs\a.log') 'logs\ 应被排除'
Assert-FileMissing (Join-Path $verifyDir 'My Code Space\Cache\c.bin') '!*Cache 应命中 Cache 目录'
}
# ============================================================================
# 4. 删源后恢复,逐字节对拍
# ============================================================================
Remove-Item -LiteralPath $source -Recurse -Force
Test-Case '源目录确实已被删除(保证下面的恢复不是空操作)' {
Assert-FileMissing $source
}
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -Force
$restoreExitCode = $LASTEXITCODE
Test-Case '恢复退出码为 0' {
Assert-Equal 0 $restoreExitCode
}
Test-Case '恢复出的文件与源逐字节一致' {
foreach ($relative in $expectedHashes.Keys) {
$restored = Join-Path $source $relative
Assert-FileExists $restored
Assert-Equal $expectedHashes[$relative] (Get-FileHash -LiteralPath $restored -Algorithm SHA256).Hash "对拍 $relative"
}
}
Test-Case '被排除的内容没有被恢复出来' {
Assert-FileMissing (Join-Path $source 'logs\a.log')
Assert-FileMissing (Join-Path $source 'Cache\c.bin')
}
# ============================================================================
# 4.5 保护规则:有警告时不拿不完整的归档覆盖完整归档
# ============================================================================
$lockSource = Join-Path $sourceParent 'Locked Case'
$lockBackupDir = Join-Path $WorkRoot 'Backups-lock'
$lockList = Join-Path $WorkRoot 'lock.txt'
New-Item -ItemType Directory -Path $lockSource -Force | Out-Null
Set-Content -LiteralPath (Join-Path $lockSource 'a.txt') -Value 'aaa' -Encoding UTF8
[System.IO.File]::WriteAllText($lockList, "$lockSource`n", [System.Text.UTF8Encoding]::new($false))
# 第一轮:没有占用,归档是"干净"的
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool
$cleanExitCode = $LASTEXITCODE
$cleanArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
$cleanSize = $cleanArchive.Length
$lockManifestPath = Join-Path $lockBackupDir 'manifest.json'
$cleanRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$cleanArchive.BaseName]
Test-Case '干净归档:退出码 0,manifest 记 warnings=false' {
Assert-Equal 0 $cleanExitCode
Assert-Equal 'backed-up' $cleanRecord.action
Assert-Equal $false $cleanRecord.warnings
}
# 第二轮:独占锁住一个文件,真实制造"有文件读不到"的 7z 警告
Set-Content -LiteralPath (Join-Path $lockSource 'b.txt') -Value 'bbb' -Encoding UTF8
$lockedPath = Join-Path $lockSource 'locked.bin'
Set-Content -LiteralPath $lockedPath -Value 'locked' -Encoding UTF8
$lockStream = [System.IO.File]::Open($lockedPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None)
try {
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool
$warnExitCode = $LASTEXITCODE
$afterArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
$afterRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
Test-Case '有警告时拒绝覆盖完整归档:退出码 1,旧归档原样保留' {
Assert-Equal 1 $warnExitCode
Assert-Equal $cleanSize $afterArchive.Length '在位归档应与被保护的那份完全一致'
Assert-Equal 'failed' $afterRecord.action
Assert-Equal $false $afterRecord.warnings '在位归档仍然是完整的'
Assert-Equal $true $afterRecord.attemptWarnings '这次尝试确实报了警告'
}
# 明确接受之后才允许覆盖
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool -AcceptWarnings
$acceptExitCode = $LASTEXITCODE
$acceptedRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
Test-Case '-AcceptWarnings 允许覆盖,并在 manifest 记 warnings=true' {
Assert-Equal 0 $acceptExitCode
Assert-Equal 'backed-up' $acceptedRecord.action
Assert-Equal $true $acceptedRecord.warnings
}
} finally {
$lockStream.Close()
$lockStream.Dispose()
}
# ============================================================================
# 5. DryRun 不写盘
# ============================================================================
if (Test-Path -LiteralPath $source) { Remove-Item -LiteralPath $source -Recurse -Force }
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -DryRun
$dryRestoreExitCode = $LASTEXITCODE
Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' {
Assert-Equal 0 $dryRestoreExitCode
Assert-FileMissing $source
}
& $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -Force -QuietTool -DryRun
$dryBackupExitCode = $LASTEXITCODE
Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' {
Assert-Equal 0 $dryBackupExitCode
if (Test-Path -LiteralPath $dryBackupDir) {
$written = @(Get-ChildItem -LiteralPath $dryBackupDir -File -ErrorAction SilentlyContinue)
Assert-Equal 0 $written.Count '试运行不应写入任何文件'
}
}
# ============================================================================
# 6. 失败路径:源不存在时必须留下可核对的记录
# ============================================================================
$missingList = Join-Path $WorkRoot 'missing.txt'
[System.IO.File]::WriteAllText($missingList, "Z:\definitely-not-here-12345`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $missingList -BackupDir $backupDir -Force -QuietTool
$missingExitCode = $LASTEXITCODE
Test-Case '源路径不存在被记为 missing-source,退出码仍为 0(跳过不算失败)' {
Assert-Equal 0 $missingExitCode
$after = Read-BaknretManifest -Path $manifestPath
$record = $after.items['definitely-not-here-12345_from_Z_']
Assert-True ($null -ne $record) 'manifest 里应有该条目的记录'
Assert-Equal 'missing-source' $record.action
}
# ============================================================================
if ($KeepWorkRoot) {
Write-Host "`n工作目录保留在:$WorkRoot" -ForegroundColor Yellow
} else {
Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction SilentlyContinue
}
$failed = Write-TestSummary -Title '端到端验收'
if ($failed -gt 0) { exit 1 }
exit 0