<# .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=` 控制单个归档加密; * 软件名条目 -> 一个归档,归档内是 `\<内容>`(Path 是文件时是名为 `` 的文件);手写路径条目沿用历史布局 `<末级名>\...`。 跑法: .\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:\ac|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 'ac: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 '口令不会进日志: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 '空目录的摘要给 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-FolderSummary -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-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') } }