chore: 记录改造前基线

改造开始前的完整状态,作为可回退的基点。此提交之后:Pester 175 项、零依赖套件 101 项全绿;PowerShell 5.1 尚不可用(源文件无 BOM)。

包含此前未提交的在制品:安全描述符套件、Hyper-V 实验环境(tools/lab)、agent 约定(AGENTS.md 与 docs/agents)。

.gitignore 增加 *.key / *.pfx:BackupConfig.psd1 的 PasswordFile 此前默认指向仓库内的 baknret.key,一次 git add -A 就会把口令提交进版本库。默认值在后续提交中改为空。
This commit is contained in:
Shuery committed 2026-09-26 21:46:55 +08:00
1 parent 7173e8ae10
commit 2937eb6652
32 files changed
+8775 -1691

No files matched your search

+432 -128
View File
@@ -1,17 +1,24 @@
<#
.SYNOPSIS
BakNRet 端到端验收:真实备份 -> 校验排除 -> 删源 -> 恢复 -> 逐字节对拍。
BakNRet 端到端验收:真实备份 -> 校验归档布局与排除 -> 删源 -> 恢复 -> 逐字节对拍。
.DESCRIPTION
单元测试只验证函数行为,这个脚本验证整条链路真的能用:
1. 造一个含可排除内容的源目录(目录名故意带空格,顺带验证命令行引用);
2. 跑 Backup.ps1,断言退出码为 0、归档生成、manifest 记录正确;
3. 解压归档,断言被排除的内容确实不在里面;
4. 删掉源目录,跑 Restore.ps1,断言文件逐字节还原、被排除的内容没有被还原;
5. 断言 Backup -DryRun 与 Restore -DryRun 都不写盘;
6. 源路径不存在时记为 missing-source,而不是静默忽略。
单元测试只验证函数行为,这个脚本验证整条链路真的能用。覆盖重构后的新契约:
全程只在临时目录里操作,不会碰到真实备份。
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
@@ -37,6 +44,78 @@ 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($false))
}
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' # 名字带空格,专门压一下命令行引用
@@ -44,14 +123,9 @@ $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. 造数据
# ============================================================================
$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
@@ -67,18 +141,14 @@ $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))
[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
$expectedHashes[$relative] = Get-Sha256 (Join-Path $source $relative)
}
# ============================================================================
# 2. 备份
# ============================================================================
& $backupScript -BackupListPath $listPath -BackupDir $backupDir -Force -QuietTool
& $backupScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -Force -QuietTool
$backupExitCode = $LASTEXITCODE
Test-Case '备份退出码为 0(旧实现会把成功的压缩判成失败)' {
@@ -94,7 +164,7 @@ Test-Case '归档已生成' {
$manifestPath = Join-Path $backupDir 'manifest.json'
Test-Case 'manifest 记录了条目、动作与校验结果' {
Test-Case 'manifest 记录了条目、动作、校验结果、roots 与 layouts' {
Assert-FileExists $manifestPath
$manifest = Read-BaknretManifest -Path $manifestPath
Assert-Equal 1 $manifest.items.Count
@@ -105,28 +175,26 @@ Test-Case 'manifest 记录了条目、动作与校验结果' {
Assert-Equal $true $record.verified
Assert-Equal 0 $record.exitCode
Assert-Equal $source $record.source
Assert-True ($record.sourceFiles -ge 4) '源文件数应不少于 4'
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 = Join-Path $projectRoot 'logs'
$logs = @(Get-ChildItem -LiteralPath $logDir -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
Test-Case '备份过程写了日志文件(写进临时 LogDir,不污染仓库)' {
$logs = @(Get-ChildItem -LiteralPath $logDir1 -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) {
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 '归档里保留了应当保留的内容' {
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')
@@ -137,17 +205,13 @@ Test-Case '归档里不含被排除的 logs\ 与 !*Cache 命中项' {
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
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -Force
$restoreExitCode = $LASTEXITCODE
Test-Case '恢复退出码为 0' {
@@ -158,7 +222,7 @@ 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"
Assert-Equal $expectedHashes[$relative] (Get-Sha256 $restored) "对拍 $relative"
}
}
@@ -168,18 +232,20 @@ Test-Case '被排除的内容没有被恢复出来' {
}
# ============================================================================
# 4.5 保护规则:有警告时不拿不完整的归档覆盖完整归档
# 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($false))
# 第一轮:没有占用,归档是"干净"的
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool
& $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
@@ -199,7 +265,7 @@ 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
& $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]
@@ -213,7 +279,7 @@ try {
}
# 明确接受之后才允许覆盖
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool -AcceptWarnings
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool -AcceptWarnings
$acceptExitCode = $LASTEXITCODE
$acceptedRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
@@ -228,12 +294,321 @@ try {
}
# ============================================================================
# 5. DryRun 不写盘
# 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($false))
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($false))
# settings.json 刚才被删了,重建一份,让 Include 的宿主机路径存在
Set-Content -LiteralPath $settingsFile -Value '{"slot":"file"}' -Encoding UTF8
$expectedBase = Get-BackupBaseName -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($false))
[System.IO.File]::WriteAllText($ovrList, "ovr-app :: $ovrTarget`n", [System.Text.UTF8Encoding]::new($false))
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 -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 -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($false))
New-E2EConfig -Path $dirConfig -LogDir $dirLogDir -SoftwareCatalog $dirCatalog
[System.IO.File]::WriteAllText($dirList1, "+ app-a`napp-b`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($dirList2, "+ app-a`n- app-b`n", [System.Text.UTF8Encoding]::new($false))
& $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 -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($false))
[System.IO.File]::WriteAllText($legacyList, "legacy-file`n", [System.Text.UTF8Encoding]::new($false))
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 -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 -DryRun
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -DryRun
$dryRestoreExitCode = $LASTEXITCODE
Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' {
@@ -241,7 +616,7 @@ Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' {
Assert-FileMissing $source
}
& $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -Force -QuietTool -DryRun
& $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -ConfigPath $cfg1 -Force -QuietTool -DryRun
$dryBackupExitCode = $LASTEXITCODE
Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' {
@@ -253,68 +628,9 @@ Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' {
}
# ============================================================================
# 6. 软件名录:清单里写软件名,归档名就是软件名
# 8. 失败路径
# ============================================================================
$catRoot = Join-Path $WorkRoot 'catalog'
$catSource = Join-Path $catRoot 'src'
$catTarget = Join-Path $catSource 'My App' # 真实目录名与软件名刻意不同
$catBackupDir = Join-Path $catRoot 'Backups'
$catFile = Join-Path $catRoot 'SoftwareCatalog.psd1'
$catList = Join-Path $catRoot 'list.txt'
$catConfig = Join-Path $catRoot 'config.psd1'
New-Item -ItemType Directory -Path $catTarget -Force | Out-Null
Set-Content -LiteralPath (Join-Path $catTarget 'data.txt') -Value 'catalog-test' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $catTarget 'skip.bin') -Value 'nope' -Encoding UTF8
[System.IO.File]::WriteAllText($catFile, "@{`n 'my-app' = '$catTarget'`n}`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($catList, "my-app :: skip.bin`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($catConfig, "@{ SoftwareCatalog = '$catFile' }`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
$catExitCode = $LASTEXITCODE
$catArchive = Join-Path $catBackupDir 'my-app.7z'
Test-Case '清单里写软件名 -> 归档名就是软件名' {
Assert-Equal 0 $catExitCode
Assert-FileExists $catArchive
}
Test-Case '软件名条目的归档内容与历史布局一致(根目录仍是源目录名)' {
$extract = Join-Path $catRoot 'verify'
New-Item -ItemType Directory -Path $extract -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$extract", $catArchive)
# 归档文件名是软件名 my-app,但包内根目录是源目录名 My App
Assert-FileExists (Join-Path $extract 'My App\data.txt')
Assert-FileMissing (Join-Path $extract 'my-app') '包内不应多出一层软件名'
Assert-FileMissing (Join-Path $extract 'My App\skip.bin') '排除模式以源目录名为前缀,仍然生效'
}
}
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`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $pathList -BackupDir $pathNameDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE
# 名录里存的是绝对路径,所以路径命名结果也基于它
$expectedBase = Get-BackupBaseName -RawPath $catTarget
Assert-FileExists (Join-Path $pathNameDir ($expectedBase + '.7z'))
}
Test-Case '软件名录条目:删源后能按原路径恢复' {
Remove-Item -LiteralPath $catTarget -Recurse -Force
& $restoreScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force
Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $catTarget 'data.txt')
Assert-Equal 'catalog-test' (Get-Content -LiteralPath (Join-Path $catTarget 'data.txt') -Raw).Trim()
}
Test-Case '归档名重复时直接报失败,不静默互相覆盖' {
$dupList = Join-Path $catRoot 'dup.txt'
[System.IO.File]::WriteAllText($dupList, "my-app`nmy-app`n", [System.Text.UTF8Encoding]::new($false))
@@ -324,28 +640,16 @@ Test-Case '归档名重复时直接报失败,不静默互相覆盖' {
Test-Case '字面路径不受名录影响,仍走路径命名' {
$literalList = Join-Path $catRoot 'list-literal.txt'
[System.IO.File]::WriteAllText($literalList, "$catTarget`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $literalList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
$literalDir = Join-Path $catRoot 'Backups-literal'
[System.IO.File]::WriteAllText($literalList, "$dirA2`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $literalList -BackupDir $literalDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $literalDir ((Get-BackupBaseName -RawPath $dirA2) + '.7z'))
}
Test-Case '名录里没有该软件名时记为 missing-source,而不是崩掉' {
$badList = Join-Path $catRoot 'bad.txt'
[System.IO.File]::WriteAllText($badList, "no-such-app`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $badList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE '跳过不算失败'
$rec = (Read-BaknretManifest -Path (Join-Path $catBackupDir 'manifest.json')).items['no-such-app']
Assert-True ($null -ne $rec) '应留下记录'
Assert-Equal 'missing-source' $rec.action
}
# ============================================================================
# 7. 失败路径:源不存在时必须留下可核对的记录
# ============================================================================
$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
& $backupScript -BackupListPath $missingList -BackupDir $backupDir -ConfigPath $cfg1 -Force -QuietTool
$missingExitCode = $LASTEXITCODE
Test-Case '源路径不存在被记为 missing-source,退出码仍为 0(跳过不算失败)' {