Files
BakNRet/tests/BakNRet.Formats.Tests.ps1
T
Shuery 232a82cd3c style: 逐条修静态分析告警(706 → 44),并把 MaxDepth 这条假承诺删掉
修掉的:空 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。
2026-09-27 10:16:10 +08:00

647 lines
35 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
清单与名录的"格式契约"Pester 测试:软件名 / 手写路径两种写法,
Slot 形状的 SoftwareCatalog.psd1,以及 `::` / `:-` / `:+` / `:encrypt` / `@ Key='Value'`。
.DESCRIPTION
这里覆盖的是清单/名录的**输入格式与归档布局**契约(重构后的新契约):
* 写法一:直接写 SoftwareCatalog.psd1 里的软件名;
* 写法二:用户手写目录(含 \ / 或 % 就按路径处理);
* 软件名条目 -> 一个归档,归档内是 `<Slot>\<内容>`;Path 是文件时归档内是名为
`<Slot>` 的文件(没有扩展名);
* 手写路径条目 -> 历史布局 `<末级名>\...`,现有清单不需要改写;
* `::` 覆盖 Path(不再是 `:-` 的别名),排除一律写 `:-`;
* `:+` / `@ Include=` 是 `<归档内相对路径>:<宿主机绝对路径>`;
* 同一条目里两个归档项抢同一个包内位置时,必须在归档前就明确报错(Blocking)。
跟 BakNRet.Tests.ps1 一样,脚本调用统一走**子进程**:Backup.ps1 / Restore.ps1 结尾会
`exit`,同进程 `&` 调用会把 Pester 宿主一起带走。
.EXAMPLE
pwsh -File .\tests\Run-Pester.ps1
#>
$script:HasSevenZip = [bool](Get-Command 7z -ErrorAction SilentlyContinue)
$script:HasPwsh = [bool](Get-Command pwsh -ErrorAction SilentlyContinue)
BeforeAll {
$script:ProjectRoot = Split-Path -Parent $PSScriptRoot
$script:BackupScript = Join-Path $script:ProjectRoot 'Backup.ps1'
$script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1'
$script:SevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
Import-Module (Join-Path $script:ProjectRoot 'BakNRet\BakNRet.psd1') -Force
$script:Sandbox = Join-Path $env:TEMP ('baknret-formats-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null
# 见 BakNRet.Tests.ps1 里的说明:本机沙箱禁止 PowerShell 为捕获原生子进程输出建管道,
# 所以走"临时 .cmd + 文件重定向 + Invoke-ExternalCommand(继承 stdio)"这条路。
function Invoke-BakNRetScript {
param(
[Parameter(Mandatory = $true)][string]$Script,
[hashtable]$Parameters = @{}
)
$arguments = @('-NoProfile', '-NonInteractive', '-File', $Script)
foreach ($name in ($Parameters.Keys | Sort-Object)) {
$value = $Parameters[$name]
if ($value -is [bool]) {
if ($value) { $arguments += "-$name" }
continue
}
$arguments += "-$name"
if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value }
}
$outFile = Join-Path ([System.IO.Path]::GetTempPath()) ("bnr-out-" + [guid]::NewGuid().ToString('N') + '.txt')
$cmdFile = Join-Path ([System.IO.Path]::GetTempPath()) ("bnr-cmd-" + [guid]::NewGuid().ToString('N') + '.cmd')
$argString = (@($arguments | ForEach-Object { '"' + ($_ -replace '"', '\"') + '"' }) -join ' ')
$batch = "@echo off`r`n" + 'pwsh ' + $argString + ' > "' + $outFile + '" 2>&1' + "`r`nexit /b %ERRORLEVEL%`r`n"
[System.IO.File]::WriteAllText($cmdFile, $batch, [System.Text.UTF8Encoding]::new($false))
$exitCode = $null
$lines = @()
try {
$exitCode = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', $cmdFile)
$lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue)
}
finally {
Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $cmdFile -Force -ErrorAction SilentlyContinue
}
return [pscustomobject]@{
ExitCode = $exitCode
Lines = @($lines | ForEach-Object { [string]$_ })
Output = (($lines | Out-String))
}
}
function Write-ListFile {
param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Content)
[System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($true))
return $Path
}
}
AfterAll {
if ($script:Sandbox -and (Test-Path -LiteralPath $script:Sandbox)) {
Remove-Item -LiteralPath $script:Sandbox -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================
Describe '软件名录:Slot 形状(新契约)' {
# ============================================================================
BeforeAll {
$script:FormatRoot = Join-Path $script:Sandbox 'format'
New-Item -ItemType Directory -Path $script:FormatRoot -Force | Out-Null
$script:DirA = Join-Path $script:FormatRoot 'dirA'
$script:DirB = Join-Path $script:FormatRoot 'dirB'
foreach ($directory in $script:DirA, $script:DirB) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $directory 'keep.txt') "keep-$directory"
}
New-Item -ItemType Directory -Path (Join-Path $script:DirA 'Cache') -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:DirA 'Cache\c.bin') 'cache'
$script:CfgFile = Join-Path $script:FormatRoot 'settings.json'
Set-Content -Encoding UTF8 -LiteralPath $script:CfgFile '{"a":1}'
# 两个不同父目录下各有一个**同名**子目录 —— 供"归档内同名"冲突测试用
$script:CollideRoot = Join-Path $script:FormatRoot 'collide'
foreach ($parent in 'p1', 'p2') {
New-Item -ItemType Directory -Path (Join-Path $script:CollideRoot "$parent\dupdir") -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:CollideRoot "$parent\dupdir\x.txt") $parent
}
$script:MissingDir = Join-Path $script:FormatRoot 'not-here'
$script:FormatCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat.psd1') -Content @"
@{
'pair' = @{
A = @{ Path = '$script:DirA'; Description = '第一个 Slot' }
B = @{ Path = '$script:DirB'; Description = '第二个 Slot' }
}
'solo' = @{ Only = @{ Path = '$script:DirA' } }
'partial' = @{ Ok = @{ Path = '$script:DirA' }; Gone = @{ Path = '$script:MissingDir' } }
'slotex' = @{ Data = @{ Path = '$script:DirA'; Exclude = '!*Cache,logs\' } }
'fileapp' = @{ Cfg = @{ Path = '$script:CfgFile'; Encrypt = `$true } }
'conflict' = @{ Data = @{ Path = '$script:DirA' } }
'legacyarr' = @('$script:DirA', '$script:DirB')
'legacydirs' = @{ Dirs = @('$script:DirA', '$script:DirB') }
'legacystr' = '$script:DirA'
}
"@
}
It '一个软件多个 Slot:Kind=Multi,Slot 按名排序且说明被保留' {
$catalog = Get-BakNRetSoftwareCatalog -Path $script:FormatCatalog -NoCache
$catalog['pair'].Kind | Should -Be 'Multi'
@($catalog['pair'].Slots).Count | Should -Be 2
(@($catalog['pair'].Slots | ForEach-Object { $_.Name }) -join ',') | Should -Be 'A,B'
$catalog['pair'].Slots[0].Description | Should -Be '第一个 Slot'
$catalog['pair'].Slots[1].Description | Should -Be '第二个 Slot'
$catalog['pair'].Slots[0].Resolved | Should -Be $script:DirA
$catalog['pair'].Slots[1].Resolved | Should -Be $script:DirB
}
It '每个 Slot 都是一个独立的归档项来源(Kind=slot / Origin=catalog)' {
$entry = ConvertFrom-BackupListLine -Line 'pair'
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog
@($resolved.Items).Count | Should -Be 2
(@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'A,B'
(@($resolved.Items | ForEach-Object { $_.Kind }) -join ',') | Should -Be 'slot,slot'
(@($resolved.Items | ForEach-Object { $_.Origin }) -join ',') | Should -Be 'catalog,catalog'
$resolved.Items[0].RealPath | Should -Be $script:DirA
$resolved.Items[0].Description | Should -Be '第一个 Slot'
$resolved.Items[1].Description | Should -Be '第二个 Slot'
}
It 'Slot 级排除写在 Slot 自己身上(相对本 Slot 的归档根)' {
$catalog = Get-BakNRetSoftwareCatalog -Path $script:FormatCatalog -NoCache
(@($catalog['slotex'].Slots[0].Exclude) -join '|') | Should -Be '!*Cache|logs\'
$entry = ConvertFrom-BackupListLine -Line 'slotex'
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog
(@($resolved.Items[0].Exclude) -join '|') | Should -Be '!*Cache|logs\'
$resolved.HasExcludeOverride | Should -BeFalse
}
It '数组里"当前不存在"的 Slot 仍然产出归档项(恢复要靠它还原回原位)' {
$catalog = Get-BakNRetSoftwareCatalog -Path $script:FormatCatalog -NoCache
$catalog['partial'].Kind | Should -Be 'Partial'
@($catalog['partial'].Missing) | Should -Contain $script:MissingDir
$entry = ConvertFrom-BackupListLine -Line 'partial'
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog
@($resolved.Items).Count | Should -Be 2
@($resolved.Items | ForEach-Object { $_.RealPath }) | Should -Contain $script:MissingDir
$resolved.Blocking | Should -BeNullOrEmpty # 源不存在不是致命错误
}
It '文件 Slot:归档项是文件项(归档里就是名为 Slot 的文件)' {
$entry = ConvertFrom-BackupListLine -Line 'fileapp'
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog
@($resolved.Items).Count | Should -Be 1
$resolved.Items[0].IsFile | Should -BeTrue
$resolved.Items[0].ArchivePath | Should -Be 'Cfg'
$resolved.Items[0].RealPath | Should -Be $script:CfgFile
$resolved.Encrypt | Should -BeTrue
}
It '旧的裸字符串 / 字符串数组写法被拒绝(ERROR + 跳过)' {
$catalog = Get-BakNRetSoftwareCatalog -Path $script:FormatCatalog -NoCache
$catalog.ContainsKey('legacystr') | Should -BeFalse
$catalog.ContainsKey('legacyarr') | Should -BeFalse
}
It '旧的 @{ Dirs = @(...) } 写法不再展开,留下 Invalid 与原因' {
$catalog = Get-BakNRetSoftwareCatalog -Path $script:FormatCatalog -NoCache
$catalog.ContainsKey('legacydirs') | Should -BeTrue
$catalog['legacydirs'].Kind | Should -Be 'Invalid'
$catalog['legacydirs'].Error | Should -Not -BeNullOrEmpty
@($catalog['legacydirs'].Slots).Count | Should -Be 0
}
It '多 Slot 的名录条目在清单里仍然按软件名命名归档' {
$entry = ConvertFrom-BackupListLine -Line 'pair'
(Get-BakNRetItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog) | Should -Be 'pair'
}
}
# ============================================================================
Describe '清单修饰符:新契约格式' {
# ============================================================================
BeforeAll {
$script:FormatRoot = Join-Path $script:Sandbox 'format'
$script:FormatCatalog = Join-Path $script:FormatRoot 'cat.psd1'
$script:DirA = Join-Path $script:FormatRoot 'dirA'
$script:DirB = Join-Path $script:FormatRoot 'dirB'
$script:CollideRoot = Join-Path $script:FormatRoot 'collide'
}
It ':: 覆盖 Path:单 Slot 条目直接生效' {
$entry = ConvertFrom-BackupListLine -Line "solo :: $script:DirB"
$entry.Overrides.ContainsKey('Path') | Should -BeTrue
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog
@($resolved.Items).Count | Should -Be 1
$resolved.Items[0].ArchivePath | Should -Be 'Only'
$resolved.Items[0].RealPath | Should -Be $script:DirB
$resolved.Blocking | Should -BeNullOrEmpty
}
It ':: 覆盖遇到多 Slot 条目 -> Blocking(不知道给哪一个,绝不猜)' {
$entry = ConvertFrom-BackupListLine -Line "pair :: $script:DirB"
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog
@($resolved.Items).Count | Should -Be 0
$resolved.Blocking | Should -Match '不能用一个'
}
It ':- 排除与 :+ 包含并存,顺序任意' {
$entry = ConvertFrom-BackupListLine -Line "pair :- logs\,!*Cache :+ Mods:$script:DirB"
(@($entry.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache'
(@($entry.Includes) -join '|') | Should -Be "Mods:$script:DirB"
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog
$resolved.HasExcludeOverride | Should -BeTrue
$resolved.HasIncludeOverride | Should -BeTrue
@($resolved.Items | ForEach-Object { $_.ArchivePath }) | Should -Contain 'Mods'
$resolved.Blocking | Should -BeNullOrEmpty
}
It '@ Exclude / @ Include / @ Path 与记号写法等价' {
$marks = ConvertFrom-BackupListLine -Line "pair :- logs\ :+ Mods:$script:DirB"
$ats = ConvertFrom-BackupListLine -Line "pair @ Exclude='logs\' @ Include='Mods:$script:DirB'"
(@($marks.ExcludePatterns) -join '|') | Should -Be (@($ats.ExcludePatterns) -join '|')
(@($marks.Includes) -join '|') | Should -Be (@($ats.Includes) -join '|')
}
It ':encrypt / :!encrypt 覆盖名录里的加密默认值' {
$base = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp') -CatalogPath $script:FormatCatalog
$base.Encrypt | Should -BeTrue # 名录里 Cfg Slot 标了 Encrypt
$off = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp :!encrypt') -CatalogPath $script:FormatCatalog
$off.Encrypt | Should -BeFalse
$on = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'pair :encrypt') -CatalogPath $script:FormatCatalog
$on.Encrypt | Should -BeTrue
}
It '遗留写法 @encrypt / @pathname / @root= 仍可解析' {
(ConvertFrom-BackupListLine -Line 'pair @encrypt').Overrides['Encrypt'] | Should -BeTrue
(ConvertFrom-BackupListLine -Line 'pair @pathname').Flags | Should -Contain 'pathname'
(ConvertFrom-BackupListLine -Line 'pair @root=Bar').Flags | Should -Contain 'root=Bar'
# @pathname 对软件名条目也会改用真实路径命名
$entry = ConvertFrom-BackupListLine -Line 'pair @pathname'
$expected = Get-BakNRetBackupBaseName -RawPath $script:DirA
(Get-BakNRetItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog) | Should -Be $expected
}
It '同一行里重复写同类记号会累积(不静默丢掉前一条规则)' {
# `:+ a :+ b` 与 `:+ a,b` 等价:两条规则都生效。
# 静默丢掉前一条排除/追加规则是这工具最不该犯的错,所以这里是"累加"语义。
$entry = ConvertFrom-BackupListLine -Line "pair :+ Mods:$script:DirB,More:$script:DirA"
(@($entry.Includes) -join '|') | Should -Be "Mods:$script:DirB|More:$script:DirA"
$repeated = ConvertFrom-BackupListLine -Line "pair :+ Mods:$script:DirB :+ More:$script:DirA"
(@($repeated.Includes) -join '|') | Should -Be "Mods:$script:DirB|More:$script:DirA"
$excludes = ConvertFrom-BackupListLine -Line 'pair :- logs\ :- !*Cache :- temp\'
(@($excludes.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache|temp\'
# @ Exclude= 与 :- 也是累加关系
$mixed = ConvertFrom-BackupListLine -Line "pair @ Exclude='a' :- b"
(@($mixed.ExcludePatterns) -join '|') | Should -Be 'a|b'
}
It '行尾说明与缺少目标的行' {
$entry = ConvertFrom-BackupListLine -Line 'pair :- logs\ # 日志可再生'
$entry.Comment | Should -Be '日志可再生'
(@($entry.ExcludePatterns) -join '|') | Should -Be 'logs\'
ConvertFrom-BackupListLine -Line ':- logs\' | Should -BeNullOrEmpty
}
}
# ============================================================================
Describe '集成:Slot 布局的打包与恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
BeforeAll {
$script:SlotRoot = Join-Path $script:Sandbox 'slots-e2e'
$script:SlotAppOne = Join-Path $script:SlotRoot 'apps\AppOne'
$script:SlotAppTwo = Join-Path $script:SlotRoot 'apps\AppTwo'
$script:SlotCfgDir = Join-Path $script:SlotRoot 'apps\AppCfg'
$script:SlotInclude = Join-Path $script:SlotRoot 'psmodules'
New-Item -ItemType Directory -Path (Join-Path $script:SlotAppOne 'Cache') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:SlotAppOne 'sub') -Force | Out-Null
New-Item -ItemType Directory -Path $script:SlotAppTwo -Force | Out-Null
New-Item -ItemType Directory -Path $script:SlotCfgDir -Force | Out-Null
New-Item -ItemType Directory -Path $script:SlotInclude -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'one.txt') 'one'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'sub\deep.txt') 'deep'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'Cache\c.bin') 'cache'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppTwo 'two.txt') 'two'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotCfgDir 'settings.json') '{"a":1}'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotInclude 'mod.txt') 'mod'
$appOne = $script:SlotAppOne
$appTwo = $script:SlotAppTwo
$cfgFile = Join-Path $script:SlotCfgDir 'settings.json'
$includeDir = $script:SlotInclude
$script:SlotCatalog = Write-ListFile -Path (Join-Path $script:SlotRoot 'cat.psd1') -Content @"
@{
'appkit' = @{
Cfg = @{ Path = '$cfgFile' }
Data = @{ Path = '$appOne'; Exclude = '!*Cache' }
Extra = @{ Path = '$appTwo'; Include = 'Modules:$includeDir' }
}
}
"@
$script:SlotConfig = Write-ListFile -Path (Join-Path $script:SlotRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:SlotCatalog' }"
$script:SlotList = Write-ListFile -Path (Join-Path $script:SlotRoot 'list.txt') -Content "appkit`n"
$script:SlotBackupDir = Join-Path $script:SlotRoot 'Backups'
$script:SlotBackupRun = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:SlotList
BackupDir = $script:SlotBackupDir
ConfigPath = $script:SlotConfig
Force = $true
QuietTool = $true
}
$script:SlotManifest = Read-BakNRetManifest -Path (Join-Path $script:SlotBackupDir 'manifest.json')
$script:SlotArchive = @(Get-ChildItem -LiteralPath $script:SlotBackupDir -File -Filter *.7z)[0]
}
It '备份退出码 0,归档名就是软件名' {
$script:SlotBackupRun.ExitCode | Should -Be 0
$script:SlotArchive.BaseName | Should -Be 'appkit'
}
It '归档顶层就是各个 Slot 名(目录 Slot + 文件 Slot + Include 项)' {
$top = @(Get-BakNRetArchiveTopLevelNames -ArchivePath $script:SlotArchive.FullName -SevenZip $script:SevenZip)
(@($top | Sort-Object) -join ',') | Should -Be 'Cfg,Data,Extra,Modules'
}
It 'manifest.layouts 记下每个归档项是目录还是文件' {
$record = $script:SlotManifest.items['appkit']
$record.action | Should -Be 'backed-up'
$record.roots | Should -Contain 'Data'
(@($record.layouts | ForEach-Object { $_.name + ':' + $_.kind }) -join ',') | Should -Be 'Cfg:file,Data:dir,Extra:dir,Modules:dir'
}
It '归档内容:<Slot>\<内容> 布局,文件 Slot 是名为 Slot 的文件,Slot 排除生效' {
$verify = Join-Path $script:SlotRoot 'verify'
New-Item -ItemType Directory -Path $verify -Force | Out-Null
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $script:SlotArchive.FullName)) | Should -Be 0
Test-Path -LiteralPath (Join-Path $verify 'Data\one.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'Data\sub\deep.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'Data\Cache\c.bin') | Should -BeFalse
Test-Path -LiteralPath (Join-Path $verify 'Extra\two.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'Modules\mod.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'Cfg') -PathType Leaf | Should -BeTrue
(Get-Content -Encoding UTF8 -LiteralPath (Join-Path $verify 'Cfg') -Raw).Trim() | Should -Be '{"a":1}'
}
It '真实恢复:目录 Slot、文件 Slot 与 Include 都落回各自的原位' {
Remove-Item -LiteralPath (Join-Path $script:SlotRoot 'apps') -Recurse -Force
Remove-Item -LiteralPath $script:SlotInclude -Recurse -Force
$run = Invoke-BakNRetScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:SlotList
BackupDir = $script:SlotBackupDir
ConfigPath = $script:SlotConfig
Force = $true
}
$run.ExitCode | Should -Be 0
$run.Output | Should -Match '恢复成功: appkit'
(Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'one.txt') -Raw).Trim() | Should -Be 'one'
(Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'sub\deep.txt') -Raw).Trim() | Should -Be 'deep'
(Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppTwo 'two.txt') -Raw).Trim() | Should -Be 'two'
(Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotInclude 'mod.txt') -Raw).Trim() | Should -Be 'mod'
# 被 Slot 排除的缓存没有进过归档,自然也不会被恢复出来
Test-Path -LiteralPath (Join-Path $script:SlotAppOne 'Cache\c.bin') | Should -BeFalse
}
It '文件 Slot 在目标不存在时靠 manifest.layouts 恢复成文件(而不是目录)' {
# 目标文件被删掉了,名录解析只能得到 IsFile=false;判据要靠 manifest 的 layouts。
$restored = Join-Path $script:SlotCfgDir 'settings.json'
(Test-Path -LiteralPath $restored -PathType Leaf) | Should -BeTrue
(Get-Content -Encoding UTF8 -LiteralPath $restored -Raw).Trim() | Should -Be '{"a":1}'
}
It '恢复之后 manifest 记下 lastRestoreAt' {
$manifest = Read-BakNRetManifest -Path (Join-Path $script:SlotBackupDir 'manifest.json')
$manifest.items['appkit'].lastRestoreAt | Should -Not -BeNullOrEmpty
}
}
# ============================================================================
Describe '集成:旧布局归档的回退恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
# Slot 布局是重构后才有的,Backups\ 里还躺着按旧布局(包内直接是 <源目录名>\...)
# 生成的归档。恢复这类归档时必须回退到"把 <目标末级名> 解到目标父目录"的旧语义。
BeforeAll {
$script:LegacyRoot = Join-Path $script:Sandbox 'legacy-layout'
$script:LegacyHolder = Join-Path $script:LegacyRoot 'holder'
$script:LegacyDestParent = Join-Path $script:LegacyRoot 'dest'
$script:LegacyLeaf = 'My Code Space'
New-Item -ItemType Directory -Path (Join-Path $script:LegacyHolder $script:LegacyLeaf) -Force | Out-Null
New-Item -ItemType Directory -Path $script:LegacyDestParent -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:LegacyHolder "$script:LegacyLeaf\legacy.txt") 'old-layout'
$destPath = Join-Path $script:LegacyDestParent $script:LegacyLeaf
$script:LegacyCatalog = Write-ListFile -Path (Join-Path $script:LegacyRoot 'cat.psd1') -Content @"
@{
'oldapp' = @{ SlotX = @{ Path = '$destPath' } }
}
"@
$script:LegacyConfig = Write-ListFile -Path (Join-Path $script:LegacyRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:LegacyCatalog' }"
$script:LegacyList = Write-ListFile -Path (Join-Path $script:LegacyRoot 'list.txt') -Content "oldapp`n"
$script:LegacyBackupDir = Join-Path $script:LegacyRoot 'Backups'
New-Item -ItemType Directory -Path $script:LegacyBackupDir -Force | Out-Null
# 手工造一个旧布局归档:顶层就是源目录名,不是 Slot 名。
$script:LegacyArchive = Join-Path $script:LegacyBackupDir 'oldapp.7z'
(Invoke-ExternalCommand -FilePath $script:SevenZip `
-ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $script:LegacyArchive, $script:LegacyLeaf) `
-WorkingDirectory $script:LegacyHolder) | Should -Be 0
}
It '归档确实是旧布局:顶层是源目录名而不是 Slot 名' {
$top = @(Get-BakNRetArchiveTopLevelNames -ArchivePath $script:LegacyArchive -SevenZip $script:SevenZip)
(@($top | Sort-Object) -join ',') | Should -Be $script:LegacyLeaf
}
It '归档里缺 Slot 层(真实旧归档)时按旧布局回退,把内容还原回原位' {
# 归档里没有 SlotX,但有旧布局的 <目标末级名>;恢复端必须先问归档"这条路径在不在",
# 不能靠 7z 的退出码猜(7z 对不存在的条目同样返回 0)。
Remove-Item -LiteralPath (Join-Path $script:LegacyDestParent $script:LegacyLeaf) -Recurse -Force -ErrorAction SilentlyContinue
$run = Invoke-BakNRetScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:LegacyList
BackupDir = $script:LegacyBackupDir
ConfigPath = $script:LegacyConfig
Force = $true
}
$run.ExitCode | Should -Be 0
$run.Output | Should -Match '按旧布局回退'
Test-Path -LiteralPath (Join-Path $script:LegacyDestParent "$script:LegacyLeaf\legacy.txt") | Should -BeTrue
(Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:LegacyDestParent "$script:LegacyLeaf\legacy.txt") -Raw).Trim() | Should -Be 'old-layout'
}
It '归档里既没有 Slot 层、也没有旧布局名字时明确失败(不再"成功地什么都没恢复")' {
# 用 :: 覆盖把目标换成一个归档里根本不存在的末级名:两条路都走不通,
# 必须报失败并说明原因,而不是打一句"恢复成功"却一个文件都没落地。
$missingList = Write-ListFile -Path (Join-Path $script:LegacyRoot 'missing-list.txt') `
-Content "oldapp :: $script:LegacyRoot\dest2\Nothing Here`n"
$run = Invoke-BakNRetScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $missingList
BackupDir = $script:LegacyBackupDir
ConfigPath = $script:LegacyConfig
Force = $true
}
$run.ExitCode | Should -Be 1
$run.Output | Should -Match '既没有'
Test-Path -LiteralPath (Join-Path $script:LegacyRoot 'dest2\Nothing Here') | Should -BeFalse
}
}
# ============================================================================
Describe '集成:归档内路径冲突会被拒绝执行' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
BeforeAll {
$script:RejectRoot = Join-Path $script:Sandbox 'collide-e2e'
$script:RejectCatalog = Join-Path $script:RejectRoot 'cat.psd1'
$p1 = Join-Path $script:RejectRoot 'p1\dupdir'
$p2 = Join-Path $script:RejectRoot 'p2\dupdir'
foreach ($directory in $p1, $p2) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $directory 'x.txt') 'x'
}
# Slot 叫 Data,Include 也要放进包内的 Data -> 同一个位置,必须报错
Write-ListFile -Path $script:RejectCatalog -Content "@{`n 'collide' = @{ Data = @{ Path = '$p1' } }`n}`n" | Out-Null
$script:RejectList = Write-ListFile -Path (Join-Path $script:RejectRoot 'list.txt') -Content "collide :+ Data:$p2`n"
$script:RejectConfig = Write-ListFile -Path (Join-Path $script:RejectRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:RejectCatalog' }`n"
$script:RejectBackupDir = Join-Path $script:RejectRoot 'Backups'
$script:RejectRun = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:RejectList
BackupDir = $script:RejectBackupDir
ConfigPath = $script:RejectConfig
Force = $true
QuietTool = $true
}
}
It '退出码 1,且给出"归档内路径冲突"的原因,不生成归档' {
$script:RejectRun.ExitCode | Should -Be 1
$script:RejectRun.Output | Should -Match '归档内路径冲突'
@(Get-ChildItem -LiteralPath $script:RejectBackupDir -File -Filter *.7z -ErrorAction SilentlyContinue).Count | Should -Be 0
}
It 'manifest 里记下这次是 failed,并带上原因' {
$manifest = Read-BakNRetManifest -Path (Join-Path $script:RejectBackupDir 'manifest.json')
$record = $manifest.items['collide']
$record.action | Should -Be 'failed'
$record.reason | Should -Match '归档内路径冲突'
}
}
# ============================================================================
Describe '条目从清单里消失后,旧归档必须被点名为孤儿' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
# 这是"合并/改名条目"的真实后果:归档还在,manifest 里也还留着历史记录,
# 但清单里已经没有任何条目指向它 —— Restore.ps1 按条目名找归档,所以它恢复不到。
# 审计如果拿 manifest 当"已知",这种归档会被历史记录永远遮住,正是最该报的情况。
BeforeAll {
$script:OrphanRoot = Join-Path $script:Sandbox 'orphan-e2e'
$script:OrphanSource = Join-Path $script:OrphanRoot 'src\My App'
New-Item -ItemType Directory -Path $script:OrphanSource -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:OrphanSource 'data.txt') 'hello'
$script:OrphanCatalog = Write-ListFile -Path (Join-Path $script:OrphanRoot 'cat.psd1') `
-Content "@{ 'my-app' = @{ Default = @{ Path = '$script:OrphanSource' } } }`n"
$script:OrphanConfig = Write-ListFile -Path (Join-Path $script:OrphanRoot 'config.psd1') `
-Content "@{ SoftwareCatalog = '$script:OrphanCatalog' }`n"
$script:OrphanList = Join-Path $script:OrphanRoot 'list.txt'
$script:OrphanBackupDir = Join-Path $script:OrphanRoot 'Backups'
# 第一次:清单里有 my-app
Write-ListFile -Path $script:OrphanList -Content "my-app`n" | Out-Null
$null = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:OrphanList
BackupDir = $script:OrphanBackupDir
ConfigPath = $script:OrphanConfig
Force = $true
QuietTool = $true
}
# 第二次:把条目从清单里拿掉,归档留在磁盘上
Write-ListFile -Path $script:OrphanList -Content "# 清单里没有条目了`n" | Out-Null
$script:OrphanRun = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:OrphanList
BackupDir = $script:OrphanBackupDir
ConfigPath = $script:OrphanConfig
QuietTool = $true
}
}
It '归档确实还在磁盘上,manifest 里也还留着历史记录' {
Test-Path -LiteralPath (Join-Path $script:OrphanBackupDir 'my-app.7z') | Should -BeTrue
$manifest = Read-BakNRetManifest -Path (Join-Path $script:OrphanBackupDir 'manifest.json')
$manifest.items.Contains('my-app') | Should -BeTrue
}
It '第二次运行把 my-app.7z 点名成孤儿,并说明 manifest 里还有历史记录' {
$script:OrphanRun.ExitCode | Should -Be 0
$script:OrphanRun.Output | Should -Match '孤儿归档'
$script:OrphanRun.Output | Should -Match 'my-app\.7z'
$script:OrphanRun.Output | Should -Match 'manifest 里还留着它的历史记录'
}
}
# ============================================================================
Describe 'manifest 一致性:archive 字段只在文件真的存在时才写' {
# ============================================================================
BeforeAll {
$script:SyncRoot = Join-Path $script:Sandbox 'sync'
New-Item -ItemType Directory -Path $script:SyncRoot -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SyncRoot 'real.7z') 'x'
$script:SyncManifestPath = Join-Path $script:SyncRoot 'manifest.json'
$manifest = Read-BakNRetManifest -Path $script:SyncManifestPath
$manifest.items['real'] = [pscustomobject]@{ archive = 'real.7z'; action = 'backed-up'; source = 'real' }
$manifest.items['gone'] = [pscustomobject]@{ archive = 'gone.7z'; action = 'missing-source'; source = 'gone' }
$manifest.items['none'] = [pscustomobject]@{ archive = $null; action = 'missing-source'; source = 'none' }
Write-BakNRetManifest -Path $script:SyncManifestPath -Manifest $manifest | Out-Null
}
It '存在的归档保留 archive,不存在的被清空' {
$manifest = Read-BakNRetManifest -Path $script:SyncManifestPath
$cleared = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot
$cleared.Count | Should -Be 1
$cleared[0] | Should -Be 'gone'
$manifest.items['real'].archive | Should -Be 'real.7z'
$manifest.items['gone'].archive | Should -BeNullOrEmpty
$manifest.items['none'].archive | Should -BeNullOrEmpty
}
It '清空 archive 时保留条目本身的历史(source / action 不动)' {
$manifest = Read-BakNRetManifest -Path $script:SyncManifestPath
$null = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot
$manifest.items['gone'].source | Should -Be 'gone'
$manifest.items['gone'].action | Should -Be 'missing-source'
}
It '人工删掉归档之后再同步一次,记录会被纠正过来' {
Remove-Item -LiteralPath (Join-Path $script:SyncRoot 'real.7z') -Force
$manifest = Read-BakNRetManifest -Path $script:SyncManifestPath
$cleared = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot
$cleared | Should -Contain 'real'
$manifest.items['real'].archive | Should -BeNullOrEmpty
}
}