Files
BakNRet/tests/BakNRet.Tests.ps1
T
Shuery d32d4b511f fix: 暂存目录半途失败会留下指向真实数据的 junction
缺陷:Backup.ps1 用 `$stagingRoot = $null` + try/finally 清理暂存目录,而
New-BaknretArchiveStaging 中途抛错时没有返回值 —— 赋值没发生,finally 拿到的还是 $null,
而 Remove-BaknretArchiveStaging 对 $null 直接 return。结果是已经建好的 junction 与临时
目录永久留在 %TEMP%,而那些 junction 指向真实数据;临时目录迟早会被某次
Remove-Item -Recurse 扫到,那一下就会走进真实数据。全仓唯一会伤到数据的缺陷。

修法:把清理责任放回函数自己身上 —— 循环包进 try,catch 先调
Remove-BaknretArchiveStaging 清掉已经建出来的东西,再 throw 原始错误。调用方的 finally
保持不动(它管的是"暂存建好之后下游才失败"那条路)。自清理失败时只告警并点名残留路径,
不覆盖真正的失败原因 —— 那才是排查需要的。

回归断言(零依赖与 Pester 各一份):第一项走 junction 成功挂上,第二项因源文件不
存在必然抛错;然后断言沙盒里不留任何条目,尤其不留 junction。

断言的有效性做了红绿证明:把 catch 里那行清理临时停用后,同一段场景残留 1 个
junction(.\sandbox\stage\Good,指向真实目录)→ 断言变红;还原后文件哈希一致、断言转绿。
一个不会红的断言不算保护。

验收:test.ps1 9/9 全绿(7 与 5.1);tests\Run-RealSmoke.ps1 4/4 全绿。
2026-09-26 22:36:44 +08:00

