修掉的:空 catch 5 处;名词白名单 7 处;default-value 开关、自带 -WhatIf、lab 的明文口令与 irm|iex 各挂抑制并写明理由。 MaxDepth:它是分析器拓出来的真 bug —— 参数声明了却从未使用,也就是配置里的 CatalogMaxDepth 是假的,前缀补全实际只查 1 层,而配置注释与 README 都承诺「向下找几层」。按确认过的原则处理:**先让文档不撒谎**,所以把整条链路去掉(配置默认值、三个函数的参数、70 处实参、配置注释),而不是留一个假旋钮。零行为变化。想真的支持多层补全时,那是一个独立决定。 剩下 3 条都是分析器的误判,而且我实测确认过其中一条:$sourcePath 被报「赋值后从未使用」,我照着改成 $null = 之后,Set-StrictMode -Version 3.0 下读未定义变量直接抛错,Security 套件的 BeforeAll 挂掉、4 条用例连带失败。恢复后才绿。 这一类误判有共同成因:静态分析看不到「在传给 Test-Case / It / Where-Object 的 scriptblock 里被使用」。所以我只对能证明是误判的挂抑制并写明理由,不为了数字好看去改代码。 验收:test.ps1 9/9 全绿(7 与 5.1)、100 个文件两版解析零错、Run-RealSmoke 4/4。
675 lines
32 KiB
PowerShell
675 lines
32 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
BakNRet 端到端验收:真实备份 -> 校验归档布局与排除 -> 删源 -> 恢复 -> 逐字节对拍。
|
||
|
||
.DESCRIPTION
|
||
单元测试只验证函数行为,这个脚本验证整条链路真的能用。覆盖重构后的新契约:
|
||
|
||
1. 字面路径条目:`:-` 排除 -> 删源 -> 恢复 -> 逐字节对拍(历史 `<末级名>\...` 布局);
|
||
2. 软件名录条目:一个软件一个归档,包内顶层是各 Slot(`<Slot>\<内容>`);
|
||
文件 Slot 在包内是一个**名为 Slot 的文件**;
|
||
3. `:+` / Include 把宿主机目录放到指定的归档内位置;
|
||
4. Slot 前缀的排除模式(`:- AlphaData\plain`)只作用于对应 Slot;
|
||
5. 名录 Slot 自己的 Exclude(未写条目级 `:-` 时)同样生效;
|
||
6. `::` 覆盖单 Slot 条目的真实路径;
|
||
7. 行首 `+` / `-` 方向:备份端跳过 `-`、恢复端跳过 `+`,
|
||
且 `-` 条目的归档名仍然算"有主",不会被孤儿审计误报;
|
||
8. manifest 记录 `roots` 与 `layouts`(每条归档项是 dir 还是 file);
|
||
9. 重构前旧布局归档的恢复(manifest 无 layouts 时按 `<末级名>` 回退);
|
||
10. @pathname 用名录里的真实路径命名,DryRun 不写盘,失败路径留记录。
|
||
|
||
全程只在临时目录里操作,不会碰到真实 Backups\。
|
||
|
||
.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 'BakNRet\BakNRet.psd1') -Force
|
||
|
||
Reset-TestResult
|
||
|
||
if (-not $WorkRoot) {
|
||
$WorkRoot = Join-Path $env:TEMP ('bnr-' + [guid]::NewGuid().ToString('N').Substring(0, 6))
|
||
}
|
||
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
|
||
|
||
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||
|
||
Write-Host ""
|
||
Write-Host '== 端到端:备份 -> 布局/排除 -> 删源 -> 恢复 -> 对拍 ==' -ForegroundColor Cyan
|
||
Write-Host " 工作目录:$WorkRoot"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 工具
|
||
# ---------------------------------------------------------------------------
|
||
|
||
function New-E2EConfig {
|
||
<# .SYNOPSIS 写一份只指向临时目录的配置,避免污染仓库日志。 #>
|
||
param(
|
||
[Parameter(Mandatory = $true)][string]$Path,
|
||
[Parameter(Mandatory = $true)][string]$LogDir,
|
||
[string]$SoftwareCatalog
|
||
)
|
||
$lines = @(
|
||
'@{'
|
||
" LogDir = '$LogDir'"
|
||
" ToolOutput = 'quiet'"
|
||
' CompressionLevel = 1'
|
||
' MinFreeSpaceGB = 0'
|
||
)
|
||
if ($SoftwareCatalog) { $lines += " SoftwareCatalog = '$SoftwareCatalog'" }
|
||
$lines += '}'
|
||
[System.IO.File]::WriteAllText($Path, ($lines -join "`r`n"), [System.Text.UTF8Encoding]::new($true))
|
||
}
|
||
|
||
function Get-NewestLog {
|
||
<# .SYNOPSIS 取日志目录里最新的 backup-*.log / restore-*.log。 #>
|
||
param([Parameter(Mandatory = $true)][string]$LogDir, [Parameter(Mandatory = $true)][string]$Prefix)
|
||
return Get-ChildItem -LiteralPath $LogDir -File -Filter "$Prefix-*.log" -ErrorAction SilentlyContinue |
|
||
Sort-Object LastWriteTime | Select-Object -Last 1
|
||
}
|
||
|
||
function Get-ArchiveName {
|
||
param([Parameter(Mandatory = $true)][string]$Dir)
|
||
return @(Get-ChildItem -LiteralPath $Dir -File -Filter *.7z -ErrorAction SilentlyContinue |
|
||
Select-Object -ExpandProperty BaseName)
|
||
}
|
||
|
||
function Get-OrphanName {
|
||
<# .SYNOPSIS 从备份日志里解析出"孤儿归档"那一段点名的归档名。 #>
|
||
param([Parameter(Mandatory = $true)][string]$LogPath)
|
||
|
||
$names = @()
|
||
$inSection = $false
|
||
foreach ($line in @(Get-Content -LiteralPath $LogPath -Encoding UTF8)) {
|
||
if ($line -match '孤儿归档') { $inSection = $true; continue }
|
||
if (-not $inSection) { continue }
|
||
if ($line -match '-\s+([^\s()]+?)(') {
|
||
$names += $Matches[1]
|
||
}
|
||
else {
|
||
$inSection = $false
|
||
}
|
||
}
|
||
return @($names)
|
||
}
|
||
|
||
function Get-Sha256 {
|
||
param([Parameter(Mandatory = $true)][string]$Path)
|
||
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
|
||
}
|
||
|
||
# ============================================================================
|
||
# 1. 字面路径条目:备份 -> 排除 -> 删源 -> 恢复 -> 逐字节对拍
|
||
# ============================================================================
|
||
|
||
$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'
|
||
$logDir1 = Join-Path $WorkRoot 'logs1'
|
||
$cfg1 = Join-Path $WorkRoot 'cfg1.psd1'
|
||
New-E2EConfig -Path $cfg1 -LogDir $logDir1
|
||
|
||
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($true))
|
||
|
||
$expectedHashes = @{}
|
||
foreach ($relative in 'keep.txt', 'sub\b.txt', 'blob.bin') {
|
||
$expectedHashes[$relative] = Get-Sha256 (Join-Path $source $relative)
|
||
}
|
||
|
||
& $backupScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -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 记录了条目、动作、校验结果、roots 与 layouts' {
|
||
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-Equal 5 $record.sourceFiles '源里 5 个文件(排除只影响打包,不影响统计)'
|
||
Assert-Equal 1 $record.roots.Count
|
||
Assert-Equal 'My Code Space' $record.roots[0]
|
||
Assert-Equal 1 $record.layouts.Count
|
||
Assert-Equal 'My Code Space' $record.layouts[0].name
|
||
Assert-Equal 'dir' $record.layouts[0].kind
|
||
}
|
||
|
||
Test-Case '备份过程写了日志文件(写进临时 LogDir,不污染仓库)' {
|
||
$logs = @(Get-ChildItem -LiteralPath $logDir1 -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
|
||
Assert-True ($logs.Count -gt 0) '应生成 backup-*.log'
|
||
}
|
||
|
||
if ($sevenZip) {
|
||
New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null
|
||
$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 目录'
|
||
}
|
||
|
||
Remove-Item -LiteralPath $source -Recurse -Force
|
||
|
||
Test-Case '源目录确实已被删除(保证下面的恢复不是空操作)' {
|
||
Assert-FileMissing $source
|
||
}
|
||
|
||
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -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-Sha256 $restored) "对拍 $relative"
|
||
}
|
||
}
|
||
|
||
Test-Case '被排除的内容没有被恢复出来' {
|
||
Assert-FileMissing (Join-Path $source 'logs\a.log')
|
||
Assert-FileMissing (Join-Path $source 'Cache\c.bin')
|
||
}
|
||
|
||
# ============================================================================
|
||
# 2. 保护规则:有警告时不拿不完整的归档覆盖完整归档
|
||
# ============================================================================
|
||
|
||
$lockSource = Join-Path $sourceParent 'Locked Case'
|
||
$lockBackupDir = Join-Path $WorkRoot 'Backups-lock'
|
||
$lockList = Join-Path $WorkRoot 'lock.txt'
|
||
$logDir2 = Join-Path $WorkRoot 'logs2'
|
||
$cfg2 = Join-Path $WorkRoot 'cfg2.psd1'
|
||
New-E2EConfig -Path $cfg2 -LogDir $logDir2
|
||
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($true))
|
||
|
||
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -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 -ConfigPath $cfg2 -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 -ConfigPath $cfg2 -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()
|
||
}
|
||
|
||
# ============================================================================
|
||
# 3. 软件名录:Slot 布局(目录 Slot / 文件 Slot / Include / Slot 前缀排除)
|
||
# ============================================================================
|
||
|
||
$catRoot = Join-Path $WorkRoot 'catalog'
|
||
$catAppRoot = Join-Path $catRoot 'apps'
|
||
$dirA = Join-Path $catAppRoot 'A'
|
||
$dirA2 = Join-Path $catAppRoot 'A2'
|
||
$settingsFile = Join-Path $catAppRoot 'settings.json'
|
||
$incDir = Join-Path $catAppRoot 'inc'
|
||
$catBackupDir = Join-Path $catRoot 'Backups'
|
||
$catFile = Join-Path $catRoot 'SoftwareCatalog.psd1'
|
||
$catList = Join-Path $catRoot 'list.txt'
|
||
$catConfig = Join-Path $catRoot 'config.psd1'
|
||
$catLogDir = Join-Path $catRoot 'logs'
|
||
$catExtract = Join-Path $catRoot 'verify'
|
||
|
||
foreach ($dir in (Join-Path $dirA 'sub'), (Join-Path $dirA 'plain'), (Join-Path $dirA 'skip'), (Join-Path $dirA2 'skip'), $incDir) {
|
||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||
}
|
||
Set-Content -LiteralPath (Join-Path $dirA 'keep.txt') -Value 'A-keep' -Encoding UTF8
|
||
Set-Content -LiteralPath (Join-Path $dirA 'sub\keep2.txt') -Value 'A-sub' -Encoding UTF8
|
||
Set-Content -LiteralPath (Join-Path $dirA 'plain\p.bin') -Value 'A-plain' -Encoding UTF8
|
||
Set-Content -LiteralPath (Join-Path $dirA 'skip\s.bin') -Value 'A-skip' -Encoding UTF8
|
||
Set-Content -LiteralPath (Join-Path $dirA2 'keep.txt') -Value 'A2-keep' -Encoding UTF8
|
||
Set-Content -LiteralPath (Join-Path $dirA2 'skip\s.bin') -Value 'A2-skip' -Encoding UTF8
|
||
Set-Content -LiteralPath $settingsFile -Value '{"slot":"file"}' -Encoding UTF8
|
||
Set-Content -LiteralPath (Join-Path $incDir 'i.txt') -Value 'included' -Encoding UTF8
|
||
|
||
[System.IO.File]::WriteAllText($catFile, @"
|
||
@{
|
||
'my-app' = @{
|
||
AlphaData = @{ Path = '$dirA' }
|
||
BetaFile = @{ Path = '$settingsFile' }
|
||
}
|
||
'cat-excl' = @{
|
||
Data = @{ Path = '$dirA2'; Exclude = '!*skip' }
|
||
}
|
||
}
|
||
"@, [System.Text.UTF8Encoding]::new($false))
|
||
|
||
# 条目级排除用 Slot 前缀点名(AlphaData\plain)+ 任意层级(!*skip);:+ 把 inc 放到归档内 Modules\
|
||
[System.IO.File]::WriteAllText($catList, "my-app :- AlphaData\plain,!*skip :+ Modules:$incDir`ncat-excl`n", [System.Text.UTF8Encoding]::new($true))
|
||
New-E2EConfig -Path $catConfig -LogDir $catLogDir -SoftwareCatalog $catFile
|
||
|
||
$catHashes = @{
|
||
(Join-Path $dirA 'keep.txt') = Get-Sha256 (Join-Path $dirA 'keep.txt')
|
||
(Join-Path $dirA 'sub\keep2.txt') = Get-Sha256 (Join-Path $dirA 'sub\keep2.txt')
|
||
$settingsFile = Get-Sha256 $settingsFile
|
||
(Join-Path $incDir 'i.txt') = Get-Sha256 (Join-Path $incDir 'i.txt')
|
||
}
|
||
|
||
& $backupScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
|
||
$catExitCode = $LASTEXITCODE
|
||
|
||
Test-Case '名录条目:一个软件一个归档,归档名 = 软件名;独立条目各自成包' {
|
||
Assert-Equal 0 $catExitCode
|
||
Assert-FileExists (Join-Path $catBackupDir 'my-app.7z')
|
||
Assert-FileExists (Join-Path $catBackupDir 'cat-excl.7z')
|
||
}
|
||
|
||
Test-Case 'manifest.roots 列出各归档项的顶层名,layouts 标出 dir / file' {
|
||
$record = (Read-BakNRetManifest -Path (Join-Path $catBackupDir 'manifest.json')).items['my-app']
|
||
Assert-True ($null -ne $record)
|
||
$roots = @($record.roots | Sort-Object)
|
||
Assert-Equal 3 $roots.Count
|
||
Assert-Equal 'AlphaData' $roots[0]
|
||
Assert-Equal 'BetaFile' $roots[1]
|
||
Assert-Equal 'Modules' $roots[2]
|
||
|
||
$layoutMap = @{}
|
||
foreach ($layout in $record.layouts) { $layoutMap[$layout.name] = $layout.kind }
|
||
Assert-Equal 'dir' $layoutMap['AlphaData']
|
||
Assert-Equal 'file' $layoutMap['BetaFile']
|
||
Assert-Equal 'dir' $layoutMap['Modules']
|
||
}
|
||
|
||
New-Item -ItemType Directory -Path $catExtract -Force | Out-Null
|
||
if ($sevenZip) {
|
||
$null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$catExtract", (Join-Path $catBackupDir 'my-app.7z'))
|
||
}
|
||
|
||
Test-Case '归档内顶层是 Slot 名:<Slot>\<内容>' {
|
||
Assert-FileExists (Join-Path $catExtract 'AlphaData\keep.txt') 'AlphaData 必须是包内的一层目录'
|
||
Assert-FileExists (Join-Path $catExtract 'AlphaData\sub\keep2.txt')
|
||
Assert-FileMissing (Join-Path $catExtract 'A\keep.txt') '包内不该出现宿主机上的目录名'
|
||
Assert-FileMissing (Join-Path $catExtract 'my-app') '包内不该多出一层软件名'
|
||
}
|
||
|
||
Test-Case '文件 Slot 在包内是一个名为 Slot 的文件(没有扩展名)' {
|
||
Assert-True (Test-Path -LiteralPath (Join-Path $catExtract 'BetaFile') -PathType Leaf) 'BetaFile 应是文件'
|
||
Assert-FileMissing (Join-Path $catExtract 'BetaFile.json')
|
||
Assert-FileMissing (Join-Path $catExtract 'settings.json') '文件 Slot 不保留原文件名'
|
||
}
|
||
|
||
Test-Case ':+ / Include 把宿主机目录放到指定的归档内位置' {
|
||
Assert-FileExists (Join-Path $catExtract 'Modules\i.txt')
|
||
}
|
||
|
||
Test-Case 'Slot 前缀的排除模式只作用在对应 Slot 上(AlphaData\plain)' {
|
||
Assert-FileMissing (Join-Path $catExtract 'AlphaData\plain\p.bin')
|
||
Assert-True (Test-Path -LiteralPath (Join-Path $catExtract 'AlphaData\keep.txt')) '未被点名的文件必须留着'
|
||
}
|
||
|
||
Test-Case '任意层级模式 (!*skip) 广播到每个归档项' {
|
||
Assert-FileMissing (Join-Path $catExtract 'AlphaData\skip\s.bin')
|
||
}
|
||
|
||
Test-Case '名录 Slot 自己的 Exclude 在没有条目级 :- 时同样生效' {
|
||
$exclExtract = Join-Path $catRoot 'verify-excl'
|
||
New-Item -ItemType Directory -Path $exclExtract -Force | Out-Null
|
||
if ($sevenZip) {
|
||
$null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$exclExtract", (Join-Path $catBackupDir 'cat-excl.7z'))
|
||
}
|
||
Assert-FileExists (Join-Path $exclExtract 'Data\keep.txt')
|
||
Assert-FileMissing (Join-Path $exclExtract 'Data\skip\s.bin') '名录 Slot 的 Exclude 应把 skip 挡在包外'
|
||
}
|
||
|
||
Remove-Item -LiteralPath $dirA -Recurse -Force
|
||
Remove-Item -LiteralPath $settingsFile -Force
|
||
Remove-Item -LiteralPath $incDir -Recurse -Force
|
||
|
||
Test-Case '删源后按 Slot 恢复:目录 / 文件 / 追加项各自回到自己的 Path' {
|
||
& $restoreScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force
|
||
Assert-Equal 0 $LASTEXITCODE
|
||
foreach ($path in $catHashes.Keys) {
|
||
Assert-FileExists $path
|
||
Assert-Equal $catHashes[$path] (Get-Sha256 $path) "对拍 $path"
|
||
}
|
||
}
|
||
|
||
Test-Case '恢复不会把被排除的内容带回来' {
|
||
Assert-FileMissing (Join-Path $dirA 'plain\p.bin')
|
||
Assert-FileMissing (Join-Path $dirA 'skip\s.bin')
|
||
Assert-FileMissing (Join-Path $catAppRoot 'BetaFile') '文件 Slot 的归档内名字不该落到宿主机上'
|
||
}
|
||
|
||
Test-Case '@pathname 覆盖:用名录里的真实路径跑命名算法(独立备份目录,避免污染共享 manifest)' {
|
||
$pathList = Join-Path $catRoot 'list-pathname.txt'
|
||
$pathNameDir = Join-Path $catRoot 'Backups-pathname'
|
||
[System.IO.File]::WriteAllText($pathList, "my-app @pathname :+ Modules:$settingsFile`n", [System.Text.UTF8Encoding]::new($true))
|
||
# settings.json 刚才被删了,重建一份,让 Include 的宿主机路径存在
|
||
Set-Content -LiteralPath $settingsFile -Value '{"slot":"file"}' -Encoding UTF8
|
||
$expectedBase = Get-BakNRetBackupBaseName -RawPath $dirA
|
||
& $backupScript -BackupListPath $pathList -BackupDir $pathNameDir -ConfigPath $catConfig -Force -QuietTool
|
||
Assert-Equal 0 $LASTEXITCODE
|
||
Assert-FileExists (Join-Path $pathNameDir ($expectedBase + '.7z'))
|
||
}
|
||
|
||
# ============================================================================
|
||
# 4. :: 覆盖单 Slot 条目的真实路径
|
||
# ============================================================================
|
||
|
||
$ovrRoot = Join-Path $WorkRoot 'override'
|
||
$ovrTarget = Join-Path $ovrRoot 'target'
|
||
$ovrBackupDir = Join-Path $ovrRoot 'Backups'
|
||
$ovrCatalog = Join-Path $ovrRoot 'catalog.psd1'
|
||
$ovrList = Join-Path $ovrRoot 'list.txt'
|
||
$ovrConfig = Join-Path $ovrRoot 'config.psd1'
|
||
$ovrExtract = Join-Path $ovrRoot 'verify'
|
||
New-Item -ItemType Directory -Path $ovrTarget -Force | Out-Null
|
||
Set-Content -LiteralPath (Join-Path $ovrTarget 't.txt') -Value 'override-target' -Encoding UTF8
|
||
[System.IO.File]::WriteAllText($ovrCatalog, "@{`n 'ovr-app' = @{ DefaultData = @{ Path = '$ovrRoot\missing-src' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
|
||
[System.IO.File]::WriteAllText($ovrList, "ovr-app :: $ovrTarget`n", [System.Text.UTF8Encoding]::new($true))
|
||
New-E2EConfig -Path $ovrConfig -LogDir (Join-Path $ovrRoot 'logs') -SoftwareCatalog $ovrCatalog
|
||
|
||
& $backupScript -BackupListPath $ovrList -BackupDir $ovrBackupDir -ConfigPath $ovrConfig -Force -QuietTool
|
||
$ovrExitCode = $LASTEXITCODE
|
||
$ovrArchives = @(Get-ChildItem -LiteralPath $ovrBackupDir -File -Filter *.7z -ErrorAction SilentlyContinue)
|
||
|
||
Test-Case ':: 覆盖:备份包内容来自被覆盖的路径,且归档项名仍是 Slot 名' {
|
||
Assert-Equal 0 $ovrExitCode
|
||
Assert-Equal 1 $ovrArchives.Count
|
||
New-Item -ItemType Directory -Path $ovrExtract -Force | Out-Null
|
||
if ($sevenZip) {
|
||
$null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$ovrExtract", $ovrArchives[0].FullName)
|
||
}
|
||
Assert-FileExists (Join-Path $ovrExtract 'DefaultData\t.txt')
|
||
Assert-Equal 'override-target' (Get-Content -Encoding UTF8 -LiteralPath (Join-Path $ovrExtract 'DefaultData\t.txt') -Raw).Trim()
|
||
}
|
||
|
||
Test-Case ':: 覆盖:删掉被覆盖的源后仍能恢复回该路径' {
|
||
Remove-Item -LiteralPath $ovrTarget -Recurse -Force
|
||
& $restoreScript -BackupListPath $ovrList -BackupDir $ovrBackupDir -ConfigPath $ovrConfig -Force
|
||
Assert-Equal 0 $LASTEXITCODE
|
||
Assert-FileExists (Join-Path $ovrTarget 't.txt')
|
||
Assert-Equal 'override-target' (Get-Content -Encoding UTF8 -LiteralPath (Join-Path $ovrTarget 't.txt') -Raw).Trim()
|
||
}
|
||
|
||
# ============================================================================
|
||
# 5. 方向标记:备份跳 `-`、恢复跳 `+`;`-` 的归档名仍算有主(孤儿审计)
|
||
# ============================================================================
|
||
|
||
$dirRoot = Join-Path $WorkRoot 'direction'
|
||
$appA = Join-Path $dirRoot 'A'
|
||
$appB = Join-Path $dirRoot 'B'
|
||
$dirBackupDir = Join-Path $dirRoot 'Backups'
|
||
$dirCatalog = Join-Path $dirRoot 'catalog.psd1'
|
||
$dirList1 = Join-Path $dirRoot 'list1.txt'
|
||
$dirList2 = Join-Path $dirRoot 'list2.txt'
|
||
$dirConfig = Join-Path $dirRoot 'config.psd1'
|
||
$dirLogDir = Join-Path $dirRoot 'logs'
|
||
New-Item -ItemType Directory -Path $appA -Force | Out-Null
|
||
New-Item -ItemType Directory -Path $appB -Force | Out-Null
|
||
Set-Content -LiteralPath (Join-Path $appA 'a.txt') -Value 'dir-a' -Encoding UTF8
|
||
Set-Content -LiteralPath (Join-Path $appB 'b.txt') -Value 'dir-b' -Encoding UTF8
|
||
[System.IO.File]::WriteAllText($dirCatalog, "@{`n 'app-a' = @{ DefaultData = @{ Path = '$appA' } }`n 'app-b' = @{ DefaultData = @{ Path = '$appB' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
|
||
New-E2EConfig -Path $dirConfig -LogDir $dirLogDir -SoftwareCatalog $dirCatalog
|
||
[System.IO.File]::WriteAllText($dirList1, "+ app-a`napp-b`n", [System.Text.UTF8Encoding]::new($true))
|
||
[System.IO.File]::WriteAllText($dirList2, "+ app-a`n- app-b`n", [System.Text.UTF8Encoding]::new($true))
|
||
|
||
& $backupScript -BackupListPath $dirList1 -BackupDir $dirBackupDir -ConfigPath $dirConfig -Force -QuietTool
|
||
Test-Case '行首 + = 仅备份:仍然会被打包' {
|
||
Assert-Equal 0 $LASTEXITCODE
|
||
Assert-FileExists (Join-Path $dirBackupDir 'app-a.7z')
|
||
Assert-FileExists (Join-Path $dirBackupDir 'app-b.7z')
|
||
}
|
||
|
||
# 造一个真孤儿,验证审计仍然会点名它
|
||
Copy-Item -LiteralPath (Join-Path $dirBackupDir 'app-a.7z') -Destination (Join-Path $dirBackupDir 'zzz-orphan.7z')
|
||
Start-Sleep -Milliseconds 1100 # 日志按秒命名,避免两次运行撞进同一个文件名
|
||
|
||
& $backupScript -BackupListPath $dirList2 -BackupDir $dirBackupDir -ConfigPath $dirConfig -Force -QuietTool
|
||
$dirExitCode = $LASTEXITCODE
|
||
$dirLog = Get-NewestLog -LogDir $dirLogDir -Prefix 'backup'
|
||
|
||
Test-Case '行首 - = 仅备份端跳过(日志点名),不产生归档' {
|
||
Assert-Equal 0 $dirExitCode
|
||
$content = Get-Content -LiteralPath $dirLog.FullName -Raw -Encoding UTF8
|
||
Assert-True ($content -like '*跳过(行首 -,仅恢复)*') '日志里应说明为什么跳过'
|
||
}
|
||
|
||
Test-Case '孤儿审计:`-` 条目的归档名算有主,真孤儿才被点名' {
|
||
$orphans = Get-OrphanName -LogPath $dirLog.FullName
|
||
Assert-True ($orphans -contains 'zzz-orphan.7z') '真孤儿必须被点名'
|
||
Assert-False ($orphans -contains 'app-b.7z') '`-` 条目的归档不能被误报成孤儿'
|
||
}
|
||
|
||
Test-Case '恢复端跳过行首 + 的条目、照常恢复 - 的条目' {
|
||
Remove-Item -LiteralPath $appA -Recurse -Force
|
||
Remove-Item -LiteralPath $appB -Recurse -Force
|
||
# -Verbose 打开 DEBUG 日志,才能从日志里读到"为什么跳过"(默认只打 INFO)
|
||
& $restoreScript -BackupListPath $dirList2 -BackupDir $dirBackupDir -ConfigPath $dirConfig -Force -Verbose
|
||
Assert-Equal 0 $LASTEXITCODE
|
||
Assert-FileMissing $appA '行首 + 的条目不该被恢复'
|
||
Assert-FileExists (Join-Path $appB 'b.txt')
|
||
Assert-Equal 'dir-b' (Get-Content -Encoding UTF8 -LiteralPath (Join-Path $appB 'b.txt') -Raw).Trim()
|
||
|
||
$restoreLog = Get-NewestLog -LogDir $dirLogDir -Prefix 'restore'
|
||
$restoreContent = Get-Content -LiteralPath $restoreLog.FullName -Raw -Encoding UTF8
|
||
Assert-True ($restoreContent -like '*跳过(行首 +,仅备份)*') '日志里应说明为什么跳过'
|
||
}
|
||
|
||
# ============================================================================
|
||
# 6. 旧布局归档的恢复(manifest 没有 layouts 时按 <末级名> 回退)
|
||
# ============================================================================
|
||
|
||
$legacyRoot = Join-Path $WorkRoot 'legacy'
|
||
$legacyLive = Join-Path $legacyRoot 'live\settings.json'
|
||
$legacyBackupDir = Join-Path $legacyRoot 'Backups'
|
||
$legacyCatalog = Join-Path $legacyRoot 'catalog.psd1'
|
||
$legacyList = Join-Path $legacyRoot 'list.txt'
|
||
$legacyConfig = Join-Path $legacyRoot 'config.psd1'
|
||
$legacyLogDir = Join-Path $legacyRoot 'logs'
|
||
$legacyScratch = Join-Path $legacyRoot 'scratch'
|
||
New-Item -ItemType Directory -Path (Split-Path -Parent $legacyLive) -Force | Out-Null
|
||
New-Item -ItemType Directory -Path $legacyBackupDir -Force | Out-Null
|
||
New-Item -ItemType Directory -Path $legacyScratch -Force | Out-Null
|
||
Set-Content -LiteralPath $legacyLive -Value '{"version":"old-layout"}' -Encoding UTF8
|
||
|
||
# 手工造一份重构前布局的归档:包内顶层直接是源文件的名字
|
||
$legacySourceFile = Join-Path $legacyScratch 'settings.json'
|
||
Copy-Item -LiteralPath $legacyLive -Destination $legacySourceFile
|
||
if ($sevenZip) {
|
||
$null = Invoke-ExternalCommand -FilePath $sevenZip `
|
||
-ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', (Join-Path $legacyBackupDir 'legacy-file.7z'), 'settings.json') `
|
||
-WorkingDirectory $legacyScratch
|
||
}
|
||
|
||
[System.IO.File]::WriteAllText($legacyCatalog, "@{`n 'legacy-file' = @{ LegacyData = @{ Path = '$legacyLive' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
|
||
[System.IO.File]::WriteAllText($legacyList, "legacy-file`n", [System.Text.UTF8Encoding]::new($true))
|
||
New-E2EConfig -Path $legacyConfig -LogDir $legacyLogDir -SoftwareCatalog $legacyCatalog
|
||
|
||
$legacyManifest = Read-BakNRetManifest -Path (Join-Path $legacyBackupDir 'manifest.json')
|
||
$legacyManifest.items['legacy-file'] = [ordered]@{
|
||
baseName = 'legacy-file'
|
||
source = 'legacy-file'
|
||
archive = 'legacy-file.7z'
|
||
action = 'backed-up'
|
||
encrypted = $false
|
||
}
|
||
Write-BakNRetManifest -Path (Join-Path $legacyBackupDir 'manifest.json') -Manifest $legacyManifest | Out-Null
|
||
|
||
# 让"恢复确实做了事"可验证:把活文件改成别的内容,恢复后应回到归档里的内容
|
||
Set-Content -LiteralPath $legacyLive -Value '{"version":"changed-after-backup"}' -Encoding UTF8
|
||
|
||
if ($sevenZip) {
|
||
& $restoreScript -BackupListPath $legacyList -BackupDir $legacyBackupDir -ConfigPath $legacyConfig -Force
|
||
$legacyExitCode = $LASTEXITCODE
|
||
|
||
Test-Case '旧布局归档:按 <末级名> 回退,把文件还原回原位' {
|
||
Assert-Equal 0 $legacyExitCode
|
||
Assert-Equal '{"version":"old-layout"}' (Get-Content -Encoding UTF8 -LiteralPath $legacyLive -Raw).Trim()
|
||
$legacyLog = Get-NewestLog -LogDir $legacyLogDir -Prefix 'restore'
|
||
$legacyContent = Get-Content -LiteralPath $legacyLog.FullName -Raw -Encoding UTF8
|
||
Assert-True ($legacyContent -like '*按旧布局回退*') '回退时必须给出明确告警'
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
# 7. DryRun 不写盘
|
||
# ============================================================================
|
||
|
||
if (Test-Path -LiteralPath $source) { Remove-Item -LiteralPath $source -Recurse -Force }
|
||
|
||
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -DryRun
|
||
$dryRestoreExitCode = $LASTEXITCODE
|
||
|
||
Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' {
|
||
Assert-Equal 0 $dryRestoreExitCode
|
||
Assert-FileMissing $source
|
||
}
|
||
|
||
& $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -ConfigPath $cfg1 -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 '试运行不应写入任何文件'
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
# 8. 失败路径
|
||
# ============================================================================
|
||
|
||
Test-Case '归档名重复时直接报失败,不静默互相覆盖' {
|
||
$dupList = Join-Path $catRoot 'dup.txt'
|
||
[System.IO.File]::WriteAllText($dupList, "my-app`nmy-app`n", [System.Text.UTF8Encoding]::new($true))
|
||
& $backupScript -BackupListPath $dupList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
|
||
Assert-Equal 1 $LASTEXITCODE '重复条目应让退出码为 1'
|
||
}
|
||
|
||
Test-Case '字面路径不受名录影响,仍走路径命名' {
|
||
$literalList = Join-Path $catRoot 'list-literal.txt'
|
||
$literalDir = Join-Path $catRoot 'Backups-literal'
|
||
[System.IO.File]::WriteAllText($literalList, "$dirA2`n", [System.Text.UTF8Encoding]::new($true))
|
||
& $backupScript -BackupListPath $literalList -BackupDir $literalDir -ConfigPath $catConfig -Force -QuietTool
|
||
Assert-Equal 0 $LASTEXITCODE
|
||
Assert-FileExists (Join-Path $literalDir ((Get-BakNRetBackupBaseName -RawPath $dirA2) + '.7z'))
|
||
}
|
||
|
||
$missingList = Join-Path $WorkRoot 'missing.txt'
|
||
[System.IO.File]::WriteAllText($missingList, "Z:\definitely-not-here-12345`n", [System.Text.UTF8Encoding]::new($true))
|
||
& $backupScript -BackupListPath $missingList -BackupDir $backupDir -ConfigPath $cfg1 -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
|