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

+353 -175
View File
@@ -1,15 +1,18 @@
<#
.SYNOPSIS
清单"两种写法 + 追加/排除"的 Pester 测试:软件名、手写路径,:+/:- 两者都要生效。
清单与名录的"格式契约"Pester 测试:软件名 / 手写路径两种写法,
Slot 形状的 SoftwareCatalog.psd1,以及 `::` / `:-` / `:+` / `:encrypt` / `@ Key='Value'`。
.DESCRIPTION
这里覆盖的是清单/名录的**输入格式**契约:
这里覆盖的是清单/名录的**输入格式与归档布局**契约(重构后的新契约):
* 写法一:直接写 SoftwareCatalog.psd1 里的软件名;
* 写法二:用户手写目录(含 \ / 或 % 就按路径处理);
* 两种写法都要支持 `:+` 追加与 `:-` 排除;
* 名录里一个软件可以挂**对象数组**(每个目录带 Description),运行时会逐条介绍;
* 同一条目里出现两个同名目录时,必须在归档前就明确报错(Blocking),
而不是把两棵树悄悄混在一起。
* 软件名条目 -> 一个归档,归档内是 `<Slot>\<内容>`;Path 是文件时归档内是名为
`<Slot>` 的文件(没有扩展名);
* 手写路径条目 -> 历史布局 `<末级名>\...`,现有清单不需要改写;
* `::` 覆盖 Path(不再是 `:-` 的别名),排除一律写 `:-`;
* `:+` / `@ Include=` 是 `<归档内相对路径>:<宿主机绝对路径>`;
* 同一条目里两个归档项抢同一个包内位置时,必须在归档前就明确报错(Blocking)。
跟 BakNRet.Tests.ps1 一样,脚本调用统一走**子进程**:Backup.ps1 / Restore.ps1 结尾会
`exit`,同进程 `&` 调用会把 Pester 宿主一起带走。
@@ -32,6 +35,8 @@ BeforeAll {
$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,
@@ -49,9 +54,24 @@ BeforeAll {
if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value }
}
$lines = & pwsh @arguments 2>&1
$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 = $LASTEXITCODE
ExitCode = $exitCode
Lines = @($lines | ForEach-Object { [string]$_ })
Output = (($lines | Out-String))
}
@@ -71,7 +91,7 @@ AfterAll {
}
# ============================================================================
Describe '软件名录:对象数组写法' {
Describe '软件名录:Slot 形状(新契约)' {
# ============================================================================
BeforeAll {
@@ -84,249 +104,406 @@ Describe '软件名录:对象数组写法' {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
Set-Content -LiteralPath (Join-Path $directory 'keep.txt') "keep-$directory"
}
New-Item -ItemType Directory -Path (Join-Path $script:DirA 'Cache') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $script:DirA 'Cache\c.bin') 'cache'
$script:CfgFile = Join-Path $script:FormatRoot 'settings.json'
Set-Content -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 -LiteralPath (Join-Path $script:CollideRoot "$parent\dupdir\x.txt") $parent
}
$dirAPath = $script:DirA
$dirBPath = $script:DirB
$collideP1 = Join-Path $script:CollideRoot 'p1\dupdir'
$collideP2 = Join-Path $script:CollideRoot 'p2\dupdir'
$script:MissingDir = Join-Path $script:FormatRoot 'not-here'
$script:FormatCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat.psd1') -Content @"
@{
'pair' = @(
@{ Path = '$dirAPath'; Description = '第一个目录' }
@{ Path = '$dirBPath'; Description = '第二个目录' }
)
'collide' = @(
@{ Path = '$collideP1'; Description = 'p1 里的' }
@{ Path = '$collideP2'; Description = 'p2 里的' }
)
}
"@
$script:MissingDir = Join-Path $script:FormatRoot 'not-here'
$missingPath = $script:MissingDir
$script:PartialCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-partial.psd1') -Content @"
@{
'pair' = @(
@{ Path = '$dirAPath'; Description = '存在' }
@{ Path = '$missingPath'; Description = '不存在' }
)
'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 '一个软件多个目录:顺序与说明都被保留' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3
It '一个软件多个 Slot:Kind=Multi,Slot 按名排序且说明被保留' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$catalog['pair'].Kind | Should -Be 'Multi'
@($catalog['pair'].Items).Count | Should -Be 2
$catalog['pair'].Items[0].Resolved | Should -Be $script:DirA
$catalog['pair'].Items[0].Description | Should -Be '第一个目录'
$catalog['pair'].Items[1].Description | Should -Be '第二个目录'
@($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 '解析成多个源,每个源带着自己的说明' {
It '每个 Slot 都是一个独立的归档项来源(Kind=slot / Origin=catalog)' {
$entry = ConvertFrom-BackupListLine -Line 'pair'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 2
$resolved.Sources[0].SourcePath | Should -Be $script:DirA
$resolved.Sources[0].Description | Should -Be '第一个目录'
$resolved.Sources[1].Description | Should -Be '第二个目录'
@($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Be @('catalog', 'catalog')
@($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 '数组里"当前不存在"的目录仍然产出源(恢复要靠它还原回原位)' {
$entry = ConvertFrom-BackupListLine -Line 'pair'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:PartialCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 2
@($resolved.Sources | ForEach-Object { $_.SourcePath }) | Should -Contain $script:MissingDir
$resolved.Error | Should -Not -BeNullOrEmpty # 有提示
$resolved.Blocking | Should -BeNullOrEmpty # 但不算致命
It 'Slot 级排除写在 Slot 自己身上(相对本 Slot 的归档根)' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
(@($catalog['slotex'].Slots[0].Exclude) -join '|') | Should -Be '!*Cache|logs\'
$entry = ConvertFrom-BackupListLine -Line 'slotex'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
(@($resolved.Items[0].Exclude) -join '|') | Should -Be '!*Cache|logs\'
$resolved.HasExcludeOverride | Should -BeFalse
}
It '纯字符串数组写法继续可用' {
$plain = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-plain.psd1') -Content "@{ 'pair2' = @('$script:DirA', '$script:DirB') }"
$catalog = Get-SoftwareCatalog -Path $plain -MaxDepth 3
$catalog['pair2'].Kind | Should -Be 'Multi'
@($catalog['pair2'].Dirs).Count | Should -Be 2
It '数组里"当前不存在"的 Slot 仍然产出归档项(恢复要靠它还原回原位)' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$catalog['partial'].Kind | Should -Be 'Partial'
@($catalog['partial'].Missing) | Should -Contain $script:MissingDir
$entry = ConvertFrom-BackupListLine -Line 'partial'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 2
@($resolved.Items | ForEach-Object { $_.RealPath }) | Should -Contain $script:MissingDir
$resolved.Blocking | Should -BeNullOrEmpty # 源不存在不是致命错误
}
It '旧的 @{ Dirs = @(...) } 写法继续可用' {
$legacy = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-legacy.psd1') -Content "@{ 'pair3' = @{ Dirs = @('$script:DirA', '$script:DirB') } }"
$catalog = Get-SoftwareCatalog -Path $legacy -MaxDepth 3
$catalog['pair3'].Kind | Should -Be 'Multi'
@($catalog['pair3'].Dirs).Count | Should -Be 2
It '文件 Slot:归档项是文件项(归档里就是名为 Slot 的文件)' {
$entry = ConvertFrom-BackupListLine -Line 'fileapp'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($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 '数组形式的名录条目在清单里仍然按软件名命名归档' {
It '旧的裸字符串 / 字符串数组写法被拒绝(ERROR + 跳过)' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$catalog.ContainsKey('legacystr') | Should -BeFalse
$catalog.ContainsKey('legacyarr') | Should -BeFalse
}
It '旧的 @{ Dirs = @(...) } 写法不再展开,留下 Invalid 与原因' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -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-ItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be 'pair'
}
}
# ============================================================================
Describe '两种写法都要支持 :+ 追加与 :- 排除' {
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:FormatCatalog = Join-Path $script:FormatRoot 'cat.psd1'
$script:CollideRoot = Join-Path $script:FormatRoot 'collide'
}
It '软件名写法::+ 追加一个目录' {
$entry = ConvertFrom-BackupListLine -Line "pair :+ $script:CollideRoot"
It ':: 覆盖 Path:单 Slot 条目直接生效' {
$entry = ConvertFrom-BackupListLine -Line "solo :: $script:DirB"
$entry.Overrides.ContainsKey('Path') | Should -BeTrue
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 3
@($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Contain 'append-path'
}
It '软件名写法::+ 追加"另一个软件名"会按名录展开成它的全部目录' {
$entry = ConvertFrom-BackupListLine -Line 'pair :+ pair'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 4
@($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Contain 'append-catalog'
}
# ---- 回归:手写路径的 :+ 以前会被整段丢掉 ----
It '[回归] 手写路径写法::+ 追加一个目录' {
$entry = ConvertFrom-BackupListLine -Line "$script:DirA :+ $script:DirB"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 2
@($resolved.Sources | ForEach-Object { $_.SourcePath }) | Should -Contain $script:DirB
@($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Contain 'append-path'
}
It '手写路径写法::- 排除与 :+ 追加并存' {
$entry = ConvertFrom-BackupListLine -Line "$script:DirA :+ $script:DirB :- skip.log,!*Cache"
$entry.ExcludePatterns.Count | Should -Be 2
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 2
@($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 '软件名与手写路径混在一行也认得(主目录是软件名,追加是路径)' {
$entry = ConvertFrom-BackupListLine -Line "pair :+ $script:CollideRoot :- logs\"
It ':: 覆盖遇到多 Slot 条目 -> Blocking(不知道给哪一个,绝不猜)' {
$entry = ConvertFrom-BackupListLine -Line "pair :: $script:DirB"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
$resolved.IsName | Should -BeTrue
@($resolved.Sources).Count | Should -Be 3
$entry.ExcludePatterns | Should -Be @('logs\')
@($resolved.Items).Count | Should -Be 0
$resolved.Blocking | Should -Match '不能用一个'
}
It '同一条目里出现两个同名目录 -> Blocking(明确报错,不静默混成一棵树)' {
$entry = ConvertFrom-BackupListLine -Line 'collide'
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-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 2
$resolved.Blocking | Should -Match '顶层同名'
$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-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp') -CatalogPath $script:FormatCatalog -MaxDepth 3
$base.Encrypt | Should -BeTrue # 名录里 Cfg Slot 标了 Encrypt
$off = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp :!encrypt') -CatalogPath $script:FormatCatalog -MaxDepth 3
$off.Encrypt | Should -BeFalse
$on = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'pair :encrypt') -CatalogPath $script:FormatCatalog -MaxDepth 3
$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-BackupBaseName -RawPath $script:DirA
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | 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 '清单行尾的 `# 说明`' {
# ============================================================================
It '会作为这条目的说明解析出来' {
$entry = ConvertFrom-BackupListLine -Line 'Edge :- !*Cache # 缓存可再生'
$entry.Path | Should -Be 'Edge'
$entry.ExcludePatterns | Should -Be @('!*Cache')
$entry.Comment | Should -Be '缓存可再生'
}
It '路径里紧贴的 # 不会被当成注释' {
$entry = ConvertFrom-BackupListLine -Line 'C:\a#b\c'
$entry.Path | Should -Be 'C:\a#b\c'
$entry.Comment | Should -BeNullOrEmpty
}
It '没有说明时 Comment 为空' {
ConvertFrom-BackupListLine -Line 'legendary' | Select-Object -ExpandProperty Comment | Should -BeNullOrEmpty
}
}
# ============================================================================
Describe '集成:手写路径 + :+ 追加 的打包与恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
Describe '集成:Slot 布局的打包与恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
BeforeAll {
$script:AppendRoot = Join-Path $script:Sandbox 'append-e2e'
# 刻意放在**两个不同的父目录**下:只有这样才能验证
# "恢复时不会把兄弟目录也复制过去"
$script:AppendA = Join-Path $script:AppendRoot 'srcA\dirA'
$script:AppendB = Join-Path $script:AppendRoot 'srcB\dirB'
foreach ($directory in $script:AppendA, $script:AppendB) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
}
Set-Content -LiteralPath (Join-Path $script:AppendA 'a.txt') 'A'
Set-Content -LiteralPath (Join-Path $script:AppendB 'b.txt') 'B'
Set-Content -LiteralPath (Join-Path $script:AppendA 'skip.log') 'S'
$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'
$script:AppendList = Write-ListFile -Path (Join-Path $script:AppendRoot 'list.txt') `
-Content "$script:AppendA :+ $script:AppendB :- skip.log`n"
$script:AppendBackupDir = Join-Path $script:AppendRoot 'Backups'
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
$script:AppendBackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:AppendList
BackupDir = $script:AppendBackupDir
Set-Content -LiteralPath (Join-Path $script:SlotAppOne 'one.txt') 'one'
Set-Content -LiteralPath (Join-Path $script:SlotAppOne 'sub\deep.txt') 'deep'
Set-Content -LiteralPath (Join-Path $script:SlotAppOne 'Cache\c.bin') 'cache'
Set-Content -LiteralPath (Join-Path $script:SlotAppTwo 'two.txt') 'two'
Set-Content -LiteralPath (Join-Path $script:SlotCfgDir 'settings.json') '{"a":1}'
Set-Content -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 '备份前会打印空间预估与"够不够"的结论' {
$script:AppendBackupRun.Output | Should -Match '备份前空间预估'
$script:AppendBackupRun.Output | Should -Match '要重打'
$script:AppendBackupRun.Output | Should -Match '结论:'
It '备份退出码 0,归档名就是软件名' {
$script:SlotBackupRun.ExitCode | Should -Be 0
$script:SlotArchive.BaseName | Should -Be 'appkit'
}
It '备份成功,manifest.roots 记录两棵子树' {
$script:AppendBackupRun.ExitCode | Should -Be 0
$archive = @(Get-ChildItem -LiteralPath $script:AppendBackupDir -File -Filter *.7z)[0]
$record = (Read-BaknretManifest -Path (Join-Path $script:AppendBackupDir 'manifest.json')).items[$archive.BaseName]
$record.roots | Should -Contain 'dirA'
$record.roots | Should -Contain 'dirB'
It '归档顶层就是各个 Slot 名(目录 Slot + 文件 Slot + Include 项)' {
$top = @(Get-ArchiveTopLevelNames -ArchivePath $script:SlotArchive.FullName -SevenZip $script:SevenZip)
(@($top | Sort-Object) -join ',') | Should -Be 'Cfg,Data,Extra,Modules'
}
It '归档里两棵树都在,且 :- 排除生效' {
$verify = Join-Path $script:AppendRoot 'verify'
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
$archive = @(Get-ChildItem -LiteralPath $script:AppendBackupDir -File -Filter *.7z)[0]
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive.FullName)) | Should -Be 0
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $script:SlotArchive.FullName)) | Should -Be 0
Test-Path -LiteralPath (Join-Path $verify 'dirA\a.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'dirB\b.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'dirA\skip.log') | Should -BeFalse
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 -LiteralPath (Join-Path $verify 'Cfg') -Raw).Trim() | Should -Be '{"a":1}'
}
It '删源后恢复:每个目录只落回自己的父目录,兄弟目录不会被复制过去' {
Remove-Item -LiteralPath $script:AppendA -Recurse -Force
Remove-Item -LiteralPath $script:AppendB -Recurse -Force
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:AppendList
BackupDir = $script:AppendBackupDir
BackupListPath = $script:SlotList
BackupDir = $script:SlotBackupDir
ConfigPath = $script:SlotConfig
Force = $true
}
$run.ExitCode | Should -Be 0
$run.Output | Should -Match '恢复成功: appkit'
Test-Path -LiteralPath (Join-Path $script:AppendA 'a.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $script:AppendB 'b.txt') | Should -BeTrue
(Get-Content -LiteralPath (Join-Path $script:SlotAppOne 'one.txt') -Raw).Trim() | Should -Be 'one'
(Get-Content -LiteralPath (Join-Path $script:SlotAppOne 'sub\deep.txt') -Raw).Trim() | Should -Be 'deep'
(Get-Content -LiteralPath (Join-Path $script:SlotAppTwo 'two.txt') -Raw).Trim() | Should -Be 'two'
(Get-Content -LiteralPath (Join-Path $script:SlotInclude 'mod.txt') -Raw).Trim() | Should -Be 'mod'
# 关键:srcA 下不该冒出 dirB,srcB 下也不该冒出 dirA
Test-Path -LiteralPath (Join-Path $script:AppendRoot 'srcA\dirB') | Should -BeFalse
Test-Path -LiteralPath (Join-Path $script:AppendRoot 'srcB\dirA') | Should -BeFalse
# 被 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 -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)) {
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 -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-ArchiveTopLevelNames -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 -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 {
@@ -338,8 +515,9 @@ Describe '集成:同一条目里两个同名目录会被拒绝执行' -Skip:(-
New-Item -ItemType Directory -Path $directory -Force | Out-Null
Set-Content -LiteralPath (Join-Path $directory 'x.txt') 'x'
}
Write-ListFile -Path $script:RejectCatalog -Content "@{`n 'collide' = @('$p1', '$p2')`n}`n" | Out-Null
$script:RejectList = Write-ListFile -Path (Join-Path $script:RejectRoot 'list.txt') -Content "collide`n"
# 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'
@@ -352,9 +530,9 @@ Describe '集成:同一条目里两个同名目录会被拒绝执行' -Skip:(-
}
}
It '退出码 1,且给出"顶层同名"的原因,不生成归档' {
It '退出码 1,且给出"归档内路径冲突"的原因,不生成归档' {
$script:RejectRun.ExitCode | Should -Be 1
$script:RejectRun.Output | Should -Match '顶层同名'
$script:RejectRun.Output | Should -Match '归档内路径冲突'
@(Get-ChildItem -LiteralPath $script:RejectBackupDir -File -Filter *.7z -ErrorAction SilentlyContinue).Count | Should -Be 0
}
@@ -362,7 +540,7 @@ Describe '集成:同一条目里两个同名目录会被拒绝执行' -Skip:(-
$manifest = Read-BaknretManifest -Path (Join-Path $script:RejectBackupDir 'manifest.json')
$record = $manifest.items['collide']
$record.action | Should -Be 'failed'
$record.reason | Should -Match '顶层同名'
$record.reason | Should -Match '归档内路径冲突'
}
}
@@ -381,7 +559,7 @@ Describe '条目从清单里消失后,旧归档必须被点名为孤儿' -Skip
Set-Content -LiteralPath (Join-Path $script:OrphanSource 'data.txt') 'hello'
$script:OrphanCatalog = Write-ListFile -Path (Join-Path $script:OrphanRoot 'cat.psd1') `
-Content "@{`n 'my-app' = '$script:OrphanSource'`n}`n"
-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'