<# .SYNOPSIS BakNRet 端到端验收:真实备份 -> 校验归档布局与排除 -> 删源 -> 恢复 -> 逐字节对拍。 .DESCRIPTION 单元测试只验证函数行为,这个脚本验证整条链路真的能用。覆盖重构后的新契约: 1. 字面路径条目:`:-` 排除 -> 删源 -> 恢复 -> 逐字节对拍(历史 `<末级名>\...` 布局); 2. 软件名录条目:一个软件一个归档,包内顶层是各 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, [int]$CatalogMaxDepth = 5 ) $lines = @( '@{' " LogDir = '$LogDir'" " ToolOutput = 'quiet'" ' CompressionLevel = 1' ' MinFreeSpaceGB = 0' ) if ($SoftwareCatalog) { $lines += " SoftwareCatalog = '$SoftwareCatalog'" } $lines += " CatalogMaxDepth = $CatalogMaxDepth" $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-ArchiveNames { param([Parameter(Mandatory = $true)][string]$Dir) return @(Get-ChildItem -LiteralPath $Dir -File -Filter *.7z -ErrorAction SilentlyContinue | Select-Object -ExpandProperty BaseName) } function Get-OrphanNames { <# .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 名:\<内容>' { 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-OrphanNames -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