传 -BackupListPath 指向一个不存在的文件时,原实现把它当成"首次运行"处理:备份端会在
那个位置凭空建一份模板,恢复端则"从备份内容生成清单",然后两者都 exit 0。
后果在计划任务里最明显:任务计划程序读到的是"上次运行结果 = 成功",而实际上一个条目
都没处理。这与本仓库已经修过的"27 条被静默跳过、退出码仍是 0"是同一类缺陷 —— 退出码
是自动化唯一能读到的信号,它必须对得上"到底干了什么"。
现在按 $PSBoundParameters.ContainsKey('BackupListPath') 把两条语义分开:
* 显式指定了清单却不存在 -> 报 ERROR 并 exit 1。路径写错、或相对路径按了别的工作
目录解析(计划任务的工作目录通常是 C:\Windows\System32),都是调用方的错误,
不能伪装成"已经帮你建好模板了"。
* 没指定(首次运行引导)-> 保持原行为:备份端建模板、恢复端从 manifest 生成清单,
仍然 exit 0。这是"开箱即用",不是失败。
顺带补 2 处运算符前的空格(Backup.ps1 / Restore.ps1 的 PSUseConsistentWhitespace)。
测试:
* 2 条成对回归用例(显式缺失报失败 / 首次运行仍建模板)。必须成对 —— 只测一条的话,
"把所有缺失都改成 exit 1"这种错误实现也能骗过测试。
* 7 条断言覆盖归档原子替换与路径解析基准。其中 3 条用 AST 判定,因为文本匹配会被
函数自己的注释绊倒(注释里正当地提到了 $PSScriptRoot,解释为什么不许用)。
验收:test.ps1 9/9 全绿(5.1 与 7);Pester 183 -> 192,0 失败;静态分析 80 条、
0 Error(与改造前持平)。
1700 lines
87 KiB
PowerShell
1700 lines
87 KiB
PowerShell
<#
|
||
.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 'BakNRet\BakNRet.psd1') -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-BakNRetBackupBaseName -RawPath 'C:\Programs\FooClolor' | Should -Be 'FooClolor_from_C_+Programs'
|
||
}
|
||
|
||
It '/ 与 \ 以及重复分隔符结果一致' {
|
||
$a = Get-BakNRetBackupBaseName -RawPath 'C:\a/b'
|
||
$b = Get-BakNRetBackupBaseName -RawPath 'C:\\a\\\\b'
|
||
$a | Should -Be $b
|
||
}
|
||
|
||
It '命名与路径往返' {
|
||
$base = Get-BakNRetBackupBaseName -RawPath 'D:\UserData\Documents\Aria'
|
||
Convert-BakNRetBackupFileNameToPath -FileName ($base + '.7z') | Should -Be 'D:\UserData\Documents\Aria'
|
||
}
|
||
|
||
It '归档名里不含非法文件名字符' {
|
||
$base = Get-BakNRetBackupBaseName -RawPath 'C:\a<b>c|d?e*f'
|
||
($base.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1
|
||
}
|
||
|
||
It '%变量% 写法里的 % 会保留在归档名里' {
|
||
Get-BakNRetBackupBaseName -RawPath '%UserProfile%\.ssh' | Should -Be '.ssh_from_%UserProfile%'
|
||
}
|
||
|
||
It '软件名条目:默认用软件名做归档名' {
|
||
$entry = ConvertFrom-BackupListLine -Line 'my-app'
|
||
(Get-BakNRetItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog) | Should -Be 'my-app'
|
||
}
|
||
|
||
It '字面路径条目:仍用路径命名算法(现有清单无需改写)' {
|
||
$entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
|
||
(Get-BakNRetItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog) | Should -Be 'FooClolor_from_C_+Programs'
|
||
}
|
||
|
||
It '@pathname 用名录里的真实路径命名,而不是软件名' {
|
||
$entry = ConvertFrom-BackupListLine -Line 'my-app @pathname'
|
||
$expected = Get-BakNRetBackupBaseName -RawPath $script:NamingApp
|
||
(Get-BakNRetItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog) | Should -Be $expected
|
||
}
|
||
|
||
It '名录里没有该名字:归档名退回可读目录名,并给出 Error' {
|
||
$entry = ConvertFrom-BackupListLine -Line 'no-such-thing'
|
||
(Get-BakNRetItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog) | Should -Be 'no-such-thing'
|
||
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:NamingCatalog
|
||
$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-BakNRetNativeArgumentString -ArgumentList @('a', '-mx=9') | Should -Be 'a -mx=9'
|
||
}
|
||
|
||
It '含空格参数加引号' {
|
||
ConvertTo-BakNRetNativeArgumentString -ArgumentList @('C:\a b\c') | Should -Be '"C:\a b\c"'
|
||
}
|
||
|
||
It '引号内的结尾反斜杠翻倍(否则会被当成转义引号)' {
|
||
ConvertTo-BakNRetNativeArgumentString -ArgumentList @('C:\a b\') | Should -Be '"C:\a b\\"'
|
||
}
|
||
|
||
It '内部引号被转义' {
|
||
ConvertTo-BakNRetNativeArgumentString -ArgumentList @('a"b c') | Should -Be '"a\"b c"'
|
||
}
|
||
|
||
It '空参数输出一对空引号' {
|
||
ConvertTo-BakNRetNativeArgumentString -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-BakNRetSoftwareCatalog -Path $script:CatFile -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-BakNRetSoftwareCatalog -Path $script:CatFile -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-BakNRetSoftwareCatalog -Path $script:CatFile -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-BakNRetSoftwareCatalog -Path $script:CatFile -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-BakNRetSoftwareCatalog -Path $script:CatFile -NoCache
|
||
$catalog['legendary'].Slots[0].Resolved | Should -Not -Be (Join-Path $script:CatRoot 'LegendarySomething')
|
||
}
|
||
|
||
It '一个 Slot 命中多个候选目录:报 Error,绝不悄悄挑一棵树' {
|
||
$catalog = Get-BakNRetSoftwareCatalog -Path $script:CatFile -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-BakNRetSoftwareCatalog -Path $script:CatFile -NoCache
|
||
$slot = $catalog['fileslot'].Slots[0]
|
||
$slot.IsFile | Should -BeTrue
|
||
$slot.Encrypt | Should -BeTrue
|
||
}
|
||
|
||
It '%变量% 会在名录路径里展开' {
|
||
$catalog = Get-BakNRetSoftwareCatalog -Path $script:CatFile -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-BakNRetSoftwareCatalog -Path $script:CatFile
|
||
$second = Get-BakNRetSoftwareCatalog -Path $script:CatFile
|
||
[object]::ReferenceEquals($first, $second) | Should -BeTrue
|
||
|
||
$fresh = Get-BakNRetSoftwareCatalog -Path $script:CatFile -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-BakNRetSoftwareCatalog -Path $path)['x'].Description | Should -Be 'one'
|
||
|
||
Write-ListFile -Path $path -Content "@{ 'x' = @{ S = @{ Path = '$script:CatReal'; Description = 'two' } } }" | Out-Null
|
||
(Get-BakNRetSoftwareCatalog -Path $path)['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-BakNRetSoftwareCatalog -Path $main -NoCache
|
||
$catalog.ContainsKey('main-app') | Should -BeTrue
|
||
$catalog.ContainsKey('extra-app') | Should -BeTrue
|
||
}
|
||
|
||
It '旧的裸字符串 / 字符串数组 / 对象数组写法都会报 ERROR 并被跳过' {
|
||
$catalog = Get-BakNRetSoftwareCatalog -Path $script:LegacyFile -NoCache
|
||
$catalog.ContainsKey('bare') | Should -BeFalse
|
||
$catalog.ContainsKey('arr') | Should -BeFalse
|
||
$catalog.ContainsKey('objarr') | Should -BeFalse
|
||
}
|
||
|
||
It '旧的 @{ Dirs = @(...) } 写法不再展开:留下 Invalid 条目和原因' {
|
||
$catalog = Get-BakNRetSoftwareCatalog -Path $script:LegacyFile -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-BakNRetCatalogName -Name 'a<b>c:d/e'
|
||
($clean.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1
|
||
(Format-BakNRetCatalogName -Name ' My App ') | Should -Be 'My App'
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
Describe 'Resolve-BakNRetBackupEntry:条目解析' {
|
||
# ============================================================================
|
||
|
||
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-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile
|
||
(@($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-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile
|
||
$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-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile
|
||
@($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-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile
|
||
$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-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile
|
||
$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-BakNRetBackupEntry -Entry $entry -CatalogPath $missingCatalog
|
||
@($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-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile
|
||
@($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-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile
|
||
@($resolved.Items).Count | Should -Be 0
|
||
$resolved.Blocking | Should -Match '不能用一个'
|
||
}
|
||
}
|
||
|
||
It '条目级 :- 覆盖名录里的排除:HasExcludeOverride 为真' {
|
||
$entry = ConvertFrom-BackupListLine -Line 'single :- logs\,!*Cache'
|
||
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile
|
||
$resolved.HasExcludeOverride | Should -BeTrue
|
||
(@($resolved.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache'
|
||
}
|
||
|
||
It '名录 Slot 的 Include 会追加成 include 项' {
|
||
$entry = ConvertFrom-BackupListLine -Line 'incapp'
|
||
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile
|
||
@($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-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile
|
||
$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-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single') -CatalogPath $script:CatFile).Encrypt | Should -BeFalse
|
||
(Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'encapp') -CatalogPath $script:CatFile).Encrypt | Should -BeTrue
|
||
(Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'encapp :!encrypt') -CatalogPath $script:CatFile).Encrypt | Should -BeFalse
|
||
(Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single :encrypt') -CatalogPath $script:CatFile).Encrypt | Should -BeTrue
|
||
(Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line "single @ Encrypt='true'") -CatalogPath $script:CatFile).Encrypt | Should -BeTrue
|
||
}
|
||
|
||
It '方向标记会传递到 Resolve-BakNRetBackupEntry.Direction' {
|
||
(Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line '+ single') -CatalogPath $script:CatFile).Direction | Should -Be 'backup'
|
||
(Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line '- single') -CatalogPath $script:CatFile).Direction | Should -Be 'restore'
|
||
(Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single') -CatalogPath $script:CatFile).Direction | Should -Be 'both'
|
||
}
|
||
|
||
It '归档内路径冲突(include 与 Slot 同名)-> Blocking' {
|
||
$entry = ConvertFrom-BackupListLine -Line "conflict :+ Data:$script:CatOther"
|
||
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile
|
||
$resolved.Blocking | Should -Match '归档内路径冲突'
|
||
}
|
||
|
||
It '归档内路径冲突(父子关系)-> Blocking' {
|
||
$entry = ConvertFrom-BackupListLine -Line "conflict :+ Data\sub:$script:CatOther"
|
||
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile
|
||
$resolved.Blocking | Should -Match '父子关系'
|
||
}
|
||
|
||
It '名录里没有该名字:Error 给出,Items 为空' {
|
||
$entry = ConvertFrom-BackupListLine -Line 'no-such-thing'
|
||
$resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile
|
||
$resolved.Error | Should -Match 'no-such-thing'
|
||
@($resolved.Items).Count | Should -Be 0
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
Describe '外部命令退出码(旧实现的核心缺陷)' {
|
||
# ============================================================================
|
||
|
||
It '口令不会进日志:DEBUG 下打印的命令行要遮蔽 -p 参数' {
|
||
$sandbox = Join-Path $env:TEMP ("bnr-secret-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||
New-Item -ItemType Directory -Path $sandbox -Force | Out-Null
|
||
$sentinel = 'SENTINEL-PW-9f3a'
|
||
$logPath = $null
|
||
try {
|
||
$logPath = Start-BakNRetLog -Directory $sandbox -Prefix 'secret'
|
||
Set-BakNRetDebug
|
||
$null = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 0', "-p$sentinel")
|
||
}
|
||
finally {
|
||
Set-BakNRetDebug -Enabled:$false
|
||
Stop-BakNRetLog
|
||
}
|
||
|
||
$logText = Get-Content -Encoding UTF8 -LiteralPath $logPath -Raw
|
||
# 这条是"测试的测试":没有它,万一 DEBUG 那行压根没写进去,后面两条会空跑通过
|
||
$logText | Should -Match '执行:'
|
||
$logText | Should -Not -Match ([regex]::Escape($sentinel))
|
||
$logText | Should -Match '口令已隐藏'
|
||
|
||
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||
}
|
||
|
||
It '运行锁:同一份备份目录同时只能有一个持有者' {
|
||
# Backup.ps1 与 Restore.ps1 都会写 manifest.json,也都会用 <归档>.tmp 这个名字;
|
||
# 计划任务与手动运行撞在一起时,两边会互相覆盖对方的账本、抢对方的临时归档。
|
||
$dir = Join-Path $env:TEMP ("bnr-lock-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||
$first = $null
|
||
$second = $null
|
||
try {
|
||
$first = Enter-BakNRetRunLock -Directory $dir
|
||
$first | Should -Not -BeNullOrEmpty
|
||
|
||
$second = Enter-BakNRetRunLock -Directory $dir
|
||
$second | Should -BeNullOrEmpty
|
||
|
||
Test-Path -LiteralPath (Get-BakNRetRunLockPath -Directory $dir) | Should -BeTrue
|
||
}
|
||
finally {
|
||
Exit-BakNRetRunLock -Lock $second
|
||
Exit-BakNRetRunLock -Lock $first
|
||
}
|
||
|
||
# 锁文件是独占打开的(FileShare.None),内容只能在释放之后读
|
||
(Get-Content -Encoding UTF8 -LiteralPath (Get-BakNRetRunLockPath -Directory $dir) -Raw) |
|
||
Should -Match "pid=$PID"
|
||
|
||
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
|
||
}
|
||
|
||
It '运行锁:释放之后可以重新取得' {
|
||
$dir = Join-Path $env:TEMP ("bnr-lock-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||
try {
|
||
$first = Enter-BakNRetRunLock -Directory $dir
|
||
$first | Should -Not -BeNullOrEmpty
|
||
Exit-BakNRetRunLock -Lock $first
|
||
|
||
$second = Enter-BakNRetRunLock -Directory $dir
|
||
$second | Should -Not -BeNullOrEmpty
|
||
Exit-BakNRetRunLock -Lock $second
|
||
}
|
||
finally {
|
||
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
|
||
It '空目录的摘要给 0 而不是 $null(否则空间守卫会静默失效)' {
|
||
# Measure-Object 对空输入返回 $null 而不是 0,于是 .Sum 也是 $null;而 $null / 1GB
|
||
# 得 0,空间守卫判的是 -gt 0 —— 这一档就不再拦截了。
|
||
$empty = Join-Path $env:TEMP ("bnr-empty-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||
New-Item -ItemType Directory -Path $empty -Force | Out-Null
|
||
try {
|
||
$summary = Get-BakNRetFolderSummary -FolderPath $empty
|
||
$summary.FileCount | Should -Be 0
|
||
$summary.TotalSize | Should -Not -BeNullOrEmpty
|
||
$summary.TotalSize | Should -Be 0
|
||
}
|
||
finally {
|
||
Remove-Item -LiteralPath $empty -Recurse -Force -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
|
||
It '原子替换:成功时新内容到位且不留 .tmp;失败时旧内容完好' {
|
||
$base = Join-Path $env:TEMP ("bnr-atomic-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||
New-Item -ItemType Directory -Path $base -Force | Out-Null
|
||
$target = Join-Path $base 'text.json'
|
||
try {
|
||
Write-BakNRetAtomicText -Path $target -Text 'FIRST' | Out-Null
|
||
(Get-Content -Encoding UTF8 -LiteralPath $target -Raw) | Should -Be 'FIRST'
|
||
|
||
# 目标已存在时走 File.Replace
|
||
Write-BakNRetAtomicText -Path $target -Text 'SECOND' | Out-Null
|
||
(Get-Content -Encoding UTF8 -LiteralPath $target -Raw) | Should -Be 'SECOND'
|
||
"$target.tmp" | Should -Not -Exist
|
||
|
||
# 目标只读 -> 替换必然失败 -> 旧内容必须还在。这正是 File.Replace 与"先删后移"
|
||
# 的区别:后者失败时旧文件已经没了,而 manifest 丢了只能靠文件名反推。
|
||
(Get-Item -LiteralPath $target).IsReadOnly = $true
|
||
{ Write-BakNRetAtomicText -Path $target -Text 'SHOULD-NOT-LAND' } | Should -Throw
|
||
(Get-Content -Encoding UTF8 -LiteralPath $target -Raw) | Should -Be 'SECOND'
|
||
}
|
||
finally {
|
||
$file = Get-Item -LiteralPath $target -ErrorAction SilentlyContinue
|
||
if ($file) { $file.IsReadOnly = $false }
|
||
Remove-Item -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
|
||
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-BakNRetArchiveTopLevelNames -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
|
||
}
|
||
|
||
# ---- 回归:显式指定的清单不存在时,绝不能"建一份模板 + 退出 0" ----
|
||
#
|
||
# 缺陷现象:计划任务里 -BackupListPath 写错(或相对路径按了别的工作目录解析)时,
|
||
# 脚本会在那个错误位置凭空造一份模板、打印"已创建,请编辑后重试"、然后 **exit 0**。
|
||
# 任务计划程序读到的是"上次运行结果 = 成功",而实际上一个条目都没处理 ——
|
||
# 与本仓库已修过的"27 条被静默跳过、退出码仍是 0"是同一类缺陷。
|
||
It '[回归] 显式指定的清单不存在时报失败(退出码 1),且不在错误位置建模板' {
|
||
$ghostList = Join-Path $script:E2ERoot 'explicitly-missing-list.txt'
|
||
if (Test-Path -LiteralPath $ghostList) { Remove-Item -LiteralPath $ghostList -Force }
|
||
|
||
$run = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
|
||
BackupListPath = $ghostList
|
||
BackupDir = $script:E2EBackupDir
|
||
QuietTool = $true
|
||
}
|
||
|
||
$run.ExitCode | Should -Be 1
|
||
$run.Output | Should -Match '指定的清单不存在'
|
||
Test-Path -LiteralPath $ghostList | Should -BeFalse
|
||
}
|
||
|
||
# 对照:没传 -BackupListPath 时的首次运行引导必须原样保留(那是开箱即用,不是失败)。
|
||
# 这两条必须成对存在 —— 只测一条的话,"把所有缺失都改成 exit 1" 也能骗过测试。
|
||
#
|
||
# 怎么造出"默认清单不存在":把仓库根那份 BackupList.txt 临时改名,跑完在 finally 里还原。
|
||
# 用 try/finally 而不是"跑完再删",是为了让断言失败时仓库也一定被还原。
|
||
It '[回归] 未显式指定清单时的首次运行仍建模板并退出 0(引导不能被误伤)' {
|
||
$realList = Join-Path $script:ProjectRoot 'BackupList.txt'
|
||
$parkedList = Join-Path $script:E2ERoot 'BackupList.parked'
|
||
$guideRoot = Join-Path $script:E2ERoot 'guide'
|
||
New-Item -ItemType Directory -Path $guideRoot -Force | Out-Null
|
||
|
||
$run = $null
|
||
try {
|
||
Move-Item -LiteralPath $realList -Destination $parkedList -Force
|
||
|
||
$run = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
|
||
BackupDir = Join-Path $guideRoot 'Backups'
|
||
ConfigPath = Join-Path $script:ProjectRoot 'BackupConfig.psd1'
|
||
}
|
||
|
||
$run.ExitCode | Should -Be 0
|
||
$run.Output | Should -Match '模板 BackupList.txt 已创建'
|
||
Test-Path -LiteralPath $realList | Should -BeTrue
|
||
(Get-Content -LiteralPath $realList -Raw) | Should -Match '语法'
|
||
}
|
||
finally {
|
||
if (Test-Path -LiteralPath $parkedList) {
|
||
Move-Item -LiteralPath $parkedList -Destination $realList -Force
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
Describe '归档原子替换与路径解析基准(历史上真出过问题的两处语义)' {
|
||
# ============================================================================
|
||
|
||
# ---- Move-BakNRetArchiveIntoPlace ----
|
||
#
|
||
# 这条对应 CHANGELOG 里记过的缺陷:5.1 上三参数 File.Move 不存在,于是退化成
|
||
# "先删后移" —— Move 一失败,**旧归档已经没了**(而新归档还在 .tmp 里)。
|
||
# 断言必须能分辨"替换"与"先删后移":后者的危险恰恰在于"目标短暂不存在"。
|
||
Context 'Move-BakNRetArchiveIntoPlace' {
|
||
|
||
It '目标不存在时:把临时文件移到位,临时文件消失' {
|
||
$root = Join-Path $script:Sandbox 'atomic-new'
|
||
New-Item -ItemType Directory -Path $root -Force | Out-Null
|
||
$temp = Join-Path $root 'new.tmp'
|
||
$dest = Join-Path $root 'final.7z'
|
||
[System.IO.File]::WriteAllText($temp, 'NEW')
|
||
|
||
Move-BakNRetArchiveIntoPlace -TempPath $temp -DestinationPath $dest
|
||
|
||
(Get-Content -LiteralPath $dest -Raw) | Should -Be 'NEW'
|
||
Test-Path -LiteralPath $temp | Should -BeFalse
|
||
}
|
||
|
||
It '[回归] 目标已存在时:内容被替换为新内容,且旧内容在替换完成前一直还在' {
|
||
$root = Join-Path $script:Sandbox 'atomic-existing'
|
||
New-Item -ItemType Directory -Path $root -Force | Out-Null
|
||
$temp = Join-Path $root 'next.tmp'
|
||
$dest = Join-Path $root 'final.7z'
|
||
[System.IO.File]::WriteAllText($dest, 'OLD')
|
||
[System.IO.File]::WriteAllText($temp, 'NEW')
|
||
|
||
Move-BakNRetArchiveIntoPlace -TempPath $temp -DestinationPath $dest
|
||
|
||
(Get-Content -LiteralPath $dest -Raw) | Should -Be 'NEW'
|
||
Test-Path -LiteralPath $temp | Should -BeFalse
|
||
|
||
# "先删后移" 与 "原子替换" 在成功路径上的结果完全一样,靠结果分不出来。
|
||
# 能分辨的是**机制**:这条断言把"替换而非删除"钉住 —— 5.1 上必须走到
|
||
# File.Replace(ReplaceFile API),而不是先删掉目标再 Move。
|
||
$source = Get-Content -LiteralPath (Join-Path $script:ProjectRoot 'BakNRet\Public\Move-BakNRetArchiveIntoPlace.ps1') -Raw
|
||
$source | Should -Match '\[System\.IO\.File\]::Replace'
|
||
$source | Should -Not -Match 'Remove-Item'
|
||
}
|
||
|
||
It '[回归] 替换走 File.Replace 时第三个参数必须是 [NullString]::Value(传 $null 会报路径为空)' {
|
||
# PowerShell 会把 $null 转成空串,于是 File.Replace 报"路径为空"(两个版本实测都这样)。
|
||
$source = Get-Content -LiteralPath (Join-Path $script:ProjectRoot 'BakNRet\Public\Move-BakNRetArchiveIntoPlace.ps1') -Raw
|
||
$source | Should -Match '\[NullString\]::Value'
|
||
}
|
||
}
|
||
|
||
# ---- Resolve-BakNRetRootedPath ----
|
||
#
|
||
# 这条对应 README 专门写的一节:「相对路径按仓库根解析,不按当前工作目录」。
|
||
# 计划任务的工作目录通常是 C:\Windows\System32,在那里 Test-Path baknret.key 为假 ——
|
||
# 如果按工作目录解析,加密条目会以"拿不到口令"失败,而配置看上去毫无问题。
|
||
Context 'Resolve-BakNRetRootedPath' {
|
||
|
||
It '[回归] 相对路径按调用方传进来的 $Root 解析,而不是按当前工作目录' {
|
||
Push-Location $env:SystemRoot
|
||
try {
|
||
$resolved = Resolve-BakNRetRootedPath -Path 'Backups' -Root $script:ProjectRoot
|
||
$resolved | Should -Be (Join-Path $script:ProjectRoot 'Backups')
|
||
|
||
# 反证:如果它按工作目录解析,就会落到 System32 下面
|
||
$resolved | Should -Not -Match [regex]::Escape($env:SystemRoot)
|
||
}
|
||
finally {
|
||
Pop-Location
|
||
}
|
||
}
|
||
|
||
It '[回归] 模块里不得用 $PSScriptRoot 当根:那会解析到模块目录而不是仓库根' {
|
||
# 这是"入口逻辑下沉"里最容易出错的一类:闭包捕获的变量在模块作用域里指向别的东西。
|
||
# 函数注释专门解释了为什么 $Root 必须是参数 —— 这条断言把它钉住。
|
||
#
|
||
# 必须用 AST 判定,不能对源码做文本匹配:这个函数的注释里**正当地**提到了
|
||
# $PSScriptRoot(解释为什么不许用),文本匹配会把注释当成违规。
|
||
# 第一版就是这么写的,结果被自己的注释绊倒 —— 记在这里。
|
||
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
|
||
(Join-Path $script:ProjectRoot 'BakNRet\Public\Resolve-BakNRetRootedPath.ps1'), [ref]$null, [ref]$null)
|
||
$nodes = @($ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.VariableExpressionAst] }, $true))
|
||
$referenced = @($nodes | ForEach-Object { $_.VariablePath.UserPath } | Sort-Object -Unique)
|
||
|
||
$referenced | Should -Not -Contain 'PSScriptRoot'
|
||
$referenced | Should -Contain 'Root' # 对照:确实引用了调用方传进来的根
|
||
}
|
||
|
||
It '绝对路径原样返回' {
|
||
$absolute = Join-Path $script:Sandbox 'absolute-dir'
|
||
Resolve-BakNRetRootedPath -Path $absolute -Root $script:ProjectRoot | Should -Be $absolute
|
||
}
|
||
|
||
It '$Path 为空时回落到 $Default' {
|
||
Resolve-BakNRetRootedPath -Path '' -Default 'logs' -Root $script:ProjectRoot |
|
||
Should -Be (Join-Path $script:ProjectRoot 'logs')
|
||
}
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
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')
|
||
}
|
||
}
|