修复(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 注册每日计划任务。 - 归档命名算法保持不变,已有归档不会失联。
283 lines
12 KiB
PowerShell
283 lines
12 KiB
PowerShell
<#
|
||
.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
|