修掉的:空 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。
647 lines
35 KiB
PowerShell
647 lines
35 KiB
PowerShell
<#
|
||
.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
|
||
}
|
||
}
|