修复(P0) - 退出码:改用 .NET Process 继承控制台启动外部命令。Start-Process -PassThru 的 ExitCode 在 PowerShell 7.7.0-preview.4 上恒为 $null,会把成功的压缩判成失败, 并让 "exit 2 -> 删档重试" 的自愈分支永远不可达。 - BackupList.txt 解析:先按第一个 :: 切开再处理引号,修正整行被引号包住时 排除表被吞进路径的问题(该条目此前被静默跳过,其 2.8 GB 归档成了孤儿)。 - 排除分隔符同时接受 , 与 ;:此前解析器只认 ; 而清单里写的是 ,, 等于所有排除规则都没生效。 - 7z 排除参数不再嵌引号,含空格的模式自动转成 ?:旧写法 -x!"路径" 会让引号 成为模式的一部分,导致排除对所有条目都失效。 加固(P1) - 先写临时归档 -> 7z t 校验 -> 原子替换,中断不再污染正式归档。 - 放弃 7z 的更新模式 u:固实压缩下收益极小,却让排除规则改动与已删文件 永远进不了归档。 - 新增 Backups/manifest.json 与 logs/*.log,跳过/失败有据可查。 - 结尾按失败数 exit;恢复支持 -WhatIf / -DryRun / -VerifyOnly / -Only。 - 恢复优先用 manifest 定位归档,并精确比较 BaseName(不再用 -Filter 通配)。 - 修正 tar 分支用 $LASTEXITCODE 判断成功与否的缺陷。 - 有警告(文件被占用)时拒绝用不完整的归档覆盖完整归档,需显式 -AcceptWarnings。 策略与安全(P2) - Edge 条目加排除规则:解压后 4.22 GB 中 3.79 GB 是可再生的缓存/遥测/扩展本体, 保留书签、密码、偏好、历史与站点数据。 - 可选 7z 加密(@encrypt 标记或全局开关),取不到口令时明确失败,绝不写明文。 - 磁盘空间守卫:放不下就跳过该条目,低于阈值告警。 工程化(P3) - 新增 BackupConfig.psd1、README.md、.gitignore。 - tests/Run-Tests.ps1(32 项)与 tests/Run-E2E.ps1(16 项端到端验收)。 - tools/Register-BackupTask.ps1 注册每日计划任务。 - 归档命名算法保持不变,已有归档不会失联。
345 lines
15 KiB
PowerShell
345 lines
15 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
BakNRet 测试套件(零依赖,Windows PowerShell 5.1 与 PowerShell 7.x 均可运行)。
|
||
|
||
.DESCRIPTION
|
||
为什么不用 Pester:本机只装了 Pester 3.4.0,`Should -Be` 这类 Pester 4+ 语法
|
||
直接语法错误,而为了跑测试去装 Pester 5 不值得。这里自带一个极简断言运行器,
|
||
对备份工具这种小脚本来说更可靠。
|
||
|
||
覆盖范围:
|
||
* BackupList.txt 语法解析(包含历史上出错的两种写法)
|
||
* 归档命名与逆向解析
|
||
* 7z 排除参数翻译(含空格、! 前缀、重复前缀、禁用引号)
|
||
* Windows 命令行拼接
|
||
* manifest 读写往返
|
||
* 配置默认值与嵌套合并
|
||
* 集成测试:真的调用 7z 打包 + 解压,验证排除结果落在文件系统上
|
||
|
||
.EXAMPLE
|
||
pwsh -File .\tests\Run-Tests.ps1
|
||
#>
|
||
|
||
[CmdletBinding()]
|
||
param(
|
||
[string]$ModulePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'Common.psm1')
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
Import-Module $ModulePath -Force
|
||
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
|
||
|
||
Reset-TestResult
|
||
|
||
# ============================================================================
|
||
Write-Host "`n== BackupList 解析 ==" -ForegroundColor Cyan
|
||
# ============================================================================
|
||
|
||
Test-Case '注释与空行返回 $null' {
|
||
Assert-Null (ConvertFrom-BackupListLine -Line '# 这是注释')
|
||
Assert-Null (ConvertFrom-BackupListLine -Line ' ')
|
||
Assert-Null (ConvertFrom-BackupListLine -Line '')
|
||
}
|
||
|
||
Test-Case '裸路径' {
|
||
$r = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
|
||
Assert-Equal 'C:\Programs\FooClolor' $r.Path
|
||
Assert-Equal 0 $r.ExcludePatterns.Count
|
||
Assert-Equal 0 $r.Flags.Count
|
||
}
|
||
|
||
Test-Case '逗号分隔的排除表被拆成多个模式(旧实现只认分号,这里曾经整串当成一个)' {
|
||
$r = ConvertFrom-BackupListLine -Line 'C:\Programs\March7thAssistant :: a\b,c\d\'
|
||
Assert-Equal 'C:\Programs\March7thAssistant' $r.Path
|
||
Assert-Equal 2 $r.ExcludePatterns.Count
|
||
Assert-Equal 'a\b' $r.ExcludePatterns[0]
|
||
Assert-Equal 'c\d\' $r.ExcludePatterns[1]
|
||
}
|
||
|
||
Test-Case '分号分隔同样支持' {
|
||
$r = ConvertFrom-BackupListLine -Line 'C:\x :: a;b'
|
||
Assert-Equal 2 $r.ExcludePatterns.Count
|
||
}
|
||
|
||
Test-Case '引号只包路径时排除表正常解析' {
|
||
$r = ConvertFrom-BackupListLine -Line '"C:\Program Files\App" :: cache\*,logs\'
|
||
Assert-Equal 'C:\Program Files\App' $r.Path
|
||
Assert-Equal 2 $r.ExcludePatterns.Count
|
||
}
|
||
|
||
Test-Case '整行被一对引号包住时,:: 之后的排除表不被吞进路径(真实第 27 行)' {
|
||
$line = '"C:\Programs\ScoopApps\persist :: persist\a\DawnCache,persist\a\GPUCache"'
|
||
$r = ConvertFrom-BackupListLine -Line $line
|
||
Assert-Equal 'C:\Programs\ScoopApps\persist' $r.Path
|
||
Assert-Equal 2 $r.ExcludePatterns.Count
|
||
Assert-Equal 'persist\a\DawnCache' $r.ExcludePatterns[0]
|
||
}
|
||
|
||
Test-Case '@ 标记单独出现' {
|
||
$r = ConvertFrom-BackupListLine -Line '%UserProfile%\.ssh @encrypt'
|
||
Assert-Equal '%UserProfile%\.ssh' $r.Path
|
||
Assert-Equal 1 $r.Flags.Count
|
||
Assert-Equal 'encrypt' $r.Flags[0]
|
||
Assert-Equal 0 $r.ExcludePatterns.Count
|
||
}
|
||
|
||
Test-Case '@ 标记跟在排除表后面' {
|
||
$r = ConvertFrom-BackupListLine -Line 'C:\x :: a,b @encrypt'
|
||
Assert-Equal 'C:\x' $r.Path
|
||
Assert-Equal 2 $r.ExcludePatterns.Count
|
||
Assert-Equal 'encrypt' $r.Flags[0]
|
||
}
|
||
|
||
# ============================================================================
|
||
Write-Host "`n== 归档命名 ==" -ForegroundColor Cyan
|
||
# ============================================================================
|
||
|
||
Test-Case '基础命名规则' {
|
||
Assert-Equal 'legendary_from_%UserProfile%+.config' (Get-BackupBaseName -RawPath '%UserProfile%/.config/legendary')
|
||
Assert-Equal 'FooClolor_from_C_+Programs' (Get-BackupBaseName -RawPath 'C:\Programs\FooClolor')
|
||
Assert-Equal 'settings.json_from_C_+Programs' (Get-BackupBaseName -RawPath 'C:\Programs\settings.json')
|
||
}
|
||
|
||
Test-Case '/ 与 \ 以及重复分隔符结果一致' {
|
||
$a = Get-BackupBaseName -RawPath '%UserProfile%/.config/scoop'
|
||
$b = Get-BackupBaseName -RawPath '%UserProfile%\.config\scoop'
|
||
$c = Get-BackupBaseName -RawPath '%UserProfile%\\Documents\\WindowsPowerShell'
|
||
Assert-Equal $a $b
|
||
Assert-Equal 'WindowsPowerShell_from_%UserProfile%+Documents' $c
|
||
}
|
||
|
||
Test-Case '命名 <-> 路径往返' {
|
||
$cases = @(
|
||
'%UserProfile%/.config/legendary',
|
||
'C:\Programs\FooClolor',
|
||
'%LocalAppData%\Microsoft\Edge\User Data'
|
||
)
|
||
foreach ($case in $cases) {
|
||
$base = Get-BackupBaseName -RawPath $case
|
||
$back = Convert-BackupFileNameToPath -FileName "$base.7z"
|
||
Assert-Equal ($case -replace '[/\\]+', '\') $back "往返 $case"
|
||
}
|
||
}
|
||
|
||
Test-Case '归档名里不含非法文件名字符' {
|
||
$base = Get-BackupBaseName -RawPath 'C:\a\b?c*d|e'
|
||
$invalid = [System.IO.Path]::GetInvalidFileNameChars() | Where-Object { $_ -notin @('&', '%', '+') }
|
||
foreach ($ch in $invalid) {
|
||
Assert-False ($base.Contains([string]$ch)) "归档名里出现了非法字符 $ch"
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
Write-Host "`n== 7z 排除参数翻译 ==" -ForegroundColor Cyan
|
||
# ============================================================================
|
||
|
||
Test-Case '相对模式自动补上归档根目录名' {
|
||
$exclude = Get-ArchiveExcludeArgument -ItemName 'March7thAssistant' -Patterns @('logs\')
|
||
Assert-Equal '-x!March7thAssistant\logs' $exclude[0]
|
||
}
|
||
|
||
Test-Case '已经带根目录名时不重复前缀' {
|
||
$exclude = Get-ArchiveExcludeArgument -ItemName 'March7thAssistant' -Patterns @('March7thAssistant\logs\')
|
||
Assert-Equal '-x!March7thAssistant\logs' $exclude[0]
|
||
}
|
||
|
||
Test-Case '! 前缀翻译成递归组件匹配' {
|
||
$exclude = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('!*Cache')
|
||
Assert-Equal '-xr!*Cache' $exclude[0]
|
||
}
|
||
|
||
Test-Case '模式里的空格转成 ? (7z 的模式不支持空格)' {
|
||
$exclude = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('Default\Code Cache')
|
||
Assert-Equal '-x!User?Data\Default\Code?Cache' $exclude[0]
|
||
}
|
||
|
||
Test-Case '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' {
|
||
$patterns = @('Default\Code Cache', '!*Cache', 'logs\', 'March7thAssistant\3rdparty\WebBrowser\UserProfile\Integrated')
|
||
foreach ($pattern in $patterns) {
|
||
foreach ($entry in (Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @($pattern))) {
|
||
Assert-False ($entry.Contains('"')) "参数里出现了引号: $entry"
|
||
}
|
||
}
|
||
}
|
||
|
||
Test-Case '空模式被忽略' {
|
||
$exclude = Get-ArchiveExcludeArgument -ItemName 'x' -Patterns @('', ' ', 'real\')
|
||
Assert-Equal 1 $exclude.Count
|
||
}
|
||
|
||
# ============================================================================
|
||
Write-Host "`n== 命令行拼接 ==" -ForegroundColor Cyan
|
||
# ============================================================================
|
||
|
||
Test-Case '无空格参数原样输出' {
|
||
Assert-Equal 'a -t7z' (ConvertTo-NativeArgumentString -ArgumentList @('a', '-t7z'))
|
||
}
|
||
|
||
Test-Case '含空格参数加引号' {
|
||
Assert-Equal '"C:\Program Files\x"' (ConvertTo-NativeArgumentString -ArgumentList @('C:\Program Files\x'))
|
||
}
|
||
|
||
Test-Case '引号内的结尾反斜杠翻倍(否则会被当成转义引号)' {
|
||
Assert-Equal '"C:\Program Files\\"' (ConvertTo-NativeArgumentString -ArgumentList @('C:\Program Files\'))
|
||
}
|
||
|
||
Test-Case '内部引号被转义' {
|
||
Assert-Equal '"say \"hi\""' (ConvertTo-NativeArgumentString -ArgumentList @('say "hi"'))
|
||
}
|
||
|
||
Test-Case '空参数输出一对空引号' {
|
||
Assert-Equal '""' (ConvertTo-NativeArgumentString -ArgumentList @(''))
|
||
}
|
||
|
||
# ============================================================================
|
||
Write-Host "`n== manifest 与配置 ==" -ForegroundColor Cyan
|
||
# ============================================================================
|
||
|
||
$sandbox = Join-Path $env:TEMP ("baknret-tests-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||
|
||
Test-Case 'manifest 读写往返' {
|
||
New-Item -ItemType Directory -Path $sandbox -Force | Out-Null
|
||
$path = Join-Path $sandbox 'manifest.json'
|
||
|
||
$m = Read-BaknretManifest -Path $path
|
||
Assert-Equal 0 $m.items.Count
|
||
|
||
$m.compressor = [pscustomobject]@{ name = '7z'; version = '26.03' }
|
||
$m.items['FooClolor_from_C_+Programs'] = [ordered]@{ baseName = 'FooClolor_from_C_+Programs'; action = 'backed-up'; archiveBytes = 12345 }
|
||
Write-BaknretManifest -Path $path -Manifest $m | Out-Null
|
||
|
||
$again = Read-BaknretManifest -Path $path
|
||
Assert-Equal 1 $again.items.Count
|
||
Assert-Equal 'backed-up' $again.items['FooClolor_from_C_+Programs'].action
|
||
|
||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||
Assert-False (($bytes[0] -eq 0xEF) -and ($bytes[1] -eq 0xBB)) 'manifest 不应带 BOM'
|
||
}
|
||
|
||
Test-Case 'manifest 损坏时不抛异常' {
|
||
$path = Join-Path $sandbox 'broken.json'
|
||
[System.IO.File]::WriteAllText($path, '{ this is not json')
|
||
$m = Read-BaknretManifest -Path $path
|
||
Assert-Equal 0 $m.items.Count
|
||
}
|
||
|
||
Test-Case '配置缺失时返回默认值' {
|
||
$config = Get-BaknretConfig -Path (Join-Path $sandbox 'nope.psd1')
|
||
Assert-Equal 'Backups' $config.BackupDir
|
||
Assert-True ($config.MinFreeSpaceGB -gt 0)
|
||
}
|
||
|
||
Test-Case '配置嵌套段落合并且不丢默认键' {
|
||
$path = Join-Path $sandbox 'cfg.psd1'
|
||
Set-Content -LiteralPath $path -Value "@{ Encryption = @{ Enabled = `$true }; CompressionLevel = 5 }"
|
||
$config = Get-BaknretConfig -Path $path
|
||
Assert-Equal 5 $config.CompressionLevel
|
||
Assert-True $config.Encryption.Enabled
|
||
Assert-True $config.Encryption.EncryptHeaders '未覆盖的嵌套键应保留默认值'
|
||
}
|
||
|
||
Test-Case '口令:环境变量可读取,取不到返回 $null' {
|
||
$old = $env:BAKNRET_PASSWORD
|
||
try {
|
||
Remove-Item Env:BAKNRET_PASSWORD -ErrorAction SilentlyContinue
|
||
Assert-Null (Get-BaknretPassword -PasswordFile (Join-Path $sandbox 'nope'))
|
||
$env:BAKNRET_PASSWORD = 'from-env'
|
||
Assert-Equal 'from-env' (Get-BaknretPassword)
|
||
} finally {
|
||
Remove-Item Env:BAKNRET_PASSWORD -ErrorAction SilentlyContinue
|
||
if ($old) { $env:BAKNRET_PASSWORD = $old }
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
Write-Host "`n== 集成测试:真的跑 7z,验证排除结果落在文件系统上 ==" -ForegroundColor Cyan
|
||
# ============================================================================
|
||
|
||
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||
|
||
if (-not $sevenZip) {
|
||
Write-Host ' 跳过:未找到 7z' -ForegroundColor Yellow
|
||
} else {
|
||
Test-Case '排除规则与空格处理在真实归档上生效' {
|
||
$root = Join-Path $sandbox 'src'
|
||
$itemName = 'User Data'
|
||
$source = Join-Path $root $itemName
|
||
New-Item -ItemType Directory -Path $root -Force | Out-Null
|
||
|
||
foreach ($d in 'Default\Cache', 'Default\Code Cache', 'Default\Extensions', 'component_crx_cache', 'Default\IndexedDB') {
|
||
New-Item -ItemType Directory -Path (Join-Path $source $d) -Force | Out-Null
|
||
}
|
||
Set-Content -LiteralPath (Join-Path $source 'keep.txt') 'keep'
|
||
Set-Content -LiteralPath (Join-Path $source 'Default\Cache\c.bin') 'c'
|
||
Set-Content -LiteralPath (Join-Path $source 'Default\Code Cache\cc.bin') 'cc'
|
||
Set-Content -LiteralPath (Join-Path $source 'Default\Extensions\e.bin') 'e'
|
||
Set-Content -LiteralPath (Join-Path $source 'component_crx_cache\x.bin') 'x'
|
||
Set-Content -LiteralPath (Join-Path $source 'Default\IndexedDB\i.bin') 'i'
|
||
|
||
$patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB')
|
||
$excludeArgs = Get-ArchiveExcludeArgument -ItemName $itemName -Patterns $patterns
|
||
|
||
$archive = Join-Path $sandbox 'excl.7z'
|
||
$argument = @('a', '-t7z', '-mx=1', '-bso0', '-bsp0') + $excludeArgs + @($archive, $itemName)
|
||
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument -WorkingDirectory $root
|
||
Assert-Equal 0 $code '7z 打包退出码'
|
||
|
||
$verify = Join-Path $sandbox 'verify'
|
||
New-Item -ItemType Directory -Path $verify -Force | Out-Null
|
||
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive)
|
||
Assert-Equal 0 $code '7z 解压退出码'
|
||
|
||
Assert-True (Test-Path -LiteralPath (Join-Path $verify "$itemName\keep.txt")) '保留的文件应当存在'
|
||
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Cache\c.bin")) 'Default\Cache 应被 !*Cache 排除'
|
||
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Code Cache\cc.bin")) 'Code Cache 应被排除(空格转 ? 生效)'
|
||
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Extensions\e.bin")) 'Extensions 应被排除'
|
||
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\component_crx_cache\x.bin")) 'component_crx_cache 应被排除'
|
||
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\IndexedDB\i.bin")) 'IndexedDB 应被排除'
|
||
}
|
||
|
||
Test-Case '对照组:不加排除时被排除的文件确实在归档里(证明上一条不是空归档)' {
|
||
$root = Join-Path $sandbox 'src'
|
||
$itemName = 'User Data'
|
||
$archive = Join-Path $sandbox 'full.7z'
|
||
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $archive, $itemName) -WorkingDirectory $root
|
||
Assert-Equal 0 $code
|
||
|
||
$verify = Join-Path $sandbox 'verify-full'
|
||
New-Item -ItemType Directory -Path $verify -Force | Out-Null
|
||
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive)
|
||
Assert-Equal 0 $code
|
||
|
||
Assert-True (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Cache\c.bin")) '对照组应当包含被排除的那个文件'
|
||
}
|
||
|
||
Test-Case '7z t 对完好归档返回 0,对损坏归档返回非 0' {
|
||
$good = Join-Path $sandbox 'full.7z'
|
||
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('t', '-bso0', '-bsp0', $good)
|
||
Assert-Equal 0 $code '完好归档应当校验通过'
|
||
|
||
$bad = Join-Path $sandbox '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)
|
||
|
||
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('t', '-bso0', '-bsp0', $bad)
|
||
Assert-True ($code -ne 0) '损坏的归档应当校验失败'
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
Write-Host "`n== 外部命令退出码 ==" -ForegroundColor Cyan
|
||
# ============================================================================
|
||
|
||
Test-Case 'Invoke-ExternalCommand 能拿到真实退出码(旧实现用 Start-Process 拿不到)' {
|
||
$code = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 7')
|
||
Assert-Equal 7 $code
|
||
}
|
||
|
||
# ============================================================================
|
||
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||
|
||
$failed = Write-TestSummary -Title '单元测试'
|
||
if ($failed -gt 0) { exit 1 }
|
||
exit 0
|