P0 正确性 - 退出码:改用 .NET Process 直接启动、让子进程继承控制台,不再用 Start-Process -PassThru (在 7.7.0-preview.4 上 ExitCode 恒为 $null,会把成功的压缩判成失败); 7z / RAR / tar 三条解压分支统一走同一个取退出码的封装。 - BackupList 解析:先按第一个 :: 切段再处理引号(整行被一对引号包住的写法不再把排除表 吞进路径);排除表同时接受 , 与 ;(旧实现只认 ;,导致排除从未生效);支持 :- / :+ / @flag。 - 补回 .ssh 与孤儿归档:.ssh 进清单;孤儿归档在备份端也做审计并点名; 带 -Only / -Skip 时不再把未选中的归档误报成孤儿。 - Resolve-BackupEntry 里 $rootName 在赋值前被引用(会读到外层作用域残留值),已提前赋值。 P1 归档可靠性 - 每个条目写进 manifest.json:源、归档、时间、退出码、校验结果、失败原因, 并区分 warnings(在位归档)与 attemptWarnings(本次尝试)。 - 归档后做 7z t 内容校验,先写 .tmp、校验通过再原子替换(File.Move overwrite)。 - manifest.roots 记录归档内**真实**的顶层条目名(原先记的是软件名,Edge 实际是 "User Data")。 P2 可用性 - Restore 支持 -WhatIf / -DryRun / -VerifyOnly / -Only / -Skip; 这三种"只看不写"的模式一个字节都不写(原先会写回 manifest.json)。 - Edge 等高缓存条目加排除规则并实测:1781 MB / 27961 项 -> 72 MB / 2294 项; 书签、密码、Cookies、偏好、历史、IndexedDB、Local Storage 全部保留。 普通模式是相对归档根目录锚定的,嵌套的那些(如 OneAuth\WebView2 里的 Crashpad) 改用 ! 组件形式才会命中。 - 日志落盘 logs/<backup|restore>-<时间戳>.log;退出码按失败数返回。 - tools/Register-BackupTask.ps1 注册每日计划任务;tools/Rename-Archives.ps1 迁移旧归档名。 - root= 标记此前静默失效,现在明确告警(该功能尚未实现)。 P3 测试与验证 - tests/BakNRet.Tests.ps1:Pester 5 套件 62 项(含用子进程跑 Backup.ps1 / Restore.ps1 的端到端与针对上述缺陷的回归)。 - tests/Run-Pester.ps1 + tools/Install-TestDependencies.ps1:把 Pester 装到仓库内 .tools/, 不动机器上的全局模块(系统自带的 3.4.0 缺 Should -Be)。 - tests/Restore-Drill.ps1:真实归档恢复演练,明确区分"源在备份后变过"与"归档/解压有问题"。 - tests/Run-Tests.ps1(49 项,零依赖)与 tests/Run-E2E.ps1(23 项)继续可用;三套共 134 项全通过。 真实机器验证 - 生产归档 22/22 通过 7z t;-VerifyOnly 不再改动 manifest.json(SHA256 前后一致)。 - 真实恢复演练 12/12 通过,27,670 个文件与活源逐字节一致。 - 修复了生产 scoop-persist.7z:原先只有 90 字节(空归档)而源有 1.3 GB, 重打包后 233 MB,恢复演练 26981/26981 全部一致。
698 lines
33 KiB
PowerShell
698 lines
33 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
BakNRet 的 Pester 测试套件(单元 + 集成 + 回归)。
|
||
|
||
.DESCRIPTION
|
||
与 tests/Run-Tests.ps1 的分工:
|
||
* Run-Tests.ps1 是**零依赖**冒烟套件——机器上没装 Pester 时也能跑,
|
||
适合"插上别人的机器先确认没坏";
|
||
* 本文件是 Pester 套件,覆盖同样的单元面,并额外用**子进程**真正调用
|
||
Backup.ps1 / Restore.ps1。用子进程有两个原因:
|
||
1. 两个脚本结尾都会 `exit`,在同一个进程里用 `&` 调用会把 Pester 宿主
|
||
一起带走;
|
||
2. 子进程给出的是真正的进程退出码,正好独立验证"退出码取法"这条修复。
|
||
|
||
跑法:
|
||
.\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:CatalogPath = Join-Path $script:ProjectRoot 'SoftwareCatalog.psd1'
|
||
$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,拿到真实退出码与完整输出。
|
||
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 }
|
||
}
|
||
|
||
$lines = & pwsh @arguments 2>&1
|
||
return [pscustomobject]@{
|
||
ExitCode = $LASTEXITCODE
|
||
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($false))
|
||
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 '裸路径' {
|
||
$result = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
|
||
$result.Path | Should -Be 'C:\Programs\FooClolor'
|
||
$result.ExcludePatterns.Count | Should -Be 0
|
||
$result.Flags.Count | Should -Be 0
|
||
}
|
||
|
||
It '逗号分隔的排除表被拆成多个模式(旧实现只认分号,整串会被当成一个模式)' {
|
||
$result = ConvertFrom-BackupListLine -Line 'C:\Programs\March7thAssistant :: a\b,c\d\'
|
||
$result.Path | Should -Be 'C:\Programs\March7thAssistant'
|
||
$result.ExcludePatterns.Count | Should -Be 2
|
||
$result.ExcludePatterns[0] | Should -Be 'a\b'
|
||
$result.ExcludePatterns[1] | Should -Be 'c\d\'
|
||
}
|
||
|
||
It '分号分隔同样支持' {
|
||
$result = ConvertFrom-BackupListLine -Line 'C:\x :: a;b'
|
||
$result.ExcludePatterns.Count | Should -Be 2
|
||
}
|
||
|
||
It '引号只包路径时排除表正常解析' {
|
||
$result = ConvertFrom-BackupListLine -Line '"C:\Programs\Foo" :: logs\'
|
||
$result.Path | Should -Be 'C:\Programs\Foo'
|
||
$result.ExcludePatterns[0] | Should -Be 'logs\'
|
||
}
|
||
|
||
It '整行被一对引号包住时,:: 之后的排除表不被吞进路径(真实踩过的坑)' {
|
||
$result = ConvertFrom-BackupListLine -Line '"C:\a b\CodeSpace :: Shuery-Shuai\immortalwrt\"'
|
||
$result.Path | Should -Be 'C:\a b\CodeSpace'
|
||
$result.ExcludePatterns.Count | Should -Be 1
|
||
$result.ExcludePatterns[0] | Should -Be 'Shuery-Shuai\immortalwrt\'
|
||
}
|
||
|
||
It '@ 标记单独出现' {
|
||
$result = ConvertFrom-BackupListLine -Line '.ssh @encrypt'
|
||
$result.Path | Should -Be '.ssh'
|
||
$result.Flags | Should -Contain 'encrypt'
|
||
}
|
||
|
||
It '@ 标记跟在排除表后面' {
|
||
$result = ConvertFrom-BackupListLine -Line 'C:\x :: a,b @encrypt,pathname'
|
||
$result.Flags | Should -Contain 'encrypt'
|
||
$result.Flags | Should -Contain 'pathname'
|
||
$result.ExcludePatterns.Count | Should -Be 2
|
||
}
|
||
|
||
It ':+ 追加目录(可多个、位置无关)' {
|
||
# 每个 :+ 记号后面跟**一个**路径:多个目录要写多个记号。
|
||
# 段内不会按空格再切分——路径本来就可以带空格。
|
||
$result = ConvertFrom-BackupListLine -Line 'Foo :+ D:\a :+ D:\b'
|
||
$result.Path | Should -Be 'Foo'
|
||
$result.AddedPaths.Count | Should -Be 2
|
||
$result.AddedPaths[0] | Should -Be 'D:\a'
|
||
$result.AddedPaths[1] | Should -Be 'D:\b'
|
||
$result.ExcludePatterns.Count | Should -Be 0
|
||
}
|
||
|
||
It ':- 排除,与 :+ 混用且顺序任意' {
|
||
$result = ConvertFrom-BackupListLine -Line 'Foo :- logs\ :+ D:\a :- !*Cache'
|
||
$result.Path | Should -Be 'Foo'
|
||
$result.AddedPaths.Count | Should -Be 1
|
||
$result.AddedPaths[0] | Should -Be 'D:\a'
|
||
$result.ExcludePatterns.Count | Should -Be 2
|
||
$result.ExcludePatterns[0] | Should -Be 'logs\'
|
||
$result.ExcludePatterns[1] | Should -Be '!*Cache'
|
||
}
|
||
|
||
It ':: 与 :- 等价' {
|
||
$a = ConvertFrom-BackupListLine -Line 'Foo :: logs\'
|
||
$b = ConvertFrom-BackupListLine -Line 'Foo :- logs\'
|
||
$a.ExcludePatterns | Should -Be $b.ExcludePatterns
|
||
}
|
||
|
||
It '盘符里的单个冒号不被误当分隔符' {
|
||
$result = ConvertFrom-BackupListLine -Line 'C:\Programs\Foo :: a\b'
|
||
$result.Path | Should -Be 'C:\Programs\Foo'
|
||
}
|
||
|
||
It 'root= 标记会被识别出来(备份端据此告警:该功能尚未实现)' {
|
||
$result = ConvertFrom-BackupListLine -Line 'Foo @root=Bar'
|
||
$result.Flags | Should -Contain 'root=Bar'
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
Describe '归档命名' {
|
||
# ============================================================================
|
||
|
||
# 注意:Pester 的 It 名字里出现 $( ) 会被求值,所以标题里不要写子表达式。
|
||
It '基础命名规则:末级名_from_上级路径用加号连接' {
|
||
Get-BackupBaseName -RawPath 'C:\Programs\FooClolor' | Should -Be 'FooClolor_from_C_+Programs'
|
||
}
|
||
|
||
It '/ 与 \ 以及重复分隔符结果一致' {
|
||
$a = Get-BackupBaseName -RawPath 'C:\a/b'
|
||
$b = Get-BackupBaseName -RawPath 'C:\\a\\\\b'
|
||
$a | Should -Be $b
|
||
}
|
||
|
||
It '命名与路径往返' {
|
||
$base = Get-BackupBaseName -RawPath 'D:\UserData\Documents\Aria'
|
||
Convert-BackupFileNameToPath -FileName ($base + '.7z') | Should -Be 'D:\UserData\Documents\Aria'
|
||
}
|
||
|
||
It '归档名里不含非法文件名字符' {
|
||
$base = Get-BackupBaseName -RawPath 'C:\a<b>c|d?e*f'
|
||
($base.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
Describe '7z 排除参数翻译' {
|
||
# ============================================================================
|
||
|
||
It '相对模式自动补上归档根目录名' {
|
||
$arguments = Get-ArchiveExcludeArgument -ItemName 'Foo' -Patterns @('logs\')
|
||
$arguments | Should -Contain '-x!Foo\logs'
|
||
}
|
||
|
||
It '已经带根目录名时不重复前缀' {
|
||
$arguments = Get-ArchiveExcludeArgument -ItemName 'Foo' -Patterns @('Foo\logs\')
|
||
$arguments | Should -Contain '-x!Foo\logs'
|
||
}
|
||
|
||
It '! 前缀翻译成递归组件匹配' {
|
||
$arguments = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('!*Cache')
|
||
$arguments | Should -Contain '-xr!*Cache'
|
||
}
|
||
|
||
It '模式里的空格转成 ? (7z 的模式不支持空格)' {
|
||
$arguments = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('Default\Code Cache')
|
||
$arguments | Should -Contain '-x!User?Data\Default\Code?Cache'
|
||
}
|
||
|
||
It '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' {
|
||
$arguments = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('!*Cache', 'Default\Code Cache')
|
||
($arguments -join ' ') | Should -Not -Match '"'
|
||
}
|
||
|
||
It '空模式被忽略' {
|
||
$arguments = Get-ArchiveExcludeArgument -ItemName 'Foo' -Patterns @('', ' ', '!')
|
||
@($arguments).Count | Should -Be 0
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
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 损坏时不抛异常,而是重建空清单' {
|
||
$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:CatalogSandbox = Join-Path $script:Sandbox 'catalog'
|
||
New-Item -ItemType Directory -Path $script:CatalogSandbox -Force | Out-Null
|
||
|
||
$script:CatDir = Join-Path $script:CatalogSandbox 'src'
|
||
New-Item -ItemType Directory -Path (Join-Path $script:CatDir 'legendary_2.0.4') -Force | Out-Null
|
||
New-Item -ItemType Directory -Path (Join-Path $script:CatDir 'LegendarySomething') -Force | Out-Null
|
||
New-Item -ItemType Directory -Path (Join-Path $script:CatDir 'Real App') -Force | Out-Null
|
||
|
||
# 先把路径算好再拼名录内容:在 @"..."@ 里嵌 $(...) 再嵌单引号很容易把
|
||
# 收尾的引号吃掉(踩过一次:生成的 .psd1 直接解析失败,而"名录读不到"
|
||
# 会让下面几条断言静默通过)。
|
||
$realApp = Join-Path $script:CatDir 'Real App'
|
||
$legendarySomething = Join-Path $script:CatDir 'LegendarySomething'
|
||
$legendaryTarget = Join-Path $script:CatDir 'legendary'
|
||
|
||
$script:CatFile = Write-ListFile -Path (Join-Path $script:CatalogSandbox 'SoftwareCatalog.psd1') -Content @"
|
||
@{
|
||
'my-app' = '$realApp'
|
||
'dotted' = '$realApp'
|
||
'multi' = @{ Dirs = @('$realApp', '$legendarySomething') }
|
||
'legendary' = '$legendaryTarget'
|
||
}
|
||
"@
|
||
|
||
# 名录一旦写坏,后面几条测试会全部"静默通过"(目录查不到 → 候选集为空,
|
||
# Should -Not -Contain 自然成立)。这里先把"名录本身能读进来"钉死。
|
||
$script:CatalogEntryCount = (Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3).Count
|
||
}
|
||
|
||
It '名录解析:裸键、引号键、带 - 与 . 的名字' {
|
||
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3
|
||
$catalog.ContainsKey('my-app') | Should -BeTrue
|
||
$catalog.ContainsKey('dotted') | Should -BeTrue
|
||
$catalog['my-app'].Name | Should -Be 'my-app'
|
||
}
|
||
|
||
It '名录解析:目录带版本后缀时按前缀补全(legendary -> legendary_2.0.4)' {
|
||
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3
|
||
$catalog['legendary'].ResolvedPath | Should -Be (Join-Path $script:CatDir 'legendary_2.0.4')
|
||
$catalog['legendary'].Kind | Should -Be 'Single'
|
||
}
|
||
|
||
It '名录解析:不会把 Legendary 误配成 LegendarySomething' {
|
||
$script:CatalogEntryCount | Should -Be 4 # 先确认名录真的读进来了,避免空集静默通过
|
||
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3
|
||
$catalog['legendary'].Variants | Should -Contain (Join-Path $script:CatDir 'legendary_2.0.4')
|
||
$catalog['legendary'].Variants | Should -Not -Contain (Join-Path $script:CatDir 'LegendarySomething')
|
||
}
|
||
|
||
It '软件名条目:默认用软件名做归档名' {
|
||
$entry = ConvertFrom-BackupListLine -Line 'my-app'
|
||
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3) | Should -Be 'my-app'
|
||
}
|
||
|
||
It '字面路径条目:仍用路径命名算法(现有清单无需改写)' {
|
||
$entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
|
||
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3) | Should -Be 'FooClolor_from_C_+Programs'
|
||
}
|
||
|
||
It '@pathname 用真实路径命名,而不是软件名' {
|
||
$entry = ConvertFrom-BackupListLine -Line 'my-app @pathname'
|
||
$expected = Get-BackupBaseName -RawPath (Join-Path $script:CatDir 'Real App')
|
||
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3) | Should -Be $expected
|
||
}
|
||
|
||
It '名录里没有该名字:BaseName 退回可读目录名,并给出 Error' {
|
||
$entry = ConvertFrom-BackupListLine -Line 'no-such-thing'
|
||
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
|
||
$resolved.BaseName | Should -Be 'no-such-thing'
|
||
$resolved.Sources.Count | Should -Be 0
|
||
$resolved.Error | Should -Not -BeNullOrEmpty
|
||
}
|
||
|
||
It '名录里的路径不存在时仍给出 Sources(恢复要靠它还原回原位)' {
|
||
$missingCatalog = Write-ListFile -Path (Join-Path $script:CatalogSandbox 'Missing.psd1') -Content "@{ 'gone' = 'C:\definitely-not-here-98765' }"
|
||
$entry = ConvertFrom-BackupListLine -Line 'gone'
|
||
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $missingCatalog -MaxDepth 3
|
||
$resolved.Sources.Count | Should -Be 1
|
||
$resolved.Error | Should -Not -BeNullOrEmpty
|
||
}
|
||
|
||
It 'Includes:分文件维护的名录会被合并' {
|
||
Write-ListFile -Path (Join-Path $script:CatalogSandbox 'Extra.psd1') -Content "@{ 'extra-app' = '$script:CatDir' }" | Out-Null
|
||
$main = Write-ListFile -Path (Join-Path $script:CatalogSandbox 'Main.psd1') -Content "@{ Includes = @('Extra.psd1'); 'main-app' = '$script:CatDir' }"
|
||
$catalog = Get-SoftwareCatalog -Path $main -MaxDepth 3
|
||
$catalog.ContainsKey('main-app') | Should -BeTrue
|
||
$catalog.ContainsKey('extra-app') | Should -BeTrue
|
||
}
|
||
|
||
It '软件名会被规范化成合法文件名' {
|
||
$clean = Format-CatalogName -Name 'a<b>c:d/e'
|
||
($clean.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1
|
||
}
|
||
|
||
# ---- 回归:Resolve-BackupEntry 曾经在给 $rootName 赋值之前就引用它 ----
|
||
It '[回归] 名录里没有该名字时,不会把调用方作用域里残留的 $rootName 泄漏进返回值' {
|
||
# PowerShell 是动态作用域:函数会沿着**调用方**的作用域链找变量。
|
||
# 修复前 $rootName 在 return 之后才赋值,于是这里会读到 'LEAKED'。
|
||
$rootName = 'LEAKED'
|
||
$entry = ConvertFrom-BackupListLine -Line 'no-such-thing'
|
||
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
|
||
$resolved.RootName | Should -Not -Be 'LEAKED'
|
||
$resolved.RootName | Should -Be 'no-such-thing'
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
Describe '外部命令退出码(旧实现的核心缺陷)' {
|
||
# ============================================================================
|
||
|
||
It 'Invoke-ExternalCommand 能拿到真实退出码' {
|
||
(Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 7')) | Should -Be 7
|
||
}
|
||
|
||
It '成功时拿到 0' {
|
||
(Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 0')) | Should -Be 0
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
Describe '集成:真实 7z 压缩与排除规则' -Skip:(-not $script:HasSevenZip) {
|
||
# ============================================================================
|
||
|
||
BeforeAll {
|
||
$script:IntegrationRoot = Join-Path $script:Sandbox 'integration'
|
||
$script:IntegrationSource = Join-Path $script:IntegrationRoot 'src'
|
||
$script:IntegrationItem = 'User Data'
|
||
$script:IntegrationPath = Join-Path $script:IntegrationSource $script:IntegrationItem
|
||
|
||
foreach ($directory in 'Default\Cache', 'Default\Code Cache', 'Default\Extensions', 'component_crx_cache', 'Default\IndexedDB') {
|
||
New-Item -ItemType Directory -Path (Join-Path $script:IntegrationPath $directory) -Force | Out-Null
|
||
}
|
||
Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'keep.txt') 'keep'
|
||
Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'Default\Cache\c.bin') 'c'
|
||
Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'Default\Code Cache\cc.bin') 'cc'
|
||
Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'Default\Extensions\e.bin') 'e'
|
||
Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'component_crx_cache\x.bin') 'x'
|
||
Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'Default\IndexedDB\i.bin') 'i'
|
||
}
|
||
|
||
It '排除规则与空格处理在真实归档上生效' {
|
||
$patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB')
|
||
$excludeArguments = Get-ArchiveExcludeArgument -ItemName $script:IntegrationItem -Patterns $patterns
|
||
|
||
$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
|
||
}
|
||
|
||
It '备份退出码为 0(旧实现会把成功的压缩判成失败)' {
|
||
$script:BackupRun.ExitCode | Should -Be 0
|
||
}
|
||
|
||
It '归档已生成且 manifest 记录了条目、动作与校验结果' {
|
||
$archives = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)
|
||
$archives.Count | Should -Be 1
|
||
$archives[0].Length | Should -BeGreaterThan 0
|
||
|
||
$record = $script:E2EManifest.items[$archives[0].BaseName]
|
||
$record | Should -Not -BeNullOrEmpty
|
||
$record.action | Should -Be 'backed-up'
|
||
$record.verified | Should -BeTrue
|
||
$record.exitCode | Should -Be 0
|
||
}
|
||
|
||
It '备份过程写了日志文件' {
|
||
$logs = @(Get-ChildItem -LiteralPath (Join-Path $script:ProjectRoot 'logs') -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
|
||
$logs.Count | Should -BeGreaterThan 0
|
||
}
|
||
|
||
It '归档里保留了应当保留的内容,且不含被排除项' {
|
||
$verify = Join-Path $script:E2ERoot 'verify'
|
||
New-Item -ItemType Directory -Path $verify -Force | Out-Null
|
||
$archive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0]
|
||
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive.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 记录的是归档内真实的顶层条目名' {
|
||
$archive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0]
|
||
$record = $script:E2EManifest.items[$archive.BaseName]
|
||
|
||
# 源目录名带空格,正好同时压一下"顶层名不能被空白拆开"这条
|
||
$record.roots | Should -Contain 'My Code Space'
|
||
|
||
# 和归档里的真实内容对一次账,而不是只信 manifest 自己
|
||
$listing = & $script:SevenZip l -ba -slt $archive.FullName 2>$null
|
||
$topLevel = @($listing |
|
||
Where-Object { $_ -like 'Path = *' } |
|
||
ForEach-Object { $_.Substring(7) } |
|
||
Where-Object { $_ -notmatch '\\' } |
|
||
Select-Object -Unique)
|
||
$topLevel | 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.Values)[0]
|
||
$record.lastRestoreAt | Should -Not -BeNullOrEmpty
|
||
}
|
||
|
||
It '孤儿归档会被点名报告,但不影响退出码' {
|
||
$archive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0]
|
||
Copy-Item -LiteralPath $archive.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
|
||
}
|
||
}
|