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'
+487
View File
@@ -0,0 +1,487 @@
<#
.SYNOPSIS
安全描述符(NTFS 属主 / ACL)的测试套件。
.DESCRIPTION
为什么单独一套:这一块的核心契约不是"文件内容对不对",而是**安全描述符的形状**——
* `C:\ProgramData` 下的目录 ACL 里有 `(A;OICIIO;GA;;;CO)`:CREATOR OWNER 是访问
检查时才替换的占位符,替换成"被检查对象的属主"。只回放 ACE 文本、不恢复属主,
等于把"谁创建的东西谁有全权"里的"谁"换成跑脚本的账户;
* 归档格式(.7z)根本不承载安全描述符(7-Zip 的 -sni 只能写进 WIM),
所以这一块全部靠 <归档名>.acl.json 旁挂文件 + 显式的回放步骤。
断言用的"安全指纹"刻意**不含** ACE 的继承标志位与 ID(inherited)标志:
继承到文件子对象时容器继承位会被系统去掉,而 ID 标志写不回去(不是可写的输入)。
这两处差异都不改变有效权限,进等式只会制造假失败。
跑法:
.\tests\Run-Pester.ps1 # 会连这一套一起跑
Invoke-Pester -Path .\tests\BakNRet.Security.Tests.ps1
#>
# 发现阶段(discovery)也会执行文件顶层代码,-Skip: 用到的判据必须在这里算好
$script:HasSevenZip = [bool](Get-Command 7z -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'
Import-Module (Join-Path $script:ProjectRoot 'Common.psm1') -Force
$script:Sandbox = Join-Path $env:TEMP ('baknret-acl-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null
# 一个"带刺"的 DACL:CREATOR OWNER(inherit-only, GENERIC_ALL) + 全权给 SYSTEM/Administrators
# + 一条**孤儿 SID** 的显式 ACE(数值形式的 SID,绝不按账户名写)+ DACL protected。
# 这正是 ProgramData 下那些目录的形态,也是"名字解析会把权限落到脚本头上"的现场。
$script:OrphanSid = 'S-1-5-21-1111111111-2222222222-3333333333-4444'
$script:SpecialDacl = 'D:PAI(A;OICIIO;GA;;;CO)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)(A;;0x1201bf;;;' + $script:OrphanSid + ')'
function Set-AclRaw {
<# .SYNOPSIS 写安全描述符:.NET Core 走扩展方法,5.1 走实例方法。 #>
param([Parameter(Mandatory = $true)][System.IO.FileSystemInfo]$Item, [Parameter(Mandatory = $true)]$Security)
if ($PSVersionTable.PSEdition -eq 'Core') {
[System.IO.FileSystemAclExtensions]::SetAccessControl($Item, $Security)
} else {
$Item.SetAccessControl($Security)
}
}
function Get-AclFingerprint {
<#
.SYNOPSIS
逐对象的"安全指纹":属主 | 属组 | protected | 全部 ACE 的 类型|SID|掩码(排序)。
.DESCRIPTION
比 SDDL 原文更适合做断言:继承标志位与 ID 标志的差异不改变有效权限,
而它们的表现形式依赖对象类型(文件没有容器继承)与写入方式,进等式只会假失败。
#>
param([Parameter(Mandatory = $true)][string]$Path)
$acl = Get-Acl -LiteralPath $Path
$sid = [System.Security.Principal.SecurityIdentifier]
$aces = @($acl.GetAccessRules($true, $true, $sid) |
ForEach-Object { '{0}|{1}|{2}' -f $_.AccessControlType, $_.IdentityReference.Value, [int]$_.FileSystemRights } |
Sort-Object)
return ('O={0} G={1} P={2} [{3}]' -f $acl.GetOwner($sid).Value, $acl.GetGroup($sid).Value, $acl.AreAccessRulesProtected, ($aces -join ' '))
}
function New-AclSourceTree {
<#
.SYNOPSIS
造源目录树并打上"带刺"的 DACL,返回逐对象的安全指纹。
.NOTES
DACL 是在子树建好**之后**才打的 —— 这样 sub / a.txt 上会留下"父目录改过权限、
自己还留着老 ACE"的陈旧继承 ACE,正是采集端必须处理的那种对象。
#>
param([Parameter(Mandatory = $true)][string]$Root)
New-Item -ItemType Directory -Path (Join-Path $Root 'sub') -Force | Out-Null
[System.IO.File]::WriteAllText((Join-Path $Root 'sub\a.txt'), 'acl payload')
$security = New-Object System.Security.AccessControl.DirectorySecurity
$security.SetSecurityDescriptorSddlForm($script:SpecialDacl, [System.Security.AccessControl.AccessControlSections]::Access)
Set-AclRaw -Item (Get-Item -LiteralPath $Root) -Security $security
$fingerprints = @{}
foreach ($relative in '.', 'sub', 'sub\a.txt') {
$path = if ($relative -eq '.') { $Root } else { Join-Path $Root $relative }
$fingerprints[$relative] = Get-AclFingerprint -Path $path
}
return $fingerprints
}
function Reset-AclTree {
<# .SYNOPSIS 先把 ACL 复位再删:拒绝型 / protected 的 DACL 会让 Remove-Item 直接失败。 #>
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return }
& takeown.exe /F $Path /R /D Y 2>&1 | Out-Null
& icacls.exe $Path /reset /T /C /Q 2>&1 | Out-Null
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue
}
function Invoke-BaknretScript {
<# .SYNOPSIS 用子进程跑 Backup.ps1 / Restore.ps1(两个脚本结尾都 exit,必须独立进程)。 #>
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-aclout-' + [guid]::NewGuid().ToString('N') + '.txt')
$cmdFile = Join-Path ([System.IO.Path]::GetTempPath()) ('bnr-aclcmd-' + [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 New-AclEntryHarness {
<#
.SYNOPSIS
造一份独立的 BackupList / BackupConfig,返回各个路径。
.NOTES
用**手写路径**条目,不依赖 SoftwareCatalog:归档名由路径推出,
测试也就不用管名录的解析规则。
#>
param([Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$Root)
$dir = Join-Path $script:Sandbox $Name
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$sourcePath = Join-Path $dir 'source'
$backupDir = Join-Path $dir 'backups'
New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
$listPath = Join-Path $dir 'BackupList.txt'
[System.IO.File]::WriteAllText($listPath, "$sourcePath`n", [System.Text.UTF8Encoding]::new($false))
$configPath = Join-Path $dir 'BackupConfig.psd1'
$configText = @"
@{
BackupDir = '$backupDir'
LogDir = '$(Join-Path $dir 'logs')'
SnapshotDir = '$(Join-Path $backupDir 'snapshots')'
SoftwareCatalog = 'NoSuchCatalog.psd1'
MinFreeSpaceGB = 0
VerifyArchive = `$true
ComputeHash = `$false
CompressionLevel = 1
ToolOutput = 'quiet'
Snapshot = @{ Enabled = `$false }
Encryption = @{ Enabled = `$false; PasswordFile = '' }
Security = @{ Mode = 'Full'; IncludeSacl = `$false; SidMap = @{}; FailOnError = `$false }
DefaultExcludes = @()
}
"@
[System.IO.File]::WriteAllText($configPath, $configText, [System.Text.UTF8Encoding]::new($false))
return [pscustomobject]@{
Dir = $dir
SourcePath = $sourcePath
BackupDir = $backupDir
ListPath = $listPath
ConfigPath = $configPath
}
}
}
AfterAll {
foreach ($name in 'walk', 'capture', 'restore', 'integration') {
$path = Join-Path $script:Sandbox $name
Reset-AclTree -Path $path
}
if ($script:Sandbox -and (Test-Path -LiteralPath $script:Sandbox)) {
Reset-AclTree -Path $script:Sandbox
Remove-Item -LiteralPath $script:Sandbox -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================
Describe '排除判定与 7z 的 -x! / -xr! 语义对齐' {
# ============================================================================
It '锚定模式只命中它自己那棵子树' {
Test-BaknretPathExcluded -RelativePath 'Default\Cache' -Patterns @('Default\Cache') | Should -BeTrue
Test-BaknretPathExcluded -RelativePath 'Default\Cache\sub\x.bin' -Patterns @('Default\Cache') | Should -BeFalse
Test-BaknretPathExcluded -RelativePath 'Other\Cache' -Patterns @('Default\Cache') | Should -BeFalse
}
It '! 通配按任意层级的组件名匹配(* 不是正则)' {
Test-BaknretPathExcluded -RelativePath 'a\Code Cache\f' -Patterns @('!*Cache') | Should -BeTrue
Test-BaknretPathExcluded -RelativePath 'Code Cache' -Patterns @('!*Cache') | Should -BeTrue
Test-BaknretPathExcluded -RelativePath 'a\teamcache.bin' -Patterns @('!*Cache') | Should -BeFalse
}
It '!re: 走正则,且组件名与整条相对路径都算命中' {
Test-BaknretPathExcluded -RelativePath 'x\y\a.log' -Patterns @('!re:\.log$') | Should -BeTrue
Test-BaknretPathExcluded -RelativePath 'x\y\a.txt' -Patterns @('!re:\.log$') | Should -BeFalse
Test-BaknretPathExcluded -RelativePath 'Cache\data' -Patterns @('!re:^Cache$') | Should -BeTrue
}
It '没有模式时一律不排除' {
Test-BaknretPathExcluded -RelativePath 'a\b' -Patterns @() | Should -BeFalse
Test-BaknretPathExcluded -RelativePath '' -Patterns @('!*') | Should -BeFalse
}
It '模式里的空格按 7z 的规矩当 ? 处理' {
Test-BaknretPathExcluded -RelativePath 'a\Cache' -Patterns @('!*Cache Extras') | Should -BeFalse
Test-BaknretPathExcluded -RelativePath 'a\Cache Extras' -Patterns @('!*Cache Extras') | Should -BeTrue
}
}
# ============================================================================
Describe 'SID 映射(跨机恢复)' {
# ============================================================================
It '整 SID 精确替换' {
$sddl = 'O:S-1-5-21-1-2-3-1001G:S-1-5-21-1-2-3-1001D:(A;;FA;;;S-1-5-21-1-2-3-1001)'
$mapped = Convert-BaknretSidMap -Sddl $sddl -SidMap @{ 'S-1-5-21-1-2-3-1001' = 'S-1-5-21-9-8-7-1001' }
$mapped | Should -Be 'O:S-1-5-21-9-8-7-1001G:S-1-5-21-9-8-7-1001D:(A;;FA;;;S-1-5-21-9-8-7-1001)'
}
It '不会误伤以它为前缀的更长的 SID' {
$sddl = 'D:(A;;FA;;;S-1-5-21-1-2-3-1001)(A;;FA;;;S-1-5-21-1-2-3-10012)'
$mapped = Convert-BaknretSidMap -Sddl $sddl -SidMap @{ 'S-1-5-21-1-2-3-1001' = 'S-1-5-21-9-8-7-1001' }
$mapped | Should -Be 'D:(A;;FA;;;S-1-5-21-9-8-7-1001)(A;;FA;;;S-1-5-21-1-2-3-10012)'
}
It '空映射表时原样返回' {
$sddl = 'D:(A;;FA;;;SY)'
Convert-BaknretSidMap -Sddl $sddl -SidMap @{} | Should -Be $sddl
}
}
# ============================================================================
Describe '安全描述符采集' {
# ============================================================================
BeforeAll {
$script:CaptureRoot = Join-Path $script:Sandbox 'capture\Data'
$script:CaptureItem = [pscustomobject]@{ ArchivePath = 'Data'; RealPath = $script:CaptureRoot }
$script:CaptureFingerprints = New-AclSourceTree -Root $script:CaptureRoot
}
It 'Full:每个对象一条记录,键是归档内相对路径' {
$capture = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full
$capture.Scanned | Should -Be 3
$capture.Kept | Should -Be 3
$capture.Errors | Should -Be 0
@($capture.Records | ForEach-Object { $_.p }) | Should -Be @('Data', 'Data\sub', 'Data\sub\a.txt')
}
It '根记录的 SDDL 保留了 CREATOR OWNER、IO 标志、孤儿 SID 和 protected 位' {
$capture = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full
$root = @($capture.Records | Where-Object { $_.p -eq 'Data' })[0]
$root.s | Should -Match 'D:PAI'
$root.s | Should -Match '\(A;OICIIO;GA;;;CO\)'
$root.s | Should -BeLike "*$script:OrphanSid*"
$root.o | Should -Be $script:CaptureFingerprints['.'].Split(' ')[0].Substring(2)
}
It 'Smart 比 Full 少,但根永远保留' {
$full = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full
$smart = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Smart
$smart.Kept | Should -BeLessOrEqual $full.Kept
@($smart.Records | ForEach-Object { $_.p }) | Should -Contain 'Data'
}
It 'Roots 只存归档项的根,不再往下走' {
$roots = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Roots
$roots.Kept | Should -Be 1
$roots.Records[0].p | Should -Be 'Data'
}
It 'sidecar 往返:条数与 SDDL 原样保留' {
$capture = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full
$path = Join-Path $script:Sandbox 'roundtrip.acl.json'
Save-BaknretSecuritySidecar -Path $path -Records $capture.Records -Mode Full -Errors $capture.Errors -Scanned $capture.Scanned | Out-Null
$sidecar = Read-BaknretSecuritySidecar -Path $path
$sidecar.Records.Count | Should -Be 3
$record = @($sidecar.Records | Where-Object { $_.p -eq 'Data\sub\a.txt' })[0]
$record.k | Should -Be 'f'
$record.s | Should -Match 'D:'
}
It '旁挂文件不存在时读出 $null(调用方据此打告警,而不是静默当没事)' {
Read-BaknretSecuritySidecar -Path (Join-Path $script:Sandbox 'nope.acl.json') | Should -BeNullOrEmpty
}
It '排除模式在采集时同样生效(采集树 == 归档树)' {
# 刻意用一棵**不带**特殊 DACL 的树:带刺的 ACL 里没有"新建子目录"的权限,
# 在它里面造测试数据会被系统直接拒绝(那本身也是这套功能要防的事)。
$walkRoot = Join-Path $script:Sandbox 'walk\Data'
New-Item -ItemType Directory -Path (Join-Path $walkRoot 'Cache') -Force | Out-Null
[System.IO.File]::WriteAllText((Join-Path $walkRoot 'Cache\c.bin'), 'x')
[System.IO.File]::WriteAllText((Join-Path $walkRoot 'keep.txt'), 'x')
$walkItem = [pscustomobject]@{ ArchivePath = 'Data'; RealPath = $walkRoot }
$capture = Get-BaknretSecurityRecords -Items @($walkItem) -Mode Full -ScopeMap @{ 0 = @('!Cache') }
@($capture.Records | ForEach-Object { $_.p }) | Should -Not -Contain 'Data\Cache'
@($capture.Records | ForEach-Object { $_.p }) | Should -Contain 'Data\keep.txt'
}
}
# ============================================================================
Describe '安全描述符回放' {
# ============================================================================
BeforeAll {
$script:RestoreRoot = Join-Path $script:Sandbox 'restore\Data'
$script:TargetRoot = Join-Path $script:Sandbox 'restore\target'
$script:RestoreFingerprints = New-AclSourceTree -Root $script:RestoreRoot
$capture = Get-BaknretSecurityRecords -Items @([pscustomobject]@{ ArchivePath = 'Data'; RealPath = $script:RestoreRoot }) -Mode Full
$script:RestoreSidecarPath = Join-Path $script:Sandbox 'restore.acl.json'
Save-BaknretSecuritySidecar -Path $script:RestoreSidecarPath -Records $capture.Records -Mode Full -Errors $capture.Errors -Scanned $capture.Scanned | Out-Null
$script:RestoreSidecar = Read-BaknretSecuritySidecar -Path $script:RestoreSidecarPath
}
It '回放后根对象的安全描述符与源逐字节一致(protected / CO / 孤儿 SID 全在)' {
# 干净目标:只拷内容,不带 ACL(ACL 是新建对象的默认值)
& robocopy.exe $script:RestoreRoot $script:TargetRoot /E /COPY:DAT /NFL /NDL /NJH /NJS /NP | Out-Null
$result = Restore-BaknretSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Data' -TargetPath $script:TargetRoot
$result.Total | Should -Be 3
$result.Failed | Should -Be 0
$result.Applied | Should -Be 3
(Get-Acl -LiteralPath $script:TargetRoot).Sddl | Should -Be (Get-Acl -LiteralPath $script:RestoreRoot).Sddl
}
It '全部对象的安全指纹与源一致(属主/属组/ACE 集合)' {
foreach ($relative in '.', 'sub', 'sub\a.txt') {
$sourcePath = if ($relative -eq '.') { $script:RestoreRoot } else { Join-Path $script:RestoreRoot $relative }
$targetPath = if ($relative -eq '.') { $script:TargetRoot } else { Join-Path $script:TargetRoot $relative }
# 唯一允许的差异:陈旧继承 ACE 被"冻结"成显式 + protected 的对象,
# protected 位会从 False 变 True(见 Get-BaknretSecuritySddlWithStale)。
$expected = $script:RestoreFingerprints[$relative] -replace ' P=(True|False) ', ' P='
$actual = (Get-AclFingerprint -Path $targetPath) -replace ' P=(True|False) ', ' P='
$actual | Should -Be $expected -Because "$relative 的 ACE 集合应当与源一致"
}
}
It '目标不存在或不是普通对象时记 Skipped,不记 Failed' {
$result = Restore-BaknretSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Data' `
-TargetPath (Join-Path $script:Sandbox 'restore\does-not-exist')
$result.Total | Should -Be 3
$result.Skipped | Should -Be 3
$result.Failed | Should -Be 0
}
It '归档根名对不上时一条都不回放(不会把兄弟项的 ACL 倒过来)' {
$result = Restore-BaknretSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Other' -TargetPath $script:TargetRoot
$result.Total | Should -Be 0
$result.Applied | Should -Be 0
}
It '属组写不进去时不会连累 DACL:回退到底也要把 ACL 落下去' {
$path = Join-Path $script:Sandbox 'restore\bogus-group'
New-Item -ItemType Directory -Path $path -Force | Out-Null
# 属组写成一个本机不存在的 SID:改主组需要特权,这一层必然失败
$sddl = 'O:' + (Get-Acl -LiteralPath $path).GetOwner([System.Security.Principal.SecurityIdentifier]).Value +
'G:' + $script:OrphanSid + 'D:(A;;FA;;;SY)'
$sidecar = [pscustomobject]@{
Records = @([pscustomobject]@{ p = 'Data'; k = 'd'; s = $sddl })
}
$result = Restore-BaknretSecurity -Sidecar $sidecar -ArchiveRoot 'Data' -TargetPath $path
$result.Failed | Should -Be 0
($result.Applied + $result.OwnerFailed) | Should -Be 1
(Get-Acl -LiteralPath $path).Sddl | Should -Match '\(A;;FA;;;SY\)'
}
It '对象的安全描述符读不到时带 e 记账,回放时跳过而不是写坏' {
$record = [pscustomobject]@{ p = 'Data'; k = 'd'; s = $null; e = '读不到' }
$sidecar = [pscustomobject]@{ Records = @($record) }
$path = Join-Path $script:Sandbox 'restore\bogus-group'
$result = Restore-BaknretSecurity -Sidecar $sidecar -ArchiveRoot 'Data' -TargetPath $path
$result.Skipped | Should -Be 1
$result.Applied | Should -Be 0
$result.Failed | Should -Be 0
}
}
# ============================================================================
Describe '与 Backup.ps1 / Restore.ps1 的集成' -Skip:(-not $script:HasSevenZip) {
# ============================================================================
BeforeAll {
$script:Harness = New-AclEntryHarness -Name 'integration' -Root $script:Sandbox
$script:IntegrationFingerprints = New-AclSourceTree -Root $script:Harness.SourcePath
}
It '备份会写出 <归档名>.acl.json,并在 manifest 里记下它' {
$result = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:Harness.ListPath
ConfigPath = $script:Harness.ConfigPath
BackupDir = $script:Harness.BackupDir
}
$result.ExitCode | Should -Be 0
$sidecars = @(Get-ChildItem -LiteralPath $script:Harness.BackupDir -Filter '*.acl.json' -ErrorAction SilentlyContinue)
$sidecars.Count | Should -Be 1
$result.Output | Should -Match '安全描述符:3 个对象'
$manifest = Get-Content -LiteralPath (Join-Path $script:Harness.BackupDir 'manifest.json') -Raw | ConvertFrom-Json
$key = @($manifest.items.PSObject.Properties.Name)[0]
$manifest.items.$key.security.file | Should -Be $sidecars[0].Name
$manifest.items.$key.security.objects | Should -Be 3
$manifest.items.$key.security.errors | Should -Be 0
}
It '恢复会把安全描述符回放回去(删源之后仍然逐对象与备份前一致)' {
Reset-AclTree -Path $script:Harness.SourcePath
(Test-Path -LiteralPath $script:Harness.SourcePath) | Should -BeFalse
$result = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:Harness.ListPath
ConfigPath = $script:Harness.ConfigPath
BackupDir = $script:Harness.BackupDir
Force = $true
}
$result.ExitCode | Should -Be 0
$result.Output | Should -Match '安全描述符:回放 3/3 个对象'
(Get-Acl -LiteralPath $script:Harness.SourcePath).Sddl | Should -Match '\(A;OICIIO;GA;;;CO\)'
(Get-Acl -LiteralPath $script:Harness.SourcePath).Sddl | Should -BeLike "*$script:OrphanSid*"
foreach ($relative in '.', 'sub', 'sub\a.txt') {
$path = if ($relative -eq '.') { $script:Harness.SourcePath } else { Join-Path $script:Harness.SourcePath $relative }
$expected = $script:IntegrationFingerprints[$relative] -replace ' P=(True|False) ', ' P='
$actual = (Get-AclFingerprint -Path $path) -replace ' P=(True|False) ', ' P='
$actual | Should -Be $expected -Because "$relative 的安全指纹应当与备份前一致"
}
}
It '-SkipSecurity 时不回放(目标保持新建对象的默认 ACL)' {
Reset-AclTree -Path $script:Harness.SourcePath
$result = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:Harness.ListPath
ConfigPath = $script:Harness.ConfigPath
BackupDir = $script:Harness.BackupDir
Force = $true
SkipSecurity = $true
}
$result.ExitCode | Should -Be 0
(Get-Acl -LiteralPath $script:Harness.SourcePath).Sddl | Should -Not -Match '\(A;OICIIO;GA;;;CO\)'
}
It '归档旁边没有 acl.json 时打告警、不算失败(旧归档照样恢复得出来)' {
Reset-AclTree -Path $script:Harness.SourcePath
Get-ChildItem -LiteralPath $script:Harness.BackupDir -Filter '*.acl.json' | Remove-Item -Force
$result = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:Harness.ListPath
ConfigPath = $script:Harness.ConfigPath
BackupDir = $script:Harness.BackupDir
Force = $true
}
$result.ExitCode | Should -Be 0
$result.Output | Should -Match '没有安全描述符旁挂文件'
(Test-Path -LiteralPath (Join-Path $script:Harness.SourcePath 'sub\a.txt')) | Should -BeTrue
}
}
+852 -164
View File
File diff suppressed because it is too large. Load diff
+261 -71
View File
@@ -8,14 +8,23 @@
* 本脚本证明的是"**这一批真实归档**解得开,而且解出来的东西和源一致"。
关键设计:**绝不碰真实目录**。做法是给一份临时名录(SoftwareCatalog),
把软件名映射到临时目标目录,于是 Restore.ps1 会把归档解到临时目录,
把归档里的顶层条目名映射到临时目标目录,于是 Restore.ps1 会把归档解到临时目录,
而不是 ~\.ssh、C:\Programs\... 这些真地方。真实归档本身只被读取。
归档内的一层名字怎么定,取决于**这个归档是哪种布局**(Backups\ 里两种都有):
* 重构后的新布局:包内顶层是 Slot 名(`<Slot>\<内容>`,文件 Slot 就是名为
`<Slot>` 的文件)——manifest 记录的 layouts 里有这个名字;
* 重构前的旧布局:包内顶层是源路径的末级名(`<末级名>\...`)——manifest 没有 layouts。
本脚本按 manifest 判断,把临时名录的 Slot 名设成归档里**真实存在的那一层名字**,
因此新旧布局都能被 Restore.ps1 正常解出来,而不是依赖"解不出来再回退"。
对拍规则(关键:先把"源变了"和"归档坏了"分开):
* 恢复树里每个文件都必须在活源里存在 —— 否则失败(说明归档里混进了别的东西);
* 内容不一致时看活源文件的修改时间:晚于归档时间 ⇒ 源在备份之后被改过,
只提示、不算失败;不晚于归档时间却内容不同 ⇒ 归档或解压有问题,算失败;
* 活源里在备份之后新增 / 删掉的文件只提示;
* 归档里有、活源里没有的文件:如果它所在的活源目录(或最近的还在的祖辈)
的修改时间晚于归档时间 ⇒ 是备份之后从源里删掉的,只提示、不算失败;
否则 ⇒ 归档里混进了源里没有的东西,算失败;
* 活源里在备份之后新增的文件只提示;
* 一个条目一个文件都对不上 —— 失败(多半是空归档,必须点名)。
真实机器上的归档常常是几周前的,所以"必须和今天逐字节一致"不是合理判据;
@@ -26,8 +35,8 @@
pwsh -File .\tests\Restore-Drill.ps1
.EXAMPLE
# 只演练指定条目,并保留下临时工作目录
pwsh -File .\tests\Restore-Drill.ps1 -Entries '.ssh','legendary' -KeepWorkRoot
# 只演练指定条目(写 BackupList.txt 里那样的行:软件名或绝对路径),并保留临时目录
pwsh -File .\tests\Restore-Drill.ps1 -Entries 'OpenSSH','C:\Programs\MiFlash' -KeepWorkRoot
#>
[CmdletBinding()]
@@ -35,11 +44,13 @@ param(
# 归档所在目录;默认取 BackupConfig.psd1 里的 BackupDir
[string]$BackupDir,
# 要演练的条目(软件名)。默认是一组"小、静态、无排除规则"的条目
# 要演练的条目,写法与 BackupList.txt 的一行相同(软件名或绝对路径)。
# 默认是一组"小、静态、无排除规则"的条目;不存在的源 / 归档会被干净地跳过。
[string[]]$Entries = @(
'.ssh', 'legendary', 'scoop-config', 'opencode',
'PowerShell', 'WindowsPowerShell', 'MiFlash', 'MiFlash_Unlock',
'Startup', 'WindowsTerminal', 'Aria'
'OpenSSH', 'Legendary', 'OpenCode', 'PowerShell', 'WindowsPowerShell',
'WindowsTerminal', 'TranslucentTB', 'Kazumi', 'PiliPlus',
'C:\Programs\MiFlash', 'C:\Programs\MiFlash_Unlock',
'D:\UserData\Documents\Aria'
),
[string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupConfig.psd1'),
@@ -82,9 +93,23 @@ if (-not $WorkRoot) {
}
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
$manifest = Read-BaknretManifest -Path (Join-Path $BackupDir 'manifest.json')
$archiveFiles = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') })
# Restore.ps1 恢复成功后会**写回 manifest.json**(记 lastRestoreAt)。真实 Backups\ 只能读,
# 所以给子进程一个临时 BackupDir:里面放一份 manifest 副本 + 指向真实归档的符号链接
# (建不出符号链接就退化成复制)。这样归档还是那批真货,但写只会写进临时目录。
$scratchBackupRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('bnr-drill-backups-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $scratchBackupRoot -Force | Out-Null
$realManifestPath = Join-Path $BackupDir 'manifest.json'
if (Test-Path -LiteralPath $realManifestPath) {
Copy-Item -LiteralPath $realManifestPath -Destination (Join-Path $scratchBackupRoot 'manifest.json') -Force
}
Write-Host ''
Write-Host '== 真实归档恢复演练:归档 -> 临时目标 -> 与活源逐字节对拍 ==' -ForegroundColor Cyan
Write-Host " 归档目录:$BackupDir"
Write-Host " 归档目录:$BackupDir(只读;恢复写盘只写临时目录)"
Write-Host " 软件名录:$catalogPath"
Write-Host " 工作目录:$WorkRoot"
Write-Host ''
@@ -93,6 +118,34 @@ Write-Host ''
# 工具
# ---------------------------------------------------------------------------
function Test-RemovedFromLiveAfterBackup {
<#
.SYNOPSIS
归档里有、活源里没有的文件,是不是"备份之后从源里删掉了"。
.DESCRIPTION
从活源根往下走,停在第一个不存在的层级,看最近的那个还在的祖辈的修改时间:
晚于归档时间 ⇒ 这个文件是在备份之后被删的(源变了,不是归档坏了);
不晚于归档时间 ⇒ 它本该还在,归档里却有别人没有的东西,算失败。
#>
param(
[Parameter(Mandatory = $true)][string]$LiveRoot,
[Parameter(Mandatory = $true)][string]$Relative,
[Parameter(Mandatory = $true)][datetime]$ArchiveTime
)
$probe = $LiveRoot
foreach ($segment in @($Relative -split '[\\/]' | Where-Object { $_ })) {
$next = Join-Path $probe $segment
if (-not (Test-Path -LiteralPath $next)) { break }
$probe = $next
}
$item = Get-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
return ($item.LastWriteTime -gt $ArchiveTime)
}
function Compare-RestoredTree {
<#
.SYNOPSIS
@@ -115,6 +168,7 @@ function Compare-RestoredTree {
Restored = 0
Matched = 0
Stale = @()
Removed = @()
Changed = @()
Extra = @()
Missing = @()
@@ -151,7 +205,11 @@ function Compare-RestoredTree {
$liveFile = Join-Path $liveRoot $relative
if (-not (Test-Path -LiteralPath $liveFile)) {
$report.Extra += $relative
if (Test-RemovedFromLiveAfterBackup -LiveRoot $liveRoot -Relative $relative -ArchiveTime $ArchiveTime) {
$report.Removed += $relative
} else {
$report.Extra += $relative
}
continue
}
@@ -179,6 +237,66 @@ function Compare-RestoredTree {
return $report
}
function Get-ArchiveRelativeName {
<#
.SYNOPSIS
决定一个归档项在**这个归档里**实际叫什么名字。
.DESCRIPTION
manifest 里有 layouts(重构后写的归档)时,项名就是 Slot 名;
没有 layouts(重构前的归档)时,包内那一层是源路径的末级名。
名字对不上就解不出东西,所以这里必须按归档的真实布局来选。
#>
param($Item, $LayoutKinds)
if ($LayoutKinds.Count -gt 0) {
if ($LayoutKinds.ContainsKey([string]$Item.ArchivePath)) { return [string]$Item.ArchivePath }
return $null
}
return (Split-Path -Path ([string]$Item.RealPath) -Leaf)
}
function Invoke-ScratchRestore {
<#
.SYNOPSIS
用子进程跑 Restore.ps1,返回退出码与它自己的日志文件。
.DESCRIPTION
绝不能 `$lines = & pwsh @args 2>&1`:那会给子进程建管道,本机沙箱直接拒绝
(Access to the path '\\.\pipe\LOCAL\dotnet_...' is denied)。
Invoke-ExternalCommand 继承 stdio、不建管道,退出码可靠,所以这里用它启动子进程;
子进程的输出不用管道拿,而是读它自己写下的 restore-*.log。
#>
param(
[Parameter(Mandatory = $true)][string]$ScratchList,
[Parameter(Mandatory = $true)][string]$ScratchConfig,
[Parameter(Mandatory = $true)][string]$ScratchLogDir,
[Parameter(Mandatory = $true)][string]$ScratchBackupDir
)
$pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source)
if (-not $pwshExe) { $pwshExe = 'pwsh' }
New-Item -ItemType Directory -Path $ScratchLogDir -Force | Out-Null
$before = @(Get-ChildItem -LiteralPath $ScratchLogDir -File -Filter 'restore-*.log' -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty FullName)
$code = Invoke-ExternalCommand -FilePath $pwshExe -ArgumentList @(
'-NoProfile', '-NonInteractive', '-File', $restoreScript,
'-BackupListPath', $ScratchList,
'-ConfigPath', $ScratchConfig,
'-BackupDir', $ScratchBackupDir,
'-Force'
)
$log = Get-ChildItem -LiteralPath $ScratchLogDir -File -Filter 'restore-*.log' -ErrorAction SilentlyContinue |
Where-Object { $before -notcontains $_.FullName } |
Sort-Object LastWriteTime | Select-Object -Last 1
return [pscustomobject]@{ Code = $code; Log = $log }
}
# ---------------------------------------------------------------------------
# 演练
# ---------------------------------------------------------------------------
@@ -186,8 +304,10 @@ function Compare-RestoredTree {
$rows = @()
$failures = @()
$checked = 0
$entryIndex = 0
foreach ($name in $Entries) {
$entryIndex++
$entry = ConvertFrom-BackupListLine -Line $name
if (-not $entry) { continue }
@@ -199,84 +319,147 @@ foreach ($name in $Entries) {
continue
}
$archivePath = Join-Path $BackupDir ($resolved.BaseName + '.7z')
if (-not (Test-Path -LiteralPath $archivePath)) {
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "归档不存在:$($resolved.BaseName).7z" }
continue
}
$archiveTime = (Get-Item -LiteralPath $archivePath).LastWriteTime
$sources = @($resolved.Sources)
if ($sources.Count -eq 0) {
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '名录解析不出源路径' }
$items = @($resolved.Items)
if ($items.Count -eq 0) {
$reason = if ($resolved.Error) { $resolved.Error } else { '解析不出归档项' }
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = $reason }
continue
}
if (@($sources | Where-Object { Test-Path -LiteralPath $_.SourcePath }).Count -eq 0) {
# 找到归档:manifest 记录优先,其次按归档基础名 / 源路径末级名精确匹配文件。
# Backups\ 里既有按软件名命名的归档,也有按路径算法命名的旧归档。
$legacyLeaves = @($items | ForEach-Object { Split-Path -Path ([string]$_.RealPath) -Leaf } | Where-Object { $_ })
$archiveFile = $null
$record = $null
if ($manifest.items.Contains($resolved.BaseName)) { $record = $manifest.items[$resolved.BaseName] }
if ($record -and ($record.PSObject.Properties.Name -contains 'archive') -and $record.archive) {
$candidate = Join-Path $BackupDir ([string]$record.archive)
if (Test-Path -LiteralPath $candidate) { $archiveFile = Get-Item -LiteralPath $candidate }
}
if (-not $archiveFile) {
$matched = @()
foreach ($file in $archiveFiles) {
if ($file.BaseName -ieq $resolved.BaseName) { $matched += $file; continue }
foreach ($leaf in $legacyLeaves) {
if ($file.BaseName -ieq $leaf) { $matched += $file; break }
}
}
if ($matched.Count -gt 1) {
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "多个归档都可能是它:$(($matched | ForEach-Object { $_.Name }) -join '、')" }
continue
}
if ($matched.Count -eq 1) { $archiveFile = $matched[0] }
}
if (-not $archiveFile) {
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "归档不存在(基础名 $($resolved.BaseName))" }
continue
}
$archiveTime = $archiveFile.LastWriteTime
# 让子进程的 BackupDir 里也"有"这个归档:优先符号链接(零拷贝),不行才复制
$scratchArchive = Join-Path $scratchBackupRoot $archiveFile.Name
if (-not (Test-Path -LiteralPath $scratchArchive)) {
try {
New-Item -ItemType SymbolicLink -Path $scratchArchive -Target $archiveFile.FullName -ErrorAction Stop | Out-Null
} catch {
Copy-Item -LiteralPath $archiveFile.FullName -Destination $scratchArchive -Force
}
}
# 归档里那一层的真名:manifest.layouts 决定(新布局 = Slot 名,旧布局 = 末级名)
if (-not $record -and $manifest.items.Contains($archiveFile.BaseName)) { $record = $manifest.items[$archiveFile.BaseName] }
$layoutKinds = @{}
if ($record -and ($record.PSObject.Properties.Name -contains 'layouts') -and $record.layouts) {
foreach ($layout in @($record.layouts)) {
if (-not $layout) { continue }
$layoutName = [string]$layout.name
if (-not [string]::IsNullOrWhiteSpace($layoutName)) { $layoutKinds[$layoutName] = [string]$layout.kind }
}
}
$entryRoot = Join-Path (Join-Path $WorkRoot 'restore') ("e$entryIndex")
New-Item -ItemType Directory -Path $entryRoot -Force | Out-Null
$pairs = @()
$slotLines = @()
$skipReason = $null
for ($index = 0; $index -lt $items.Count; $index++) {
$item = $items[$index]
$livePath = [string]$item.RealPath
if ([string]::IsNullOrWhiteSpace($livePath)) { continue }
$liveItem = Get-Item -LiteralPath $livePath -Force -ErrorAction SilentlyContinue
if (-not $liveItem) { continue } # 源没了,跳过(归档里也不该有它)
$archiveName = Get-ArchiveRelativeName -Item $item -LayoutKinds $layoutKinds
if ([string]::IsNullOrWhiteSpace($archiveName)) {
$skipReason = "manifest.layouts 里没有归档项 '$($item.ArchivePath)'(名录改过?)"
break
}
if (@($pairs | Where-Object { $_.Name -ieq $archiveName }).Count -gt 0) {
$skipReason = "多个源都映射到归档内的同一个名字 '$archiveName',无法判定谁是谁(旧归档常见)"
break
}
$target = Join-Path $entryRoot ([string]$index)
if ($liveItem.PSIsContainer) {
New-Item -ItemType Directory -Path $target -Force | Out-Null
} else {
[System.IO.File]::WriteAllText($target, '')
}
$pairs += [pscustomobject]@{ Name = $archiveName; Restored = $target; Live = $livePath }
$slotLines += " '$archiveName' = @{ Path = '$target' }"
}
if ($skipReason) {
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = $skipReason }
continue
}
if ($pairs.Count -eq 0) {
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '所有源目录当前都不存在,无法对拍' }
continue
}
$entryRoot = Join-Path (Join-Path $WorkRoot 'restore') $name
New-Item -ItemType Directory -Path $entryRoot -Force | Out-Null
# 临时名录:键 = 归档基础名(这样 Restore 能通过 manifest / 文件名找到归档),
# 每个 Slot 的 Path 指向一个临时目标 —— Restore 就解到这里,碰不到真实目录。
$catalogKey = $archiveFile.BaseName
$scratchCatalog = Join-Path $WorkRoot ("catalog-e$entryIndex.psd1")
$scratchList = Join-Path $WorkRoot ("list-e$entryIndex.txt")
$scratchConfig = Join-Path $WorkRoot ("config-e$entryIndex.psd1")
$scratchLogDir = Join-Path $WorkRoot ("logs\e$entryIndex")
# 每个源各自映射到一个临时目标:临时名录保持**同样的个数与顺序**,
# 于是 Restore 会把第 i 个源还原到第 i 个临时目录,再和第 i 个活源逐字节对拍。
# (一个条目可以挂多个目录:软件名录的数组写法、以及清单里的 :+ 追加。)
$scratchEntries = @()
$pairs = @()
for ($index = 0; $index -lt $sources.Count; $index++) {
$source = $sources[$index]
$leaf = @($source.RelativePaths)[0]
$scratchTarget = Join-Path (Join-Path $entryRoot $index) $leaf
$scratchEntries += $scratchTarget
$pairs += [pscustomobject]@{
Restored = $scratchTarget
Live = $source.SourcePath
Exists = (Test-Path -LiteralPath $source.SourcePath)
}
}
# 临时名录:把这些目录全指到临时目标,Restore 就解到这里,碰不到真实目录
$scratchCatalog = Join-Path $WorkRoot ("catalog-$name.psd1")
$scratchList = Join-Path $WorkRoot ("list-$name.txt")
$scratchConfig = Join-Path $WorkRoot ("config-$name.psd1")
$itemLines = @($scratchEntries | ForEach-Object { " @{ Path = '$_' }" }) -join "`n"
[System.IO.File]::WriteAllText($scratchCatalog,
"@{`n '$name' = @(`n$itemLines`n )`n}`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($scratchList, "$name`n", [System.Text.UTF8Encoding]::new($false))
"@{`n '$catalogKey' = @{`n$($slotLines -join "`n")`n }`n}`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($scratchList, "$catalogKey`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($scratchConfig, @"
@{
BackupDir = '$BackupDir'
LogDir = '$(Join-Path $WorkRoot 'logs')'
BackupDir = '$scratchBackupRoot'
LogDir = '$scratchLogDir'
SoftwareCatalog = '$scratchCatalog'
CatalogMaxDepth = $($config.CatalogMaxDepth)
VerifyArchive = `$true
}
"@, [System.Text.UTF8Encoding]::new($false))
Write-Host ("-- 演练 {0}(归档 {1}.7z,{2} 个目录)" -f $name, $resolved.BaseName, $sources.Count) -ForegroundColor Gray
Write-Host ("-- 演练 {0}(归档 {1},{2} 个源)" -f $name, $archiveFile.Name, $pairs.Count) -ForegroundColor Gray
# 用**子进程**跑 Restore.ps1:它结尾会 exit,子进程既不会打断演练,
# 给出的也是真正的进程退出码(和 Pester 套件里的做法一致)。
$restoreExit = 0
$restoreOutput = @()
try {
$restoreOutput = & pwsh -NoProfile -NonInteractive -File $restoreScript `
-BackupListPath $scratchList -ConfigPath $scratchConfig -BackupDir $BackupDir -Force 2>&1
$restoreExit = $LASTEXITCODE
} catch {
$restoreExit = -1
Write-Host (" Restore.ps1 调用失败:$_") -ForegroundColor Red
}
$restore = Invoke-ScratchRestore -ScratchList $scratchList -ScratchConfig $scratchConfig -ScratchLogDir $scratchLogDir -ScratchBackupDir $scratchBackupRoot
if ($restoreExit -ne 0) {
foreach ($line in @($restoreOutput | Select-Object -Last 12)) {
Write-Host (" | {0}" -f $line) -ForegroundColor DarkGray
if ($restore.Code -ne 0) {
if ($restore.Log) {
foreach ($line in @(Get-Content -LiteralPath $restore.Log.FullName -ErrorAction SilentlyContinue | Select-Object -Last 12)) {
Write-Host (" | {0}" -f $line) -ForegroundColor DarkGray
}
}
$rows += [pscustomobject]@{ Entry = $name; Status = 'FAIL'; Detail = "Restore.ps1 退出码 $restoreExit" }
$failures += "$name :Restore.ps1 退出码 $restoreExit"
$rows += [pscustomobject]@{ Entry = $name; Status = 'FAIL'; Detail = "Restore.ps1 退出码 $($restore.Code)" }
$failures += "$name :Restore.ps1 退出码 $($restore.Code)"
continue
}
@@ -285,17 +468,17 @@ foreach ($name in $Entries) {
$restoredCount = 0
$extra = @()
$stale = @()
$removed = @()
$changed = @()
$notArchived = @()
foreach ($pair in $pairs) {
# 活源本来就没了的不对拍(归档里也不该有它)
if (-not $pair.Exists) { continue }
$one = Compare-RestoredTree -RestoredPath $pair.Restored -LivePath $pair.Live -ArchiveTime $archiveTime
$matched += $one.Matched
$restoredCount += $one.Restored
$extra += $one.Extra
$stale += $one.Stale
$removed += $one.Removed
$changed += $one.Changed
$notArchived += $one.Missing
}
@@ -312,6 +495,10 @@ foreach ($name in $Entries) {
# 活源在归档之后被改过:源变了,不是归档坏了,只提示
$detail += ";源在备份后变过 $($stale.Count) 个(不算失败)"
}
if ($removed.Count -gt 0) {
# 归档里有、活源里没了,且源目录在归档之后动过:也是"源变了",只提示
$detail += ";备份后从源里删掉 $($removed.Count) 个(不算失败)"
}
if ($changed.Count -gt 0) {
if ($AllowChanged) {
$detail += ";与活源不一致 $($changed.Count) 个(-AllowChanged,已容忍)"
@@ -338,6 +525,9 @@ foreach ($name in $Entries) {
# 报告
# ---------------------------------------------------------------------------
# 临时 BackupDir 用完即删:删符号链接只会删链接本身,真实的归档不受影响
Remove-Item -LiteralPath $scratchBackupRoot -Recurse -Force -ErrorAction SilentlyContinue
Write-Host ''
Write-Host '演练结果:' -ForegroundColor Cyan
$rows | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
+432 -128
View File
@@ -1,17 +1,24 @@
<#
.SYNOPSIS
BakNRet 端到端验收:真实备份 -> 校验排除 -> 删源 -> 恢复 -> 逐字节对拍。
BakNRet 端到端验收:真实备份 -> 校验归档布局与排除 -> 删源 -> 恢复 -> 逐字节对拍。
.DESCRIPTION
单元测试只验证函数行为,这个脚本验证整条链路真的能用:
1. 造一个含可排除内容的源目录(目录名故意带空格,顺带验证命令行引用);
2. 跑 Backup.ps1,断言退出码为 0、归档生成、manifest 记录正确;
3. 解压归档,断言被排除的内容确实不在里面;
4. 删掉源目录,跑 Restore.ps1,断言文件逐字节还原、被排除的内容没有被还原;
5. 断言 Backup -DryRun 与 Restore -DryRun 都不写盘;
6. 源路径不存在时记为 missing-source,而不是静默忽略。
单元测试只验证函数行为,这个脚本验证整条链路真的能用。覆盖重构后的新契约:
全程只在临时目录里操作,不会碰到真实备份。
1. 字面路径条目:`:-` 排除 -> 删源 -> 恢复 -> 逐字节对拍(历史 `<末级名>\...` 布局);
2. 软件名录条目:一个软件一个归档,包内顶层是各 Slot(`<Slot>\<内容>`);
文件 Slot 在包内是一个**名为 Slot 的文件**;
3. `:+` / Include 把宿主机目录放到指定的归档内位置;
4. Slot 前缀的排除模式(`:- AlphaData\plain`)只作用于对应 Slot;
5. 名录 Slot 自己的 Exclude(未写条目级 `:-` 时)同样生效;
6. `::` 覆盖单 Slot 条目的真实路径;
7. 行首 `+` / `-` 方向:备份端跳过 `-`、恢复端跳过 `+`,
且 `-` 条目的归档名仍然算"有主",不会被孤儿审计误报;
8. manifest 记录 `roots` 与 `layouts`(每条归档项是 dir 还是 file);
9. 重构前旧布局归档的恢复(manifest 无 layouts 时按 `<末级名>` 回退);
10. @pathname 用名录里的真实路径命名,DryRun 不写盘,失败路径留记录。
全程只在临时目录里操作,不会碰到真实 Backups\。
.EXAMPLE
pwsh -File .\tests\Run-E2E.ps1
@@ -37,6 +44,78 @@ Reset-TestResult
if (-not $WorkRoot) {
$WorkRoot = Join-Path $env:TEMP ('bnr-' + [guid]::NewGuid().ToString('N').Substring(0, 6))
}
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
Write-Host ""
Write-Host '== 端到端:备份 -> 布局/排除 -> 删源 -> 恢复 -> 对拍 ==' -ForegroundColor Cyan
Write-Host " 工作目录:$WorkRoot"
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
function New-E2EConfig {
<# .SYNOPSIS 写一份只指向临时目录的配置,避免污染仓库日志。 #>
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$LogDir,
[string]$SoftwareCatalog,
[int]$CatalogMaxDepth = 5
)
$lines = @(
'@{'
" LogDir = '$LogDir'"
" ToolOutput = 'quiet'"
' CompressionLevel = 1'
' MinFreeSpaceGB = 0'
)
if ($SoftwareCatalog) { $lines += " SoftwareCatalog = '$SoftwareCatalog'" }
$lines += " CatalogMaxDepth = $CatalogMaxDepth"
$lines += '}'
[System.IO.File]::WriteAllText($Path, ($lines -join "`r`n"), [System.Text.UTF8Encoding]::new($false))
}
function Get-NewestLog {
<# .SYNOPSIS 取日志目录里最新的 backup-*.log / restore-*.log。 #>
param([Parameter(Mandatory = $true)][string]$LogDir, [Parameter(Mandatory = $true)][string]$Prefix)
return Get-ChildItem -LiteralPath $LogDir -File -Filter "$Prefix-*.log" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime | Select-Object -Last 1
}
function Get-ArchiveNames {
param([Parameter(Mandatory = $true)][string]$Dir)
return @(Get-ChildItem -LiteralPath $Dir -File -Filter *.7z -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty BaseName)
}
function Get-OrphanNames {
<# .SYNOPSIS 从备份日志里解析出"孤儿归档"那一段点名的归档名。 #>
param([Parameter(Mandatory = $true)][string]$LogPath)
$names = @()
$inSection = $false
foreach ($line in @(Get-Content -LiteralPath $LogPath -Encoding UTF8)) {
if ($line -match '孤儿归档') { $inSection = $true; continue }
if (-not $inSection) { continue }
if ($line -match '-\s+([^\s()]+?)(') {
$names += $Matches[1]
} else {
$inSection = $false
}
}
return @($names)
}
function Get-Sha256 {
param([Parameter(Mandatory = $true)][string]$Path)
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
}
# ============================================================================
# 1. 字面路径条目:备份 -> 排除 -> 删源 -> 恢复 -> 逐字节对拍
# ============================================================================
$sourceParent = Join-Path $WorkRoot 'src'
$source = Join-Path $sourceParent 'My Code Space' # 名字带空格,专门压一下命令行引用
@@ -44,14 +123,9 @@ $backupDir = Join-Path $WorkRoot 'Backups'
$listPath = Join-Path $WorkRoot 'list.txt'
$dryBackupDir = Join-Path $WorkRoot 'Backups-dry'
$verifyDir = Join-Path $WorkRoot 'verify'
Write-Host ""
Write-Host '== 端到端:备份 -> 排除 -> 删源 -> 恢复 -> 对拍 ==' -ForegroundColor Cyan
Write-Host " 工作目录:$WorkRoot"
# ============================================================================
# 1. 造数据
# ============================================================================
$logDir1 = Join-Path $WorkRoot 'logs1'
$cfg1 = Join-Path $WorkRoot 'cfg1.psd1'
New-E2EConfig -Path $cfg1 -LogDir $logDir1
foreach ($dir in 'logs', 'sub', 'Cache') {
New-Item -ItemType Directory -Path (Join-Path $source $dir) -Force | Out-Null
@@ -67,18 +141,14 @@ $blob = New-Object byte[] 8192
(New-Object System.Random 42).NextBytes($blob)
[System.IO.File]::WriteAllBytes((Join-Path $source 'blob.bin'), $blob)
[System.IO.File]::WriteAllText($listPath, "# e2e`n$source :: logs\,!*Cache`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($listPath, "# e2e`n$source :- logs\,!*Cache`n", [System.Text.UTF8Encoding]::new($false))
$expectedHashes = @{}
foreach ($relative in 'keep.txt', 'sub\b.txt', 'blob.bin') {
$expectedHashes[$relative] = (Get-FileHash -LiteralPath (Join-Path $source $relative) -Algorithm SHA256).Hash
$expectedHashes[$relative] = Get-Sha256 (Join-Path $source $relative)
}
# ============================================================================
# 2. 备份
# ============================================================================
& $backupScript -BackupListPath $listPath -BackupDir $backupDir -Force -QuietTool
& $backupScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -Force -QuietTool
$backupExitCode = $LASTEXITCODE
Test-Case '备份退出码为 0(旧实现会把成功的压缩判成失败)' {
@@ -94,7 +164,7 @@ Test-Case '归档已生成' {
$manifestPath = Join-Path $backupDir 'manifest.json'
Test-Case 'manifest 记录了条目、动作与校验结果' {
Test-Case 'manifest 记录了条目、动作、校验结果、roots 与 layouts' {
Assert-FileExists $manifestPath
$manifest = Read-BaknretManifest -Path $manifestPath
Assert-Equal 1 $manifest.items.Count
@@ -105,28 +175,26 @@ Test-Case 'manifest 记录了条目、动作与校验结果' {
Assert-Equal $true $record.verified
Assert-Equal 0 $record.exitCode
Assert-Equal $source $record.source
Assert-True ($record.sourceFiles -ge 4) '源文件数应不少于 4'
Assert-Equal 5 $record.sourceFiles '源里 5 个文件(排除只影响打包,不影响统计)'
Assert-Equal 1 $record.roots.Count
Assert-Equal 'My Code Space' $record.roots[0]
Assert-Equal 1 $record.layouts.Count
Assert-Equal 'My Code Space' $record.layouts[0].name
Assert-Equal 'dir' $record.layouts[0].kind
}
Test-Case '备份过程写了日志文件' {
$logDir = Join-Path $projectRoot 'logs'
$logs = @(Get-ChildItem -LiteralPath $logDir -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
Test-Case '备份过程写了日志文件(写进临时 LogDir,不污染仓库)' {
$logs = @(Get-ChildItem -LiteralPath $logDir1 -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
Assert-True ($logs.Count -gt 0) '应生成 backup-*.log'
}
# ============================================================================
# 3. 解压归档,验证排除真的生效
# ============================================================================
New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if ($sevenZip) {
New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null
$null = Invoke-ExternalCommand -FilePath $sevenZip `
-ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verifyDir", $archives[0].FullName)
}
Test-Case '归档里保留了应当保留的内容' {
Test-Case '字面路径条目保留历史布局(包内顶层是源目录名)' {
Assert-FileExists (Join-Path $verifyDir 'My Code Space\keep.txt')
Assert-FileExists (Join-Path $verifyDir 'My Code Space\sub\b.txt')
Assert-FileExists (Join-Path $verifyDir 'My Code Space\blob.bin')
@@ -137,17 +205,13 @@ Test-Case '归档里不含被排除的 logs\ 与 !*Cache 命中项' {
Assert-FileMissing (Join-Path $verifyDir 'My Code Space\Cache\c.bin') '!*Cache 应命中 Cache 目录'
}
# ============================================================================
# 4. 删源后恢复,逐字节对拍
# ============================================================================
Remove-Item -LiteralPath $source -Recurse -Force
Test-Case '源目录确实已被删除(保证下面的恢复不是空操作)' {
Assert-FileMissing $source
}
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -Force
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -Force
$restoreExitCode = $LASTEXITCODE
Test-Case '恢复退出码为 0' {
@@ -158,7 +222,7 @@ Test-Case '恢复出的文件与源逐字节一致' {
foreach ($relative in $expectedHashes.Keys) {
$restored = Join-Path $source $relative
Assert-FileExists $restored
Assert-Equal $expectedHashes[$relative] (Get-FileHash -LiteralPath $restored -Algorithm SHA256).Hash "对拍 $relative"
Assert-Equal $expectedHashes[$relative] (Get-Sha256 $restored) "对拍 $relative"
}
}
@@ -168,18 +232,20 @@ Test-Case '被排除的内容没有被恢复出来' {
}
# ============================================================================
# 4.5 保护规则:有警告时不拿不完整的归档覆盖完整归档
# 2. 保护规则:有警告时不拿不完整的归档覆盖完整归档
# ============================================================================
$lockSource = Join-Path $sourceParent 'Locked Case'
$lockBackupDir = Join-Path $WorkRoot 'Backups-lock'
$lockList = Join-Path $WorkRoot 'lock.txt'
$logDir2 = Join-Path $WorkRoot 'logs2'
$cfg2 = Join-Path $WorkRoot 'cfg2.psd1'
New-E2EConfig -Path $cfg2 -LogDir $logDir2
New-Item -ItemType Directory -Path $lockSource -Force | Out-Null
Set-Content -LiteralPath (Join-Path $lockSource 'a.txt') -Value 'aaa' -Encoding UTF8
[System.IO.File]::WriteAllText($lockList, "$lockSource`n", [System.Text.UTF8Encoding]::new($false))
# 第一轮:没有占用,归档是"干净"的
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool
$cleanExitCode = $LASTEXITCODE
$cleanArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
$cleanSize = $cleanArchive.Length
@@ -199,7 +265,7 @@ Set-Content -LiteralPath $lockedPath -Value 'locked' -Encoding UTF8
$lockStream = [System.IO.File]::Open($lockedPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None)
try {
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool
$warnExitCode = $LASTEXITCODE
$afterArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
$afterRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
@@ -213,7 +279,7 @@ try {
}
# 明确接受之后才允许覆盖
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool -AcceptWarnings
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool -AcceptWarnings
$acceptExitCode = $LASTEXITCODE
$acceptedRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
@@ -228,12 +294,321 @@ try {
}
# ============================================================================
# 5. DryRun 不写盘
# 3. 软件名录:Slot 布局(目录 Slot / 文件 Slot / Include / Slot 前缀排除)
# ============================================================================
$catRoot = Join-Path $WorkRoot 'catalog'
$catAppRoot = Join-Path $catRoot 'apps'
$dirA = Join-Path $catAppRoot 'A'
$dirA2 = Join-Path $catAppRoot 'A2'
$settingsFile = Join-Path $catAppRoot 'settings.json'
$incDir = Join-Path $catAppRoot 'inc'
$catBackupDir = Join-Path $catRoot 'Backups'
$catFile = Join-Path $catRoot 'SoftwareCatalog.psd1'
$catList = Join-Path $catRoot 'list.txt'
$catConfig = Join-Path $catRoot 'config.psd1'
$catLogDir = Join-Path $catRoot 'logs'
$catExtract = Join-Path $catRoot 'verify'
foreach ($dir in (Join-Path $dirA 'sub'), (Join-Path $dirA 'plain'), (Join-Path $dirA 'skip'), (Join-Path $dirA2 'skip'), $incDir) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Set-Content -LiteralPath (Join-Path $dirA 'keep.txt') -Value 'A-keep' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $dirA 'sub\keep2.txt') -Value 'A-sub' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $dirA 'plain\p.bin') -Value 'A-plain' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $dirA 'skip\s.bin') -Value 'A-skip' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $dirA2 'keep.txt') -Value 'A2-keep' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $dirA2 'skip\s.bin') -Value 'A2-skip' -Encoding UTF8
Set-Content -LiteralPath $settingsFile -Value '{"slot":"file"}' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $incDir 'i.txt') -Value 'included' -Encoding UTF8
[System.IO.File]::WriteAllText($catFile, @"
@{
'my-app' = @{
AlphaData = @{ Path = '$dirA' }
BetaFile = @{ Path = '$settingsFile' }
}
'cat-excl' = @{
Data = @{ Path = '$dirA2'; Exclude = '!*skip' }
}
}
"@, [System.Text.UTF8Encoding]::new($false))
# 条目级排除用 Slot 前缀点名(AlphaData\plain)+ 任意层级(!*skip);:+ 把 inc 放到归档内 Modules\
[System.IO.File]::WriteAllText($catList, "my-app :- AlphaData\plain,!*skip :+ Modules:$incDir`ncat-excl`n", [System.Text.UTF8Encoding]::new($false))
New-E2EConfig -Path $catConfig -LogDir $catLogDir -SoftwareCatalog $catFile
$catHashes = @{
(Join-Path $dirA 'keep.txt') = Get-Sha256 (Join-Path $dirA 'keep.txt')
(Join-Path $dirA 'sub\keep2.txt') = Get-Sha256 (Join-Path $dirA 'sub\keep2.txt')
$settingsFile = Get-Sha256 $settingsFile
(Join-Path $incDir 'i.txt') = Get-Sha256 (Join-Path $incDir 'i.txt')
}
& $backupScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
$catExitCode = $LASTEXITCODE
Test-Case '名录条目:一个软件一个归档,归档名 = 软件名;独立条目各自成包' {
Assert-Equal 0 $catExitCode
Assert-FileExists (Join-Path $catBackupDir 'my-app.7z')
Assert-FileExists (Join-Path $catBackupDir 'cat-excl.7z')
}
Test-Case 'manifest.roots 列出各归档项的顶层名,layouts 标出 dir / file' {
$record = (Read-BaknretManifest -Path (Join-Path $catBackupDir 'manifest.json')).items['my-app']
Assert-True ($null -ne $record)
$roots = @($record.roots | Sort-Object)
Assert-Equal 3 $roots.Count
Assert-Equal 'AlphaData' $roots[0]
Assert-Equal 'BetaFile' $roots[1]
Assert-Equal 'Modules' $roots[2]
$layoutMap = @{}
foreach ($layout in $record.layouts) { $layoutMap[$layout.name] = $layout.kind }
Assert-Equal 'dir' $layoutMap['AlphaData']
Assert-Equal 'file' $layoutMap['BetaFile']
Assert-Equal 'dir' $layoutMap['Modules']
}
New-Item -ItemType Directory -Path $catExtract -Force | Out-Null
if ($sevenZip) {
$null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$catExtract", (Join-Path $catBackupDir 'my-app.7z'))
}
Test-Case '归档内顶层是 Slot 名:<Slot>\<内容>' {
Assert-FileExists (Join-Path $catExtract 'AlphaData\keep.txt') 'AlphaData 必须是包内的一层目录'
Assert-FileExists (Join-Path $catExtract 'AlphaData\sub\keep2.txt')
Assert-FileMissing (Join-Path $catExtract 'A\keep.txt') '包内不该出现宿主机上的目录名'
Assert-FileMissing (Join-Path $catExtract 'my-app') '包内不该多出一层软件名'
}
Test-Case '文件 Slot 在包内是一个名为 Slot 的文件(没有扩展名)' {
Assert-True (Test-Path -LiteralPath (Join-Path $catExtract 'BetaFile') -PathType Leaf) 'BetaFile 应是文件'
Assert-FileMissing (Join-Path $catExtract 'BetaFile.json')
Assert-FileMissing (Join-Path $catExtract 'settings.json') '文件 Slot 不保留原文件名'
}
Test-Case ':+ / Include 把宿主机目录放到指定的归档内位置' {
Assert-FileExists (Join-Path $catExtract 'Modules\i.txt')
}
Test-Case 'Slot 前缀的排除模式只作用在对应 Slot 上(AlphaData\plain)' {
Assert-FileMissing (Join-Path $catExtract 'AlphaData\plain\p.bin')
Assert-True (Test-Path -LiteralPath (Join-Path $catExtract 'AlphaData\keep.txt')) '未被点名的文件必须留着'
}
Test-Case '任意层级模式 (!*skip) 广播到每个归档项' {
Assert-FileMissing (Join-Path $catExtract 'AlphaData\skip\s.bin')
}
Test-Case '名录 Slot 自己的 Exclude 在没有条目级 :- 时同样生效' {
$exclExtract = Join-Path $catRoot 'verify-excl'
New-Item -ItemType Directory -Path $exclExtract -Force | Out-Null
if ($sevenZip) {
$null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$exclExtract", (Join-Path $catBackupDir 'cat-excl.7z'))
}
Assert-FileExists (Join-Path $exclExtract 'Data\keep.txt')
Assert-FileMissing (Join-Path $exclExtract 'Data\skip\s.bin') '名录 Slot 的 Exclude 应把 skip 挡在包外'
}
Remove-Item -LiteralPath $dirA -Recurse -Force
Remove-Item -LiteralPath $settingsFile -Force
Remove-Item -LiteralPath $incDir -Recurse -Force
Test-Case '删源后按 Slot 恢复:目录 / 文件 / 追加项各自回到自己的 Path' {
& $restoreScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force
Assert-Equal 0 $LASTEXITCODE
foreach ($path in $catHashes.Keys) {
Assert-FileExists $path
Assert-Equal $catHashes[$path] (Get-Sha256 $path) "对拍 $path"
}
}
Test-Case '恢复不会把被排除的内容带回来' {
Assert-FileMissing (Join-Path $dirA 'plain\p.bin')
Assert-FileMissing (Join-Path $dirA 'skip\s.bin')
Assert-FileMissing (Join-Path $catAppRoot 'BetaFile') '文件 Slot 的归档内名字不该落到宿主机上'
}
Test-Case '@pathname 覆盖:用名录里的真实路径跑命名算法(独立备份目录,避免污染共享 manifest)' {
$pathList = Join-Path $catRoot 'list-pathname.txt'
$pathNameDir = Join-Path $catRoot 'Backups-pathname'
[System.IO.File]::WriteAllText($pathList, "my-app @pathname :+ Modules:$settingsFile`n", [System.Text.UTF8Encoding]::new($false))
# settings.json 刚才被删了,重建一份,让 Include 的宿主机路径存在
Set-Content -LiteralPath $settingsFile -Value '{"slot":"file"}' -Encoding UTF8
$expectedBase = Get-BackupBaseName -RawPath $dirA
& $backupScript -BackupListPath $pathList -BackupDir $pathNameDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $pathNameDir ($expectedBase + '.7z'))
}
# ============================================================================
# 4. :: 覆盖单 Slot 条目的真实路径
# ============================================================================
$ovrRoot = Join-Path $WorkRoot 'override'
$ovrTarget = Join-Path $ovrRoot 'target'
$ovrBackupDir = Join-Path $ovrRoot 'Backups'
$ovrCatalog = Join-Path $ovrRoot 'catalog.psd1'
$ovrList = Join-Path $ovrRoot 'list.txt'
$ovrConfig = Join-Path $ovrRoot 'config.psd1'
$ovrExtract = Join-Path $ovrRoot 'verify'
New-Item -ItemType Directory -Path $ovrTarget -Force | Out-Null
Set-Content -LiteralPath (Join-Path $ovrTarget 't.txt') -Value 'override-target' -Encoding UTF8
[System.IO.File]::WriteAllText($ovrCatalog, "@{`n 'ovr-app' = @{ DefaultData = @{ Path = '$ovrRoot\missing-src' } }`n}`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($ovrList, "ovr-app :: $ovrTarget`n", [System.Text.UTF8Encoding]::new($false))
New-E2EConfig -Path $ovrConfig -LogDir (Join-Path $ovrRoot 'logs') -SoftwareCatalog $ovrCatalog
& $backupScript -BackupListPath $ovrList -BackupDir $ovrBackupDir -ConfigPath $ovrConfig -Force -QuietTool
$ovrExitCode = $LASTEXITCODE
$ovrArchives = @(Get-ChildItem -LiteralPath $ovrBackupDir -File -Filter *.7z -ErrorAction SilentlyContinue)
Test-Case ':: 覆盖:备份包内容来自被覆盖的路径,且归档项名仍是 Slot 名' {
Assert-Equal 0 $ovrExitCode
Assert-Equal 1 $ovrArchives.Count
New-Item -ItemType Directory -Path $ovrExtract -Force | Out-Null
if ($sevenZip) {
$null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$ovrExtract", $ovrArchives[0].FullName)
}
Assert-FileExists (Join-Path $ovrExtract 'DefaultData\t.txt')
Assert-Equal 'override-target' (Get-Content -LiteralPath (Join-Path $ovrExtract 'DefaultData\t.txt') -Raw).Trim()
}
Test-Case ':: 覆盖:删掉被覆盖的源后仍能恢复回该路径' {
Remove-Item -LiteralPath $ovrTarget -Recurse -Force
& $restoreScript -BackupListPath $ovrList -BackupDir $ovrBackupDir -ConfigPath $ovrConfig -Force
Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $ovrTarget 't.txt')
Assert-Equal 'override-target' (Get-Content -LiteralPath (Join-Path $ovrTarget 't.txt') -Raw).Trim()
}
# ============================================================================
# 5. 方向标记:备份跳 `-`、恢复跳 `+`;`-` 的归档名仍算有主(孤儿审计)
# ============================================================================
$dirRoot = Join-Path $WorkRoot 'direction'
$appA = Join-Path $dirRoot 'A'
$appB = Join-Path $dirRoot 'B'
$dirBackupDir = Join-Path $dirRoot 'Backups'
$dirCatalog = Join-Path $dirRoot 'catalog.psd1'
$dirList1 = Join-Path $dirRoot 'list1.txt'
$dirList2 = Join-Path $dirRoot 'list2.txt'
$dirConfig = Join-Path $dirRoot 'config.psd1'
$dirLogDir = Join-Path $dirRoot 'logs'
New-Item -ItemType Directory -Path $appA -Force | Out-Null
New-Item -ItemType Directory -Path $appB -Force | Out-Null
Set-Content -LiteralPath (Join-Path $appA 'a.txt') -Value 'dir-a' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $appB 'b.txt') -Value 'dir-b' -Encoding UTF8
[System.IO.File]::WriteAllText($dirCatalog, "@{`n 'app-a' = @{ DefaultData = @{ Path = '$appA' } }`n 'app-b' = @{ DefaultData = @{ Path = '$appB' } }`n}`n", [System.Text.UTF8Encoding]::new($false))
New-E2EConfig -Path $dirConfig -LogDir $dirLogDir -SoftwareCatalog $dirCatalog
[System.IO.File]::WriteAllText($dirList1, "+ app-a`napp-b`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($dirList2, "+ app-a`n- app-b`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $dirList1 -BackupDir $dirBackupDir -ConfigPath $dirConfig -Force -QuietTool
Test-Case '行首 + = 仅备份:仍然会被打包' {
Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $dirBackupDir 'app-a.7z')
Assert-FileExists (Join-Path $dirBackupDir 'app-b.7z')
}
# 造一个真孤儿,验证审计仍然会点名它
Copy-Item -LiteralPath (Join-Path $dirBackupDir 'app-a.7z') -Destination (Join-Path $dirBackupDir 'zzz-orphan.7z')
Start-Sleep -Milliseconds 1100 # 日志按秒命名,避免两次运行撞进同一个文件名
& $backupScript -BackupListPath $dirList2 -BackupDir $dirBackupDir -ConfigPath $dirConfig -Force -QuietTool
$dirExitCode = $LASTEXITCODE
$dirLog = Get-NewestLog -LogDir $dirLogDir -Prefix 'backup'
Test-Case '行首 - = 仅备份端跳过(日志点名),不产生归档' {
Assert-Equal 0 $dirExitCode
$content = Get-Content -LiteralPath $dirLog.FullName -Raw -Encoding UTF8
Assert-True ($content -like '*跳过(行首 -,仅恢复)*') '日志里应说明为什么跳过'
}
Test-Case '孤儿审计:`-` 条目的归档名算有主,真孤儿才被点名' {
$orphans = Get-OrphanNames -LogPath $dirLog.FullName
Assert-True ($orphans -contains 'zzz-orphan.7z') '真孤儿必须被点名'
Assert-False ($orphans -contains 'app-b.7z') '`-` 条目的归档不能被误报成孤儿'
}
Test-Case '恢复端跳过行首 + 的条目、照常恢复 - 的条目' {
Remove-Item -LiteralPath $appA -Recurse -Force
Remove-Item -LiteralPath $appB -Recurse -Force
# -Verbose 打开 DEBUG 日志,才能从日志里读到"为什么跳过"(默认只打 INFO)
& $restoreScript -BackupListPath $dirList2 -BackupDir $dirBackupDir -ConfigPath $dirConfig -Force -Verbose
Assert-Equal 0 $LASTEXITCODE
Assert-FileMissing $appA '行首 + 的条目不该被恢复'
Assert-FileExists (Join-Path $appB 'b.txt')
Assert-Equal 'dir-b' (Get-Content -LiteralPath (Join-Path $appB 'b.txt') -Raw).Trim()
$restoreLog = Get-NewestLog -LogDir $dirLogDir -Prefix 'restore'
$restoreContent = Get-Content -LiteralPath $restoreLog.FullName -Raw -Encoding UTF8
Assert-True ($restoreContent -like '*跳过(行首 +,仅备份)*') '日志里应说明为什么跳过'
}
# ============================================================================
# 6. 旧布局归档的恢复(manifest 没有 layouts 时按 <末级名> 回退)
# ============================================================================
$legacyRoot = Join-Path $WorkRoot 'legacy'
$legacyLive = Join-Path $legacyRoot 'live\settings.json'
$legacyBackupDir = Join-Path $legacyRoot 'Backups'
$legacyCatalog = Join-Path $legacyRoot 'catalog.psd1'
$legacyList = Join-Path $legacyRoot 'list.txt'
$legacyConfig = Join-Path $legacyRoot 'config.psd1'
$legacyLogDir = Join-Path $legacyRoot 'logs'
$legacyScratch = Join-Path $legacyRoot 'scratch'
New-Item -ItemType Directory -Path (Split-Path -Parent $legacyLive) -Force | Out-Null
New-Item -ItemType Directory -Path $legacyBackupDir -Force | Out-Null
New-Item -ItemType Directory -Path $legacyScratch -Force | Out-Null
Set-Content -LiteralPath $legacyLive -Value '{"version":"old-layout"}' -Encoding UTF8
# 手工造一份重构前布局的归档:包内顶层直接是源文件的名字
$legacySourceFile = Join-Path $legacyScratch 'settings.json'
Copy-Item -LiteralPath $legacyLive -Destination $legacySourceFile
if ($sevenZip) {
$null = Invoke-ExternalCommand -FilePath $sevenZip `
-ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', (Join-Path $legacyBackupDir 'legacy-file.7z'), 'settings.json') `
-WorkingDirectory $legacyScratch
}
[System.IO.File]::WriteAllText($legacyCatalog, "@{`n 'legacy-file' = @{ LegacyData = @{ Path = '$legacyLive' } }`n}`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($legacyList, "legacy-file`n", [System.Text.UTF8Encoding]::new($false))
New-E2EConfig -Path $legacyConfig -LogDir $legacyLogDir -SoftwareCatalog $legacyCatalog
$legacyManifest = Read-BaknretManifest -Path (Join-Path $legacyBackupDir 'manifest.json')
$legacyManifest.items['legacy-file'] = [ordered]@{
baseName = 'legacy-file'
source = 'legacy-file'
archive = 'legacy-file.7z'
action = 'backed-up'
encrypted = $false
}
Write-BaknretManifest -Path (Join-Path $legacyBackupDir 'manifest.json') -Manifest $legacyManifest | Out-Null
# 让"恢复确实做了事"可验证:把活文件改成别的内容,恢复后应回到归档里的内容
Set-Content -LiteralPath $legacyLive -Value '{"version":"changed-after-backup"}' -Encoding UTF8
if ($sevenZip) {
& $restoreScript -BackupListPath $legacyList -BackupDir $legacyBackupDir -ConfigPath $legacyConfig -Force
$legacyExitCode = $LASTEXITCODE
Test-Case '旧布局归档:按 <末级名> 回退,把文件还原回原位' {
Assert-Equal 0 $legacyExitCode
Assert-Equal '{"version":"old-layout"}' (Get-Content -LiteralPath $legacyLive -Raw).Trim()
$legacyLog = Get-NewestLog -LogDir $legacyLogDir -Prefix 'restore'
$legacyContent = Get-Content -LiteralPath $legacyLog.FullName -Raw -Encoding UTF8
Assert-True ($legacyContent -like '*按旧布局回退*') '回退时必须给出明确告警'
}
}
# ============================================================================
# 7. DryRun 不写盘
# ============================================================================
if (Test-Path -LiteralPath $source) { Remove-Item -LiteralPath $source -Recurse -Force }
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -DryRun
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -DryRun
$dryRestoreExitCode = $LASTEXITCODE
Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' {
@@ -241,7 +616,7 @@ Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' {
Assert-FileMissing $source
}
& $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -Force -QuietTool -DryRun
& $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -ConfigPath $cfg1 -Force -QuietTool -DryRun
$dryBackupExitCode = $LASTEXITCODE
Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' {
@@ -253,68 +628,9 @@ Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' {
}
# ============================================================================
# 6. 软件名录:清单里写软件名,归档名就是软件名
# 8. 失败路径
# ============================================================================
$catRoot = Join-Path $WorkRoot 'catalog'
$catSource = Join-Path $catRoot 'src'
$catTarget = Join-Path $catSource 'My App' # 真实目录名与软件名刻意不同
$catBackupDir = Join-Path $catRoot 'Backups'
$catFile = Join-Path $catRoot 'SoftwareCatalog.psd1'
$catList = Join-Path $catRoot 'list.txt'
$catConfig = Join-Path $catRoot 'config.psd1'
New-Item -ItemType Directory -Path $catTarget -Force | Out-Null
Set-Content -LiteralPath (Join-Path $catTarget 'data.txt') -Value 'catalog-test' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $catTarget 'skip.bin') -Value 'nope' -Encoding UTF8
[System.IO.File]::WriteAllText($catFile, "@{`n 'my-app' = '$catTarget'`n}`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($catList, "my-app :: skip.bin`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($catConfig, "@{ SoftwareCatalog = '$catFile' }`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
$catExitCode = $LASTEXITCODE
$catArchive = Join-Path $catBackupDir 'my-app.7z'
Test-Case '清单里写软件名 -> 归档名就是软件名' {
Assert-Equal 0 $catExitCode
Assert-FileExists $catArchive
}
Test-Case '软件名条目的归档内容与历史布局一致(根目录仍是源目录名)' {
$extract = Join-Path $catRoot 'verify'
New-Item -ItemType Directory -Path $extract -Force | Out-Null
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if ($sevenZip) {
$null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$extract", $catArchive)
# 归档文件名是软件名 my-app,但包内根目录是源目录名 My App
Assert-FileExists (Join-Path $extract 'My App\data.txt')
Assert-FileMissing (Join-Path $extract 'my-app') '包内不应多出一层软件名'
Assert-FileMissing (Join-Path $extract 'My App\skip.bin') '排除模式以源目录名为前缀,仍然生效'
}
}
Test-Case '@pathname 覆盖:强制用路径命名算法(用独立备份目录,避免污染共享 manifest)' {
$pathList = Join-Path $catRoot 'list-pathname.txt'
$pathNameDir = Join-Path $catRoot 'Backups-pathname'
[System.IO.File]::WriteAllText($pathList, "my-app @pathname`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $pathList -BackupDir $pathNameDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE
# 名录里存的是绝对路径,所以路径命名结果也基于它
$expectedBase = Get-BackupBaseName -RawPath $catTarget
Assert-FileExists (Join-Path $pathNameDir ($expectedBase + '.7z'))
}
Test-Case '软件名录条目:删源后能按原路径恢复' {
Remove-Item -LiteralPath $catTarget -Recurse -Force
& $restoreScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force
Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $catTarget 'data.txt')
Assert-Equal 'catalog-test' (Get-Content -LiteralPath (Join-Path $catTarget 'data.txt') -Raw).Trim()
}
Test-Case '归档名重复时直接报失败,不静默互相覆盖' {
$dupList = Join-Path $catRoot 'dup.txt'
[System.IO.File]::WriteAllText($dupList, "my-app`nmy-app`n", [System.Text.UTF8Encoding]::new($false))
@@ -324,28 +640,16 @@ Test-Case '归档名重复时直接报失败,不静默互相覆盖' {
Test-Case '字面路径不受名录影响,仍走路径命名' {
$literalList = Join-Path $catRoot 'list-literal.txt'
[System.IO.File]::WriteAllText($literalList, "$catTarget`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $literalList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
$literalDir = Join-Path $catRoot 'Backups-literal'
[System.IO.File]::WriteAllText($literalList, "$dirA2`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $literalList -BackupDir $literalDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $literalDir ((Get-BackupBaseName -RawPath $dirA2) + '.7z'))
}
Test-Case '名录里没有该软件名时记为 missing-source,而不是崩掉' {
$badList = Join-Path $catRoot 'bad.txt'
[System.IO.File]::WriteAllText($badList, "no-such-app`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $badList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE '跳过不算失败'
$rec = (Read-BaknretManifest -Path (Join-Path $catBackupDir 'manifest.json')).items['no-such-app']
Assert-True ($null -ne $rec) '应留下记录'
Assert-Equal 'missing-source' $rec.action
}
# ============================================================================
# 7. 失败路径:源不存在时必须留下可核对的记录
# ============================================================================
$missingList = Join-Path $WorkRoot 'missing.txt'
[System.IO.File]::WriteAllText($missingList, "Z:\definitely-not-here-12345`n", [System.Text.UTF8Encoding]::new($false))
& $backupScript -BackupListPath $missingList -BackupDir $backupDir -Force -QuietTool
& $backupScript -BackupListPath $missingList -BackupDir $backupDir -ConfigPath $cfg1 -Force -QuietTool
$missingExitCode = $LASTEXITCODE
Test-Case '源路径不存在被记为 missing-source,退出码仍为 0(跳过不算失败)' {
+17 -2
View File
@@ -74,11 +74,26 @@ $configuration.Run.Exit = $false
$configuration.Output.Verbosity = $Verbosity
if ($Tag) { $configuration.Filter.Tag = $Tag }
# 关掉 Pester 的 TestRegistry:它会去写注册表(HKCU 下的测试键),
# 在受限环境 / 沙箱里会被拒绝,于是**所有**容器都以
# "Was not able to registry key for TestRegistry" 失败。
# 本套件不用 TestRegistry(只用临时目录),关掉它不影响任何用例。
$configuration.TestRegistry.Enabled = $false
$result = Invoke-Pester -Configuration $configuration
# 容器级失败(发现阶段的语法错误、Describe 外的异常)不会进 FailedCount,
# 只会在输出里出现一行 "Container failed" —— 不显式检查就会把"根本没跑起来"
# 报成"全部通过"。这里把它也当成失败。
$failedContainers = @($result.Containers | Where-Object { $_.Result -eq 'Failed' })
Write-Host ''
if ($result.FailedCount -gt 0) {
Write-Host ("Pester 测试通过 {0} 项,失败 {1} 项,跳过 {2} 项" -f $result.PassedCount, $result.FailedCount, $result.SkippedCount) -ForegroundColor Red
if ($result.FailedCount -gt 0 -or $failedContainers.Count -gt 0) {
Write-Host ("Pester 测试通过 {0} 项,失败 {1} 项,跳过 {2} 项,容器级失败 {3} 个" -f `
$result.PassedCount, $result.FailedCount, $result.SkippedCount, $failedContainers.Count) -ForegroundColor Red
foreach ($container in $failedContainers) {
Write-Host (" 容器失败:{0}" -f $container.Item) -ForegroundColor Red
}
exit 1
}
+773 -153
View File
File diff suppressed because it is too large. Load diff