Files
BakNRet/tests/Run-Tests.ps1
T
Shuery dbc0c00554 重构为可核对、可恢复的备份工具(P0-P3)
修复(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 注册每日计划任务。
- 归档命名算法保持不变,已有归档不会失联。
2026-09-21 20:10:18 +08:00

345 lines
15 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
BakNRet 测试套件(零依赖,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