1434 lines
73 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
BakNRet 的 Pester 测试套件(单元 + 集成 + 回归)。
.DESCRIPTION
与 tests/Run-Tests.ps1 的分工:
* Run-Tests.ps1 是**零依赖**冒烟套件——机器上没装 Pester 时也能跑,
适合"插上别人的机器先确认没坏";
* 本文件是 Pester 套件,覆盖同样的单元面,并额外用**子进程**真正调用
Backup.ps1 / Restore.ps1。用子进程有两个原因:
1. 两个脚本结尾都会 `exit`,在同一个进程里用 `&` 调用会把 Pester 宿主
一起带走;
2. 子进程给出的是真正的进程退出码,正好独立验证"退出码取法"这条修复。
本套件断言的是**重构后的新契约**(BackupList.txt / SoftwareCatalog.psd1):
* 行首 `+` = 仅备份、`-` = 仅恢复;
* `::` 覆盖 Path(不再是 `:-` 的别名),排除一律写 `:-`;
* `:+` / `@ Include=` 是"把宿主机路径放到归档内指定位置";
* `:encrypt` / `:!encrypt` / `@ Encrypt=` 控制单个归档加密;
* 软件名条目 -> 一个归档,归档内是 `<Slot>\<内容>`(Path 是文件时是名为
`<Slot>` 的文件);手写路径条目沿用历史布局 `<末级名>\...`。
跑法:
.\tests\Run-Pester.ps1 # 推荐:会自动找到 Pester(含 .tools 下的本地副本)
Invoke-Pester -Path .\tests\BakNRet.Tests.ps1
需要 Pester 5.0+。Pester 3.4.0 不支持 `Should -Be`,会被显式拒绝并给出提示。
.EXAMPLE
pwsh -File .\tests\Run-Pester.ps1
#>
# 发现阶段(discovery)也会执行文件顶层代码,因此 -Skip: 用到的判据必须在这里算好:
# BeforeAll 里的变量在 discovery 阶段还不存在,直接引用会永远是 $null。
$script:HasSevenZip = [bool](Get-Command 7z -ErrorAction SilentlyContinue)
$script:HasPwsh = [bool](Get-Command pwsh -ErrorAction SilentlyContinue)
BeforeAll {
$script:ProjectRoot = Split-Path -Parent $PSScriptRoot
$script:BackupScript = Join-Path $script:ProjectRoot 'Backup.ps1'
$script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1'
$script:SevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
Import-Module (Join-Path $script:ProjectRoot 'Common.psm1') -Force
$script:Sandbox = Join-Path $env:TEMP ('baknret-pester-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null
# 在子进程里跑 Backup.ps1 / Restore.ps1,拿到真实退出码与完整输出。
#
# 为什么不用 `$lines = & pwsh @arguments 2>&1`:本机沙箱禁止 PowerShell 为捕获
# 原生子进程输出创建管道(“Access to the path '\\.\pipe\LOCAL\dotnet_...' is denied”),
# 那条路会直接失败。Invoke-ExternalCommand 让子进程继承 stdio,不受影响,
# 于是这里改成:把整条命令写进临时 .cmd,由 cmd 自己把输出重定向到文件,
# 再用 Invoke-ExternalCommand 调 `cmd /c <批处理>`。退出码来自真实进程,
# 输出从文件读回,全程不经过 PowerShell 的管道。
function Invoke-BaknretScript {
param(
[Parameter(Mandatory = $true)][string]$Script,
[hashtable]$Parameters = @{}
)
$arguments = @('-NoProfile', '-NonInteractive', '-File', $Script)
foreach ($name in ($Parameters.Keys | Sort-Object)) {
$value = $Parameters[$name]
if ($value -is [bool]) {
if ($value) { $arguments += "-$name" }
continue
}
$arguments += "-$name"
if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value }
}
$outFile = Join-Path ([System.IO.Path]::GetTempPath()) ("bnr-out-" + [guid]::NewGuid().ToString('N') + '.txt')
$cmdFile = Join-Path ([System.IO.Path]::GetTempPath()) ("bnr-cmd-" + [guid]::NewGuid().ToString('N') + '.cmd')
$argString = (@($arguments | ForEach-Object { '"' + ($_ -replace '"', '\"') + '"' }) -join ' ')
$batch = "@echo off`r`n" + 'pwsh ' + $argString + ' > "' + $outFile + '" 2>&1' + "`r`nexit /b %ERRORLEVEL%`r`n"
[System.IO.File]::WriteAllText($cmdFile, $batch, [System.Text.UTF8Encoding]::new($false))
$exitCode = $null
$lines = @()
try {
$exitCode = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', $cmdFile)
$lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue)
} finally {
Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $cmdFile -Force -ErrorAction SilentlyContinue
}
return [pscustomobject]@{
ExitCode = $exitCode
Lines = @($lines | ForEach-Object { [string]$_ })
Output = (($lines | Out-String))
}
}
# 造一棵确定性的源目录树,返回需要逐字节对拍的文件(相对路径 -> SHA256)。
function New-VerifiableSourceTree {
param([Parameter(Mandatory = $true)][string]$Root)
New-Item -ItemType Directory -Path $Root -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $Root 'sub') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $Root 'Cache') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $Root 'logs') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $Root 'keep.txt') -Value 'keep-me' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $Root 'sub\b.txt') -Value 'keep-me-too' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $Root 'logs\a.log') -Value 'must-be-excluded' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $Root 'Cache\c.bin') -Value 'must-be-excluded-too' -Encoding UTF8
$blob = New-Object byte[] 8192
(New-Object System.Random 42).NextBytes($blob)
[System.IO.File]::WriteAllBytes((Join-Path $Root 'blob.bin'), $blob)
$hashes = @{}
foreach ($relative in 'keep.txt', 'sub\b.txt', 'blob.bin') {
$hashes[$relative] = (Get-FileHash -LiteralPath (Join-Path $Root $relative) -Algorithm SHA256).Hash
}
return $hashes
}
function Write-ListFile {
param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Content)
[System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($true))
return $Path
}
}
AfterAll {
if ($script:Sandbox -and (Test-Path -LiteralPath $script:Sandbox)) {
Remove-Item -LiteralPath $script:Sandbox -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================
Describe 'BackupList.txt 解析' {
# ============================================================================
It '注释行与空行返回 $null' {
ConvertFrom-BackupListLine -Line '# 这是注释' | Should -BeNullOrEmpty
ConvertFrom-BackupListLine -Line ' ' | Should -BeNullOrEmpty
ConvertFrom-BackupListLine -Line '' | Should -BeNullOrEmpty
}
It '裸路径:Path 原样、方向 both、不是软件名、没有任何覆盖' {
$result = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
$result.Path | Should -Be 'C:\Programs\FooClolor'
$result.IsName | Should -BeFalse
$result.Direction | Should -Be 'both'
@($result.Overrides.Keys).Count | Should -Be 0
@($result.ExcludePatterns).Count | Should -Be 0
@($result.Includes).Count | Should -Be 0
@($result.Flags).Count | Should -Be 0
@($result.UnknownKeys).Count | Should -Be 0
$result.Comment | Should -BeNullOrEmpty
$result.Raw | Should -Be 'C:\Programs\FooClolor'
}
It '软件名:IsName 为真' {
$result = ConvertFrom-BackupListLine -Line 'my-app'
$result.Path | Should -Be 'my-app'
$result.IsName | Should -BeTrue
}
It 'IsName 判定:含分隔符或 % 就算字面路径' {
(ConvertFrom-BackupListLine -Line 'a/b').IsName | Should -BeFalse
(ConvertFrom-BackupListLine -Line '%TEMP%\x').IsName | Should -BeFalse
(ConvertFrom-BackupListLine -Line 'my-app').IsName | Should -BeTrue
}
It '标记必须是独立记号:C:\a:-b 仍然只是一个路径' {
$result = ConvertFrom-BackupListLine -Line 'C:\a:-b'
$result.Path | Should -Be 'C:\a:-b'
$result.Overrides.ContainsKey('Exclude') | Should -BeFalse
@($result.ExcludePatterns).Count | Should -Be 0
}
It '行首 + 仅备份、- 仅恢复,方向标记不进入目标' {
$backupOnly = ConvertFrom-BackupListLine -Line '+ Foo'
$backupOnly.Direction | Should -Be 'backup'
$backupOnly.Path | Should -Be 'Foo'
$restoreOnly = ConvertFrom-BackupListLine -Line '- Foo'
$restoreOnly.Direction | Should -Be 'restore'
$restoreOnly.Path | Should -Be 'Foo'
(ConvertFrom-BackupListLine -Line 'Foo').Direction | Should -Be 'both'
}
It '只有方向标记、没有目标 -> 忽略该行' {
ConvertFrom-BackupListLine -Line '+' | Should -BeNullOrEmpty
ConvertFrom-BackupListLine -Line '-' | Should -BeNullOrEmpty
}
It '行首标记贴在目标上也算(+Name / -Path),且标记不进入目标' {
# 本仓库清单里的主流写法,也是曾经被静默忽略的那种:标记与目标之间没有空格。
# 过去只认独立记号,于是 `+WindowsTerminal` 被当成"名叫 +WindowsTerminal 的软件名",
# 名录查不到、目录不存在,整条静默记成 missing-source 跳过,而备份仍然退出 0。
$attached = ConvertFrom-BackupListLine -Line '+FooClolor'
$attached.Direction | Should -Be 'backup'
$attached.Path | Should -Be 'FooClolor'
$attached.IsName | Should -BeTrue
$restoreAttached = ConvertFrom-BackupListLine -Line '-C:\Programs\Foo'
$restoreAttached.Direction | Should -Be 'restore'
$restoreAttached.Path | Should -Be 'C:\Programs\Foo'
$restoreAttached.IsName | Should -BeFalse
}
It '非行首的 + / - 不是方向标记' {
# 只放宽"行首"这一个位置:记号中间仍然只是普通字符。
(ConvertFrom-BackupListLine -Line 'C:\a+b').Path | Should -Be 'C:\a+b'
(ConvertFrom-BackupListLine -Line 'C:\a+b').Direction | Should -Be 'both'
(ConvertFrom-BackupListLine -Line 'foo-bar').Path | Should -Be 'foo-bar'
}
# ---- 新契约的核心::: 表示覆盖 Path,不再是 :- 的别名 ----
It ':: 现在表示"覆盖 Path",与 :- 彻底分开' {
$result = ConvertFrom-BackupListLine -Line 'Foo :: D:\bar'
$result.Path | Should -Be 'Foo'
$result.Overrides.ContainsKey('Path') | Should -BeTrue
$result.Overrides['Path'] | Should -Be 'D:\bar'
$result.Overrides.ContainsKey('Exclude') | Should -BeFalse
@($result.ExcludePatterns).Count | Should -Be 0
}
It ':: 的值可以带空格(一直取到下一个标记之前)' {
$result = ConvertFrom-BackupListLine -Line 'Foo :: C:\Program Files\App :encrypt'
$result.Overrides['Path'] | Should -Be 'C:\Program Files\App'
$result.Overrides['Encrypt'] | Should -BeTrue
}
It ':- 的逗号 / 分号列表拆成多个模式' {
$result = ConvertFrom-BackupListLine -Line 'C:\x :- a\b,c\d\;e'
@($result.ExcludePatterns).Count | Should -Be 3
$result.ExcludePatterns[0] | Should -Be 'a\b'
$result.ExcludePatterns[1] | Should -Be 'c\d\'
$result.ExcludePatterns[2] | Should -Be 'e'
}
It '排除模式的引号只保护空白,不保护逗号' {
$spaced = ConvertFrom-BackupListLine -Line "Foo :- 'Default\Code Cache'"
@($spaced.ExcludePatterns).Count | Should -Be 1
$spaced.ExcludePatterns[0] | Should -Be 'Default\Code Cache'
$comma = ConvertFrom-BackupListLine -Line "Foo :- 'a,b'"
@($comma.ExcludePatterns).Count | Should -Be 2
}
It ':+ 的包含项是 <归档内相对路径>:<宿主机绝对路径>' {
$result = ConvertFrom-BackupListLine -Line 'Foo :+ Modules:D:\extra,More:D:\extra2'
$result.Overrides.ContainsKey('Include') | Should -BeTrue
(@($result.Includes) -join '|') | Should -Be 'Modules:D:\extra|More:D:\extra2'
}
It ':encrypt 与 :!encrypt 控制该条目的加密开关' {
$on = ConvertFrom-BackupListLine -Line 'Foo :encrypt'
$on.Overrides.ContainsKey('Encrypt') | Should -BeTrue
$on.Overrides['Encrypt'] | Should -BeTrue
$off = ConvertFrom-BackupListLine -Line 'Foo :!encrypt'
$off.Overrides['Encrypt'] | Should -BeFalse
}
It '@ Key=''Value'' 覆盖 Path / Exclude / Include / Encrypt' {
$path = ConvertFrom-BackupListLine -Line "Foo @ Path='D:\x'"
$path.Overrides['Path'] | Should -Be 'D:\x'
$exclude = ConvertFrom-BackupListLine -Line "Foo @ Exclude='a,b'"
(@($exclude.ExcludePatterns) -join '|') | Should -Be 'a|b'
$include = ConvertFrom-BackupListLine -Line "Foo @ Include='Modules:D:\extra'"
(@($include.Includes) -join '|') | Should -Be 'Modules:D:\extra'
$encryptOn = ConvertFrom-BackupListLine -Line "Foo @ Encrypt='`$true'"
$encryptOn.Overrides['Encrypt'] | Should -BeTrue
$encryptOff = ConvertFrom-BackupListLine -Line "Foo @ Encrypt='false'"
$encryptOff.Overrides['Encrypt'] | Should -BeFalse
}
It '@ 的键名大小写不敏感,且支持紧跟 @ 的写法' {
$upper = ConvertFrom-BackupListLine -Line "Foo @ PATH='D:\x'"
$upper.Overrides['Path'] | Should -Be 'D:\x'
$inline = ConvertFrom-BackupListLine -Line "Foo @Exclude='q,w'"
(@($inline.ExcludePatterns) -join '|') | Should -Be 'q|w'
}
It '历史写法 @encrypt / @!encrypt 仍然被识别成加密覆盖' {
(ConvertFrom-BackupListLine -Line 'Foo @encrypt').Overrides['Encrypt'] | Should -BeTrue
(ConvertFrom-BackupListLine -Line 'Foo @!encrypt').Overrides['Encrypt'] | Should -BeFalse
}
It '历史写法 @pathname / @root=<名> 进入 Flags' {
(ConvertFrom-BackupListLine -Line 'Foo @pathname').Flags | Should -Contain 'pathname'
(ConvertFrom-BackupListLine -Line 'Foo @root=Bar').Flags | Should -Contain 'root=Bar'
}
It '未知的 @ 字段进入 UnknownKeys,且不影响其余字段解析' {
$result = ConvertFrom-BackupListLine -Line "Foo @ UnknownKey='v' :- logs\"
$result.UnknownKeys | Should -Contain 'UnknownKey'
$result.Overrides.ContainsKey('Exclude') | Should -BeTrue
$result.Path | Should -Be 'Foo'
}
It '行尾的 # 说明会成为 Comment' {
$result = ConvertFrom-BackupListLine -Line 'Edge :- !*Cache # 缓存可再生'
$result.Path | Should -Be 'Edge'
(@($result.ExcludePatterns) -join '|') | Should -Be '!*Cache'
$result.Comment | Should -Be '缓存可再生'
}
It '路径里紧贴的 # 不会被当成注释' {
$result = ConvertFrom-BackupListLine -Line 'C:\a#b\c'
$result.Path | Should -Be 'C:\a#b\c'
$result.Comment | Should -BeNullOrEmpty
}
It '没有说明时 Comment 为空' {
ConvertFrom-BackupListLine -Line 'legendary' | Select-Object -ExpandProperty Comment | Should -BeNullOrEmpty
}
It '目标可以带空格:第一个标记之前整段都是目标' {
$bare = ConvertFrom-BackupListLine -Line 'C:\Program Files\App :- logs\'
$bare.Path | Should -Be 'C:\Program Files\App'
$bare.IsName | Should -BeFalse
$quoted = ConvertFrom-BackupListLine -Line '"C:\Program Files\App" :- logs\'
$quoted.Path | Should -Be 'C:\Program Files\App'
(@($quoted.ExcludePatterns) -join '|') | Should -Be 'logs\'
}
It '缺少目标(标记出现在第一个位置)会被忽略并告警' {
ConvertFrom-BackupListLine -Line ':- logs' | Should -BeNullOrEmpty
ConvertFrom-BackupListLine -Line "@ Exclude='x'" | Should -BeNullOrEmpty
}
It '记号切分:引号内的空白不切分,引号本身留在记号里' {
(@(Split-BaknretToken -Text 'A "B C" D') -join '|') | Should -Be 'A|"B C"|D'
# 引号不配对时按"引号延伸到行尾"处理,不抛异常
(@(Split-BaknretToken -Text 'A "B C') -join '|') | Should -Be 'A|"B C'
(@(Split-BaknretToken -Text '') -join '|') | Should -Be ''
}
It '记号识别只认完整记号' {
(Test-BaknretMarker -Token '::') | Should -Be 'path'
(Test-BaknretMarker -Token ':-') | Should -Be 'exclude'
(Test-BaknretMarker -Token ':+') | Should -Be 'include'
(Test-BaknretMarker -Token ':encrypt') | Should -Be 'encrypt'
(Test-BaknretMarker -Token ':!encrypt') | Should -Be 'noencrypt'
(Test-BaknretMarker -Token '@') | Should -Be 'at'
(Test-BaknretMarker -Token 'C:\a:-b') | Should -BeNullOrEmpty
(Test-BaknretMarker -Token ':') | Should -BeNullOrEmpty
}
It '去引号:成对才去,不成对原样返回' {
(Remove-BaknretQuote -Text '"a"') | Should -Be 'a'
(Remove-BaknretQuote -Text "'a'") | Should -Be 'a'
(Remove-BaknretQuote -Text 'a') | Should -Be 'a'
(Remove-BaknretQuote -Text '"a') | Should -Be '"a'
(Remove-BaknretQuote -Text '""') | Should -Be ''
}
}
# ============================================================================
Describe '归档命名' {
# ============================================================================
BeforeAll {
$script:NamingRoot = Join-Path $script:Sandbox 'naming'
$script:NamingApp = Join-Path $script:NamingRoot 'Real App'
New-Item -ItemType Directory -Path $script:NamingApp -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:NamingApp 'keep.txt') 'k'
$script:NamingCatalog = Write-ListFile -Path (Join-Path $script:NamingRoot 'cat.psd1') -Content @"
@{
'my-app' = @{ Main = @{ Path = '$script:NamingApp' } }
}
"@
}
# 注意:Pester 的 It 名字里出现 $( ) 会被求值,所以标题里不要写子表达式。
It '基础命名规则:末级名_from_上级路径用加号连接' {
Get-BackupBaseName -RawPath 'C:\Programs\FooClolor' | Should -Be 'FooClolor_from_C_+Programs'
}
It '/ 与 \ 以及重复分隔符结果一致' {
$a = Get-BackupBaseName -RawPath 'C:\a/b'
$b = Get-BackupBaseName -RawPath 'C:\\a\\\\b'
$a | Should -Be $b
}
It '命名与路径往返' {
$base = Get-BackupBaseName -RawPath 'D:\UserData\Documents\Aria'
Convert-BackupFileNameToPath -FileName ($base + '.7z') | Should -Be 'D:\UserData\Documents\Aria'
}
It '归档名里不含非法文件名字符' {
$base = Get-BackupBaseName -RawPath 'C:\a<b>c|d?e*f'
($base.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1
}
It '%变量% 写法里的 % 会保留在归档名里' {
Get-BackupBaseName -RawPath '%UserProfile%\.ssh' | Should -Be '.ssh_from_%UserProfile%'
}
It '软件名条目:默认用软件名做归档名' {
$entry = ConvertFrom-BackupListLine -Line 'my-app'
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'my-app'
}
It '字面路径条目:仍用路径命名算法(现有清单无需改写)' {
$entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'FooClolor_from_C_+Programs'
}
It '@pathname 用名录里的真实路径命名,而不是软件名' {
$entry = ConvertFrom-BackupListLine -Line 'my-app @pathname'
$expected = Get-BackupBaseName -RawPath $script:NamingApp
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be $expected
}
It '名录里没有该名字:归档名退回可读目录名,并给出 Error' {
$entry = ConvertFrom-BackupListLine -Line 'no-such-thing'
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'no-such-thing'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3
$resolved.Error | Should -Not -BeNullOrEmpty
@($resolved.Items).Count | Should -Be 0
}
}
# ============================================================================
Describe '7z 排除参数翻译' {
# ============================================================================
BeforeAll {
$script:ExcludeTree = Join-Path $script:Sandbox 'exclude-tree'
New-Item -ItemType Directory -Path (Join-Path $script:ExcludeTree 'sub\Cache') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:ExcludeTree 'Keep') -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:ExcludeTree 'sub\Cache\x.bin') 'x'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:ExcludeTree 'Keep\y.bin') 'y'
function New-TestArchiveItem {
param([string]$ArchivePath, [string]$RealPath = 'C:\nonexistent-path')
New-BaknretArchiveItem -ArchivePath $ArchivePath -RealPath $RealPath -Kind 'path' -Origin 'path' -Exists $false -IsFile $false
}
}
It '相对模式自动补上归档根目录名' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('logs\')
@($result.Arguments) | Should -Contain '-x!Foo\logs'
$result.Error | Should -BeNullOrEmpty
}
It '模式已带根名前缀时,Split-BaknretPatternScope 先摘掉前缀,结果不重复' {
$item = New-TestArchiveItem -ArchivePath 'Foo'
$map = Split-BaknretPatternScope -Items @($item) -Patterns @('Foo\logs\')
(@($map[0]) -join '|') | Should -Be 'logs'
$result = Get-BaknretExcludeArgument -Item $item -Patterns @($map[0])
@($result.Arguments) | Should -Contain '-x!Foo\logs'
@($result.Arguments) | Should -Not -Contain '-x!Foo\Foo\logs'
}
It '! 前缀翻译成递归组件匹配(-xr!)' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('!*Cache')
@($result.Arguments) | Should -Contain '-xr!*Cache'
}
It '模式里的空格转成 ?(归档项自己的名字按原样保留)' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('Default\Code Cache')
@($result.Arguments) | Should -Contain '-x!User Data\Default\Code?Cache'
$plain = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('Default\Code Cache')
@($plain.Arguments) | Should -Contain '-x!Foo\Default\Code?Cache'
}
It '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('!*Cache', 'Default\Code Cache')
(@($result.Arguments) -join ' ') | Should -Not -Match '"'
}
It '空模式被忽略' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('', ' ', '!')
@($result.Arguments).Count | Should -Be 0
$result.Error | Should -BeNullOrEmpty
}
It '!re: 会把正则展开成精确的 -x! 参数' {
$item = New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree
$result = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:^Cache$')
@($result.Arguments) | Should -Contain '-x!Top\sub\Cache'
# 命中目录后不再往下走(一个命中只产生一条参数,避免命令行爆炸)
$pruned = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:^sub$')
@($pruned.Arguments) | Should -Contain '-x!Top\sub'
@($pruned.Arguments).Count | Should -Be 1
}
It '非法正则给出 Error,而不是抛异常' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree) -Patterns @('!re:[')
@($result.Arguments).Count | Should -Be 0
$result.Error | Should -Not -BeNullOrEmpty
}
It '正则命中数超过上限时明确报错' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree) `
-Patterns @('!re:.') -MaxRegexMatches 1
$result.Error | Should -Not -BeNullOrEmpty
}
It '参数总长超过安全上限时明确报错' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top') `
-Patterns @('!*zzz') -MaxCommandLineChars 5
$result.Error | Should -Not -BeNullOrEmpty
}
It 'Split-BaknretPatternScope:指名 Slot 的模式只分给那个 Slot' {
$items = @(
New-TestArchiveItem -ArchivePath 'Alpha'
New-TestArchiveItem -ArchivePath 'Beta'
)
$map = Split-BaknretPatternScope -Items $items -Patterns @('Alpha\logs\')
(@($map[0]) -join '|') | Should -Be 'logs'
@($map[1]).Count | Should -Be 0
# 用 / 分隔也一样
$slash = Split-BaknretPatternScope -Items $items -Patterns @('Beta/sub')
(@($slash[1]) -join '|') | Should -Be 'sub'
@($slash[0]).Count | Should -Be 0
}
It 'Split-BaknretPatternScope:没点名任何 Slot 的模式广播给每一项' {
$items = @(
New-TestArchiveItem -ArchivePath 'Alpha'
New-TestArchiveItem -ArchivePath 'Beta'
)
$map = Split-BaknretPatternScope -Items $items -Patterns @('plain\path', 'Alpha')
(@($map[0]) -join '|') | Should -Be 'plain\path|Alpha'
(@($map[1]) -join '|') | Should -Be 'plain\path|Alpha'
}
It 'Split-BaknretPatternScope:! 与 !re: 模式广播给每一项' {
$items = @(
New-TestArchiveItem -ArchivePath 'Alpha'
New-TestArchiveItem -ArchivePath 'Beta'
)
$map = Split-BaknretPatternScope -Items $items -Patterns @('!*Cache', '!re:^x$')
(@($map[0]) -join '|') | Should -Be '!*Cache|!re:^x$'
(@($map[1]) -join '|') | Should -Be '!*Cache|!re:^x$'
}
It 'Split-BaknretPatternScope:每个归档项都有键(没有模式时是空数组)' {
$items = @(
New-TestArchiveItem -ArchivePath 'Alpha'
New-TestArchiveItem -ArchivePath 'Beta'
)
$map = Split-BaknretPatternScope -Items $items -Patterns @()
$map.ContainsKey(0) | Should -BeTrue
$map.ContainsKey(1) | Should -BeTrue
@($map[0]).Count | Should -Be 0
}
It 'Merge-BaknretExcludeArgument 去重且保持顺序' {
$merged = Merge-BaknretExcludeArgument -ArgumentLists @(@('-x!a', '-x!b'), @('-x!b', '-x!c'))
(@($merged) -join ' ') | Should -Be '-x!a -x!b -x!c'
}
It '多 Slot 条目:逐项分配 + 翻译 + 去重合成一份参数' {
$items = @(
New-BaknretArchiveItem -ArchivePath 'Alpha' -RealPath $script:ExcludeTree -Kind 'slot' -Slot 'Alpha' -Origin 'catalog' -Exists $true
New-BaknretArchiveItem -ArchivePath 'Beta' -RealPath $script:ExcludeTree -Kind 'slot' -Slot 'Beta' -Origin 'catalog' -Exists $true
)
$patterns = @('Alpha\logs\', '!*Cache', 'Beta\sub', '!*Cache')
$map = Split-BaknretPatternScope -Items $items -Patterns $patterns
$lists = @()
for ($index = 0; $index -lt $items.Count; $index++) {
$lists += , @((Get-BaknretExcludeArgument -Item $items[$index] -Patterns @($map[$index])).Arguments)
}
$effective = @(Merge-BaknretExcludeArgument -ArgumentLists $lists)
$effective | Should -Contain '-x!Alpha\logs'
$effective | Should -Contain '-x!Beta\sub'
$effective | Should -Contain '-xr!*Cache'
# 同一个 -xr!*Cache 只留一份
@($effective | Where-Object { $_ -eq '-xr!*Cache' }).Count | Should -Be 1
}
}
# ============================================================================
Describe '归档项与暂存目录' {
# ============================================================================
BeforeAll {
$script:StagingRoot = Join-Path $script:Sandbox 'staging'
$script:StagingDir = Join-Path $script:StagingRoot 'real-dir'
$script:StagingFile = Join-Path $script:StagingRoot 'settings.json'
New-Item -ItemType Directory -Path (Join-Path $script:StagingDir 'inner') -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:StagingDir 'inner\a.txt') 'a'
Set-Content -Encoding UTF8 -LiteralPath $script:StagingFile 'file-content'
}
It 'New-BaknretArchiveItem 规范化归档内路径并算出 TopName' {
$item = New-BaknretArchiveItem -ArchivePath '\Alpha\Sub\' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha'
$item.ArchivePath | Should -Be 'Alpha\Sub'
$item.TopName | Should -Be 'Alpha'
$item.Kind | Should -Be 'slot'
$item.Slot | Should -Be 'Alpha'
$item.Origin | Should -Be 'catalog'
@($item.Exclude).Count | Should -Be 0
}
It 'Get-BaknretArchiveTopName 取归档内相对路径的第一段' {
(Get-BaknretArchiveTopName -ArchivePath 'A\B\c.txt') | Should -Be 'A'
(Get-BaknretArchiveTopName -ArchivePath ' A ') | Should -Be 'A'
(Get-BaknretArchiveTopName -ArchivePath '') | Should -Be ''
}
It '暂存半途失败会自己清干净(不留指向真实数据的 junction)' {
# 函数抛错时没有返回值,调用方的 finally 拿到 $null 就什么都不会清 —— 所以清理
# 必须由函数自己在 catch 里做,不能指望调用方。
$base = Join-Path $env:TEMP ("bnr-stage-leak-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
$realDir = Join-Path $base 'real'
$sandbox = Join-Path $base 'sandbox'
New-Item -ItemType Directory -Path $realDir, $sandbox -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $realDir 'inner.txt') -Value 'inner'
$items = @(
# 第一项会成功挂上,而且是**目录走 junction** —— 这正是危险的那个物件:它指向
# 真实数据,而临时目录迟早会被某次 Remove-Item -Recurse 扫到。
New-BaknretArchiveItem -ArchivePath 'Good' -RealPath $realDir -Kind 'slot' -Slot 'Good' -Exists $true -IsFile $false
# 第二项必然失败:源文件不存在,硬链接与复制都会失败
New-BaknretArchiveItem -ArchivePath 'Missing.txt' -RealPath (Join-Path $base 'does-not-exist.txt') -Kind 'slot' -Slot 'Missing' -Exists $false -IsFile $true
)
{ New-BaknretArchiveStaging -Items $items -Root (Join-Path $sandbox 'stage') } | Should -Throw
$left = @(Get-ChildItem -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue)
@($left | Where-Object { $_.LinkType -eq 'Junction' }).Count | Should -Be 0
$left.Count | Should -Be 0
Remove-Item -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue
}
It '目录项用 junction 挂进暂存目录,文件项用硬链接(名字就是归档内路径)' {
$items = @(
New-BaknretArchiveItem -ArchivePath 'Alpha' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha' -Exists $true -IsFile $false
New-BaknretArchiveItem -ArchivePath 'Nested\Deep' -RealPath $script:StagingDir -Kind 'include' -Origin 'include' -Exists $true -IsFile $false
New-BaknretArchiveItem -ArchivePath 'Cfg' -RealPath $script:StagingFile -Kind 'slot' -Slot 'Cfg' -Exists $true -IsFile $true
)
$stage = New-BaknretArchiveStaging -Items $items
try {
$alpha = Get-Item -LiteralPath (Join-Path $stage 'Alpha') -Force
$alpha.LinkType | Should -Be 'Junction'
$nested = Get-Item -LiteralPath (Join-Path $stage 'Nested\Deep') -Force
$nested.LinkType | Should -Be 'Junction'
# 文件项:归档内的名字就叫 Cfg(没有扩展名),内容与源文件一致
$cfg = Get-Item -LiteralPath (Join-Path $stage 'Cfg') -Force
$cfg.PSIsContainer | Should -BeFalse
(Get-Content -Encoding UTF8 -LiteralPath $cfg.FullName -Raw).Trim() | Should -Be 'file-content'
} finally {
Remove-BaknretArchiveStaging -Root $stage
}
}
It 'Remove-BaknretArchiveStaging 只删连接点,不顺着走进真实目录' {
$stage = New-BaknretArchiveStaging -Items @(
New-BaknretArchiveItem -ArchivePath 'Alpha' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha' -Exists $true -IsFile $false
)
Remove-BaknretArchiveStaging -Root $stage
Test-Path -LiteralPath $stage | Should -BeFalse
Test-Path -LiteralPath (Join-Path $script:StagingDir 'inner\a.txt') | Should -BeTrue
}
}
# ============================================================================
Describe '命令行参数拼接' {
# ============================================================================
It '无空格参数原样输出' {
ConvertTo-NativeArgumentString -ArgumentList @('a', '-mx=9') | Should -Be 'a -mx=9'
}
It '含空格参数加引号' {
ConvertTo-NativeArgumentString -ArgumentList @('C:\a b\c') | Should -Be '"C:\a b\c"'
}
It '引号内的结尾反斜杠翻倍(否则会被当成转义引号)' {
ConvertTo-NativeArgumentString -ArgumentList @('C:\a b\') | Should -Be '"C:\a b\\"'
}
It '内部引号被转义' {
ConvertTo-NativeArgumentString -ArgumentList @('a"b c') | Should -Be '"a\"b c"'
}
It '空参数输出一对空引号' {
ConvertTo-NativeArgumentString -ArgumentList @('') | Should -Be '""'
}
}
# ============================================================================
Describe 'manifest 与配置' {
# ============================================================================
It 'manifest 读写往返' {
$path = Join-Path $script:Sandbox 'roundtrip\manifest.json'
$manifest = Read-BaknretManifest -Path $path
$manifest.items['demo'] = [pscustomobject]@{ archive = 'demo.7z'; action = 'backed-up'; verified = $true }
Write-BaknretManifest -Path $path -Manifest $manifest | Out-Null
$again = Read-BaknretManifest -Path $path
$again.items.Count | Should -Be 1
$again.items['demo'].archive | Should -Be 'demo.7z'
$again.items['demo'].verified | Should -BeTrue
}
It 'manifest 原样保存 layouts(name/kind),全新恢复时靠它判断目录还是文件' {
$path = Join-Path $script:Sandbox 'layouts\manifest.json'
$manifest = Read-BaknretManifest -Path $path
$manifest.items['app'] = [pscustomobject]@{
archive = 'app.7z'
layouts = @(
[pscustomobject]@{ name = 'Data'; kind = 'dir' }
[pscustomobject]@{ name = 'Cfg'; kind = 'file' }
)
}
Write-BaknretManifest -Path $path -Manifest $manifest | Out-Null
$again = Read-BaknretManifest -Path $path
@($again.items['app'].layouts).Count | Should -Be 2
(@($again.items['app'].layouts | ForEach-Object { $_.name + ':' + $_.kind }) -join '|') | Should -Be 'Data:dir|Cfg:file'
}
It 'manifest 损坏时不抛异常,而是重建空清单' {
$path = Write-ListFile -Path (Join-Path $script:Sandbox 'broken.json') -Content '{ this is not json'
{ $script:broken = Read-BaknretManifest -Path $path } | Should -Not -Throw
$script:broken.items.Count | Should -Be 0
}
It '配置缺失时返回默认值' {
$config = Get-BaknretConfig -Path (Join-Path $script:Sandbox 'no-such-config.psd1')
$config.BackupDir | Should -Be 'Backups'
$config.VerifyArchive | Should -BeTrue
}
It '配置嵌套段落合并且不丢默认键' {
$path = Write-ListFile -Path (Join-Path $script:Sandbox 'cfg.psd1') -Content "@{ MinFreeSpaceGB = 3; Encryption = @{ Enabled = `$true } }"
$config = Get-BaknretConfig -Path $path
$config.MinFreeSpaceGB | Should -Be 3
$config.Encryption.Enabled | Should -BeTrue
# 没写到的子键要保留默认值。Encryption 是哈希表,用 ContainsKey 查。
$config.Encryption.ContainsKey('EncryptHeaders') | Should -BeTrue
}
It '口令:环境变量可读取,取不到时返回 $null(绝不退化成明文)' {
$saved = $env:BAKNRET_PASSWORD
try {
$env:BAKNRET_PASSWORD = 'sekrit'
(Get-BaknretPassword) | Should -Be 'sekrit'
$env:BAKNRET_PASSWORD = $null
(Get-BaknretPassword) | Should -BeNullOrEmpty
} finally {
$env:BAKNRET_PASSWORD = $saved
}
}
}
# ============================================================================
Describe '软件名录' {
# ============================================================================
BeforeAll {
$script:CatRoot = Join-Path $script:Sandbox 'catalog'
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'legendary_2.0.4') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'LegendarySomething') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'dash-1.2') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'dup_1') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'dup_2') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'real app') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'other') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'inc') -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:CatRoot 'real app\keep.txt') 'k'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:CatRoot 'file.txt') 'file-content'
$script:CatReal = Join-Path $script:CatRoot 'real app'
$script:CatOther = Join-Path $script:CatRoot 'other'
$script:CatInc = Join-Path $script:CatRoot 'inc'
# 路径先算好再拼名录内容:在 @"..."@ 里嵌 $ 很容易把引号吃掉,
# 生成一个解析不了的 psd1,而"名录读不到"会让断言静默通过。
$script:CatFile = Write-ListFile -Path (Join-Path $script:CatRoot 'SoftwareCatalog.psd1') -Content @"
@{
'single' = @{ Main = @{ Path = '$script:CatReal'; Description = '主 Slot' } }
'multi' = @{
Zeta = @{ Path = '$script:CatOther' }
Alpha = @{ Path = '$script:CatReal'; Exclude = '!*Cache,logs\'; Description = 'alpha slot' }
}
'partial' = @{ Ok = @{ Path = '$script:CatReal' }; Gone = @{ Path = '$script:CatRoot\not-here' } }
'unres' = @{ Gone = @{ Path = '$script:CatRoot\nope' } }
'legendary' = @{ L = @{ Path = '$script:CatRoot\legendary' } }
'dashy' = @{ D = @{ Path = '$script:CatRoot\dash' } }
'dupslot' = @{ D = @{ Path = '$script:CatRoot\dup' } }
'fileslot' = @{ Cfg = @{ Path = '$script:CatRoot\file.txt'; Encrypt = `$true } }
'envpath' = @{ E = @{ Path = '%TEMP%' } }
'encapp' = @{ A = @{ Path = '$script:CatReal'; Encrypt = `$true }; B = @{ Path = '$script:CatOther' } }
'incapp' = @{ A = @{ Path = '$script:CatReal'; Include = 'Mods:$script:CatInc' } }
'conflict' = @{ Data = @{ Path = '$script:CatReal' } }
'badslot' = @{ S = 'not-a-map' }
'nopath' = @{ S = @{ Description = 'no path' } }
}
"@
$script:LegacyFile = Write-ListFile -Path (Join-Path $script:CatRoot 'Legacy.psd1') -Content @"
@{
'bare' = 'C:\x'
'arr' = @('C:\a', 'C:\b')
'objarr' = @(@{ Path = 'C:\a' }, @{ Path = 'C:\b' })
'dirstyle' = @{ Dirs = @('C:\a', 'C:\b') }
}
"@
}
It '名录解析:条目与 Slot 的字段集合就是新契约' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$entry = $catalog['single']
(@($entry.PSObject.Properties.Name) -join ',') | Should -Be 'Name,Path,Description,Slots,Kind,Missing,Error,Raw'
$entry.Name | Should -Be 'single'
$entry.Path | Should -Be $script:CatReal
$entry.Description | Should -Be '主 Slot'
$entry.Kind | Should -Be 'Single'
@($entry.Missing).Count | Should -Be 0
$entry.Error | Should -BeNullOrEmpty
$slot = @($entry.Slots)[0]
(@($slot.PSObject.Properties.Name) -join ',') | Should -Be 'Name,Declared,Resolved,Exists,IsFile,Suffixed,Description,Exclude,Include,Encrypt'
$slot.Name | Should -Be 'Main'
$slot.Declared | Should -Be $script:CatReal
$slot.Resolved | Should -Be $script:CatReal
$slot.Exists | Should -BeTrue
$slot.IsFile | Should -BeFalse
$slot.Suffixed | Should -BeFalse
$slot.Encrypt | Should -BeFalse
}
It '一个软件多个 Slot:Kind=Multi,Slot 按名字排序' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['multi'].Kind | Should -Be 'Multi'
@($catalog['multi'].Slots).Count | Should -Be 2
(@($catalog['multi'].Slots | ForEach-Object { $_.Name }) -join ',') | Should -Be 'Alpha,Zeta'
$catalog['multi'].Slots[0].Description | Should -Be 'alpha slot'
(@($catalog['multi'].Slots[0].Exclude) -join '|') | Should -Be '!*Cache|logs\'
}
It 'Kind:Single / Multi / Partial / Unresolved / Invalid' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['single'].Kind | Should -Be 'Single'
$catalog['multi'].Kind | Should -Be 'Multi'
$catalog['partial'].Kind | Should -Be 'Partial'
$catalog['unres'].Kind | Should -Be 'Unresolved'
$catalog['badslot'].Kind | Should -Be 'Invalid'
$catalog['nopath'].Kind | Should -Be 'Invalid'
@($catalog['partial'].Missing).Count | Should -Be 1
@($catalog['unres'].Missing).Count | Should -Be 1
$catalog['badslot'].Error | Should -Match 'Slot S'
$catalog['nopath'].Error | Should -Match '缺少 Path'
}
It '前缀补全:<名>_<后缀> 与 <名>-<后缀> 都会被补全' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['legendary'].Slots[0].Resolved | Should -Be (Join-Path $script:CatRoot 'legendary_2.0.4')
$catalog['legendary'].Slots[0].Suffixed | Should -BeTrue
$catalog['dashy'].Slots[0].Resolved | Should -Be (Join-Path $script:CatRoot 'dash-1.2')
$catalog['dashy'].Slots[0].Suffixed | Should -BeTrue
}
It '不会把 Legendary 误配成 LegendarySomething' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['legendary'].Slots[0].Resolved | Should -Not -Be (Join-Path $script:CatRoot 'LegendarySomething')
}
It '一个 Slot 命中多个候选目录:报 Error,绝不悄悄挑一棵树' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$entry = $catalog['dupslot']
$entry.Error | Should -Match '只能对应一个目录'
$entry.Error | Should -Match 'dup_1'
$entry.Error | Should -Match 'dup_2'
@($entry.Slots).Count | Should -Be 1
$entry.Slots[0].Suffixed | Should -BeTrue
}
It '文件 Slot:Path 指向文件时 IsFile 为真,Encrypt 也带上' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$slot = $catalog['fileslot'].Slots[0]
$slot.IsFile | Should -BeTrue
$slot.Encrypt | Should -BeTrue
}
It '%变量% 会在名录路径里展开' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['envpath'].Slots[0].Resolved | Should -Be ([Environment]::ExpandEnvironmentVariables('%TEMP%'))
}
It '$( ... ) 子表达式会被求值(含嵌套)' {
(Expand-CatalogPathText -Text '$(Join-Path $env:TEMP "s")') | Should -Be (Join-Path $env:TEMP 's')
(Expand-CatalogPathText -Text '$(Join-Path $env:TEMP "$(Join-Path ''a'' ''b'')")') | Should -Be (Join-Path $env:TEMP 'a\b')
# 括号不配对时原样保留,不抛异常
(Expand-CatalogPathText -Text '$(Join-Path') | Should -Be '$(Join-Path'
}
It '读取结果按"路径 + 时间戳 + 长度 + 内容 MD5"缓存' {
$first = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3
$second = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3
[object]::ReferenceEquals($first, $second) | Should -BeTrue
$fresh = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
[object]::ReferenceEquals($first, $fresh) | Should -BeFalse
}
It '名录文件内容变了以后缓存自动失效' {
$path = Write-ListFile -Path (Join-Path $script:CatRoot 'cache.psd1') -Content "@{ 'x' = @{ S = @{ Path = '$script:CatReal'; Description = 'one' } } }"
(Get-SoftwareCatalog -Path $path -MaxDepth 3)['x'].Description | Should -Be 'one'
Write-ListFile -Path $path -Content "@{ 'x' = @{ S = @{ Path = '$script:CatReal'; Description = 'two' } } }" | Out-Null
(Get-SoftwareCatalog -Path $path -MaxDepth 3)['x'].Description | Should -Be 'two'
}
It 'Includes:分文件维护的名录会被合并' {
Write-ListFile -Path (Join-Path $script:CatRoot 'Extra.psd1') -Content "@{ 'extra-app' = @{ S = @{ Path = '$script:CatReal' } } }" | Out-Null
$main = Write-ListFile -Path (Join-Path $script:CatRoot 'Main.psd1') -Content "@{ Includes = @('Extra.psd1'); 'main-app' = @{ S = @{ Path = '$script:CatReal' } } }"
$catalog = Get-SoftwareCatalog -Path $main -MaxDepth 3 -NoCache
$catalog.ContainsKey('main-app') | Should -BeTrue
$catalog.ContainsKey('extra-app') | Should -BeTrue
}
It '旧的裸字符串 / 字符串数组 / 对象数组写法都会报 ERROR 并被跳过' {
$catalog = Get-SoftwareCatalog -Path $script:LegacyFile -MaxDepth 3 -NoCache
$catalog.ContainsKey('bare') | Should -BeFalse
$catalog.ContainsKey('arr') | Should -BeFalse
$catalog.ContainsKey('objarr') | Should -BeFalse
}
It '旧的 @{ Dirs = @(...) } 写法不再展开:留下 Invalid 条目和原因' {
$catalog = Get-SoftwareCatalog -Path $script:LegacyFile -MaxDepth 3 -NoCache
$catalog.ContainsKey('dirstyle') | Should -BeTrue
$catalog['dirstyle'].Kind | Should -Be 'Invalid'
$catalog['dirstyle'].Error | Should -Not -BeNullOrEmpty
@($catalog['dirstyle'].Slots).Count | Should -Be 0
}
It '软件名会被规范化成合法文件名' {
$clean = Format-CatalogName -Name 'a<b>c:d/e'
($clean.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1
(Format-CatalogName -Name ' My App ') | Should -Be 'My App'
}
}
# ============================================================================
Describe 'Resolve-BackupEntry:条目解析' {
# ============================================================================
BeforeAll {
$script:CatRoot = Join-Path $script:Sandbox 'catalog'
$script:CatFile = Join-Path $script:CatRoot 'SoftwareCatalog.psd1'
$script:CatReal = Join-Path $script:CatRoot 'real app'
$script:CatOther = Join-Path $script:CatRoot 'other'
$script:CatInc = Join-Path $script:CatRoot 'inc'
}
It 'resolve 与归档项的字段集合就是新契约(旧字段已删除)' {
$entry = ConvertFrom-BackupListLine -Line 'single'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
(@($resolved.PSObject.Properties.Name) -join ',') | Should -Be 'IsName,CatalogEntry,BaseName,ArchiveFlavor,Direction,Items,Encrypt,ExcludePatterns,HasExcludeOverride,Includes,HasIncludeOverride,Source,Error,Blocking'
@($resolved.Items)[0].PSObject.Properties.Name | Should -Contain 'ArchivePath'
@($resolved.Items)[0].PSObject.Properties.Name | Should -Contain 'Origin'
$resolved.PSObject.Properties['Sources'] | Should -BeNullOrEmpty
}
It '软件名条目:isName/CatalogEntry/BaseName/ArchiveFlavor/Source' {
$entry = ConvertFrom-BackupListLine -Line 'single'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.IsName | Should -BeTrue
$resolved.BaseName | Should -Be 'single'
$resolved.ArchiveFlavor | Should -Be 'name'
$resolved.Source | Should -Be 'single'
$resolved.CatalogEntry | Should -Not -BeNullOrEmpty
$resolved.Error | Should -BeNullOrEmpty
$resolved.Blocking | Should -BeNullOrEmpty
}
It '软件名条目:Items 来自 Slot(ArchivePath=Slot 名,Kind=slot,Origin=catalog)' {
$entry = ConvertFrom-BackupListLine -Line 'multi'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
@($resolved.Items).Count | Should -Be 2
(@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'Alpha,Zeta'
(@($resolved.Items | ForEach-Object { $_.Kind }) -join ',') | Should -Be 'slot,slot'
(@($resolved.Items | ForEach-Object { $_.Origin }) -join ',') | Should -Be 'catalog,catalog'
(@($resolved.Items | ForEach-Object { $_.TopName }) -join ',') | Should -Be 'Alpha,Zeta'
$resolved.Items[0].Slot | Should -Be 'Alpha'
$resolved.Items[0].RealPath | Should -Be $script:CatReal
}
It '字面路径条目:一个 path 项(ArchivePath 是末级名)' {
$entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.IsName | Should -BeFalse
$resolved.ArchiveFlavor | Should -Be 'path'
@($resolved.Items).Count | Should -Be 1
$resolved.Items[0].ArchivePath | Should -Be 'FooClolor'
$resolved.Items[0].Kind | Should -Be 'path'
$resolved.Items[0].Origin | Should -Be 'path'
$resolved.Items[0].RealPath | Should -Be 'C:\Programs\FooClolor'
}
It '字面路径条目上的 @ Path= 覆盖目标路径,但归档名仍按原路径' {
$entry = ConvertFrom-BackupListLine -Line "C:\Programs\Foo @ Path='D:\Elsewhere'"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.Items[0].RealPath | Should -Be 'D:\Elsewhere'
$resolved.BaseName | Should -Be 'Foo_from_C_+Programs'
}
It '名录里的路径不存在时仍给出 Items(恢复要靠它还原回原位)' {
$missingCatalog = Write-ListFile -Path (Join-Path $script:Sandbox 'catalog\Missing.psd1') -Content "@{ 'gone' = @{ S = @{ Path = 'C:\definitely-not-here-98765' } } }"
$entry = ConvertFrom-BackupListLine -Line 'gone'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $missingCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 1
$resolved.Items[0].Exists | Should -BeFalse
$resolved.Items[0].RealPath | Should -Be 'C:\definitely-not-here-98765'
}
It ':: 覆盖 Path:单 Slot 条目直接生效' {
$entry = ConvertFrom-BackupListLine -Line "single :: $script:CatOther"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
@($resolved.Items).Count | Should -Be 1
$resolved.Items[0].ArchivePath | Should -Be 'Main'
$resolved.Items[0].RealPath | Should -Be $script:CatOther
$resolved.Blocking | Should -BeNullOrEmpty
}
It ':: / @ Path= 覆盖遇到多 Slot 条目 -> Blocking(不猜是哪一个)' {
foreach ($line in @("multi :: $script:CatOther", "multi @ Path='$script:CatOther'")) {
$entry = ConvertFrom-BackupListLine -Line $line
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
@($resolved.Items).Count | Should -Be 0
$resolved.Blocking | Should -Match '不能用一个'
}
}
It '条目级 :- 覆盖名录里的排除:HasExcludeOverride 为真' {
$entry = ConvertFrom-BackupListLine -Line 'single :- logs\,!*Cache'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.HasExcludeOverride | Should -BeTrue
(@($resolved.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache'
}
It '名录 Slot 的 Include 会追加成 include 项' {
$entry = ConvertFrom-BackupListLine -Line 'incapp'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
@($resolved.Items).Count | Should -Be 2
$include = @($resolved.Items | Where-Object { $_.Origin -eq 'include' })
$include.Count | Should -Be 1
$include[0].ArchivePath | Should -Be 'Mods'
$include[0].Kind | Should -Be 'include'
$include[0].RealPath | Should -Be $script:CatInc
}
It '条目级 :+ / @ Include= 覆盖名录里的 Include' {
foreach ($line in @("incapp :+ Other:$script:CatOther", "incapp @ Include='Other:$script:CatOther'")) {
$entry = ConvertFrom-BackupListLine -Line $line
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.HasIncludeOverride | Should -BeTrue
(@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'A,Other'
@($resolved.Items)[1].RealPath | Should -Be $script:CatOther
}
}
It '加密:名录里各 Slot 取或;条目级 :encrypt / :!encrypt 覆盖' {
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeFalse
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'encapp') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeTrue
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'encapp :!encrypt') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeFalse
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single :encrypt') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeTrue
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line "single @ Encrypt='true'") -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeTrue
}
It '方向标记会传递到 Resolve-BackupEntry.Direction' {
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line '+ single') -CatalogPath $script:CatFile -MaxDepth 3).Direction | Should -Be 'backup'
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line '- single') -CatalogPath $script:CatFile -MaxDepth 3).Direction | Should -Be 'restore'
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single') -CatalogPath $script:CatFile -MaxDepth 3).Direction | Should -Be 'both'
}
It '归档内路径冲突(include 与 Slot 同名)-> Blocking' {
$entry = ConvertFrom-BackupListLine -Line "conflict :+ Data:$script:CatOther"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.Blocking | Should -Match '归档内路径冲突'
}
It '归档内路径冲突(父子关系)-> Blocking' {
$entry = ConvertFrom-BackupListLine -Line "conflict :+ Data\sub:$script:CatOther"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.Blocking | Should -Match '父子关系'
}
It '名录里没有该名字:Error 给出,Items 为空' {
$entry = ConvertFrom-BackupListLine -Line 'no-such-thing'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.Error | Should -Match 'no-such-thing'
@($resolved.Items).Count | Should -Be 0
}
}
# ============================================================================
Describe '外部命令退出码(旧实现的核心缺陷)' {
# ============================================================================
It 'Invoke-ExternalCommand 能拿到真实退出码' {
(Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 7')) | Should -Be 7
}
It '成功时拿到 0' {
(Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 0')) | Should -Be 0
}
}
# ============================================================================
Describe '集成:真实 7z 压缩与排除规则' -Skip:(-not $script:HasSevenZip) {
# ============================================================================
BeforeAll {
$script:IntegrationRoot = Join-Path $script:Sandbox 'integration'
$script:IntegrationSource = Join-Path $script:IntegrationRoot 'src'
$script:IntegrationItem = 'User Data'
$script:IntegrationPath = Join-Path $script:IntegrationSource $script:IntegrationItem
foreach ($directory in 'Default\Cache', 'Default\Code Cache', 'Default\Extensions', 'component_crx_cache', 'Default\IndexedDB') {
New-Item -ItemType Directory -Path (Join-Path $script:IntegrationPath $directory) -Force | Out-Null
}
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:IntegrationPath 'keep.txt') 'keep'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:IntegrationPath 'Default\Cache\c.bin') 'c'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:IntegrationPath 'Default\Code Cache\cc.bin') 'cc'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:IntegrationPath 'Default\Extensions\e.bin') 'e'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:IntegrationPath 'component_crx_cache\x.bin') 'x'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:IntegrationPath 'Default\IndexedDB\i.bin') 'i'
}
It '排除规则与空格处理在真实归档上生效' {
$patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB')
$item = New-BaknretArchiveItem -ArchivePath $script:IntegrationItem -RealPath $script:IntegrationPath `
-Kind 'path' -Origin 'path' -Exists $true -IsFile $false
$excludeArguments = @((Get-BaknretExcludeArgument -Item $item -Patterns $patterns).Arguments)
$archive = Join-Path $script:IntegrationRoot 'excl.7z'
$arguments = @('a', '-t7z', '-mx=1', '-bso0', '-bsp0') + $excludeArguments + @($archive, $script:IntegrationItem)
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList $arguments -WorkingDirectory $script:IntegrationSource) | Should -Be 0
$verify = Join-Path $script:IntegrationRoot 'verify'
New-Item -ItemType Directory -Path $verify -Force | Out-Null
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive)) | Should -Be 0
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\keep.txt") | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\Cache\c.bin") | Should -BeFalse
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\Code Cache\cc.bin") | Should -BeFalse
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\Extensions\e.bin") | Should -BeFalse
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\component_crx_cache\x.bin") | Should -BeFalse
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\IndexedDB\i.bin") | Should -BeFalse
}
It '对照组:不加排除时被排除的文件确实在归档里(证明上一条不是空归档)' {
$archive = Join-Path $script:IntegrationRoot 'full.7z'
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $archive, $script:IntegrationItem) -WorkingDirectory $script:IntegrationSource) | Should -Be 0
$verify = Join-Path $script:IntegrationRoot 'verify-full'
New-Item -ItemType Directory -Path $verify -Force | Out-Null
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive)) | Should -Be 0
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\Cache\c.bin") | Should -BeTrue
}
It '7z t 对完好归档返回 0,对损坏归档返回非 0(归档后校验的依据)' {
$good = Join-Path $script:IntegrationRoot 'full.7z'
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('t', '-bso0', '-bsp0', $good)) | Should -Be 0
$bad = Join-Path $script:IntegrationRoot 'corrupt.7z'
$bytes = [System.IO.File]::ReadAllBytes($good)
for ($i = [math]::Max(0, $bytes.Length - 40); $i -lt $bytes.Length; $i++) { $bytes[$i] = 0xFF }
[System.IO.File]::WriteAllBytes($bad, $bytes)
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('t', '-bso0', '-bsp0', $bad)) | Should -Not -Be 0
}
}
# ============================================================================
Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
BeforeAll {
$script:E2ERoot = Join-Path $script:Sandbox 'e2e'
$script:E2ESource = Join-Path $script:E2ERoot 'src\My Code Space'
$script:E2EBackupDir = Join-Path $script:E2ERoot 'Backups'
$script:E2EList = Join-Path $script:E2ERoot 'list.txt'
$script:E2EHashes = New-VerifiableSourceTree -Root $script:E2ESource
Write-ListFile -Path $script:E2EList -Content "# e2e`n$script:E2ESource :- logs\,!*Cache`n" | Out-Null
$script:BackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir
Force = $true
QuietTool = $true
}
$script:E2EManifestPath = Join-Path $script:E2EBackupDir 'manifest.json'
$script:E2EManifest = Read-BaknretManifest -Path $script:E2EManifestPath
$script:E2EArchive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0]
}
It '备份退出码为 0(旧实现会把成功的压缩判成失败)' {
$script:BackupRun.ExitCode | Should -Be 0
}
It '归档已生成且 manifest 记录了条目、动作、校验结果与 layouts' {
@(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z).Count | Should -Be 1
$script:E2EArchive.Length | Should -BeGreaterThan 0
$record = $script:E2EManifest.items[$script:E2EArchive.BaseName]
$record | Should -Not -BeNullOrEmpty
$record.action | Should -Be 'backed-up'
$record.verified | Should -BeTrue
$record.exitCode | Should -Be 0
$record.roots | Should -Contain 'My Code Space'
@($record.layouts).Count | Should -Be 1
$record.layouts[0].name | Should -Be 'My Code Space'
$record.layouts[0].kind | Should -Be 'dir'
}
It '备份过程写了日志文件' {
$logs = @(Get-ChildItem -LiteralPath (Join-Path $script:ProjectRoot 'logs') -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
$logs.Count | Should -BeGreaterThan 0
}
It '字面路径条目沿用 <末级名> 布局;归档里保留应保留内容、不含被排除项' {
(Get-BaknretArchiveTopName -ArchivePath 'My Code Space\keep.txt') | Should -Be 'My Code Space'
$verify = Join-Path $script:E2ERoot 'verify'
New-Item -ItemType Directory -Path $verify -Force | Out-Null
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $script:E2EArchive.FullName)) | Should -Be 0
Test-Path -LiteralPath (Join-Path $verify 'My Code Space\keep.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'My Code Space\sub\b.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'My Code Space\logs\a.log') | Should -BeFalse
Test-Path -LiteralPath (Join-Path $verify 'My Code Space\Cache\c.bin') | Should -BeFalse
}
# ---- 回归:manifest.roots 曾经记录的是软件名,而不是归档里真实的顶层条目名 ----
It '[回归] manifest.roots 记录的是归档内真实的顶层条目名' {
$record = $script:E2EManifest.items[$script:E2EArchive.BaseName]
$record.roots | Should -Contain 'My Code Space'
# 和归档里的真实内容对一次账,而不是只信 manifest 自己
$topLevel = @(Get-ArchiveTopLevelNames -ArchivePath $script:E2EArchive.FullName -SevenZip $script:SevenZip)
(@($topLevel | Sort-Object) -join ',') | Should -Be 'My Code Space'
}
It 'Restore -DryRun 退出码 0,且一个字节都不写(manifest SHA256 不变)' {
$before = (Get-FileHash -LiteralPath $script:E2EManifestPath -Algorithm SHA256).Hash
Remove-Item -LiteralPath $script:E2ESource -Recurse -Force
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir
DryRun = $true
}
$run.ExitCode | Should -Be 0
Test-Path -LiteralPath $script:E2ESource | Should -BeFalse
(Get-FileHash -LiteralPath $script:E2EManifestPath -Algorithm SHA256).Hash | Should -Be $before
}
It 'Restore -WhatIf 同样不写盘' {
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir
WhatIf = $true
}
$run.ExitCode | Should -Be 0
Test-Path -LiteralPath $script:E2ESource | Should -BeFalse
}
It '真实恢复:退出码 0,文件逐字节一致,被排除项没有被恢复出来' {
Test-Path -LiteralPath $script:E2ESource | Should -BeFalse # 保证不是空操作
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir
Force = $true
}
$run.ExitCode | Should -Be 0
foreach ($relative in $script:E2EHashes.Keys) {
$restored = Join-Path $script:E2ESource $relative
Test-Path -LiteralPath $restored | Should -BeTrue
(Get-FileHash -LiteralPath $restored -Algorithm SHA256).Hash | Should -Be $script:E2EHashes[$relative]
}
Test-Path -LiteralPath (Join-Path $script:E2ESource 'logs\a.log') | Should -BeFalse
Test-Path -LiteralPath (Join-Path $script:E2ESource 'Cache\c.bin') | Should -BeFalse
}
It '真实恢复之后 manifest 才被更新(lastRestoreAt)' {
$manifest = Read-BaknretManifest -Path $script:E2EManifestPath
$record = $manifest.items[$script:E2EArchive.BaseName]
$record.lastRestoreAt | Should -Not -BeNullOrEmpty
}
It '孤儿归档会被点名报告,但不影响退出码' {
Copy-Item -LiteralPath $script:E2EArchive.FullName -Destination (Join-Path $script:E2EBackupDir 'Orphaned-Archive.7z') -Force
$run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir
QuietTool = $true
}
$run.ExitCode | Should -Be 0
$run.Output | Should -Match '孤儿归档'
$run.Output | Should -Match 'Orphaned-Archive\.7z'
}
It '带 -Only 时不做孤儿审计(避免把未选中的归档误报成孤儿)' {
$run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir
Only = @('My Code Space')
QuietTool = $true
}
$run.Output | Should -Not -Match '孤儿归档'
}
It '源路径不存在时记为 missing-source,退出码仍为 0(跳过不算失败)' {
$missingList = Write-ListFile -Path (Join-Path $script:E2ERoot 'missing.txt') -Content "Z:\definitely-not-here-12345`n"
$run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $missingList
BackupDir = $script:E2EBackupDir
QuietTool = $true
}
$run.ExitCode | Should -Be 0
$manifest = Read-BaknretManifest -Path $script:E2EManifestPath
$manifest.items['definitely-not-here-12345_from_Z_'].action | Should -Be 'missing-source'
}
It '归档名重复时直接报失败(退出码 1),不静默互相覆盖' {
$duplicateList = Write-ListFile -Path (Join-Path $script:E2ERoot 'dup.txt') -Content "$script:E2ESource`n$script:E2ESource`n"
$run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $duplicateList
BackupDir = $script:E2EBackupDir
Force = $true
QuietTool = $true
}
$run.ExitCode | Should -Be 1
}
}
# ============================================================================
Describe '集成:方向标记与孤儿审计' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
# `+` / `-` 的归档名必须在方向过滤**之前**登记:这些条目自己不产生归档,
# 但它们对应的归档是有主的,不能被孤儿审计当成没人要的孤儿。
# 同时放一个真孤儿做对照,证明审计确实在跑。
BeforeAll {
$script:DirectionRoot = Join-Path $script:Sandbox 'direction'
$script:DirectionSource = Join-Path $script:DirectionRoot 'src\Keep Me'
New-Item -ItemType Directory -Path $script:DirectionSource -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:DirectionSource 'data.txt') 'keep'
$script:DirectionBackupDir = Join-Path $script:DirectionRoot 'Backups'
$script:DirectionList = Join-Path $script:DirectionRoot 'list.txt'
# 正确方向:先正常备份出归档
Write-ListFile -Path $script:DirectionList -Content "$script:DirectionSource`n" | Out-Null
$null = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:DirectionList
BackupDir = $script:DirectionBackupDir
Force = $true
QuietTool = $true
}
$script:DirectionArchiveName = @(Get-ChildItem -LiteralPath $script:DirectionBackupDir -File -Filter *.7z)[0].BaseName
# 对照组:一个清单里没有条目指向的归档,必须被点名
Copy-Item -LiteralPath (Join-Path $script:DirectionBackupDir "$script:DirectionArchiveName.7z") `
-Destination (Join-Path $script:DirectionBackupDir 'Stray.7z') -Force
# 行首 -:仅恢复,备份端应当跳过它,但要把它算作"有主"
Write-ListFile -Path $script:DirectionList -Content "- $script:DirectionSource`n" | Out-Null
$script:BackupDirectionRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:DirectionList
BackupDir = $script:DirectionBackupDir
QuietTool = $true
}
# 行首 +:仅备份,恢复端应当跳过它,同样要登记归档名
Remove-Item -LiteralPath $script:DirectionSource -Recurse -Force
Write-ListFile -Path $script:DirectionList -Content "+ $script:DirectionSource`n" | Out-Null
$script:RestoreDirectionRun = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:DirectionList
BackupDir = $script:DirectionBackupDir
Force = $true
Verbose = $true
}
Remove-Item -LiteralPath (Join-Path $script:DirectionBackupDir 'Stray.7z') -Force -ErrorAction SilentlyContinue
}
It '行首 - 的条目:备份跳过它,但仍把它算作"有主"(不报成孤儿)' {
$script:BackupDirectionRun.ExitCode | Should -Be 0
$script:BackupDirectionRun.Output | Should -Match '行首 -'
# 对照组先在:真孤儿被点名;而 `-` 条目对应的归档没有被点名
$script:BackupDirectionRun.Output | Should -Match 'Stray\.7z'
$script:BackupDirectionRun.Output | Should -Not -Match ('- ' + [regex]::Escape($script:DirectionArchiveName) + '\.7z')
Test-Path -LiteralPath (Join-Path $script:DirectionBackupDir "$script:DirectionArchiveName.7z") | Should -BeTrue
}
It '行首 + 的条目:恢复跳过它、不写回目标,同时登记归档名' {
$script:RestoreDirectionRun.ExitCode | Should -Be 0
$script:RestoreDirectionRun.Output | Should -Match '行首 \+'
Test-Path -LiteralPath $script:DirectionSource | Should -BeFalse
$script:RestoreDirectionRun.Output | Should -Match 'Stray\.7z'
$script:RestoreDirectionRun.Output | Should -Not -Match ('- ' + [regex]::Escape($script:DirectionArchiveName) + '\.7z')
}
}