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:
1 parent
7173e8ae10
commit
2937eb6652
32 files changed
+8775
-1691
No files matched your search
+353
-175
@@ -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'
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+261
-71
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user